//! End-to-end synth pipeline: text → SSML → fallback chain →
//! transcode → VoiceNote.
//!
//! Composes everything Fases A-G produced:
//!   1. `normalise_markdown_for_tts` → sentence boundaries.
//!   2. `apply_ssml_hints` → operator markers + auto-detect.
//!   3. `strip_emojis_for_tts` + `collapse_punctuation` → safe body.
//!   4. Cache lookup `(text_sha + voice + provider + params)`.
//!   5. `fallback::execute` → first-success provider audio.
//!   6. Transcode → canonical OGG/Opus.
//!   7. `strip_voice_markers` → clean transcript for audit.
//!   8. Cache put + audit event emission (caller's concern, we
//!      return the metadata).

use std::sync::Arc;

use nexo_tool_meta::reply_kind::OutboundReplyKind;

use crate::breaker::BreakerRegistry;
use crate::cache::{AudioCache, CacheKey};
use crate::error::{ReasonCode, Result, VoiceError};
use crate::fallback::{execute, FallbackConfig, FallbackOutcome};
use crate::providers::{AudioFormat, SynthesizeRequest, TtsProvider};
use crate::ssml::normalize::{
    collapse_punctuation, normalise_markdown_for_tts, strip_emojis_for_tts,
};
use crate::ssml::{apply_ssml_hints, strip_voice_markers};
use crate::transcode::{transcode_mp3_to_opus_ogg, OUTPUT_MIME};

/// End-to-end synth output the caller hands to a channel sender.
/// `audio_bytes` is the canonical OGG/Opus; `transcript` is the
/// marker-stripped clean prose for audit / dashboard display.
#[derive(Debug, Clone)]
pub struct SynthesisOutcome {
    pub reply: OutboundReplyKind,
    pub winner: &'static str,
    pub chain_tried: Vec<&'static str>,
    pub latency_ms: u64,
    pub reason_code: ReasonCode,
    pub cache_hit: bool,
}

/// Per-call inputs for the pipeline. Borrows everywhere because
/// the hot path retries against the same inputs multiple times.
pub struct PipelineRequest<'a> {
    pub text: &'a str,
    pub voice_id: &'a str,
    pub provider_hint: Option<&'a str>,
    pub speed: Option<f32>,
    pub pitch_pct: Option<i32>,
    pub style: Option<&'a str>,
    pub model_id: Option<&'a str>,
    pub max_text_length: usize,
}

/// Run the pipeline. Always returns `Ok(SynthesisOutcome)` — synth
/// failures fall back to text-only with markers stripped instead
/// of bubbling errors up to the caller (channel senders shouldn't
/// have to handle "voice failed" as a separate code path; getting
/// text back is the natural degradation).
pub async fn synthesize_voice_note(
    providers: &[Arc<dyn TtsProvider>],
    fallback_cfg: &FallbackConfig,
    breakers: &BreakerRegistry,
    cache: &AudioCache,
    req: PipelineRequest<'_>,
) -> Result<SynthesisOutcome> {
    let started = std::time::Instant::now();

    // ── 1. Guard rails. ──
    if req.voice_id.trim().is_empty() {
        return Err(VoiceError::EmptyVoiceId);
    }
    if req.text.trim().is_empty() {
        return Err(VoiceError::EmptyText);
    }

    // ── 2. Truncate (no LLM summarize in v1). ──
    let body_in = if req.text.chars().count() > req.max_text_length {
        let truncated: String = req.text.chars().take(req.max_text_length).collect();
        tracing::warn!(
            original_len = req.text.chars().count(),
            limit = req.max_text_length,
            "voice/pipeline: text exceeds max_text_length, truncating"
        );
        truncated + "…"
    } else {
        req.text.to_string()
    };

    // ── 3. Normalise → SSML → strip → collapse. ──
    let after_md = normalise_markdown_for_tts(&body_in);
    let after_ssml = apply_ssml_hints(&after_md);
    let after_emoji = strip_emojis_for_tts(&after_ssml);
    let body_ssml = collapse_punctuation(&after_emoji);
    if body_ssml.trim().is_empty() {
        return Err(VoiceError::EmptyText);
    }

    // ── 4. Cache lookup. ──
    let params_hash = params_fingerprint(req.speed, req.pitch_pct, req.style, req.model_id);
    let provider_for_key = req
        .provider_hint
        .unwrap_or_else(|| fallback_cfg.chain.first().map(String::as_str).unwrap_or("edge"));
    let cache_key = CacheKey::build(&body_ssml, req.voice_id, provider_for_key, &params_hash);
    if let Some(cached) = cache.get(&cache_key) {
        let transcript = strip_voice_markers(&body_in);
        return Ok(SynthesisOutcome {
            reply: OutboundReplyKind::VoiceNote {
                audio_bytes: cached,
                mimetype: OUTPUT_MIME.to_string(),
                transcript: Some(transcript),
            },
            winner: "(cache)",
            chain_tried: vec!["(cache)"],
            latency_ms: started.elapsed().as_millis() as u64,
            reason_code: ReasonCode::Success,
            cache_hit: true,
        });
    }

    // ── 5. Synth via fallback chain. ──
    let synth_req = SynthesizeRequest {
        body_ssml: &body_ssml,
        voice_id: req.voice_id,
        speed: req.speed,
        pitch_pct: req.pitch_pct,
        style: req.style,
        model_id: req.model_id,
        format: AudioFormat::Mp3_24k,
    };
    let FallbackOutcome {
        output,
        winner,
        chain_tried,
    } = match execute(providers, fallback_cfg, breakers, &synth_req).await {
        Ok(o) => o,
        Err(e) => {
            // Text-only fallback — strip markers, return Text reply
            // so the channel sender still delivers the message.
            let transcript = strip_voice_markers(&body_in);
            tracing::warn!(error = %e, "voice/pipeline: synth failed; returning text fallback");
            return Ok(SynthesisOutcome {
                reply: OutboundReplyKind::Text { body: transcript },
                winner: "(text_fallback)",
                chain_tried: vec!["(text_fallback)"],
                latency_ms: started.elapsed().as_millis() as u64,
                reason_code: e.reason_code(),
                cache_hit: false,
            });
        }
    };

    // ── 6. Transcode mp3 → OGG/Opus. ──
    let ogg = match transcode_mp3_to_opus_ogg(&output.audio_bytes).await {
        Ok(b) => b,
        Err(e) => {
            let transcript = strip_voice_markers(&body_in);
            tracing::warn!(error = %e, "voice/pipeline: transcode failed; returning text fallback");
            return Ok(SynthesisOutcome {
                reply: OutboundReplyKind::Text { body: transcript },
                winner: "(text_fallback)",
                chain_tried,
                latency_ms: started.elapsed().as_millis() as u64,
                reason_code: ReasonCode::TranscodeFailed,
                cache_hit: false,
            });
        }
    };

    // ── 7. Cache put + return VoiceNote. ──
    cache.put(cache_key, ogg.clone());
    let transcript = strip_voice_markers(&body_in);
    Ok(SynthesisOutcome {
        reply: OutboundReplyKind::VoiceNote {
            audio_bytes: ogg,
            mimetype: OUTPUT_MIME.to_string(),
            transcript: Some(transcript),
        },
        winner,
        chain_tried,
        latency_ms: started.elapsed().as_millis() as u64,
        reason_code: ReasonCode::Success,
        cache_hit: false,
    })
}

/// Stable fingerprint over per-call params that should
/// discriminate cache entries. Speed `1.0` ≡ `None` (same
/// behaviour); collapsed accordingly.
fn params_fingerprint(
    speed: Option<f32>,
    pitch_pct: Option<i32>,
    style: Option<&str>,
    model_id: Option<&str>,
) -> String {
    let s = speed
        .filter(|v| (v - 1.0).abs() > f32::EPSILON)
        .map(|v| format!("s={v}"))
        .unwrap_or_default();
    let p = pitch_pct
        .filter(|v| *v != 0)
        .map(|v| format!("p={v}"))
        .unwrap_or_default();
    let st = style.map(|v| format!("style={v}")).unwrap_or_default();
    let m = model_id.map(|v| format!("m={v}")).unwrap_or_default();
    format!("{s}|{p}|{st}|{m}")
}

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

    #[test]
    fn fingerprint_collapses_default_speed_and_pitch() {
        assert_eq!(
            params_fingerprint(Some(1.0), Some(0), None, None),
            "|||"
        );
    }

    #[test]
    fn fingerprint_distinguishes_speed_changes() {
        assert_ne!(
            params_fingerprint(Some(0.9), None, None, None),
            params_fingerprint(Some(1.1), None, None, None)
        );
    }

    #[test]
    fn fingerprint_includes_model_id() {
        let a = params_fingerprint(None, None, None, Some("tts-1"));
        let b = params_fingerprint(None, None, None, Some("tts-1-hd"));
        assert_ne!(a, b);
    }
}
