//! Microsoft Edge Read-Aloud provider with a WS connection pool.
//!
//! Phase voice-plugin-extract follow-up #4. Each synth no longer
//! opens a fresh `msedge-tts` client; the provider pulls a warm
//! one from [`pool::EdgeWsPool`] (LIFO) and returns it on success.
//! Failed synths drop the client (don't return to pool) so a
//! broken connection can't poison the next caller.
//!
//! Behaviour preserved bit-exact from the pre-pool impl: empty
//! audio retry, plain-text fallback, rate composition.

use async_trait::async_trait;

use super::{AudioFormat, CostClass, SynthesizeOutput, SynthesizeRequest, TtsProvider};
use crate::error::{ReasonCode, Result, VoiceError};

pub mod pool;

pub use pool::{EdgeWsPool, EdgeWsPoolConfig, PooledClient};

/// Default Edge audio format. mp3 — the pipeline transcodes to
/// OGG/Opus via symphonia + opus-wave.
pub const EDGE_AUDIO_FORMAT: &str = "audio-24khz-48kbitrate-mono-mp3";

/// Provider state.
#[derive(Clone)]
pub struct EdgeTtsProvider {
    pub base_rate_pct: i32,
    pub audio_format: String,
    pub pool: EdgeWsPool,
}

impl std::fmt::Debug for EdgeTtsProvider {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("EdgeTtsProvider")
            .field("base_rate_pct", &self.base_rate_pct)
            .field("audio_format", &self.audio_format)
            .field("pool", &self.pool)
            .finish()
    }
}

impl Default for EdgeTtsProvider {
    fn default() -> Self {
        Self {
            base_rate_pct: -8,
            audio_format: EDGE_AUDIO_FORMAT.to_string(),
            pool: EdgeWsPool::new(EdgeWsPoolConfig::default()),
        }
    }
}

impl EdgeTtsProvider {
    /// Builder so operators can tune the pool from opaque config
    /// without touching the rest of the provider state.
    pub fn with_pool_config(cfg: EdgeWsPoolConfig) -> Self {
        Self {
            base_rate_pct: -8,
            audio_format: EDGE_AUDIO_FORMAT.to_string(),
            pool: EdgeWsPool::new(cfg),
        }
    }
}

#[async_trait]
impl TtsProvider for EdgeTtsProvider {
    fn id(&self) -> &'static str {
        "edge"
    }

    fn label(&self) -> &str {
        "Microsoft Edge Read-Aloud"
    }

    fn aliases(&self) -> &[&'static str] {
        &["microsoft"]
    }

    fn auto_select_order(&self) -> u32 {
        10
    }

    fn cost_class(&self) -> CostClass {
        CostClass::Free
    }

    fn is_configured(&self) -> bool {
        true
    }

    fn supported_locales(&self) -> &[&'static str] {
        &[
            "es-CO", "es-MX", "es-AR", "es-ES", "es-PE", "es-CL", "es-US", "es", "en-US", "en-GB",
            "en-AU", "en-CA", "en", "pt-BR", "pt-PT", "pt", "fr-FR", "fr-CA", "fr", "it-IT", "it",
            "de-DE", "de", "ja-JP", "ja", "zh-CN", "zh",
        ]
    }

    async fn synthesize(&self, req: SynthesizeRequest<'_>) -> Result<SynthesizeOutput> {
        let started = std::time::Instant::now();

        if req.voice_id.trim().is_empty() {
            return Err(VoiceError::EmptyVoiceId);
        }
        if req.body_ssml.trim().is_empty() {
            return Err(VoiceError::EmptyText);
        }

        let rate = self.base_rate_pct
            + req
                .speed
                .map(|s| ((s - 1.0) * 100.0).round() as i32)
                .unwrap_or(0);

        let pool = self.pool.clone();
        let audio_format = self.audio_format.clone();

        // First attempt: SSML body + pooled client.
        let outcome = call_edge_pooled(&pool, req.body_ssml, req.voice_id, &audio_format, rate)
            .await;
        let bytes = match outcome {
            Ok(b) if !b.is_empty() => b,
            Ok(_empty) => {
                let plain = strip_ssml_tags(req.body_ssml);
                tracing::warn!(
                    ssml_len = req.body_ssml.len(),
                    plain_len = plain.len(),
                    "voice/edge: 0 bytes; retrying plain text"
                );
                match call_edge_pooled(&pool, &plain, req.voice_id, &audio_format, rate).await {
                    Ok(b) if !b.is_empty() => b,
                    Ok(_) => {
                        return Err(VoiceError::Provider {
                            provider: "edge",
                            reason: "0 bytes after plain-text retry".into(),
                            code: ReasonCode::EmptyAudio,
                        });
                    }
                    Err(e) => return Err(e),
                }
            }
            Err(e) => return Err(e),
        };

        Ok(SynthesizeOutput {
            audio_bytes: bytes,
            raw_mime: match req.format {
                AudioFormat::Mp3_24k => "audio/mpeg",
                _ => "audio/mpeg",
            },
            latency_ms: started.elapsed().as_millis() as u64,
            reason_code: ReasonCode::Success,
        })
    }
}

/// One synth round-trip backed by the pool. Acquires a warm client
/// if available; falls back to a fresh `connect()` when the pool is
/// empty. On success, returns the client to the pool; on failure,
/// the client is dropped (the WS may be poisoned by the error).
async fn call_edge_pooled(
    pool: &EdgeWsPool,
    body: &str,
    voice: &str,
    audio_format: &str,
    rate: i32,
) -> Result<Vec<u8>> {
    let body_owned = body.to_string();
    let voice_owned = voice.to_string();
    let format_owned = audio_format.to_string();
    let pool_clone = pool.clone();

    tokio::task::spawn_blocking(move || -> Result<Vec<u8>> {
        let pooled = pool_clone.acquire().or_else(|_| {
            // Pool empty + reached cap → fall back to fresh connect.
            // If even fresh connect fails, surface the original error.
            pool_clone.fresh_client()
        })?;

        let mut client = pooled.into_inner();
        let cfg = msedge_tts::tts::SpeechConfig {
            voice_name: voice_owned,
            audio_format: format_owned,
            pitch: 0,
            rate,
            volume: 0,
        };
        match client.synthesize(&body_owned, &cfg) {
            Ok(synthesized) => {
                // Return to pool only on success.
                let _ = pool_clone.release(client);
                Ok(synthesized.audio_bytes)
            }
            Err(e) => {
                // Drop the client; do not return to pool.
                Err(VoiceError::Provider {
                    provider: "edge",
                    reason: format!("synthesize: {e}"),
                    code: ReasonCode::WsClosed,
                })
            }
        }
    })
    .await
    .map_err(|e| VoiceError::Provider {
        provider: "edge",
        reason: format!("synthesize join: {e}"),
        code: ReasonCode::NetworkError,
    })?
}

/// Drop every `<…>` tag from `input`, leaving inner text. Used as
/// a diagnostic fallback when Edge rejects an SSML body.
fn strip_ssml_tags(input: &str) -> String {
    let mut out = String::with_capacity(input.len());
    let mut in_tag = false;
    for ch in input.chars() {
        if ch == '<' {
            in_tag = true;
            continue;
        }
        if ch == '>' {
            in_tag = false;
            continue;
        }
        if !in_tag {
            out.push(ch);
        }
    }
    out
}

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

    #[test]
    fn strip_ssml_drops_break_and_prosody() {
        let input = r#"Hola <break time="200ms"/>cómo <prosody rate="-10%">estás</prosody>"#;
        let out = strip_ssml_tags(input);
        assert_eq!(out, "Hola cómo estás");
    }

    #[test]
    fn strip_ssml_idempotent_on_clean_text() {
        let s = "Hola mundo sin tags.";
        assert_eq!(strip_ssml_tags(s), s);
    }

    #[test]
    fn metadata_round_trip() {
        let p = EdgeTtsProvider::default();
        assert_eq!(p.id(), "edge");
        assert_eq!(p.cost_class(), CostClass::Free);
        assert!(p.is_configured());
        assert!(p.aliases().contains(&"microsoft"));
        assert!(p.supported_locales().contains(&"es-CO"));
    }

    #[tokio::test]
    async fn synthesize_rejects_empty_voice_id() {
        let p = EdgeTtsProvider::default();
        let err = p
            .synthesize(SynthesizeRequest {
                body_ssml: "hello",
                voice_id: "   ",
                speed: None,
                pitch_pct: None,
                style: None,
                model_id: None,
                format: AudioFormat::Mp3_24k,
            })
            .await
            .unwrap_err();
        assert!(matches!(err, VoiceError::EmptyVoiceId));
    }

    #[tokio::test]
    async fn synthesize_rejects_empty_body() {
        let p = EdgeTtsProvider::default();
        let err = p
            .synthesize(SynthesizeRequest {
                body_ssml: "  \n  ",
                voice_id: "es-MX-DaliaNeural",
                speed: None,
                pitch_pct: None,
                style: None,
                model_id: None,
                format: AudioFormat::Mp3_24k,
            })
            .await
            .unwrap_err();
        assert!(matches!(err, VoiceError::EmptyText));
    }
}
