//! Error types used across `klieo-core`.
//!
//! Public API of every foundation crate surfaces only `Error`. Each domain
//! has its own sub-error type (e.g. `LlmError`) that converts via
//! `#[from]`. This keeps the public surface narrow while preserving cause
//! chains.
//!
//! Sub-errors classify themselves as `retryable()` so the runtime can
//! apply exponential backoff to transient failures only.

use thiserror::Error;

/// Top-level error returned by `klieo-core` runtime calls.
///
/// Marked `#[non_exhaustive]` so additional variants can be introduced
/// without a major-version bump on impl crates that match on the enum.
///
/// ```
/// use klieo_core::error::{Error, LlmError};
/// let e: Error = LlmError::Timeout.into();
/// assert!(matches!(e, Error::Llm(_)));
/// ```
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Error {
    /// Underlying LLM provider failure.
    #[error("LLM error: {0}")]
    Llm(#[from] LlmError),
    /// Tool invocation failure.
    #[error("Tool error: {0}")]
    Tool(#[from] ToolError),
    /// Memory persistence failure.
    #[error("Memory error: {0}")]
    Memory(#[from] MemoryError),
    /// Inter-agent bus failure.
    #[error("Bus error: {0}")]
    Bus(#[from] BusError),
    /// Runtime ran the maximum allowed number of LLM/tool steps.
    #[error("max steps exceeded ({steps})")]
    MaxStepsExceeded {
        /// Step count that was exceeded.
        steps: u32,
    },
    /// Cooperatively cancelled.
    #[error("cancelled")]
    Cancelled,
    /// Configuration validation failure.
    #[error("config error: {0}")]
    Config(#[from] ConfigError),
    /// LLM reply could not be parsed into the requested typed shape.
    ///
    /// Surfaced from the structured-output parser when the raw text
    /// content fails JSON deserialization. Always permanent — retrying
    /// the same reply will fail identically.
    #[error("bad response: {0}")]
    BadResponse(String),
    /// Caller-installed guardrail refused the LLM call.
    #[error("guardrail refused: {reason}")]
    Refused {
        /// Human-readable reason supplied by the guardrail.
        reason: String,
    },
    /// Caller-installed guardrail requested a handoff to another agent.
    #[error("guardrail handoff to {agent}: {reason}")]
    Handoff {
        /// Name of the agent the guardrail requested.
        agent: String,
        /// Human-readable reason for the handoff.
        reason: String,
    },
    /// `App` builder rejected a `build()` call because a required port
    /// was not configured. `missing` names the port (`"llm"`,
    /// `"memory"`, `"bus"`, `"tools"`) so the caller can point at the
    /// specific setter to call.
    #[error("App build error: missing {missing}")]
    AppBuildError {
        /// Stable identifier for the missing port.
        missing: &'static str,
    },
    /// A configured model is past its sunset date in the active model registry.
    #[error("model {model} sunset (since {since})")]
    ModelSunset {
        /// Display form of the offending pin (`provider/id@version`).
        model: String,
        /// Sunset date (registry-reported), as a string.
        since: String,
    },
    /// A downstream or domain error that doesn't fit another variant.
    ///
    /// Use this to wrap errors from non-klieo code:
    /// ```
    /// use klieo_core::error::Error;
    /// let my_err = std::io::Error::other("db gone");
    /// let e = Error::Downstream(Box::new(my_err));
    /// assert!(std::error::Error::source(&e).is_some());
    /// ```
    #[error("downstream error: {0}")]
    Downstream(#[source] Box<dyn std::error::Error + Send + Sync>),
    /// Run suspended at a step awaiting human approval (ADR-045). Carries the
    /// checkpoint needed to resume via `runtime::resume_from_checkpoint`.
    #[error("run suspended: {reason}")]
    Suspended {
        /// Serializable continuation state; boxed to keep `Error` small.
        checkpoint: Box<crate::checkpoint::RunCheckpoint>,
        /// Human-readable reason the run paused (from the `ReviewPolicy`).
        reason: String,
    },
    /// A retried resume refused to re-dispatch a non-idempotent tool call
    /// (ADR-045 fail-closed). A cross-process resume that has already been
    /// attempted cannot prove the pending call did not fire before the crash,
    /// so the framework will not risk a duplicate side effect (e.g. a double
    /// payout). The checkpoint is left intact for operator reconciliation.
    #[error("resume blocked for run {run_id}: non-idempotent tool(s) {tools:?} already attempted; manual reconciliation required")]
    ResumeReplayBlocked {
        /// Key under which the un-resumed checkpoint stays for reconciliation.
        run_id: crate::ids::RunId,
        /// Pending tools that could not be proven un-fired, hence the refusal.
        tools: Vec<String>,
    },
    /// Generic wrap for errors produced by downstream consumers that
    /// don't fit any of the typed variants above (agent-impl errors
    /// from `klieo-spec::QualityLoop`, `klieo-flows::FlowError`, custom
    /// domain errors).
    ///
    /// Carries the original error as `#[source]` so `e.source()`
    /// traversal preserves the cause chain. Prefer the typed variants
    /// (`Llm`, `Tool`, `Memory`, `Bus`, `Config`) when the error class
    /// is known.
    #[error("{message}")]
    Other {
        /// Human-readable context describing where the wrap happened.
        message: String,
        /// Underlying error preserved for `std::error::Error::source()`.
        #[source]
        source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
    },
}

impl Error {
    /// One-line constructor for [`Error::Other`] that boxes the source
    /// into the `#[source]` chain.
    ///
    /// ```
    /// use klieo_core::error::Error;
    /// let io = std::io::Error::other("disk gone");
    /// let e = Error::wrap("ledger write failed", io);
    /// assert_eq!(e.to_string(), "ledger write failed");
    /// assert!(std::error::Error::source(&e).is_some());
    /// ```
    pub fn wrap<E>(message: impl Into<String>, source: E) -> Self
    where
        E: std::error::Error + Send + Sync + 'static,
    {
        Self::Other {
            message: message.into(),
            source: Some(Box::new(source)),
        }
    }

    /// Whether the operation is safe to retry without changing inputs.
    pub fn retryable(&self) -> bool {
        match self {
            Self::Llm(e) => e.retryable(),
            Self::Tool(e) => e.retryable(),
            Self::Bus(e) => e.retryable(),
            Self::Memory(_)
            | Self::MaxStepsExceeded { .. }
            | Self::Cancelled
            | Self::Config(_)
            | Self::BadResponse(_)
            | Self::Refused { .. }
            | Self::Handoff { .. }
            | Self::AppBuildError { .. }
            | Self::ModelSunset { .. }
            | Self::Suspended { .. }
            | Self::ResumeReplayBlocked { .. }
            | Self::Downstream(_)
            | Self::Other { .. } => false,
        }
    }
}

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

    #[test]
    fn suspended_error_displays_reason() {
        use crate::checkpoint::RunCheckpoint;
        let cp = RunCheckpoint::for_test();
        let e = Error::Suspended {
            checkpoint: Box::new(cp),
            reason: "needs approval".into(),
        };
        assert!(e.to_string().contains("needs approval"));
        assert!(
            !e.retryable(),
            "a suspended run resumes on an operator decision, never by blind retry"
        );
    }

    #[test]
    fn resume_replay_blocked_is_not_retryable_and_names_run_and_tools() {
        let run_id = crate::ids::RunId::new();
        let e = Error::ResumeReplayBlocked {
            run_id,
            tools: vec!["authorize_payout".into()],
        };
        assert!(
            !e.retryable(),
            "a blocked resume needs operator reconciliation, never a blind retry"
        );
        let msg = e.to_string();
        assert!(msg.contains(&run_id.to_string()) && msg.contains("authorize_payout"));
    }

    #[test]
    fn llm_suspended_displays_reason_and_is_not_retryable() {
        let e = LlmError::Suspended {
            reason: "approve the payout".into(),
        };
        assert!(e.to_string().contains("approve the payout"));
        assert!(
            !e.retryable(),
            "the streaming suspend terminal item resumes on a decision, never by retry"
        );
    }
}

#[cfg(test)]
mod other_variant_tests {
    use super::*;
    use std::error::Error as StdError;

    #[test]
    fn other_message_surfaces_through_display() {
        let e = Error::Other {
            message: "quality loop: short draft".into(),
            source: None,
        };
        assert_eq!(e.to_string(), "quality loop: short draft");
    }

    #[test]
    fn other_preserves_source_chain() {
        let inner = std::io::Error::other("disk gone");
        let e = Error::Other {
            message: "ledger write failed".into(),
            source: Some(Box::new(inner)),
        };
        let src = e.source().expect("source must be preserved");
        assert_eq!(src.to_string(), "disk gone");
    }

    #[test]
    fn other_without_source_returns_none() {
        let e = Error::Other {
            message: "no inner".into(),
            source: None,
        };
        assert!(e.source().is_none());
    }

    #[test]
    fn wrap_ctor_boxes_source_and_carries_message() {
        let inner = std::io::Error::other("disk gone");
        let e = Error::wrap("ledger write failed", inner);
        assert_eq!(e.to_string(), "ledger write failed");
        let src = e.source().expect("source must be preserved by wrap()");
        assert_eq!(src.to_string(), "disk gone");
    }

    #[test]
    fn other_is_not_retryable() {
        let with_source = Error::Other {
            message: "x".into(),
            source: Some(Box::new(std::io::Error::other("y"))),
        };
        let without_source = Error::Other {
            message: "x".into(),
            source: None,
        };
        assert!(!with_source.retryable());
        assert!(!without_source.retryable());
    }

    #[test]
    fn downstream_preserves_source_chain() {
        let inner = std::io::Error::other("db gone");
        let e = Error::Downstream(Box::new(inner));
        assert_eq!(e.to_string(), "downstream error: db gone");
        let src = e.source().expect("source must be preserved");
        assert_eq!(src.to_string(), "db gone");
    }

    #[test]
    fn downstream_is_not_retryable() {
        let e = Error::Downstream(Box::new(std::io::Error::other("transient")));
        assert!(!e.retryable());
    }
}

/// LLM provider errors.
///
/// Marked `#[non_exhaustive]` so additional variants can be introduced
/// without a major-version bump on impl crates that match on the enum.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum LlmError {
    /// Network transport failure (connection refused, DNS, etc).
    #[error("network error: {0}")]
    Network(String),
    /// Request exceeded its deadline.
    #[error("timeout")]
    Timeout,
    /// Provider returned a 429-equivalent rate-limit signal.
    #[error("rate limited (retry after {retry_after_secs}s)")]
    RateLimit {
        /// Server-suggested retry delay.
        retry_after_secs: u32,
    },
    /// Authentication or authorisation failure.
    #[error("unauthorized")]
    Unauthorized,
    /// Provider rejected the request shape.
    #[error("bad request: {0}")]
    BadRequest(String),
    /// Provider returned a 5xx-equivalent.
    #[error("server error: {0}")]
    Server(String),
    /// Response payload could not be decoded.
    #[error("decoding error: {0}")]
    Decoding(String),
    /// Capability declared unsupported by the client.
    #[error("unsupported capability: {0}")]
    Unsupported(String),
    /// Operation was cooperatively cancelled. Surfaced from the
    /// streaming runtime when `ctx.cancel` fires mid-stream so consumers
    /// can distinguish a cooperative cancel from a provider failure.
    #[error("operation cancelled")]
    Cancelled,
    /// The run suspended at a human-review gate (ADR-045) rather than
    /// failing. Emitted as the streaming path's terminal item when a
    /// `ReviewPolicy` pauses a step: the durable `RunCheckpoint` was
    /// persisted to `RunOptions::checkpoint_kv_bucket`, and the run resumes
    /// via `resume_from_checkpoint`. Not retryable — and not a failure, so the
    /// streaming driver records no `Episode::Failed` for it.
    #[error("suspended for human review: {reason}")]
    Suspended {
        /// Short, human-readable pause explanation — the same string carried by
        /// the blocking path's `Error::Suspended`, safe to surface to operators.
        reason: String,
    },
}

impl LlmError {
    /// Returns true if the runtime should retry the operation.
    pub fn retryable(&self) -> bool {
        matches!(
            self,
            Self::Network(_) | Self::Timeout | Self::RateLimit { .. } | Self::Server(_)
        )
    }
}

/// Tool invocation errors.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ToolError {
    /// Tool name not registered with the invoker.
    #[error("unknown tool: {0}")]
    UnknownTool(String),
    /// Arguments did not match the declared JSON-schema.
    #[error("invalid args: {0}")]
    InvalidArgs(String),
    /// Tool returned a transient failure; runtime may retry.
    #[error("retryable: {message} (retry after {retry_after_secs}s)")]
    Retryable {
        /// Human-readable reason.
        message: String,
        /// Suggested delay before retry.
        retry_after_secs: u32,
    },
    /// Tool returned a permanent failure.
    #[error("permanent: {0}")]
    Permanent(String),
    /// Tool execution exceeded its timeout.
    #[error("timeout")]
    Timeout,
    /// Tool invocation was cooperatively cancelled (e.g. HTTP/SSE client
    /// disconnected mid-stream).
    #[error("cancelled")]
    Cancelled,
}

impl ToolError {
    /// Returns true if the runtime should retry the tool call.
    pub fn retryable(&self) -> bool {
        matches!(self, Self::Retryable { .. } | Self::Timeout)
    }
}

/// Memory persistence errors.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum MemoryError {
    /// Underlying store rejected the operation.
    #[error("store error: {0}")]
    Store(String),
    /// Requested resource not present.
    #[error("not found")]
    NotFound,
    /// Embedding generation failed.
    #[error("embedding failed: {0}")]
    Embedding(String),
    /// Serialization failure.
    #[error("serialization: {0}")]
    Serialization(String),
}

/// Inter-agent bus errors.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum BusError {
    /// Network or broker connectivity failure.
    #[error("connection error: {0}")]
    Connection(String),
    /// Subject / queue / bucket not found.
    #[error("not found: {0}")]
    NotFound(String),
    /// CAS revision mismatch.
    #[error("cas conflict (expected {expected}, got {actual})")]
    CasConflict {
        /// Caller's expected revision.
        expected: u64,
        /// Actual current revision.
        actual: u64,
    },
    /// Operation exceeded its deadline.
    #[error("timeout")]
    Timeout,
    /// Generic transient failure flagged by impl as retryable.
    #[error("retryable: {0}")]
    Retryable(String),
    /// Generic permanent failure.
    #[error("permanent: {0}")]
    Permanent(String),
    /// Operation not implemented by this `KvStore` backend.
    #[error("unsupported operation: {0}")]
    Unsupported(String),
    /// Caller supplied an invalid argument that the bus refuses to
    /// accept (e.g. a subject segment containing reserved
    /// metacharacters). Permanent — retry would fail identically.
    #[error("invalid argument: {0}")]
    Invalid(String),
}

impl BusError {
    /// Returns true if the runtime should retry the operation.
    pub fn retryable(&self) -> bool {
        matches!(
            self,
            Self::Connection(_) | Self::Timeout | Self::Retryable(_)
        )
    }
}

/// Configuration validation errors.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ConfigError {
    /// Required configuration key absent.
    #[error("missing required key: {0}")]
    MissingKey(String),
    /// Configuration value failed validation.
    #[error("invalid value for {key}: {reason}")]
    InvalidValue {
        /// Key that was invalid.
        key: String,
        /// Reason it was invalid.
        reason: String,
    },
}

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

    #[test]
    fn llm_timeout_is_retryable() {
        let e: Error = LlmError::Timeout.into();
        assert!(e.retryable());
    }

    #[test]
    fn llm_unauthorized_is_not_retryable() {
        let e: Error = LlmError::Unauthorized.into();
        assert!(!e.retryable());
    }

    #[test]
    fn tool_invalid_args_is_not_retryable() {
        let e: Error = ToolError::InvalidArgs("bad json".into()).into();
        assert!(!e.retryable());
    }

    #[test]
    fn config_error_is_not_retryable() {
        let e: Error = ConfigError::MissingKey("x".into()).into();
        assert!(!e.retryable());
    }

    #[test]
    fn refused_is_not_retryable() {
        let e = Error::Refused {
            reason: "policy".into(),
        };
        assert!(!e.retryable());
    }

    #[test]
    fn handoff_is_not_retryable() {
        let e = Error::Handoff {
            agent: "specialist".into(),
            reason: "out of scope".into(),
        };
        assert!(!e.retryable());
    }

    #[test]
    fn cancelled_variant_renders_stable_message() {
        let e = ToolError::Cancelled;
        assert_eq!(e.to_string(), "cancelled");
        assert!(!e.retryable());
    }
}
