//! Parse + cross-reference validation for `fixtures:`.
//!
//! Lives alongside `suite::mod` so the parser file stays under the
//! production-module-size cap. Holds two helpers:
//!
//! 1. [`parse`]: read the optional `fixtures:` block off the root YAML
//!    document and round-trip it through the typed [`FixtureSpec`]
//!    deserialize so the `oneOf(tool, applies_to)` constraint fires.
//! 2. [`validate_inject_error`]: every tool test that carries
//!    `inject_error: <name>` must reference a declared fixture, and the
//!    fixture's scope must accept the test's tool. Failures are typed
//!    [`ParseError`] variants so the loader fails the suite rather than
//!    dispatching to the live server.

use serde_yaml::Value as YamlValue;

use super::types::{Action, TypedTest};
use super::ParseError;
use crate::fixtures::{ErrorScope, FixtureSpec};

/// Read the optional `fixtures:` block. Returns an empty FixtureSpec
/// when the key is missing so existing suites stay unchanged.
pub(super) fn parse(root: &YamlValue) -> Result<FixtureSpec, ParseError> {
    let Some(node) = root.get("fixtures") else {
        return Ok(FixtureSpec::default());
    };
    // Round-trip through a YAML string so the typed deserialize runs
    // its `oneOf(tool, applies_to)` constraint. A failure of either
    // step is a shape error on the `fixtures:` key.
    let yaml_string = serde_yaml::to_string(node).map_err(|_| ParseError::BadShape {
        path: "/fixtures".to_string(),
        field: "fixtures",
        expected: "a `fixtures:` block with an `errors:` array",
    })?;
    serde_yaml::from_str::<FixtureSpec>(&yaml_string).map_err(|_| ParseError::BadShape {
        path: "/fixtures".to_string(),
        field: "fixtures",
        expected: "`fixtures.errors[]` entries with exactly one of `tool` or `applies_to: any`",
    })
}

/// Enforce the `inject_error: <name>` cross-reference rule.
///
/// `inject_error:` is tool-test-only. When a tool test sets it, the
/// referenced fixture must exist and its scope must accept the test's
/// tool (`ErrorScope::Any`, or `ErrorScope::Tool` whose name matches).
/// Non-tool tests never carry an `inject_error` from the parser, but
/// the early-return below keeps the helper defensive.
pub(super) fn validate_inject_error(
    test: &TypedTest,
    fixtures: &FixtureSpec,
) -> Result<(), ParseError> {
    let Some(name) = &test.inject_error else {
        return Ok(());
    };
    let Action::ToolCall {
        tool: test_tool, ..
    } = &test.action
    else {
        return Ok(());
    };
    let fixture = fixtures
        .resolve(name)
        .ok_or_else(|| ParseError::UnknownFixture {
            test: test.name.clone(),
            name: name.clone(),
        })?;
    match &fixture.scope {
        ErrorScope::Any => Ok(()),
        ErrorScope::Tool(fixture_tool) if fixture_tool == test_tool => Ok(()),
        ErrorScope::Tool(fixture_tool) => Err(ParseError::MismatchedFixtureScope {
            test: test.name.clone(),
            name: name.clone(),
            fixture_tool: fixture_tool.clone(),
            test_tool: test_tool.clone(),
        }),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::fixtures::NamedError;
    use serde_yaml::from_str;

    fn fixtures_with(name: &str, scope: ErrorScope) -> FixtureSpec {
        FixtureSpec {
            server: None,
            errors: vec![NamedError {
                name: name.to_string(),
                code: -32603,
                message: "boom".into(),
                scope,
            }],
        }
    }

    fn tool_test(name: &str, tool: &str, inject: Option<&str>) -> TypedTest {
        TypedTest {
            name: name.into(),
            server: "fs".into(),
            action: Action::ToolCall {
                tool: tool.into(),
                args: serde_json::Value::Object(Default::default()),
            },
            expectations: Vec::new(),
            budgets: Default::default(),
            tags: Vec::new(),
            skip: None,
            only: false,
            assert_sets: Vec::new(),
            threshold: None,
            derived_metrics: Vec::new(),
            transform: None,
            inject_error: inject.map(str::to_string),
            metamorphic: None,
            fuzz: None,
            negative_path: None,
            input_responses: None,
            input_responder: None,
        }
    }

    #[test]
    fn parse_returns_empty_when_key_omitted() {
        let yaml: YamlValue = from_str("servers: { fs: { command: [\"a\"] } }").unwrap();
        let spec = parse(&yaml).expect("parses");
        assert!(spec.errors.is_empty());
    }

    #[test]
    fn parse_round_trips_a_named_error() {
        let yaml: YamlValue = from_str(
            r#"
fixtures:
  errors:
    - name: rate_limit
      code: 429
      message: too many
      applies_to: any
"#,
        )
        .unwrap();
        let spec = parse(&yaml).expect("parses");
        assert_eq!(spec.errors.len(), 1);
        assert_eq!(spec.errors[0].name, "rate_limit");
        assert!(matches!(spec.errors[0].scope, ErrorScope::Any));
    }

    #[test]
    fn parse_rejects_a_named_error_setting_both_tool_and_applies_to() {
        let yaml: YamlValue = from_str(
            r#"
fixtures:
  errors:
    - name: bad
      code: 1
      message: x
      tool: search
      applies_to: any
"#,
        )
        .unwrap();
        let err = parse(&yaml).expect_err("oneOf constraint must fire");
        assert!(matches!(err, ParseError::BadShape { .. }));
    }

    #[test]
    fn validate_passes_for_a_test_without_inject_error() {
        let fixtures = FixtureSpec::default();
        let test = tool_test("t", "search", None);
        validate_inject_error(&test, &fixtures).expect("no inject_error: no validation");
    }

    #[test]
    fn validate_passes_for_a_test_referencing_a_matching_tool_scoped_fixture() {
        let fixtures = fixtures_with("rate", ErrorScope::Tool("search".into()));
        let test = tool_test("t", "search", Some("rate"));
        validate_inject_error(&test, &fixtures).expect("matching tool passes");
    }

    #[test]
    fn validate_passes_for_a_test_referencing_an_any_scoped_fixture() {
        let fixtures = fixtures_with("rate", ErrorScope::Any);
        let test = tool_test("t", "search", Some("rate"));
        validate_inject_error(&test, &fixtures).expect("any scope passes");
    }

    #[test]
    fn validate_rejects_a_test_referencing_an_undeclared_fixture() {
        let fixtures = fixtures_with("rate", ErrorScope::Any);
        let test = tool_test("t", "search", Some("typo"));
        match validate_inject_error(&test, &fixtures).expect_err("unknown name") {
            ParseError::UnknownFixture { test: t, name } => {
                assert_eq!(t, "t");
                assert_eq!(name, "typo");
            }
            other => panic!("expected UnknownFixture, got {other:?}"),
        }
    }

    #[test]
    fn validate_rejects_a_tool_test_against_a_different_tool_scope() {
        let fixtures = fixtures_with("rate", ErrorScope::Tool("search".into()));
        let test = tool_test("t", "fetch", Some("rate"));
        match validate_inject_error(&test, &fixtures).expect_err("mismatch") {
            ParseError::MismatchedFixtureScope {
                test: t,
                name,
                fixture_tool,
                test_tool,
            } => {
                assert_eq!(t, "t");
                assert_eq!(name, "rate");
                assert_eq!(fixture_tool, "search");
                assert_eq!(test_tool, "fetch");
            }
            other => panic!("expected MismatchedFixtureScope, got {other:?}"),
        }
    }
}
