//! Notification chimes for room events (join / peer-join / peer-leave, with more
//! events in later phases).
//!
//! The WAVs are embedded in the binary (`include_bytes!`) so a deployed single
//! binary is self-contained — no asset directory to ship alongside it. On first
//! use each sound is written once to a temp file, then played fire-and-forget.
//! Linux uses `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`);
//! Windows uses PowerShell's `System.Media.SoundPlayer`. Playback runs on a
//! detached thread that waits on the child, so it never blocks the UI and never
//! leaves a zombie. Any failure (no player, no audio) is silent by design — a
//! missing chime should never disrupt a call.

#[cfg(not(windows))]
use crate::audio::ownership;
use std::collections::HashMap;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};

static ENABLED: AtomicBool = AtomicBool::new(true);
static TEMP_WAV_COUNTER: AtomicU64 = AtomicU64::new(0);

/// Per-sound enable flags (W6), indexed by `Sound::index`. The master `ENABLED`
/// toggle gates everything; these silence individual events while the master
/// stays on. Both must be true for a chime to play (see `should_play`). All
/// start true so behaviour is unchanged until a user opts a sound out.
static SOUND_ENABLED: [AtomicBool; Sound::COUNT] = {
    // The repeat-init `const` is the standard way to build an atomic array; the
    // interior-mutability lint is a false positive here (we want one fresh
    // `AtomicBool` per slot, which is exactly what this does).
    #[allow(clippy::declare_interior_mutable_const)]
    const TRUE: AtomicBool = AtomicBool::new(true);
    [TRUE; Sound::COUNT]
};

/// Enable or disable all notification chimes globally (the master toggle).
pub fn set_enabled(enabled: bool) {
    ENABLED.store(enabled, Ordering::Relaxed);
}

/// Check if notifications are enabled (the master toggle).
pub fn is_enabled() -> bool {
    ENABLED.load(Ordering::Relaxed)
}

/// Enable or disable the chime for a single event, independent of the master.
pub fn set_sound_enabled(sound: Sound, enabled: bool) {
    SOUND_ENABLED[sound.index()].store(enabled, Ordering::Relaxed);
}

/// Whether the chime for `sound` is enabled (its own flag only, not the master).
pub fn is_sound_enabled(sound: Sound) -> bool {
    SOUND_ENABLED[sound.index()].load(Ordering::Relaxed)
}

/// The single gate deciding whether a chime plays: the master toggle AND the
/// per-sound flag must both be on. Pure so the rule is unit-testable.
pub fn should_play(master_enabled: bool, sound_enabled: bool) -> bool {
    master_enabled && sound_enabled
}

/// A notification event with a distinct chime.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Sound {
    /// You joined a room.
    SelfJoin,
    /// Another participant joined.
    PeerJoin,
    /// A participant left.
    PeerLeave,
    /// Reconnecting to a peer.
    ReconnectAttempt,
    /// Reconnected to a peer.
    Reconnected,
    /// You left a room.
    SelfLeave,
    /// Mic mute/unmute toggle.
    MicToggle,
    /// Reconnect failed / peer evicted.
    ReconnectFailed,
    /// One of our chat messages was broadcast to the room.
    ChatSent,
    /// A chat message from another participant was admitted.
    ChatReceived,
    /// A saved contact was detected online on the home screen.
    ContactOnline,
    /// A saved contact previously seen online went offline on the home screen.
    ContactOffline,
}

impl Sound {
    /// Every notification event, in a stable order (also the `index` order).
    pub const ALL: [Sound; Sound::COUNT] = [
        Sound::SelfJoin,
        Sound::PeerJoin,
        Sound::PeerLeave,
        Sound::ReconnectAttempt,
        Sound::Reconnected,
        Sound::SelfLeave,
        Sound::MicToggle,
        Sound::ReconnectFailed,
        Sound::ChatSent,
        Sound::ChatReceived,
        Sound::ContactOnline,
        Sound::ContactOffline,
    ];

    /// Number of distinct notification events.
    pub const COUNT: usize = 12;

    /// Stable 0-based index into the per-sound flag array. Must match `ALL`.
    fn index(self) -> usize {
        match self {
            Sound::SelfJoin => 0,
            Sound::PeerJoin => 1,
            Sound::PeerLeave => 2,
            Sound::ReconnectAttempt => 3,
            Sound::Reconnected => 4,
            Sound::SelfLeave => 5,
            Sound::MicToggle => 6,
            Sound::ReconnectFailed => 7,
            Sound::ChatSent => 8,
            Sound::ChatReceived => 9,
            Sound::ContactOnline => 10,
            Sound::ContactOffline => 11,
        }
    }

    /// Embedded WAV bytes for this sound.
    fn bytes(self) -> &'static [u8] {
        match self {
            Sound::SelfJoin => include_bytes!("../assets/sounds/self-join.wav"),
            Sound::PeerJoin => include_bytes!("../assets/sounds/peer-join.wav"),
            Sound::PeerLeave => include_bytes!("../assets/sounds/peer-leave.wav"),
            Sound::ReconnectAttempt => include_bytes!("../assets/sounds/reconnect-attempt.wav"),
            Sound::Reconnected => include_bytes!("../assets/sounds/reconnected.wav"),
            Sound::SelfLeave => include_bytes!("../assets/sounds/self-leave.wav"),
            Sound::MicToggle => include_bytes!("../assets/sounds/mic-toggle.wav"),
            Sound::ReconnectFailed => include_bytes!("../assets/sounds/reconnect-failed.wav"),
            Sound::ChatSent => include_bytes!("../assets/sounds/chat-sent.wav"),
            Sound::ChatReceived => include_bytes!("../assets/sounds/chat-received.wav"),
            Sound::ContactOnline => include_bytes!("../assets/sounds/contact-online.wav"),
            Sound::ContactOffline => include_bytes!("../assets/sounds/contact-offline.wav"),
        }
    }

    /// Stable file stem for the cached temp file.
    fn name(self) -> &'static str {
        match self {
            Sound::SelfJoin => "self-join",
            Sound::PeerJoin => "peer-join",
            Sound::PeerLeave => "peer-leave",
            Sound::ReconnectAttempt => "reconnect-attempt",
            Sound::Reconnected => "reconnected",
            Sound::SelfLeave => "self-leave",
            Sound::MicToggle => "mic-toggle",
            Sound::ReconnectFailed => "reconnect-failed",
            Sound::ChatSent => "chat-sent",
            Sound::ChatReceived => "chat-received",
            Sound::ContactOnline => "contact-online",
            Sound::ContactOffline => "contact-offline",
        }
    }
}

/// Helper to expand `~` at the start of a path to the user's home directory.
pub fn expand_tilde(path_str: &str) -> PathBuf {
    let trimmed = path_str.trim();
    if trimmed == "~" {
        dirs::home_dir().unwrap_or_else(|| PathBuf::from(trimmed))
    } else if let Some(stripped) = trimmed.strip_prefix("~/") {
        dirs::home_dir()
            .map(|home| home.join(stripped))
            .unwrap_or_else(|| PathBuf::from(trimmed))
    } else {
        PathBuf::from(trimmed)
    }
}

/// Check if a custom path is valid (i.e. if it exists and is a file).
/// Returns `None` if empty/whitespace, `Some(true)` if valid, `Some(false)` if invalid.
pub fn validate_custom_path(path_str: &str) -> Option<bool> {
    if path_str.trim().is_empty() {
        None
    } else {
        let path = expand_tilde(path_str);
        Some(path.exists() && path.is_file())
    }
}

/// Play a notification chime, fire-and-forget. Never blocks; never errors out.
pub fn play(sound: Sound, custom_path: Option<&str>) {
    if !should_play(is_enabled(), is_sound_enabled(sound)) {
        return;
    }
    let custom_path = custom_path.map(String::from);
    std::thread::spawn(move || {
        if let Some(ref path_str) = custom_path
            && !path_str.trim().is_empty()
        {
            let path = expand_tilde(path_str);
            if path.exists() && path.is_file() {
                spawn_player(&path);
                return;
            }
        }
        if let Some(path) = cached_path(sound) {
            spawn_player(&path);
        }
    });
}

/// Materialize the embedded WAV to a temp file the first time it's needed and
/// cache the path. Returns `None` only if the file can't be written.
fn cached_path(sound: Sound) -> Option<PathBuf> {
    static CACHE: OnceLock<Mutex<HashMap<&'static str, PathBuf>>> = OnceLock::new();
    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));

    let mut guard = cache.lock().unwrap();
    if let Some(path) = guard.get(sound.name()) {
        return Some(path.clone());
    }
    let path = match write_private_wav(&std::env::temp_dir(), sound.name(), sound.bytes()) {
        Ok(path) => path,
        Err(_) => return None,
    };
    guard.insert(sound.name(), path.clone());
    Some(path)
}

fn write_private_wav(dir: &Path, stem: &str, bytes: &[u8]) -> std::io::Result<PathBuf> {
    let counter = TEMP_WAV_COUNTER.fetch_add(1, Ordering::Relaxed);
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_nanos();
    let path = dir.join(format!(
        "peerspeak-{stem}-{}-{counter}-{nanos}.wav",
        std::process::id()
    ));

    let mut options = OpenOptions::new();
    options.write(true).create_new(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        options.mode(0o600);
    }

    let mut file = options.open(&path)?;
    file.write_all(bytes)?;
    Ok(path)
}

#[cfg(any(windows, test))]
fn escape_powershell_single_quoted(s: &str) -> String {
    s.replace('\'', "''")
}

/// Try each available player in turn, waiting on the first that starts (which
/// reaps the child). Runs on a detached thread, so the wait is harmless.
#[cfg(not(windows))]
fn spawn_player(path: &Path) {
    for player in ["pw-play", "paplay", "aplay"] {
        let mut command = Command::new(player);
        command
            .arg(path)
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null());
        // Ownership tag (plan §5.1). A chime is short, but it is still our
        // audio on the default sink, and an untagged one is an unowned root
        // the exclusion engine would have to reason about from scratch.
        // Measured on this host: all three fallbacks tag correctly, `aplay`
        // included — it reaches the graph through PipeWire's ALSA plugin,
        // which honours `PIPEWIRE_PROPS` like any other client.
        ownership::tag_child(&mut command, ownership::NOTIFICATION_ROLE);
        let started = command.status();
        // `status()` errors only if the player binary isn't present; on a real
        // playback error it still returns (non-zero), so a started player ends
        // the loop either way — we don't want to double-play through fallbacks.
        if started.is_ok() {
            return;
        }
    }
}

/// Play through Windows' built-in WAV player. Runs on a detached thread, so
/// `PlaySync()` blocking for the sound duration is fine.
#[cfg(windows)]
fn spawn_player(path: &Path) {
    let path = escape_powershell_single_quoted(&path.display().to_string());
    let command = format!("(New-Object System.Media.SoundPlayer '{path}').PlaySync()");
    let _ = Command::new("powershell")
        .arg("-NoProfile")
        .arg("-NonInteractive")
        .arg("-Command")
        .arg(command)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status();
}

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

    static TEST_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);

    fn temp_wav_dir(tag: &str) -> PathBuf {
        let counter = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
        let dir = std::env::temp_dir().join(format!(
            "peerspeak-notifytest-{}-{tag}-{counter}",
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }

    /// Phase-1 exit gate, notification half (impl plan §3): a chime peerspeak
    /// actually plays produces a live PipeWire node carrying **both**
    /// ownership carriers.
    ///
    /// ⚠️ Deliberately drives `play()`, not `tag_child()`. The unit test in
    /// `audio::ownership` proves the environment is built correctly; only a
    /// live run proves this module *uses* it and that the audio stack honours
    /// it end to end. The chime is silent (a zero-filled WAV), so running it
    /// never makes noise.
    ///
    /// Live: needs a running PipeWire daemon, `pw-play`/`paplay` and
    /// `pw-dump`. `cargo test --lib -- --ignored notification_chime`
    #[test]
    #[ignore = "live: requires a running PipeWire daemon and pw-dump"]
    #[cfg(not(windows))]
    fn notification_chime_node_carries_both_ownership_carriers() {
        use crate::audio::ownership::{self, live_test};

        let dir = temp_wav_dir("ownership");
        let path = dir.join("silence.wav");
        std::fs::write(&path, live_test::silent_wav(6)).unwrap();

        set_enabled(true);
        set_sound_enabled(Sound::PeerJoin, true);
        play(Sound::PeerJoin, Some(path.to_str().unwrap()));

        let prefix = live_test::expected_prefix(ownership::NOTIFICATION_ROLE);
        let found = live_test::poll_for_owned_node(&prefix, std::time::Duration::from_secs(5));
        std::fs::remove_dir_all(&dir).ok();

        let (name, owned) =
            found.unwrap_or_else(|| panic!("no live node named {prefix:?} appeared within 5s"));
        assert!(
            name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
            "{name}"
        );
        assert_eq!(
            owned.as_deref(),
            Some(ownership::OWNED_PROP_VALUE),
            "carrier 1 must be on the live node too, not just carrier 2"
        );
    }

    #[test]
    fn test_should_play_truth_table() {
        // Plays only when BOTH the master and the per-sound flag are on.
        assert!(should_play(true, true));
        assert!(!should_play(true, false));
        assert!(!should_play(false, true));
        assert!(!should_play(false, false));
    }

    #[test]
    fn test_powershell_single_quote_escape() {
        assert_eq!(
            escape_powershell_single_quoted(r"C:\Users\O'Brien\chime.wav"),
            r"C:\Users\O''Brien\chime.wav"
        );
        assert_eq!(escape_powershell_single_quoted("a'b'c"), "a''b''c");
    }

    #[test]
    fn test_sound_indices_unique_and_match_all() {
        // `index()` must be a 0..COUNT bijection in `ALL` order, or the flag
        // array would alias two sounds onto one slot.
        for (expected, sound) in Sound::ALL.iter().enumerate() {
            assert_eq!(sound.index(), expected, "index drift for {sound:?}");
        }
        assert_eq!(Sound::ALL.len(), Sound::COUNT);
    }

    #[test]
    fn test_set_and_query_per_sound_flag() {
        // Toggling one sound's flag is independent of the others. (Uses
        // PeerLeave to avoid clashing with flags other tests might touch.)
        set_sound_enabled(Sound::PeerLeave, false);
        assert!(!is_sound_enabled(Sound::PeerLeave));
        assert!(is_sound_enabled(Sound::PeerJoin));
        set_sound_enabled(Sound::PeerLeave, true);
        assert!(is_sound_enabled(Sound::PeerLeave));
    }

    #[test]
    fn test_expand_tilde() {
        if let Some(home) = dirs::home_dir() {
            // bare `~` -> home dir
            assert_eq!(expand_tilde("~"), home);
            // `~/sub/dir/file.wav` -> home joined with `sub/dir/file.wav`
            assert_eq!(
                expand_tilde("~/sub/dir/file.wav"),
                home.join("sub/dir/file.wav")
            );
        }
        // absolute path (`/etc/foo.wav`) -> unchanged
        assert_eq!(expand_tilde("/etc/foo.wav"), PathBuf::from("/etc/foo.wav"));
        // relative path (`foo/bar.wav`) -> unchanged
        assert_eq!(expand_tilde("foo/bar.wav"), PathBuf::from("foo/bar.wav"));
        // a tilde not at the start (`/opt/~/x.wav`) -> unchanged
        assert_eq!(expand_tilde("/opt/~/x.wav"), PathBuf::from("/opt/~/x.wav"));
        // `~username` style (`~bob/x.wav`) -> unchanged
        assert_eq!(expand_tilde("~bob/x.wav"), PathBuf::from("~bob/x.wav"));
        // leading/trailing whitespace is trimmed
        if let Some(home) = dirs::home_dir() {
            assert_eq!(expand_tilde("  ~  "), home);
            assert_eq!(
                expand_tilde("  ~/sub/dir/file.wav  "),
                home.join("sub/dir/file.wav")
            );
        }
        assert_eq!(
            expand_tilde("  /etc/foo.wav  "),
            PathBuf::from("/etc/foo.wav")
        );
        assert_eq!(
            expand_tilde("  foo/bar.wav  "),
            PathBuf::from("foo/bar.wav")
        );
    }

    #[test]
    fn test_validate_custom_path() {
        // empty and whitespace-only -> None
        assert_eq!(validate_custom_path(""), None);
        assert_eq!(validate_custom_path("   "), None);
        // a path that does not exist -> Some(false)
        assert_eq!(validate_custom_path("/non/existent/file.wav"), Some(false));
        // a real existing file -> Some(true)
        let existing_file = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
        assert_eq!(validate_custom_path(existing_file), Some(true));
        // an existing directory -> Some(false)
        let existing_dir = env!("CARGO_MANIFEST_DIR");
        assert_eq!(validate_custom_path(existing_dir), Some(false));
        // a `~`-prefixed path that resolves to a non-existent file -> Some(false)
        assert_eq!(validate_custom_path("~/non/existent/file.wav"), Some(false));
    }

    #[test]
    fn write_private_wav_writes_exact_bytes() {
        let dir = temp_wav_dir("writes");
        let bytes = b"RIFFpeerspeak-test";

        let path = write_private_wav(&dir, "unit", bytes).unwrap();

        assert!(path.exists());
        assert_eq!(std::fs::read(&path).unwrap(), bytes);
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[cfg(unix)]
    #[test]
    fn write_private_wav_creates_0600_file() {
        use std::os::unix::fs::PermissionsExt;

        let dir = temp_wav_dir("mode");
        let path = write_private_wav(&dir, "unit", b"mode").unwrap();

        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
        assert_eq!(mode, 0o600);
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn write_private_wav_uses_unique_paths() {
        let dir = temp_wav_dir("unique");

        let first = write_private_wav(&dir, "same-stem", b"first").unwrap();
        let second = write_private_wav(&dir, "same-stem", b"second").unwrap();

        assert_ne!(first, second);
        assert!(first.exists());
        assert!(second.exists());
        assert_eq!(std::fs::read(&first).unwrap(), b"first");
        assert_eq!(std::fs::read(&second).unwrap(), b"second");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn create_new_refuses_existing_path() {
        let dir = temp_wav_dir("create-new");
        let path = dir.join("preexisting.wav");
        std::fs::write(&path, b"original").unwrap();

        let err = std::fs::OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(&path)
            .unwrap_err();

        assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
        assert_eq!(std::fs::read(&path).unwrap(), b"original");
        let _ = std::fs::remove_dir_all(&dir);
    }
}
