//! Named error fixtures.
//!
//! Tests often need to exercise the same failure paths (a rate limit, an
//! expired token, a 5xx blip) across many tool calls. Re-declaring the
//! error shape on every test is noisy, so the YAML surface lets users
//! declare each named error once under `fixtures.errors[]` and reference
//! it from a test via `inject_error: <name>`.
//!
//! This module owns the model. The future runner will resolve
//! a name into a [`NamedError`] and inject it in place of a live tool
//! call. The schema is the authoritative validator for the YAML shape;
//! the Rust types mirror it so the runner can pattern-match on
//! [`ErrorScope`] without re-parsing tag fields.

use serde::de::{self, Deserializer};
use serde::ser::Serializer;
use serde::{Deserialize, Serialize};

/// Reusable error fixtures declared at the top of a test file.
///
/// Wraps the `errors` list and offers a [`resolve`](FixtureSpec::resolve)
/// lookup so the runner can find an error by name without re-scanning the
/// list at every call site.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct FixtureSpec {
    /// Key into the top-level `servers` map identifying the server the
    /// fixtures attach to. Pure metadata in v1; the runner does not require
    /// it but reporters surface it for context.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub server: Option<String>,

    /// Named error scenarios declared in document order.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub errors: Vec<NamedError>,
}

impl FixtureSpec {
    /// Look up a named error by `name`. Returns `None` when the name is
    /// undeclared, leaving it to the caller to report the cross-reference
    /// error in user-facing terms.
    pub fn resolve(&self, name: &str) -> Option<&NamedError> {
        self.errors.iter().find(|e| e.name == name)
    }
}

/// A single named error scenario. Mirrors the `NamedError` `$def` in
/// `schemas/v1.json`.
///
/// The schema enforces that exactly one of `tool` or `applies_to` is
/// present via `oneOf`. The Rust model represents that union with
/// [`ErrorScope`], collapsing the two YAML keys into one tagged enum so
/// the runner can pattern-match instead of inspecting raw `Option` pairs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NamedError {
    /// Unique identifier referenced by `inject_error:` on a tool test.
    pub name: String,
    /// JSON-RPC error code returned in place of a successful tool result.
    pub code: i64,
    /// Human-readable error message, shaped like the real server would
    /// return.
    pub message: String,
    /// Which tool calls the error applies to. See [`ErrorScope`].
    pub scope: ErrorScope,
}

/// Which tool calls a [`NamedError`] applies to.
///
/// The YAML schema requires exactly one of `tool:` or `applies_to: "any"`.
/// The runner consumes [`ErrorScope::Tool`] to gate injection on a single
/// tool name and [`ErrorScope::Any`] to allow the error for every tool on
/// the configured server.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ErrorScope {
    /// Inject only when the matching tool is called.
    Tool(String),
    /// Inject for any tool on the server. Declared in YAML as
    /// `applies_to: "any"`.
    Any,
}

/// Wire-format mirror of `NamedError` used for serde plumbing.
///
/// Kept private so the public surface stays as [`NamedError`] with its
/// flat [`ErrorScope`] field. The intermediate type lets us enforce the
/// `oneOf(tool, applies_to)` constraint in pure derive form: after
/// deserializing, we collapse the two `Option` fields into a single
/// [`ErrorScope`] and return an error if both or neither are set.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct NamedErrorRaw {
    name: String,
    code: i64,
    message: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    tool: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    applies_to: Option<AppliesTo>,
}

/// The only legal value for `applies_to` is the literal `"any"`. Modeling
/// it as a single-variant enum keeps the schema and Rust types aligned so
/// a future scope (`applies_to: "all_in_group"`, say) lands as a new
/// variant rather than a stringly typed change.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum AppliesTo {
    Any,
}

impl<'de> Deserialize<'de> for NamedError {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let raw = NamedErrorRaw::deserialize(deserializer)?;
        let scope = match (raw.tool, raw.applies_to) {
            (Some(t), None) => ErrorScope::Tool(t),
            (None, Some(AppliesTo::Any)) => ErrorScope::Any,
            (Some(_), Some(_)) => {
                return Err(de::Error::custom(format!(
                    "named error '{}' sets both `tool` and `applies_to`; exactly one is allowed",
                    raw.name
                )));
            }
            (None, None) => {
                return Err(de::Error::custom(format!(
                    "named error '{}' sets neither `tool` nor `applies_to`; one is required",
                    raw.name
                )));
            }
        };
        Ok(NamedError {
            name: raw.name,
            code: raw.code,
            message: raw.message,
            scope,
        })
    }
}

impl Serialize for NamedError {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let (tool, applies_to) = match &self.scope {
            ErrorScope::Tool(t) => (Some(t.clone()), None),
            ErrorScope::Any => (None, Some(AppliesTo::Any)),
        };
        let raw = NamedErrorRaw {
            name: self.name.clone(),
            code: self.code,
            message: self.message.clone(),
            tool,
            applies_to,
        };
        raw.serialize(serializer)
    }
}

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

    #[test]
    fn deserializes_named_error_scoped_to_a_tool() {
        let yaml = r#"
server: mock-github
errors:
  - name: rate_limited
    code: -32000
    message: GitHub API rate limit exceeded
    tool: create_issue
"#;
        let spec: FixtureSpec = serde_yaml::from_str(yaml).unwrap();
        assert_eq!(spec.server.as_deref(), Some("mock-github"));
        assert_eq!(spec.errors.len(), 1);
        let err = &spec.errors[0];
        assert_eq!(err.name, "rate_limited");
        assert_eq!(err.code, -32000);
        assert_eq!(err.message, "GitHub API rate limit exceeded");
        assert_eq!(err.scope, ErrorScope::Tool("create_issue".into()));
    }

    #[test]
    fn deserializes_named_error_scoped_to_any_tool() {
        let yaml = r#"
errors:
  - name: auth_expired
    code: -32001
    message: OAuth token expired
    applies_to: any
"#;
        let spec: FixtureSpec = serde_yaml::from_str(yaml).unwrap();
        assert_eq!(spec.errors.len(), 1);
        assert_eq!(spec.errors[0].scope, ErrorScope::Any);
    }

    #[test]
    fn rejects_named_error_with_both_tool_and_applies_to() {
        // The schema is the enforcing layer for this constraint (oneOf in
        // `schemas/v1.json`). This test verifies the Rust model also
        // catches the same shape so anyone bypassing the schema (for
        // example loading a hand-built YAML in a unit test) gets a clear
        // error rather than a silently accepted value.
        let yaml = r#"
errors:
  - name: bad
    code: -1
    message: bad
    tool: create_issue
    applies_to: any
"#;
        let err = serde_yaml::from_str::<FixtureSpec>(yaml).unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("both `tool` and `applies_to`"),
            "expected both-set error, got: {msg}"
        );
    }

    #[test]
    fn rejects_named_error_with_neither_tool_nor_applies_to() {
        let yaml = r#"
errors:
  - name: bad
    code: -1
    message: bad
"#;
        let err = serde_yaml::from_str::<FixtureSpec>(yaml).unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("neither `tool` nor `applies_to`"),
            "expected neither-set error, got: {msg}"
        );
    }

    #[test]
    fn resolve_finds_declared_name_and_misses_unknown() {
        let yaml = r#"
errors:
  - name: rate_limited
    code: -32000
    message: rate limited
    tool: create_issue
  - name: auth_expired
    code: -32001
    message: token expired
    applies_to: any
"#;
        let spec: FixtureSpec = serde_yaml::from_str(yaml).unwrap();
        assert!(spec.resolve("rate_limited").is_some());
        assert!(spec.resolve("auth_expired").is_some());
        assert!(spec.resolve("does_not_exist").is_none());
    }

    #[test]
    fn round_trips_through_yaml() {
        let original = FixtureSpec {
            server: Some("mock".into()),
            errors: vec![
                NamedError {
                    name: "rate_limited".into(),
                    code: -32000,
                    message: "rate limited".into(),
                    scope: ErrorScope::Tool("create_issue".into()),
                },
                NamedError {
                    name: "auth_expired".into(),
                    code: -32001,
                    message: "expired".into(),
                    scope: ErrorScope::Any,
                },
            ],
        };
        let yaml = serde_yaml::to_string(&original).unwrap();
        let parsed: FixtureSpec = serde_yaml::from_str(&yaml).unwrap();
        assert_eq!(parsed, original);
    }
}
