//! Core error types for Meerkat

use crate::hooks::{HookId, HookPoint, HookReasonCode};
use crate::tool_catalog::ToolUnavailableReason;
use crate::types::SessionId;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum LlmFailureReason {
    RateLimited {
        retry_after: Option<std::time::Duration>,
    },
    ContextExceeded {
        max: u32,
        requested: u32,
    },
    AuthError,
    InvalidModel(String),
    ProviderError(LlmProviderError),
    /// Provider/client-native network timeout (owned by client layer)
    NetworkTimeout {
        duration_ms: u64,
    },
    /// Agent-loop hard call timeout (owned by agent loop policy)
    CallTimeout {
        duration_ms: u64,
    },
}

#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LlmProviderErrorKind {
    InvalidRequest,
    ContentFiltered,
    ServerError,
    ServerOverloaded,
    ConnectionReset,
    Unknown,
    StreamParseError,
    IncompleteResponse,
}

#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LlmProviderErrorRetryability {
    Retryable,
    NonRetryable,
}

impl LlmProviderErrorRetryability {
    pub fn is_retryable(self) -> bool {
        matches!(self, Self::Retryable)
    }
}

#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LlmProviderError {
    pub kind: LlmProviderErrorKind,
    pub retryability: LlmProviderErrorRetryability,
    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
    pub details: serde_json::Value,
}

impl LlmProviderError {
    pub fn new(
        kind: LlmProviderErrorKind,
        retryability: LlmProviderErrorRetryability,
        details: serde_json::Value,
    ) -> Self {
        Self {
            kind,
            retryability,
            details,
        }
    }

    pub fn retryable(kind: LlmProviderErrorKind, details: serde_json::Value) -> Self {
        Self::new(kind, LlmProviderErrorRetryability::Retryable, details)
    }

    pub fn non_retryable(kind: LlmProviderErrorKind, details: serde_json::Value) -> Self {
        Self::new(kind, LlmProviderErrorRetryability::NonRetryable, details)
    }

    pub fn is_retryable(&self) -> bool {
        self.retryability.is_retryable()
    }
}

/// Errors that can occur during tool validation
#[derive(Debug, Clone, thiserror::Error, PartialEq)]
pub enum ToolValidationError {
    /// The requested tool was not found
    #[error("Tool not found: {name}")]
    NotFound { name: String },
    /// The tool arguments failed validation
    #[error("Invalid arguments for tool '{name}': {reason}")]
    InvalidArguments { name: String, reason: String },
}

impl ToolValidationError {
    pub fn not_found(name: impl Into<String>) -> Self {
        Self::NotFound { name: name.into() }
    }
    pub fn invalid_arguments(name: impl Into<String>, reason: impl Into<String>) -> Self {
        Self::InvalidArguments {
            name: name.into(),
            reason: reason.into(),
        }
    }
}

/// Error returned by tool dispatch operations.
#[derive(Debug, Clone, thiserror::Error)]
pub enum ToolError {
    /// The requested tool was not found
    #[error("Tool not found: {name}")]
    NotFound { name: String },

    /// The tool exists but is currently unavailable
    #[error("Tool '{name}' is currently unavailable: {reason}")]
    Unavailable {
        name: String,
        reason: ToolUnavailableReason,
    },

    /// The tool arguments failed validation
    #[error("Invalid arguments for tool '{name}': {reason}")]
    InvalidArguments { name: String, reason: String },

    /// The tool execution failed
    #[error("Tool execution failed: {message}")]
    ExecutionFailed { message: String },

    /// The tool execution failed with structured error data for protocol surfaces.
    #[error("Tool execution failed: {message}")]
    ExecutionFailedWithData {
        message: String,
        data: serde_json::Value,
    },

    /// The tool execution timed out
    #[error("Tool '{name}' timed out after {timeout_ms}ms")]
    Timeout { name: String, timeout_ms: u64 },

    /// Tool access was denied by policy
    #[error("Tool '{name}' is not allowed by policy")]
    AccessDenied { name: String },

    /// A generic tool error with a message
    #[error("{0}")]
    Other(String),

    /// Tool call must be routed externally (callback pending)
    ///
    /// This variant signals that a tool call cannot be handled internally
    /// and must be routed to an external handler. The payload contains
    /// serialized information about the pending tool call.
    #[error("Callback pending for tool '{tool_name}'")]
    CallbackPending {
        tool_name: String,
        args: serde_json::Value,
    },
}

impl ToolError {
    pub fn error_code(&self) -> &'static str {
        match self {
            Self::NotFound { .. } => "tool_not_found",
            Self::Unavailable { .. } => "tool_unavailable",
            Self::InvalidArguments { .. } => "invalid_arguments",
            Self::ExecutionFailed { .. } | Self::ExecutionFailedWithData { .. } => {
                "execution_failed"
            }
            Self::Timeout { .. } => "timeout",
            Self::AccessDenied { .. } => "access_denied",
            Self::Other(_) => "tool_error",
            Self::CallbackPending { .. } => "callback_pending",
        }
    }

    pub fn to_error_payload(&self) -> serde_json::Value {
        let mut payload = serde_json::json!({
            "error": self.error_code(),
            "message": self.to_string(),
        });
        if let Some(data) = self.structured_data() {
            payload["data"] = data;
        }
        payload
    }

    pub fn not_found(name: impl Into<String>) -> Self {
        Self::NotFound { name: name.into() }
    }
    pub fn unavailable(name: impl Into<String>, reason: ToolUnavailableReason) -> Self {
        Self::Unavailable {
            name: name.into(),
            reason,
        }
    }
    pub fn invalid_arguments(name: impl Into<String>, reason: impl Into<String>) -> Self {
        Self::InvalidArguments {
            name: name.into(),
            reason: reason.into(),
        }
    }
    pub fn execution_failed(message: impl Into<String>) -> Self {
        Self::ExecutionFailed {
            message: message.into(),
        }
    }
    pub fn execution_failed_with_data(message: impl Into<String>, data: serde_json::Value) -> Self {
        Self::ExecutionFailedWithData {
            message: message.into(),
            data,
        }
    }
    pub fn structured_data(&self) -> Option<serde_json::Value> {
        match self {
            Self::ExecutionFailedWithData { data, .. } => Some(data.clone()),
            _ => None,
        }
    }
    pub fn timeout(name: impl Into<String>, timeout_ms: u64) -> Self {
        Self::Timeout {
            name: name.into(),
            timeout_ms,
        }
    }
    pub fn access_denied(name: impl Into<String>) -> Self {
        Self::AccessDenied { name: name.into() }
    }
    pub fn other(message: impl Into<String>) -> Self {
        Self::Other(message.into())
    }

    /// Create a callback pending error for external tool routing
    pub fn callback_pending(tool_name: impl Into<String>, args: serde_json::Value) -> Self {
        Self::CallbackPending {
            tool_name: tool_name.into(),
            args,
        }
    }

    /// Check if this is a callback pending error
    pub fn is_callback_pending(&self) -> bool {
        matches!(self, Self::CallbackPending { .. })
    }

    /// Extract callback pending info if this is a CallbackPending error
    pub fn as_callback_pending(&self) -> Option<(&str, &serde_json::Value)> {
        match self {
            Self::CallbackPending { tool_name, args } => Some((tool_name, args)),
            _ => None,
        }
    }
}

impl From<String> for ToolError {
    fn from(s: String) -> Self {
        Self::Other(s)
    }
}
impl From<&str> for ToolError {
    fn from(s: &str) -> Self {
        Self::Other(s.to_string())
    }
}

/// Errors that can occur during agent execution
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum AgentError {
    #[error("LLM error ({provider}): {message}")]
    Llm {
        provider: &'static str,
        reason: LlmFailureReason,
        message: String,
    },
    #[error("Storage error: {0}")]
    StoreError(String),
    #[error("Tool error: {0}")]
    ToolError(String),
    #[error("MCP error: {0}")]
    McpError(String),
    #[error("Session not found: {0}")]
    SessionNotFound(SessionId),
    #[error("Token budget exceeded: used {used}, limit {limit}")]
    TokenBudgetExceeded { used: u64, limit: u64 },
    #[error("Time budget exceeded: {elapsed_secs}s > {limit_secs}s")]
    TimeBudgetExceeded { elapsed_secs: u64, limit_secs: u64 },
    #[error("Tool call budget exceeded: {count} calls > {limit} limit")]
    ToolCallBudgetExceeded { count: usize, limit: usize },
    #[error("Max tokens reached on turn {turn}, partial output: {partial}")]
    MaxTokensReached { turn: u32, partial: String },
    #[error("Content filtered on turn {turn}")]
    ContentFiltered { turn: u32 },
    #[error("Max turns reached: {turns}")]
    MaxTurnsReached { turns: u32 },
    #[error("Run was cancelled")]
    Cancelled,
    #[error("Invalid state transition: {from} -> {to}")]
    InvalidStateTransition { from: String, to: String },
    #[error("Operation not found: {0}")]
    OperationNotFound(String),
    #[error("Depth limit exceeded: {depth} > {max}")]
    DepthLimitExceeded { depth: u32, max: u32 },
    #[error("Concurrency limit exceeded")]
    ConcurrencyLimitExceeded,
    #[error("Configuration error: {0}")]
    ConfigError(String),
    #[error("Invalid tool in access policy: {tool}")]
    InvalidToolAccess { tool: String },
    #[error("Internal error: {0}")]
    InternalError(String),

    /// Agent construction failed (e.g. missing API key, unknown provider).
    #[error("Build error: {0}")]
    BuildError(String),

    /// MeerkatMachine DSL observed an auth lease in `reauth_required`
    /// state at a CallingLlm boundary; the lease cannot proceed
    /// until the user re-authenticates (`rkat auth login`). This is a
    /// machine-owned terminal class (Phase 1.5-rev), distinct from
    /// [`AgentError::InternalError`] which is for genuinely
    /// unexpected failures.
    #[error("Connection `{binding_key}` requires re-authentication: {message}")]
    AuthReauthRequired {
        binding_key: String,
        message: String,
    },

    /// A tool call must be routed externally (callback pending)
    #[error("Callback pending for tool '{tool_name}'")]
    CallbackPending {
        tool_name: String,
        args: serde_json::Value,
    },

    /// Structured output validation failed after retries
    #[error("Structured output validation failed after {attempts} attempts: {reason}")]
    StructuredOutputValidationFailed {
        attempts: u32,
        reason: String,
        last_output: String,
    },

    /// Invalid output schema provided
    #[error("Invalid output schema: {0}")]
    InvalidOutputSchema(String),

    #[error("Hook '{hook_id}' denied at {point:?}: {reason_code:?} - {message}")]
    HookDenied {
        hook_id: HookId,
        point: HookPoint,
        reason_code: HookReasonCode,
        message: String,
        payload: Option<serde_json::Value>,
    },

    #[error("Hook '{hook_id}' timed out after {timeout_ms}ms")]
    HookTimeout { hook_id: HookId, timeout_ms: u64 },

    #[error("Hook execution failed for '{hook_id}': {reason}")]
    HookExecutionFailed { hook_id: HookId, reason: String },

    #[error("Hook configuration invalid: {reason}")]
    HookConfigInvalid { reason: String },

    /// Turn execution reached a terminal outcome classified as HardFailure.
    #[error("Terminal failure: {outcome:?} ({cause_kind:?}): {message}")]
    TerminalFailure {
        outcome: crate::turn_execution_authority::TurnTerminalOutcome,
        cause_kind: crate::turn_execution_authority::TurnTerminalCauseKind,
        message: String,
    },

    /// The session has no pending user/tool-results boundary for `run_pending`.
    ///
    /// Returned when `RuntimeExecutionKind::ResumePending` is requested but the
    /// session's last message is not `User` or `ToolResults`. The caller should
    /// treat this as a successful no-op (no turn ran, no output produced).
    #[error("no pending boundary for resume")]
    NoPendingBoundary,
}

impl AgentError {
    pub fn llm(
        provider: &'static str,
        reason: LlmFailureReason,
        message: impl Into<String>,
    ) -> Self {
        Self::Llm {
            provider,
            reason,
            message: message.into(),
        }
    }

    pub fn llm_empty_response(provider: &'static str) -> Self {
        Self::llm(
            provider,
            LlmFailureReason::ProviderError(LlmProviderError::retryable(
                LlmProviderErrorKind::IncompleteResponse,
                serde_json::json!({
                    "reason": "provider completed without user-visible text, images, or tool calls"
                }),
            )),
            "LLM completed without user-visible text, images, or tool calls",
        )
    }

    pub fn is_graceful(&self) -> bool {
        matches!(
            self,
            Self::TokenBudgetExceeded { .. }
                | Self::TimeBudgetExceeded { .. }
                | Self::ToolCallBudgetExceeded { .. }
                | Self::MaxTurnsReached { .. }
        )
    }
    pub fn is_rate_limited(&self) -> bool {
        matches!(
            self,
            Self::Llm {
                reason: LlmFailureReason::RateLimited { .. },
                ..
            }
        )
    }

    pub fn retry_after_hint(&self) -> Option<std::time::Duration> {
        match self {
            Self::Llm {
                reason: LlmFailureReason::RateLimited { retry_after },
                ..
            } => *retry_after,
            _ => None,
        }
    }

    pub fn is_recoverable(&self) -> bool {
        match self {
            Self::Llm { reason, .. } => match reason {
                LlmFailureReason::RateLimited { .. } => true,
                LlmFailureReason::NetworkTimeout { .. } => true,
                LlmFailureReason::CallTimeout { .. } => true,
                LlmFailureReason::ProviderError(provider_error) => provider_error.is_retryable(),
                _ => false,
            },
            _ => false,
        }
    }
}

pub fn store_error(err: impl std::fmt::Display) -> AgentError {
    AgentError::StoreError(store_error_message(err))
}
pub fn invalid_session_id(err: impl std::fmt::Display) -> AgentError {
    AgentError::StoreError(invalid_session_id_message(err))
}
pub fn store_error_message(err: impl std::fmt::Display) -> String {
    err.to_string()
}
pub fn invalid_session_id_message(err: impl std::fmt::Display) -> String {
    format!("Invalid session ID: {err}")
}

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

    #[test]
    fn test_network_timeout_is_recoverable() {
        let err = AgentError::llm(
            "anthropic",
            LlmFailureReason::NetworkTimeout { duration_ms: 30000 },
            "network timeout after 30s",
        );
        assert!(err.is_recoverable());
    }

    #[test]
    fn test_call_timeout_is_recoverable() {
        let err = AgentError::llm(
            "anthropic",
            LlmFailureReason::CallTimeout { duration_ms: 45000 },
            "call timeout after 45s",
        );
        assert!(err.is_recoverable());
    }

    #[test]
    fn test_network_timeout_typed_mapping() {
        let reason = LlmFailureReason::NetworkTimeout { duration_ms: 5000 };
        match reason {
            LlmFailureReason::NetworkTimeout { duration_ms } => {
                assert_eq!(duration_ms, 5000);
            }
            _ => panic!("expected NetworkTimeout"),
        }
    }

    #[test]
    fn test_call_timeout_typed_mapping() {
        let reason = LlmFailureReason::CallTimeout { duration_ms: 60000 };
        match reason {
            LlmFailureReason::CallTimeout { duration_ms } => {
                assert_eq!(duration_ms, 60000);
            }
            _ => panic!("expected CallTimeout"),
        }
    }

    #[test]
    fn test_timeout_variants_are_distinct() {
        let net = LlmFailureReason::NetworkTimeout { duration_ms: 1000 };
        let call = LlmFailureReason::CallTimeout { duration_ms: 1000 };
        assert_ne!(net, call);
    }

    #[test]
    fn test_auth_error_not_recoverable() {
        let err = AgentError::llm("anthropic", LlmFailureReason::AuthError, "bad key");
        assert!(!err.is_recoverable());
    }

    #[test]
    fn provider_error_uses_typed_retryability_for_recovery() {
        let err = AgentError::llm(
            "anthropic",
            LlmFailureReason::ProviderError(LlmProviderError::retryable(
                LlmProviderErrorKind::ServerOverloaded,
                serde_json::json!({
                    "message": "provider overloaded"
                }),
            )),
            "provider overloaded",
        );

        assert!(err.is_recoverable());
    }

    #[test]
    fn provider_error_fails_closed_when_json_claims_retryable() {
        let err = AgentError::llm(
            "anthropic",
            LlmFailureReason::ProviderError(LlmProviderError::non_retryable(
                LlmProviderErrorKind::InvalidRequest,
                serde_json::json!({
                    "kind": "server_overloaded",
                    "retryable": true,
                    "message": "json payload must not control retryability"
                }),
            )),
            "invalid request",
        );

        assert!(!err.is_recoverable());
    }

    // -- Rate-limit helper tests (PR #156 port) --

    #[test]
    fn test_is_rate_limited_true_for_rate_limit_error() {
        let err = AgentError::llm(
            "anthropic",
            LlmFailureReason::RateLimited {
                retry_after: Some(std::time::Duration::from_secs(30)),
            },
            "rate limited",
        );
        assert!(err.is_rate_limited());
    }

    #[test]
    fn test_is_rate_limited_false_for_other_errors() {
        let err = AgentError::llm(
            "anthropic",
            LlmFailureReason::NetworkTimeout { duration_ms: 5000 },
            "timeout",
        );
        assert!(!err.is_rate_limited());

        let err = AgentError::llm("anthropic", LlmFailureReason::AuthError, "bad key");
        assert!(!err.is_rate_limited());
    }

    #[test]
    fn test_retry_after_hint_returns_duration_for_rate_limit() {
        let err = AgentError::llm(
            "anthropic",
            LlmFailureReason::RateLimited {
                retry_after: Some(std::time::Duration::from_secs(60)),
            },
            "rate limited",
        );
        assert_eq!(
            err.retry_after_hint(),
            Some(std::time::Duration::from_secs(60))
        );
    }

    #[test]
    fn test_retry_after_hint_returns_none_for_non_rate_limit() {
        let err = AgentError::llm(
            "anthropic",
            LlmFailureReason::NetworkTimeout { duration_ms: 5000 },
            "timeout",
        );
        assert_eq!(err.retry_after_hint(), None);
    }

    #[test]
    fn test_timeout_variants_not_graceful() {
        let err = AgentError::llm(
            "anthropic",
            LlmFailureReason::NetworkTimeout { duration_ms: 1000 },
            "timeout",
        );
        assert!(!err.is_graceful());

        let err = AgentError::llm(
            "anthropic",
            LlmFailureReason::CallTimeout { duration_ms: 1000 },
            "timeout",
        );
        assert!(!err.is_graceful());
    }

    // -- P2-6: Typed BuildError variant --

    #[test]
    fn test_build_error_variant_exists_and_carries_message() {
        let err = AgentError::BuildError("Missing API key for provider 'anthropic'".to_string());
        match &err {
            AgentError::BuildError(msg) => {
                assert!(
                    msg.contains("API key"),
                    "message should contain source text"
                );
            }
            other => panic!("expected BuildError, got: {other}"),
        }
    }

    #[test]
    fn test_build_error_is_not_recoverable() {
        let err = AgentError::BuildError("Unknown provider for model 'llama-3'".to_string());
        assert!(!err.is_recoverable(), "build errors are not recoverable");
    }

    #[test]
    fn test_build_error_is_not_graceful() {
        let err = AgentError::BuildError("Missing API key".to_string());
        assert!(!err.is_graceful(), "build errors are not graceful");
    }

    #[test]
    fn test_build_error_display() {
        let err = AgentError::BuildError("Missing API key for provider 'anthropic'".to_string());
        let display = err.to_string();
        assert!(
            display.contains("Build error")
                || display.contains("build error")
                || display.contains("Missing API key"),
            "display should mention the build error: {display}"
        );
    }

    // -- P2-7: Typed TerminalFailure outcome --

    #[test]
    fn test_terminal_failure_carries_typed_outcome() {
        use crate::turn_execution_authority::{TurnTerminalCauseKind, TurnTerminalOutcome};

        // TerminalFailure must carry typed enums, not Debug-formatted strings.
        let err = AgentError::TerminalFailure {
            outcome: TurnTerminalOutcome::Failed,
            cause_kind: TurnTerminalCauseKind::LlmFailure,
            message: "llm failed".to_string(),
        };
        match &err {
            AgentError::TerminalFailure {
                outcome,
                cause_kind,
                ..
            } => {
                // If this compiles, outcome/cause_kind are typed enums, not Strings.
                assert_eq!(*outcome, TurnTerminalOutcome::Failed);
                assert_eq!(*cause_kind, TurnTerminalCauseKind::LlmFailure);
            }
            other => panic!("expected TerminalFailure, got: {other}"),
        }
    }

    #[test]
    fn test_terminal_failure_display_includes_outcome() {
        use crate::turn_execution_authority::{TurnTerminalCauseKind, TurnTerminalOutcome};

        let err = AgentError::TerminalFailure {
            outcome: TurnTerminalOutcome::TimeBudgetExceeded,
            cause_kind: TurnTerminalCauseKind::TimeBudgetExceeded,
            message: "deadline reached".to_string(),
        };
        let display = err.to_string();
        assert!(
            display.contains("TimeBudgetExceeded"),
            "display should include the outcome variant name: {display}"
        );
        assert!(
            display.contains("TimeBudgetExceeded") && display.contains("deadline reached"),
            "display should include cause and display message: {display}"
        );
    }

    #[test]
    fn test_terminal_failure_all_hard_failure_outcomes() {
        use crate::turn_execution_authority::{TurnTerminalCauseKind, TurnTerminalOutcome};

        // Both hard-failure outcomes should be representable.
        for outcome in [
            TurnTerminalOutcome::Failed,
            TurnTerminalOutcome::TimeBudgetExceeded,
        ] {
            let err = AgentError::TerminalFailure {
                outcome,
                cause_kind: TurnTerminalCauseKind::FatalFailure,
                message: "terminal".to_string(),
            };
            assert!(
                !err.is_graceful(),
                "TerminalFailure({outcome:?}) should not be graceful"
            );
        }
    }
}
