//! LLM client errors
//!
//! Categorized by whether they're retryable.

use meerkat_core::error::{LlmFailureReason, LlmProviderError, LlmProviderErrorKind};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::time::Duration;

/// Errors from LLM providers
///
/// Categorized by whether they're retryable.
#[derive(Debug, Clone, thiserror::Error, Serialize, Deserialize)]
pub enum LlmError {
    // === Retryable Errors ===
    #[error("Rate limited{}", match .retry_after_ms {
        Some(ms) => format!(", retry after {ms}ms"),
        None => String::new(),
    })]
    RateLimited { retry_after_ms: Option<u64> },

    #[error("Server overloaded (503)")]
    ServerOverloaded,

    #[error("Network timeout after {duration_ms}ms")]
    NetworkTimeout { duration_ms: u64 },

    #[error("Connection reset")]
    ConnectionReset,

    #[error("Server error: {status} - {message}")]
    ServerError { status: u16, message: String },

    // === Non-Retryable Errors ===
    #[error("Invalid request: {message}")]
    InvalidRequest { message: String },

    /// Caller sent a request shape this provider does not support
    /// (e.g. image / video-frame input on a realtime audio channel,
    /// malformed audio chunk variant). Scoped, non-terminal: the
    /// channel survives so the client can retry with a supported
    /// shape. Distinct from `InvalidRequest` so consumers can branch
    /// on it without matching error message strings.
    #[error("Invalid input shape: {message}")]
    InvalidInputShape { message: String },

    /// Provider-config rejection (model swap, audio rate mismatch,
    /// refresh-time invariant, voice/format incompatibility). Terminal
    /// on this channel: the caller must close + reopen rather than
    /// retry the same request. Distinct from `InvalidRequest` so the
    /// classification is structural, not a string match.
    #[error("Invalid provider config: {message}")]
    InvalidConfig { message: String },

    #[error("Authentication failed: {message}")]
    AuthenticationFailed { message: String },

    #[error("Content filtered: {reason}")]
    ContentFiltered { reason: String },

    #[error("Context length exceeded: {requested} > {max}")]
    ContextLengthExceeded { max: usize, requested: usize },

    #[error("Model not found: {model}")]
    ModelNotFound { model: String },

    #[error("Invalid API key")]
    InvalidApiKey,

    // === Unknown ===
    #[error("Unknown error: {message}")]
    Unknown { message: String },

    // === Streaming Errors ===
    #[error("Stream parsing error: {message}")]
    StreamParseError { message: String },

    #[error("Incomplete response: {message}")]
    IncompleteResponse { message: String },
}

impl LlmError {
    /// Whether this error should trigger a retry
    pub fn is_retryable(&self) -> bool {
        match self {
            Self::RateLimited { .. }
            | Self::ServerOverloaded
            | Self::NetworkTimeout { .. }
            | Self::ConnectionReset => true,
            Self::ServerError { status, .. } => *status >= 500,
            _ => false,
        }
    }

    /// Get retry-after hint if available
    pub fn retry_after(&self) -> Option<Duration> {
        match self {
            Self::RateLimited { retry_after_ms } => retry_after_ms.map(Duration::from_millis),
            _ => None,
        }
    }

    /// Create from HTTP status code and message
    pub fn from_http_status(status: u16, message: String, retry_after_ms: Option<u64>) -> Self {
        match status {
            401 => Self::AuthenticationFailed { message },
            403 => Self::InvalidApiKey,
            404 => Self::ModelNotFound { model: message },
            429 => Self::RateLimited { retry_after_ms },
            503 => Self::ServerOverloaded,
            s if s >= 500 => Self::ServerError { status: s, message },
            s if s >= 400 => Self::InvalidRequest { message },
            _ => Self::Unknown { message },
        }
    }

    pub fn from_http_response(
        status: u16,
        message: String,
        headers: &reqwest::header::HeaderMap,
    ) -> Self {
        let retry_after_ms = headers
            .get(reqwest::header::RETRY_AFTER)
            .and_then(|v| v.to_str().ok())
            .and_then(Self::parse_retry_after);
        Self::from_http_status(status, message, retry_after_ms)
    }

    pub fn parse_retry_after(value: &str) -> Option<u64> {
        if let Ok(secs) = value.trim().parse::<u64>() {
            return Some(secs * 1000);
        }
        if let Ok(secs) = value.trim().parse::<f64>()
            && secs > 0.0
        {
            return Some((secs * 1000.0) as u64);
        }
        None
    }

    pub fn failure_reason(&self) -> LlmFailureReason {
        fn as_u32(value: usize) -> u32 {
            u32::try_from(value).unwrap_or(u32::MAX)
        }

        match self {
            Self::RateLimited { retry_after_ms } => LlmFailureReason::RateLimited {
                retry_after: retry_after_ms.map(Duration::from_millis),
            },
            Self::ContextLengthExceeded { max, requested } => LlmFailureReason::ContextExceeded {
                max: as_u32(*max),
                requested: as_u32(*requested),
            },
            Self::AuthenticationFailed { .. } | Self::InvalidApiKey => LlmFailureReason::AuthError,
            Self::ModelNotFound { model } => LlmFailureReason::InvalidModel(model.clone()),
            Self::InvalidRequest { message }
            | Self::InvalidInputShape { message }
            | Self::InvalidConfig { message } => {
                LlmFailureReason::ProviderError(LlmProviderError::non_retryable(
                    LlmProviderErrorKind::InvalidRequest,
                    json!({
                        "message": message,
                    }),
                ))
            }
            Self::ContentFiltered { reason } => {
                LlmFailureReason::ProviderError(LlmProviderError::non_retryable(
                    LlmProviderErrorKind::ContentFiltered,
                    json!({
                        "message": reason,
                    }),
                ))
            }
            Self::ServerError { status, message } => {
                let details = json!({
                    "status": status,
                    "message": message,
                });
                if self.is_retryable() {
                    LlmFailureReason::ProviderError(LlmProviderError::retryable(
                        LlmProviderErrorKind::ServerError,
                        details,
                    ))
                } else {
                    LlmFailureReason::ProviderError(LlmProviderError::non_retryable(
                        LlmProviderErrorKind::ServerError,
                        details,
                    ))
                }
            }
            Self::ServerOverloaded => LlmFailureReason::ProviderError(LlmProviderError::retryable(
                LlmProviderErrorKind::ServerOverloaded,
                json!({
                    "message": self.to_string(),
                }),
            )),
            Self::NetworkTimeout { duration_ms } => LlmFailureReason::NetworkTimeout {
                duration_ms: *duration_ms,
            },
            Self::ConnectionReset => LlmFailureReason::ProviderError(LlmProviderError::retryable(
                LlmProviderErrorKind::ConnectionReset,
                json!({
                    "message": self.to_string(),
                }),
            )),
            Self::Unknown { message } => {
                LlmFailureReason::ProviderError(LlmProviderError::non_retryable(
                    LlmProviderErrorKind::Unknown,
                    json!({
                        "message": message,
                    }),
                ))
            }
            Self::StreamParseError { message } => {
                LlmFailureReason::ProviderError(LlmProviderError::non_retryable(
                    LlmProviderErrorKind::StreamParseError,
                    json!({
                        "message": message,
                    }),
                ))
            }
            Self::IncompleteResponse { message } => {
                LlmFailureReason::ProviderError(LlmProviderError::non_retryable(
                    LlmProviderErrorKind::IncompleteResponse,
                    json!({
                        "message": message,
                    }),
                ))
            }
        }
    }
}

#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
mod tests {
    use super::*;

    #[test]
    fn test_retryable_errors() {
        assert!(
            LlmError::RateLimited {
                retry_after_ms: Some(1000)
            }
            .is_retryable()
        );
        assert!(LlmError::ServerOverloaded.is_retryable());
        assert!(LlmError::NetworkTimeout { duration_ms: 30000 }.is_retryable());
        assert!(LlmError::ConnectionReset.is_retryable());
        assert!(
            LlmError::ServerError {
                status: 500,
                message: "Internal error".to_string()
            }
            .is_retryable()
        );
        assert!(
            LlmError::ServerError {
                status: 502,
                message: "Bad gateway".to_string()
            }
            .is_retryable()
        );
    }

    #[test]
    fn test_non_retryable_errors() {
        assert!(
            !LlmError::InvalidRequest {
                message: "Bad request".to_string()
            }
            .is_retryable()
        );
        assert!(
            !LlmError::AuthenticationFailed {
                message: "Invalid key".to_string()
            }
            .is_retryable()
        );
        assert!(!LlmError::InvalidApiKey.is_retryable());
        assert!(
            !LlmError::ContentFiltered {
                reason: "Policy".to_string()
            }
            .is_retryable()
        );
        assert!(
            !LlmError::ModelNotFound {
                model: "gpt-5".to_string()
            }
            .is_retryable()
        );
    }

    #[test]
    fn test_retry_after() {
        let err = LlmError::RateLimited {
            retry_after_ms: Some(5000),
        };
        assert_eq!(err.retry_after(), Some(Duration::from_millis(5000)));

        let err = LlmError::RateLimited {
            retry_after_ms: None,
        };
        assert_eq!(err.retry_after(), None);

        let err = LlmError::ServerOverloaded;
        assert_eq!(err.retry_after(), None);
    }

    #[test]
    fn test_from_http_status() {
        assert!(matches!(
            LlmError::from_http_status(401, "".to_string(), None),
            LlmError::AuthenticationFailed { .. }
        ));
        assert!(matches!(
            LlmError::from_http_status(429, "".to_string(), None),
            LlmError::RateLimited { .. }
        ));
        assert!(matches!(
            LlmError::from_http_status(503, "".to_string(), None),
            LlmError::ServerOverloaded
        ));
        assert!(matches!(
            LlmError::from_http_status(500, "".to_string(), None),
            LlmError::ServerError { status: 500, .. }
        ));
    }

    #[test]
    fn test_error_serialization() -> Result<(), Box<dyn std::error::Error>> {
        let errors = vec![
            LlmError::RateLimited {
                retry_after_ms: Some(1000),
            },
            LlmError::ServerOverloaded,
            LlmError::InvalidRequest {
                message: "test".to_string(),
            },
        ];

        for err in errors {
            let json = serde_json::to_string(&err)?;
            let _: LlmError = serde_json::from_str(&json)?;
        }
        Ok(())
    }

    #[test]
    fn test_network_timeout_maps_to_typed_reason() {
        let err = LlmError::NetworkTimeout { duration_ms: 30000 };
        let reason = err.failure_reason();
        assert_eq!(
            reason,
            LlmFailureReason::NetworkTimeout { duration_ms: 30000 }
        );
    }

    #[test]
    fn provider_failure_reason_uses_typed_kind_and_retryability() {
        let reason = LlmError::ServerOverloaded.failure_reason();
        let LlmFailureReason::ProviderError(provider_error) = reason else {
            panic!("expected provider error");
        };

        assert_eq!(
            provider_error.kind,
            meerkat_core::error::LlmProviderErrorKind::ServerOverloaded
        );
        assert_eq!(
            provider_error.retryability,
            meerkat_core::error::LlmProviderErrorRetryability::Retryable
        );
        assert_eq!(
            provider_error.details["message"],
            serde_json::json!("Server overloaded (503)")
        );
        assert!(
            provider_error.details.get("kind").is_none(),
            "provider error kind must not be carried in untyped JSON"
        );
        assert!(
            provider_error.details.get("retryable").is_none(),
            "provider error retryability must not be carried in untyped JSON"
        );
    }

    // -- Retry-After parsing tests (PR #156 port) --

    #[test]
    fn test_parse_retry_after_integer_seconds() {
        assert_eq!(LlmError::parse_retry_after("120"), Some(120_000));
        assert_eq!(LlmError::parse_retry_after("1"), Some(1_000));
    }

    #[test]
    fn test_parse_retry_after_fractional_seconds() {
        assert_eq!(LlmError::parse_retry_after("0.5"), Some(500));
        assert_eq!(LlmError::parse_retry_after("1.5"), Some(1_500));
    }

    #[test]
    fn test_parse_retry_after_with_whitespace() {
        assert_eq!(LlmError::parse_retry_after("  30  "), Some(30_000));
    }

    #[test]
    fn test_parse_retry_after_invalid() {
        assert_eq!(LlmError::parse_retry_after("not-a-number"), None);
    }

    #[test]
    fn test_parse_retry_after_negative() {
        assert_eq!(LlmError::parse_retry_after("-5"), None);
    }

    #[test]
    fn test_from_http_status_429_with_retry_after() {
        let err = LlmError::from_http_status(429, "rate limited".to_string(), Some(5000));
        assert!(matches!(
            err,
            LlmError::RateLimited {
                retry_after_ms: Some(5000)
            }
        ));
    }

    #[test]
    fn test_from_http_status_429_without_retry_after() {
        let err = LlmError::from_http_status(429, "rate limited".to_string(), None);
        assert!(matches!(
            err,
            LlmError::RateLimited {
                retry_after_ms: None
            }
        ));
    }

    #[test]
    fn test_from_http_status_non_429_ignores_retry_after() {
        let err = LlmError::from_http_status(500, "server error".to_string(), Some(5000));
        assert!(matches!(err, LlmError::ServerError { status: 500, .. }));
    }

    #[test]
    fn test_from_http_response_extracts_retry_after_header() {
        let mut headers = reqwest::header::HeaderMap::new();
        headers.insert(reqwest::header::RETRY_AFTER, "30".parse().unwrap());
        let err = LlmError::from_http_response(429, "rate limited".to_string(), &headers);
        assert!(matches!(
            err,
            LlmError::RateLimited {
                retry_after_ms: Some(30_000)
            }
        ));
    }

    #[test]
    fn test_from_http_response_no_header_returns_none() {
        let headers = reqwest::header::HeaderMap::new();
        let err = LlmError::from_http_response(429, "rate limited".to_string(), &headers);
        assert!(matches!(
            err,
            LlmError::RateLimited {
                retry_after_ms: None
            }
        ));
    }
}
