//! Typed error surface for the voice plugin.
//!
//! Two layers:
//!
//! - [`ReasonCode`] — fine-grained discriminant attached to every
//!   synthesis attempt (success or failure) so the audit firehose
//!   event + Prometheus counter can pivot on a stable string. The
//!   fallback chain executor uses these codes to decide whether a
//!   given attempt is retryable.
//! - [`VoiceError`] — the `Result<T>` error type for callers. Each
//!   variant carries a `ReasonCode` so wrappers don't have to
//!   re-classify.
//!
//! Adding new reason codes is intentionally cheap: the enum is
//! marked `#[non_exhaustive]` so downstream `match` arms must
//! supply a default. The Prometheus label string comes from
//! [`ReasonCode::as_metric_label`] — keep it lowercase + snake_case
//! so it round-trips through a counter label cleanly.

use thiserror::Error;

/// Discriminant tag attached to every synthesis attempt and surfaced
/// in audit events + metrics. Stable string identity — counter labels
/// rely on `as_metric_label()` round-tripping unchanged.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReasonCode {
    Success,
    NoProviderRegistered,
    ProviderNotConfigured,
    NetworkTimeout,
    NetworkError,
    WsClosed,
    EmptyAudio,
    RateLimit,
    Unauthorized,
    InvalidVoice,
    InvalidSsml,
    TextTooLong,
    TextEmpty,
    TranscodeFailed,
    CircuitOpen,
    Cancelled,
}

impl ReasonCode {
    /// Lowercase snake_case label used as the value of the
    /// `reason_code="…"` Prometheus label. Must round-trip
    /// unchanged through metrics scrapes — do not localise.
    pub fn as_metric_label(self) -> &'static str {
        match self {
            Self::Success => "success",
            Self::NoProviderRegistered => "no_provider_registered",
            Self::ProviderNotConfigured => "provider_not_configured",
            Self::NetworkTimeout => "network_timeout",
            Self::NetworkError => "network_error",
            Self::WsClosed => "ws_closed",
            Self::EmptyAudio => "empty_audio",
            Self::RateLimit => "rate_limit",
            Self::Unauthorized => "unauthorized",
            Self::InvalidVoice => "invalid_voice",
            Self::InvalidSsml => "invalid_ssml",
            Self::TextTooLong => "text_too_long",
            Self::TextEmpty => "text_empty",
            Self::TranscodeFailed => "transcode_failed",
            Self::CircuitOpen => "circuit_open",
            Self::Cancelled => "cancelled",
        }
    }

    /// True when the fallback executor should try the next provider
    /// in the chain instead of giving up. `EmptyAudio` + `WsClosed`
    /// + `NetworkTimeout` are the classic Edge endpoint flakes;
    /// `RateLimit` should retry too but with backoff (caller owns
    /// the backoff schedule). `InvalidVoice` / `Unauthorized` /
    /// `TextEmpty` are configuration / input problems where retrying
    /// would just repeat the same mistake.
    pub fn is_retryable(self) -> bool {
        matches!(
            self,
            Self::NetworkTimeout
                | Self::NetworkError
                | Self::WsClosed
                | Self::EmptyAudio
                | Self::RateLimit
                | Self::TranscodeFailed
        )
    }
}

/// Result alias for voice plugin operations.
pub type Result<T> = std::result::Result<T, VoiceError>;

/// Typed error returned by every voice plugin entry-point. Each
/// variant carries the matching [`ReasonCode`] so audit + metrics
/// path doesn't have to re-classify.
#[non_exhaustive]
#[derive(Debug, Error)]
pub enum VoiceError {
    /// SQLite operation failed (open, DDL, INSERT, SELECT).
    #[error("sqlite: {0}")]
    Sqlite(#[from] sqlx::Error),

    /// Provider-side synthesis call failed (transport, auth,
    /// rate limit, malformed response). Payload is the
    /// operator-facing reason.
    #[error("provider {provider}: {reason} ({code:?})")]
    Provider {
        provider: &'static str,
        reason: String,
        code: ReasonCode,
    },

    /// Audio transcode failed (ffmpeg non-zero exit, pure-Rust
    /// encode panic, etc).
    #[error("transcode: {0}")]
    Transcode(String),

    /// Caller supplied an empty body — pipeline can't synthesise
    /// nothing.
    #[error("empty text — nothing to synthesise")]
    EmptyText,

    /// Caller supplied an empty `voice_id`. The catalog resolver
    /// must always pick at least the install-wide default.
    #[error("empty voice_id")]
    EmptyVoiceId,

    /// Voice id not present in [`crate::catalog`].
    #[error("voice id `{0}` not found in catalog")]
    UnknownVoice(String),

    /// Provider id not registered (typo in YAML / disabled feature).
    #[error("provider `{0}` not registered")]
    UnknownProvider(String),

    /// Operator-supplied param outside accepted range (speed,
    /// pitch, …).
    #[error("invalid param: {0}")]
    InvalidParam(String),

    /// Operation deadlined out before any provider responded.
    #[error("deadline exceeded after {0}ms")]
    Deadline(u64),

    /// Circuit breaker for the requested provider is open. Caller
    /// usually falls back to the next provider in chain.
    #[error("circuit open for provider `{0}`")]
    CircuitOpen(String),

    /// Pipeline asked for a feature the build doesn't include
    /// (e.g. `provider-elevenlabs` not compiled in).
    #[error("feature not supported: {0}")]
    NotSupported(&'static str),

    /// Plumbing error from the broker / stdio bridge.
    #[error("broker: {0}")]
    Broker(String),

    /// Anything else bubbled up via anyhow.
    #[error("voice plugin error: {0}")]
    Other(#[from] anyhow::Error),
}

impl VoiceError {
    /// Pick the [`ReasonCode`] that best describes this error.
    /// Used by the synthesis pipeline to populate audit events
    /// + metric labels without re-classifying.
    pub fn reason_code(&self) -> ReasonCode {
        match self {
            Self::Provider { code, .. } => *code,
            Self::Sqlite(_) => ReasonCode::NetworkError,
            Self::Transcode(_) => ReasonCode::TranscodeFailed,
            Self::EmptyText => ReasonCode::TextEmpty,
            Self::EmptyVoiceId => ReasonCode::InvalidVoice,
            Self::UnknownVoice(_) => ReasonCode::InvalidVoice,
            Self::UnknownProvider(_) => ReasonCode::NoProviderRegistered,
            Self::InvalidParam(_) => ReasonCode::InvalidSsml,
            Self::Deadline(_) => ReasonCode::NetworkTimeout,
            Self::CircuitOpen(_) => ReasonCode::CircuitOpen,
            Self::NotSupported(_) => ReasonCode::ProviderNotConfigured,
            Self::Broker(_) => ReasonCode::NetworkError,
            Self::Other(_) => ReasonCode::NetworkError,
        }
    }
}

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

    #[test]
    fn every_reason_code_has_unique_metric_label() {
        let codes = [
            ReasonCode::Success,
            ReasonCode::NoProviderRegistered,
            ReasonCode::ProviderNotConfigured,
            ReasonCode::NetworkTimeout,
            ReasonCode::NetworkError,
            ReasonCode::WsClosed,
            ReasonCode::EmptyAudio,
            ReasonCode::RateLimit,
            ReasonCode::Unauthorized,
            ReasonCode::InvalidVoice,
            ReasonCode::InvalidSsml,
            ReasonCode::TextTooLong,
            ReasonCode::TextEmpty,
            ReasonCode::TranscodeFailed,
            ReasonCode::CircuitOpen,
            ReasonCode::Cancelled,
        ];
        let mut seen = std::collections::HashSet::new();
        for c in codes {
            assert!(
                seen.insert(c.as_metric_label()),
                "duplicate label for {c:?}"
            );
        }
        assert_eq!(seen.len(), 16);
    }

    #[test]
    fn retryable_codes_are_transient_only() {
        // Sanity check the heuristic: configuration / input errors
        // must NOT be retryable.
        assert!(!ReasonCode::Success.is_retryable());
        assert!(!ReasonCode::InvalidVoice.is_retryable());
        assert!(!ReasonCode::Unauthorized.is_retryable());
        assert!(!ReasonCode::TextEmpty.is_retryable());
        assert!(!ReasonCode::TextTooLong.is_retryable());
        assert!(!ReasonCode::CircuitOpen.is_retryable());
        assert!(!ReasonCode::Cancelled.is_retryable());

        // Transient flakes — retry is safe.
        assert!(ReasonCode::NetworkTimeout.is_retryable());
        assert!(ReasonCode::EmptyAudio.is_retryable());
        assert!(ReasonCode::WsClosed.is_retryable());
        assert!(ReasonCode::RateLimit.is_retryable());
    }

    #[test]
    fn voice_error_round_trips_reason_code() {
        let err = VoiceError::Provider {
            provider: "edge",
            reason: "ws closed unexpectedly".into(),
            code: ReasonCode::WsClosed,
        };
        assert_eq!(err.reason_code(), ReasonCode::WsClosed);
        assert_eq!(err.reason_code().as_metric_label(), "ws_closed");

        let err2 = VoiceError::EmptyText;
        assert_eq!(err2.reason_code(), ReasonCode::TextEmpty);

        let err3 = VoiceError::UnknownVoice("xx-YY-Bogus".into());
        assert_eq!(err3.reason_code(), ReasonCode::InvalidVoice);
    }

    #[test]
    fn display_includes_reason_payload() {
        let err = VoiceError::Provider {
            provider: "elevenlabs",
            reason: "429 rate limit".into(),
            code: ReasonCode::RateLimit,
        };
        let msg = format!("{err}");
        assert!(msg.contains("elevenlabs"));
        assert!(msg.contains("rate limit"));
    }
}
