//! `voice_reply_transform` — fires on every outbound reply.
//!
//! When the per-conversation toggle is on AND the reply is a
//! plain Text variant, runs the synth pipeline + returns
//! VoiceNote. Non-text replies pass through. Synth failures fall
//! back to clean text (markers stripped).

use std::str::FromStr;

use serde::Deserialize;
use serde_json::{json, Value};

use super::{invoke_pipeline, VoiceToolContext};
use crate::audit::text_sha256;
use crate::error::{Result, VoiceError};
use crate::ssml::strip_voice_markers;
use nexo_tool_meta::reply_kind::OutboundReplyKind;
use nexo_tool_meta::voice_addenda::{default_voice_for_locale, DEFAULT_VOICE_ID};
use nexo_tool_meta::Locale;

#[derive(Debug, Deserialize)]
struct Args {
    context: ReplyContext,
    reply: OutboundReplyKind,
}

#[derive(Debug, Deserialize)]
struct ReplyContext {
    conversation_key: String,
    #[serde(default)]
    language: Option<String>,
    #[serde(default)]
    #[allow(dead_code)]
    agent_id: Option<String>,
}

pub async fn handle(ctx: &VoiceToolContext, args: Value) -> Result<Value> {
    let parsed: Args = serde_json::from_value(args)
        .map_err(|e| VoiceError::InvalidParam(format!("voice_reply_transform: {e}")))?;

    // Non-text variants → passthrough (caller already produced
    // audio / image).
    let text = match &parsed.reply {
        OutboundReplyKind::Text { body } => body.clone(),
        _ => return Ok(json!({ "ok": true, "passthrough": true })),
    };

    // Toggle off → strip stray markers from text + passthrough.
    let row = match ctx.store.get_active(&parsed.context.conversation_key).await {
        Ok(Some(r)) => r,
        _ => {
            let cleaned = strip_voice_markers(&text);
            return Ok(json!({
                "ok": true,
                "reply": OutboundReplyKind::Text { body: cleaned },
            }));
        }
    };

    // SDK-parity: when stored voice equals the install-wide default
    // AND the reply's language differs from the default's family,
    // resolve to the language-matched voice. First-time activations
    // (operator clicked toggle without picking a voice) get a voice
    // that actually matches the agent's locale instead of inheriting
    // Spanish-Mexico for an English/Portuguese/French agent.
    // Operators who explicitly picked a voice keep their choice
    // (row.voice_id != DEFAULT_VOICE_ID).
    let resolved_voice = if row.voice_id == DEFAULT_VOICE_ID {
        let reply_locale = parsed
            .context
            .language
            .as_deref()
            .and_then(|s| Locale::from_str(s).ok());
        let language_default = default_voice_for_locale(reply_locale.as_ref());
        if language_default != row.voice_id {
            tracing::info!(
                conversation_key = %parsed.context.conversation_key,
                stored = %row.voice_id,
                resolved = %language_default,
                language = ?parsed.context.language,
                "voice/reply_transform: language override on default voice"
            );
        }
        language_default.to_string()
    } else {
        row.voice_id.clone()
    };

    let outcome = invoke_pipeline(
        ctx,
        &text,
        &resolved_voice,
        Some(&row.provider_id),
        row.speed,
        row.pitch_pct,
        row.style.as_deref(),
        None,
    )
    .await?;

    Ok(json!({
        "ok": true,
        "reply": outcome.reply,
        "audit": {
            "winner": outcome.winner,
            "chain_tried": outcome.chain_tried,
            "latency_ms": outcome.latency_ms,
            "reason_code": outcome.reason_code.as_metric_label(),
            "cache_hit": outcome.cache_hit,
            "text_sha256": text_sha256(&text),
        }
    }))
}
