//! Parsers for the tool-test robustness blocks (`metamorphic:`, `fuzz:`, and
//! `negative_path:`).
//!
//! Pulled out of [`super::test_parse`] so that module stays under the
//! production-module-size guard. They are called only from `parse_tool_test`
//! and stay `pub(super)`, reusing the assertion and YAML helpers from
//! [`super::test_parse`].

use serde_yaml::Value as YamlValue;

use super::test_parse::{parse_expectations, yaml_to_json};
use super::types::{FuzzSpec, MetamorphicSpec, NegativePathSpec};
use super::ParseError;

/// Parse the optional `metamorphic:` block on a tool test (WOR-1237). The
/// block is `{ presets?: [...], relations?: [...], expect?: [...] }`. A
/// `presets:` entry names a built-in curated bundle (see
/// [`crate::eval::metamorphic::preset_relations`], WOR-1370) that expands to
/// parameter-free relations runnable with no per-tool wiring. Each explicit
/// relation is either a bare string (a no-argument relation) or a single-key
/// object naming the argument it operates on, deserialized through the engine's
/// [`crate::eval::metamorphic::Relation`] type so the YAML form and the engine
/// stay in lockstep. At least one of `presets:` or `relations:` must be present.
/// Returns `None` when the key is absent.
pub(super) fn parse_metamorphic(
    entry: &YamlValue,
    path: &str,
) -> Result<Option<MetamorphicSpec>, ParseError> {
    let Some(node) = entry.get("metamorphic") else {
        return Ok(None);
    };
    let mpath = format!("{path}/metamorphic");

    // Presets expand first so an explicit `relations:` list can extend them.
    let mut relations = parse_metamorphic_presets(node, &mpath)?;

    if let Some(rel_seq) = node.get("relations").and_then(|v| v.as_sequence()) {
        for (i, item) in rel_seq.iter().enumerate() {
            let json = yaml_to_json(item)?;
            let relation = serde_json::from_value::<crate::eval::metamorphic::Relation>(json).map_err(
                |_| ParseError::BadShape {
                    path: format!("{mpath}/relations/{i}"),
                    field: "relations",
                    expected: "a known relation: idempotent, arg_order_insensitive, case_insensitive, whitespace_insensitive, or a single-key { noop_filter | subset_under_filter | monotone } object",
                },
            )?;
            // Dedup so a preset plus an explicit duplicate does not check the
            // same relation twice (which would double-count violations).
            if !relations.contains(&relation) {
                relations.push(relation);
            }
        }
    }

    if relations.is_empty() {
        return Err(ParseError::Missing {
            path: mpath,
            field: "relations",
        });
    }
    // The `expect:` list reuses the standard assertion parser, evaluated by the
    // executor against the `metamorphic.*` metrics object.
    let expect = parse_expectations(node, &mpath)?;
    Ok(Some(MetamorphicSpec { relations, expect }))
}

/// Expand the optional `presets:` list into relations. Each entry is a built-in
/// bundle name; an unknown name is a typed error naming the available presets.
fn parse_metamorphic_presets(
    node: &YamlValue,
    mpath: &str,
) -> Result<Vec<crate::eval::metamorphic::Relation>, ParseError> {
    let Some(seq) = node.get("presets").and_then(|v| v.as_sequence()) else {
        return Ok(Vec::new());
    };
    let mut relations = Vec::new();
    for (i, item) in seq.iter().enumerate() {
        let name = item.as_str().ok_or(ParseError::BadShape {
            path: format!("{mpath}/presets/{i}"),
            field: "presets",
            expected: "a preset name string (one of: universal, text)",
        })?;
        let expanded =
            crate::eval::metamorphic::preset_relations(name).ok_or(ParseError::BadShape {
                path: format!("{mpath}/presets/{i}"),
                field: "presets",
                expected: "a known preset: universal or text",
            })?;
        for relation in expanded {
            if !relations.contains(&relation) {
                relations.push(relation);
            }
        }
    }
    Ok(relations)
}

/// Parse the optional `fuzz:` block on a tool test (WOR-1238). The block is
/// `{ seed?, cases?, max_call_ms?, expect?: [...] }`. All scalars default
/// (`seed` 0, `cases` and `max_call_ms` to the engine defaults). Returns `None`
/// when the key is absent.
pub(super) fn parse_fuzz(entry: &YamlValue, path: &str) -> Result<Option<FuzzSpec>, ParseError> {
    let Some(node) = entry.get("fuzz") else {
        return Ok(None);
    };
    let fpath = format!("{path}/fuzz");
    let seed = parse_u64(node, &fpath, "seed")?.unwrap_or(0);
    let cases = parse_u64(node, &fpath, "cases")?
        .map(|n| n as usize)
        .unwrap_or(crate::eval::fuzz::DEFAULT_CASES);
    let max_call_ms =
        parse_u64(node, &fpath, "max_call_ms")?.unwrap_or(crate::eval::fuzz::DEFAULT_MAX_CALL_MS);
    let expect = parse_expectations(node, &fpath)?;
    Ok(Some(FuzzSpec {
        seed,
        cases,
        max_call_ms,
        expect,
    }))
}

/// Parse an optional non-negative integer `field`. Returns `None` when absent;
/// rejects a non-integer or negative value with a typed error.
fn parse_u64(
    entry: &YamlValue,
    path: &str,
    field: &'static str,
) -> Result<Option<u64>, ParseError> {
    let Some(node) = entry.get(field) else {
        return Ok(None);
    };
    node.as_u64().map(Some).ok_or(ParseError::BadShape {
        path: path.to_string(),
        field,
        expected: "a non-negative integer",
    })
}

/// Parse the optional `negative_path:` block on a tool test (WOR-1239). The
/// block is `{ checks?: [...], expect?: [...] }`. An omitted `checks:` runs the
/// full probe set. Each check name maps to a probe via
/// [`crate::eval::negative_path::ProbeKind::from_label`].
pub(super) fn parse_negative_path(
    entry: &YamlValue,
    path: &str,
) -> Result<Option<NegativePathSpec>, ParseError> {
    let Some(node) = entry.get("negative_path") else {
        return Ok(None);
    };
    let np_path = format!("{path}/negative_path");
    let checks = match node.get("checks") {
        None => crate::eval::negative_path::ProbeKind::ALL.to_vec(),
        Some(seq) => {
            let arr = seq.as_sequence().ok_or(ParseError::BadShape {
                path: format!("{np_path}/checks"),
                field: "checks",
                expected: "a list of probe names",
            })?;
            let mut out = Vec::with_capacity(arr.len());
            for item in arr {
                let label = item.as_str().ok_or(ParseError::BadShape {
                    path: format!("{np_path}/checks"),
                    field: "checks",
                    expected: "a probe name string",
                })?;
                let kind = crate::eval::negative_path::ProbeKind::from_label(label).ok_or(
                    ParseError::BadShape {
                        path: format!("{np_path}/checks"),
                        field: "checks",
                        expected: "a known probe: unknown_tool, missing_required, wrong_type, extra_field, oversized",
                    },
                )?;
                out.push(kind);
            }
            out
        }
    };
    let expect = parse_expectations(node, &np_path)?;
    Ok(Some(NegativePathSpec { checks, expect }))
}

#[cfg(test)]
mod tests {
    use super::parse_metamorphic;
    use crate::eval::metamorphic::Relation;
    use crate::suite::ParseError;
    use serde_yaml::Value as YamlValue;

    fn entry(yaml: &str) -> YamlValue {
        serde_yaml::from_str(yaml).expect("valid test YAML")
    }

    #[test]
    fn the_universal_preset_expands_to_its_two_relations() {
        let spec = parse_metamorphic(&entry("metamorphic:\n  presets: [universal]\n"), "/t")
            .unwrap()
            .expect("metamorphic block present");
        assert_eq!(
            spec.relations,
            vec![Relation::Idempotent, Relation::ArgOrderInsensitive]
        );
    }

    #[test]
    fn the_text_preset_expands_to_its_two_relations() {
        let spec = parse_metamorphic(&entry("metamorphic:\n  presets: [text]\n"), "/t")
            .unwrap()
            .unwrap();
        assert_eq!(
            spec.relations,
            vec![Relation::CaseInsensitive, Relation::WhitespaceInsensitive]
        );
    }

    #[test]
    fn a_preset_merges_with_explicit_relations_and_dedups() {
        // `universal` already includes idempotent, so the explicit idempotent is
        // not checked twice; case_insensitive is the only net-new relation.
        let yaml =
            "metamorphic:\n  presets: [universal]\n  relations: [idempotent, case_insensitive]\n";
        let spec = parse_metamorphic(&entry(yaml), "/t").unwrap().unwrap();
        assert_eq!(
            spec.relations,
            vec![
                Relation::Idempotent,
                Relation::ArgOrderInsensitive,
                Relation::CaseInsensitive
            ]
        );
    }

    #[test]
    fn an_unknown_preset_is_rejected() {
        let err =
            parse_metamorphic(&entry("metamorphic:\n  presets: [bogus]\n"), "/t").unwrap_err();
        assert!(matches!(
            err,
            ParseError::BadShape {
                field: "presets",
                ..
            }
        ));
    }

    #[test]
    fn presets_only_with_no_relations_list_is_accepted() {
        let spec = parse_metamorphic(&entry("metamorphic:\n  presets: [universal]\n"), "/t")
            .unwrap()
            .unwrap();
        assert!(!spec.relations.is_empty());
    }

    #[test]
    fn neither_presets_nor_relations_is_rejected() {
        let err = parse_metamorphic(&entry("metamorphic:\n  expect: []\n"), "/t").unwrap_err();
        assert!(matches!(
            err,
            ParseError::Missing {
                field: "relations",
                ..
            }
        ));
    }
}
