//! Phase 4 of the feature-complete roadmap: platform-native Read Page Aloud.
//!
//! Layer-1 scope: shell out to the OS's built-in TTS binary, return
//! immediately (non-blocking), track the spawned child so `tts_stop`
//! can cancel in-flight speech. No SSML, no pause/resume, no cloud
//! voices. User text is never interpolated into a shell string —
//! every platform passes the text via argv (or, on Windows, inside a
//! PowerShell here-string delivered through `-EncodedCommand`).

use std::process::{Child, Command, Stdio};
use std::sync::Mutex;

use serde::Serialize;

/// Active synth child process, if any. On Linux `spd-say` returns
/// immediately so the slot is usually empty; macOS `say` and Windows
/// PowerShell speak synchronously, so we store the handle to kill
/// on `tts_stop`.
#[derive(Default)]
pub struct TtsState {
    child: Mutex<Option<Child>>,
}

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

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TtsVoice {
    pub id: String,
    pub name: String,
    pub language: Option<String>,
}

/// Clamp user-supplied rate into the app's supported range.
/// `0.5` = half speed, `1.0` = normal, `2.0` = double speed.
pub fn clamp_rate(rate: f32) -> f32 {
    if !rate.is_finite() {
        return 1.0;
    }
    rate.clamp(0.5, 2.0)
}

// ---------- Linux (spd-say, espeak-ng fallback) ----------

#[cfg(target_os = "linux")]
pub fn spd_rate(rate: f32) -> i32 {
    // spd-say -r takes -100..=100. 1.0 -> 0 (default rate). We map the
    // 1.0..=2.0 half linearly to 0..=100, and the 0.5..=1.0 half
    // linearly to -50..=0. Asymmetric but simple; users who want
    // slower playback than -50 can change the system synth rate in
    // their accessibility settings.
    let r = clamp_rate(rate);
    ((r - 1.0) * 100.0).round().clamp(-100.0, 100.0) as i32
}

#[cfg(target_os = "linux")]
pub fn espeak_rate(rate: f32) -> u32 {
    // espeak-ng -s: words per minute (default 175, min 80, max 450).
    let r = clamp_rate(rate);
    ((r * 175.0).round() as i32).clamp(80, 450) as u32
}

#[cfg(target_os = "linux")]
fn spawn_linux(text: &str, rate: f32, voice: Option<&str>) -> Result<Option<Child>, String> {
    let mut cmd = Command::new("spd-say");
    cmd.arg("-r").arg(spd_rate(rate).to_string());
    if let Some(v) = voice {
        cmd.arg("-t").arg(v);
    }
    cmd.arg("--").arg(text);
    cmd.stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null());

    match cmd.spawn() {
        // spd-say is non-blocking — the child exits before speech
        // finishes, so there's nothing useful to track.
        Ok(_) => Ok(None),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            let mut fb = Command::new("espeak-ng");
            fb.arg("-s").arg(espeak_rate(rate).to_string());
            if let Some(v) = voice {
                fb.arg("-v").arg(v);
            }
            fb.arg("--").arg(text);
            fb.stdin(Stdio::null())
                .stdout(Stdio::null())
                .stderr(Stdio::null());
            fb.spawn()
                .map(Some)
                .map_err(|e| format!("neither spd-say nor espeak-ng is available: {e}"))
        }
        Err(e) => Err(format!("failed to spawn spd-say: {e}")),
    }
}

#[cfg(target_os = "linux")]
fn stop_linux() {
    // Cancels all active spd-say messages for this user. Silent
    // failure is fine — if spd-say isn't installed there's nothing
    // to cancel, and any tracked espeak-ng child is killed separately.
    let _ = Command::new("spd-say")
        .arg("-S")
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn();
}

// ---------- macOS (say) ----------

#[cfg(target_os = "macos")]
pub fn say_rate(rate: f32) -> u32 {
    // `say -r`: words per minute. 180 wpm is roughly the default; clamp
    // to a sane range so the slider can't produce an unreadable rate.
    let r = clamp_rate(rate);
    ((r * 180.0).round() as i32).clamp(90, 500) as u32
}

#[cfg(target_os = "macos")]
fn spawn_macos(text: &str, rate: f32, voice: Option<&str>) -> Result<Option<Child>, String> {
    let mut cmd = Command::new("say");
    cmd.arg("-r").arg(say_rate(rate).to_string());
    if let Some(v) = voice {
        cmd.arg("-v").arg(v);
    }
    cmd.arg("--").arg(text);
    cmd.stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null());
    cmd.spawn()
        .map(Some)
        .map_err(|e| format!("failed to spawn `say`: {e}"))
}

// ---------- Windows (PowerShell System.Speech) ----------

#[cfg(target_os = "windows")]
pub fn win_rate(rate: f32) -> i32 {
    // SpeechSynthesizer.Rate is an integer in -10..=10.
    let r = clamp_rate(rate);
    ((r - 1.0) * 10.0).round().clamp(-10.0, 10.0) as i32
}

/// A PowerShell single-quoted here-string terminates on `'@` at the
/// start of a line. Neutralise any such sequence inside user text so
/// the here-string always closes where we intend.
#[cfg(target_os = "windows")]
pub fn escape_ps_herestring(text: &str) -> String {
    text.replace("'@", "' @")
}

#[cfg(target_os = "windows")]
fn spawn_windows(text: &str, rate: f32, voice: Option<&str>) -> Result<Option<Child>, String> {
    use base64::{engine::general_purpose::STANDARD, Engine};

    let rate_int = win_rate(rate);
    let safe_text = escape_ps_herestring(text);
    let select = match voice {
        // Regular single-quoted string (not here-string): doubled
        // single quotes are the escape for a literal `'`.
        Some(v) => format!("$s.SelectVoice('{}')", v.replace('\'', "''")),
        None => String::new(),
    };
    let script = format!(
        "Add-Type -AssemblyName System.Speech;\n\
         $s = New-Object System.Speech.Synthesis.SpeechSynthesizer;\n\
         $s.Rate = {rate_int};\n\
         {select};\n\
         $s.Speak(@'\n\
         {safe_text}\n\
         '@)"
    );
    let utf16: Vec<u8> = script
        .encode_utf16()
        .flat_map(|u| u.to_le_bytes())
        .collect();
    let encoded = STANDARD.encode(&utf16);

    let mut cmd = Command::new("powershell.exe");
    cmd.args(["-NoProfile", "-NonInteractive", "-EncodedCommand", &encoded]);
    cmd.stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null());
    cmd.spawn()
        .map(Some)
        .map_err(|e| format!("failed to spawn powershell: {e}"))
}

// ---------- Voice listing ----------

#[cfg(target_os = "linux")]
fn list_voices_impl() -> Result<Vec<TtsVoice>, String> {
    // spd-say's voice set depends on the active synth module and the
    // list format isn't stable across distributions. For Phase 4 the
    // picker renders "System default" only, which matches what
    // `tts_speak` without a voice argument does anyway.
    Ok(Vec::new())
}

#[cfg(target_os = "macos")]
fn list_voices_impl() -> Result<Vec<TtsVoice>, String> {
    let out = Command::new("say")
        .arg("-v")
        .arg("?")
        .output()
        .map_err(|e| format!("failed to query voices: {e}"))?;
    if !out.status.success() {
        return Err(format!("`say -v ?` exited with status {}", out.status));
    }
    let stdout = String::from_utf8_lossy(&out.stdout);
    Ok(parse_macos_voices(&stdout))
}

#[cfg(target_os = "macos")]
pub fn parse_macos_voices(raw: &str) -> Vec<TtsVoice> {
    raw.lines()
        .filter_map(|line| {
            let (prefix, _sample) = line.split_once('#')?;
            let mut parts = prefix.split_whitespace();
            let name = parts.next()?.to_string();
            let locale = parts.next().map(str::to_string);
            Some(TtsVoice {
                id: name.clone(),
                name,
                language: locale,
            })
        })
        .collect()
}

#[cfg(target_os = "windows")]
fn list_voices_impl() -> Result<Vec<TtsVoice>, String> {
    use base64::{engine::general_purpose::STANDARD, Engine};

    let script = "Add-Type -AssemblyName System.Speech;\n\
        (New-Object System.Speech.Synthesis.SpeechSynthesizer).GetInstalledVoices() |\n\
          ForEach-Object { $_.VoiceInfo } |\n\
          ForEach-Object { @{ Name = $_.Name; Culture = $_.Culture.Name } } |\n\
          ConvertTo-Json -Compress";
    let utf16: Vec<u8> = script
        .encode_utf16()
        .flat_map(|u| u.to_le_bytes())
        .collect();
    let encoded = STANDARD.encode(&utf16);
    let out = Command::new("powershell.exe")
        .args(["-NoProfile", "-NonInteractive", "-EncodedCommand", &encoded])
        .output()
        .map_err(|e| format!("failed to query voices: {e}"))?;
    if !out.status.success() {
        return Err(format!(
            "PowerShell voice-list exited with status {}",
            out.status
        ));
    }
    let stdout = String::from_utf8_lossy(&out.stdout);
    let trimmed = stdout.trim();
    if trimmed.is_empty() {
        return Ok(Vec::new());
    }
    let parsed: serde_json::Value =
        serde_json::from_str(trimmed).map_err(|e| format!("voice-list JSON parse failed: {e}"))?;
    // ConvertTo-Json emits a bare object for a single item and an
    // array otherwise; normalise to always-array.
    let items: Vec<serde_json::Value> = match parsed {
        serde_json::Value::Array(a) => a,
        v => vec![v],
    };
    Ok(items
        .into_iter()
        .filter_map(|v| {
            let name = v.get("Name")?.as_str()?.to_string();
            let culture = v
                .get("Culture")
                .and_then(|c| c.as_str())
                .map(str::to_string);
            Some(TtsVoice {
                id: name.clone(),
                name,
                language: culture,
            })
        })
        .collect())
}

// Non-desktop fallback. Android's native TextToSpeech API would need a
// JNI bridge — out of scope for v0.1-android. The frontend already hides
// the Read Page Aloud button behind a runtime detection; this stub keeps
// the command surface consistent so a stray invoke surfaces a clear
// error rather than panicking on an unbound `list_voices_impl`.
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn list_voices_impl() -> Result<Vec<TtsVoice>, String> {
    Ok(Vec::new())
}

// ---------- Tauri commands ----------

#[tauri::command]
pub fn tts_speak(
    state: tauri::State<'_, TtsState>,
    text: String,
    rate: f32,
    voice: Option<String>,
) -> Result<(), String> {
    let trimmed = text.trim();
    if trimmed.is_empty() {
        return Ok(());
    }
    // Cut any in-flight speech so a second tap starts cleanly rather
    // than queueing on top.
    kill_child(&state);

    #[cfg(target_os = "linux")]
    let spawned = spawn_linux(trimmed, rate, voice.as_deref())?;
    #[cfg(target_os = "macos")]
    let spawned = spawn_macos(trimmed, rate, voice.as_deref())?;
    #[cfg(target_os = "windows")]
    let spawned = spawn_windows(trimmed, rate, voice.as_deref())?;
    // No bundled TTS shim for non-desktop targets. Android has its own
    // TextToSpeech APIs that would have to be reached over JNI; out of
    // scope for v0.1-android. Mirrors the paste.rs not-implemented
    // fallback so the command compiles cleanly across all targets and
    // surfaces a clear error if the frontend tries to invoke it.
    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
    {
        let _ = (state, rate, voice);
        return Err("TTS not implemented on this platform".into());
    }

    #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
    {
        if let Some(c) = spawned {
            if let Ok(mut guard) = state.child.lock() {
                *guard = Some(c);
            }
        }
        Ok(())
    }
}

#[tauri::command]
pub fn tts_stop(state: tauri::State<'_, TtsState>) -> Result<(), String> {
    kill_child(&state);
    #[cfg(target_os = "linux")]
    stop_linux();
    Ok(())
}

fn kill_child(state: &TtsState) {
    if let Ok(mut guard) = state.child.lock() {
        if let Some(mut c) = guard.take() {
            let _ = c.kill();
            let _ = c.wait();
        }
    }
}

#[tauri::command]
pub fn tts_list_voices() -> Result<Vec<TtsVoice>, String> {
    list_voices_impl()
}

// ---------- Tests ----------

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

    #[test]
    fn clamp_rate_handles_nan() {
        assert_eq!(clamp_rate(f32::NAN), 1.0);
    }

    #[test]
    fn clamp_rate_bounds() {
        assert_eq!(clamp_rate(0.1), 0.5);
        assert_eq!(clamp_rate(0.5), 0.5);
        assert_eq!(clamp_rate(1.0), 1.0);
        assert_eq!(clamp_rate(2.0), 2.0);
        assert_eq!(clamp_rate(3.0), 2.0);
    }

    #[cfg(target_os = "linux")]
    #[test]
    fn spd_rate_hits_anchors() {
        assert_eq!(spd_rate(1.0), 0);
        assert_eq!(spd_rate(2.0), 100);
        assert_eq!(spd_rate(1.5), 50);
        // 0.5 is only -50 with the simple linear slope — the asymmetry
        // is documented in the `spd_rate` doc comment.
        assert_eq!(spd_rate(0.5), -50);
    }

    #[cfg(target_os = "linux")]
    #[test]
    fn espeak_rate_stays_in_range() {
        assert_eq!(espeak_rate(1.0), 175);
        assert_eq!(espeak_rate(2.0), 350);
        // 0.5 * 175 = 87.5 → rounds to 88 → still ≥ 80 floor.
        assert_eq!(espeak_rate(0.5), 88);
        // NaN → clamp_rate returns 1.0 → rate maps to 175.
        assert_eq!(espeak_rate(f32::NAN), 175);
    }

    #[cfg(target_os = "macos")]
    #[test]
    fn say_rate_maps() {
        assert_eq!(say_rate(1.0), 180);
        assert_eq!(say_rate(2.0), 360);
        assert_eq!(say_rate(0.5), 90);
    }

    #[cfg(target_os = "windows")]
    #[test]
    fn win_rate_bounds() {
        assert_eq!(win_rate(0.5), -5);
        assert_eq!(win_rate(1.0), 0);
        assert_eq!(win_rate(2.0), 10);
    }

    #[cfg(target_os = "windows")]
    #[test]
    fn ps_herestring_terminator_is_broken() {
        let input = "hello\n'@ evil";
        let out = escape_ps_herestring(input);
        assert!(!out.contains("'@"));
        assert!(out.contains("' @"));
    }

    #[cfg(target_os = "macos")]
    #[test]
    fn parses_macos_voices() {
        let raw = "Alex                en_US    # Most people recognize me\n\
                   Fred                en_US    # I sure like being inside\n";
        let voices = parse_macos_voices(raw);
        assert_eq!(voices.len(), 2);
        assert_eq!(voices[0].name, "Alex");
        assert_eq!(voices[0].language.as_deref(), Some("en_US"));
    }
}
