//! Parsers for the agent-test gate blocks (`tool_selection:`,
//! `stability:`, `equal_function_sets:`, and `distractors:`).
//!
//! These live in their own module so `suite/mod.rs` stays under the
//! module-size ceiling. They are called only from `parse_agent_test`
//! in the parent module and stay `pub(super)`.

use serde_yaml::Value as YamlValue;

use crate::eval::distractors::{Complexity, DistractorSource, DistractorSpec};

use crate::eval::tool_edges::EdgeSet;
use crate::eval::{
    DependencyEdge, ExpectedArgs, ExpectedCall, ExpectedTrace, GoldenPath, MatchMode,
};

use super::test_parse::parse_expectations;
use super::{
    DistractorGate, EqualFunctionGate, GoldenPathGate, NameFreeDiscovery, OrchestrationGate,
    ParseError, StabilityGate, TokenEfficiencyGate, ToolEdgesGate, ToolSelectionFloor,
    TrajectoryAxesGate, TrajectoryGate,
};

/// Parse the optional `tool_selection:` block on an agent test.
/// Returns `None` when the field is absent. Requires `expected_tool`
/// and `min_selection_rate`; `max_total_tokens` is optional.
pub(super) fn parse_tool_selection(
    entry: &YamlValue,
    path: &str,
) -> Result<Option<ToolSelectionFloor>, ParseError> {
    let Some(node) = entry.get("tool_selection") else {
        return Ok(None);
    };
    let block_path = format!("{path}/tool_selection");
    let map = node.as_mapping().ok_or(ParseError::BadShape {
        path: block_path.clone(),
        field: "tool_selection",
        expected: "object with expected_tool / min_selection_rate",
    })?;
    let expected_tool = map
        .get(YamlValue::String("expected_tool".into()))
        .and_then(|v| v.as_str())
        .ok_or(ParseError::Missing {
            path: block_path.clone(),
            field: "expected_tool",
        })?
        .to_string();
    let min_selection_rate = map
        .get(YamlValue::String("min_selection_rate".into()))
        .and_then(|v| v.as_f64())
        .ok_or(ParseError::Missing {
            path: block_path.clone(),
            field: "min_selection_rate",
        })?;
    let max_total_tokens = map
        .get(YamlValue::String("max_total_tokens".into()))
        .and_then(|v| v.as_u64())
        .map(|n| n as u32);
    Ok(Some(ToolSelectionFloor {
        expected_tool,
        min_selection_rate,
        max_total_tokens,
    }))
}

/// Parse the optional `stability:` gate on an agent test.
///
/// Returns `None` when the field is absent. The block is an object whose only
/// key is an optional `expect:` list of assertions over the `stability.*`
/// targets; an empty or omitted `expect:` falls back to the default gate
/// (`stability.weakest_score >= 0.5`). The targets aggregate the N run traces,
/// so a `stability:` gate on a single-run test is a load error: a sample of
/// one cannot yield a variance or a cross-run weakest score.
pub(super) fn parse_stability_gate(
    entry: &YamlValue,
    path: &str,
    test_name: &str,
    runs: u32,
) -> Result<Option<StabilityGate>, ParseError> {
    let Some(node) = entry.get("stability") else {
        return Ok(None);
    };
    let block_path = format!("{path}/stability");
    if node.as_mapping().is_none() {
        return Err(ParseError::BadShape {
            path: block_path,
            field: "stability",
            expected: "an object with an optional expect: list",
        });
    }
    if runs < 2 {
        return Err(ParseError::StabilityGateArity {
            test: test_name.to_string(),
            runs,
        });
    }
    let expect = parse_expectations(node, &block_path)?;
    Ok(Some(StabilityGate { expect }))
}

/// Parse the optional `equal_function_sets:` gate on an agent test (MSC-Bench
/// objective tool-selection F1).
///
/// Returns `None` when the field is absent. The block is an object with a
/// required non-empty `classes:` list (each entry a `{name, members:[...]}`
/// equal-function set) and an optional `expect:` list of assertions over the
/// `tool_selection.*` targets. An empty or omitted `expect:` falls back to the
/// default gate (`tool_selection.f1 >= 50`). A block with no classes is a load
/// error: there is nothing to score against.
pub(super) fn parse_equal_function_gate(
    entry: &YamlValue,
    path: &str,
) -> Result<Option<EqualFunctionGate>, ParseError> {
    let Some(node) = entry.get("equal_function_sets") else {
        return Ok(None);
    };
    let block_path = format!("{path}/equal_function_sets");
    if node.as_mapping().is_none() {
        return Err(ParseError::BadShape {
            path: block_path,
            field: "equal_function_sets",
            expected: "an object with a `classes:` list and an optional `expect:` list",
        });
    }
    let classes = parse_equal_function_classes(node, &block_path)?;
    let expect = parse_expectations(node, &block_path)?;
    Ok(Some(EqualFunctionGate { classes, expect }))
}

/// Parse the optional `token_efficiency:` gate on an agent test (WOR-1366).
///
/// Returns `None` when the field is absent. The block reuses the
/// `equal_function_sets:` shape: a required non-empty `classes:` list and an
/// optional `expect:` over the `token_efficiency.*` targets. An empty or omitted
/// `expect:` falls back to the default gate (`token_efficiency.f1 >= 50`). A
/// block with no classes is a load error: there is nothing to score selection
/// against, so the efficiency figures would have no F1 to ride alongside.
pub(super) fn parse_token_efficiency_gate(
    entry: &YamlValue,
    path: &str,
) -> Result<Option<TokenEfficiencyGate>, ParseError> {
    let Some(node) = entry.get("token_efficiency") else {
        return Ok(None);
    };
    let block_path = format!("{path}/token_efficiency");
    if node.as_mapping().is_none() {
        return Err(ParseError::BadShape {
            path: block_path,
            field: "token_efficiency",
            expected: "an object with a `classes:` list and an optional `expect:` list",
        });
    }
    let classes = parse_equal_function_classes(node, &block_path)?;
    let expect = parse_expectations(node, &block_path)?;
    Ok(Some(TokenEfficiencyGate { classes, expect }))
}

/// Parse the optional `tool_edges:` gate on an agent test (WOR-1242).
///
/// Returns `None` when the field is absent. The block carries optional
/// `allowed:` and `restricted:` lists of tool names, an optional `delegation:`
/// list of `{ from, to }` agent hand-offs, and an optional `expect:` list over
/// the `edges.*` targets. An empty or omitted `expect:` falls back to the
/// default gate (`edges.restricted_attempts <= 0`).
pub(super) fn parse_tool_edges_gate(
    entry: &YamlValue,
    path: &str,
) -> Result<Option<ToolEdgesGate>, ParseError> {
    let Some(node) = entry.get("tool_edges") else {
        return Ok(None);
    };
    let block_path = format!("{path}/tool_edges");
    if node.as_mapping().is_none() {
        return Err(ParseError::BadShape {
            path: block_path,
            field: "tool_edges",
            expected: "an object with allowed/restricted/delegation lists and an optional expect:",
        });
    }
    let allowed = parse_edge_names(node, &block_path, "allowed")?;
    let restricted = parse_edge_names(node, &block_path, "restricted")?;
    let delegation = parse_delegation_edges(node, &block_path)?;
    let expect = parse_expectations(node, &block_path)?;
    Ok(Some(ToolEdgesGate {
        set: EdgeSet {
            allowed,
            restricted,
            delegation,
        },
        expect,
    }))
}

/// Parse an optional list of tool-name strings under `field`. Empty when absent.
fn parse_edge_names(
    node: &YamlValue,
    path: &str,
    field: &'static str,
) -> Result<Vec<String>, ParseError> {
    let Some(value) = node.get(field) else {
        return Ok(Vec::new());
    };
    let seq = value.as_sequence().ok_or(ParseError::BadShape {
        path: format!("{path}/{field}"),
        field,
        expected: "a list of tool names",
    })?;
    let mut out = Vec::with_capacity(seq.len());
    for item in seq {
        let name = item.as_str().ok_or(ParseError::BadShape {
            path: format!("{path}/{field}"),
            field,
            expected: "a tool name string",
        })?;
        out.push(name.to_string());
    }
    Ok(out)
}

/// Parse the optional `delegation:` list of `{ from, to }` hand-offs.
fn parse_delegation_edges(
    node: &YamlValue,
    path: &str,
) -> Result<Vec<(String, String)>, ParseError> {
    let Some(value) = node.get("delegation") else {
        return Ok(Vec::new());
    };
    let seq = value.as_sequence().ok_or(ParseError::BadShape {
        path: format!("{path}/delegation"),
        field: "delegation",
        expected: "a list of { from, to } objects",
    })?;
    let mut out = Vec::with_capacity(seq.len());
    for item in seq {
        let from = item.get("from").and_then(|v| v.as_str());
        let to = item.get("to").and_then(|v| v.as_str());
        match (from, to) {
            (Some(f), Some(t)) => out.push((f.to_string(), t.to_string())),
            _ => {
                return Err(ParseError::BadShape {
                    path: format!("{path}/delegation"),
                    field: "delegation",
                    expected: "each entry needs a `from` and a `to` string",
                })
            }
        }
    }
    Ok(out)
}

/// Parse the optional `discovery:` declaration on an agent test (MCP-Atlas
/// name-free discovery).
///
/// Returns `None` when the field is absent. The block is an object whose only
/// key is the boolean `name_free:`; when true it declares the prompt is
/// intent-only and names no tools or servers, so the discovered path is scored
/// against equal-function classes. An absent `name_free:` defaults to false.
pub(super) fn parse_name_free_discovery(
    entry: &YamlValue,
    path: &str,
) -> Result<Option<NameFreeDiscovery>, ParseError> {
    let Some(node) = entry.get("discovery") else {
        return Ok(None);
    };
    let block_path = format!("{path}/discovery");
    if node.as_mapping().is_none() {
        return Err(ParseError::BadShape {
            path: block_path,
            field: "discovery",
            expected: "an object with a `name_free:` boolean",
        });
    }
    let name_free = node
        .get("name_free")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);
    Ok(Some(NameFreeDiscovery { name_free }))
}

/// Parse the optional `orchestration:` gate on an agent test (MCP-Atlas
/// orchestration diagnostics).
///
/// Returns `None` when the field is absent. The block is an object whose only
/// key is an optional `expect:` list of assertions over the `orchestration.*`
/// targets; an empty or omitted `expect:` computes and reports the five
/// sub-scores without asserting a floor. The sub-scores are deterministic and
/// model-free (see [`crate::eval::orchestration`]).
pub(super) fn parse_orchestration_gate(
    entry: &YamlValue,
    path: &str,
) -> Result<Option<OrchestrationGate>, ParseError> {
    let Some(node) = entry.get("orchestration") else {
        return Ok(None);
    };
    let block_path = format!("{path}/orchestration");
    if node.as_mapping().is_none() {
        return Err(ParseError::BadShape {
            path: block_path,
            field: "orchestration",
            expected: "an object with an optional `expect:` list",
        });
    }
    let expect = parse_expectations(node, &block_path)?;
    Ok(Some(OrchestrationGate { expect }))
}

/// Parse the required `classes:` list of an `equal_function_sets:` block.
///
/// Each entry is an object with a required `name:` string and a required
/// non-empty `members:` list of `server.tool` ids. The list itself must be
/// present and non-empty so the gate always has something to score.
fn parse_equal_function_classes(
    node: &YamlValue,
    block_path: &str,
) -> Result<Vec<crate::eval::tool_selection_f1::EqualFunctionSet>, ParseError> {
    let seq = node
        .get("classes")
        .and_then(|v| v.as_sequence())
        .ok_or(ParseError::Missing {
            path: block_path.to_string(),
            field: "classes",
        })?;
    if seq.is_empty() {
        return Err(ParseError::BadShape {
            path: block_path.to_string(),
            field: "classes",
            expected: "at least one equal-function set",
        });
    }
    let mut classes = Vec::with_capacity(seq.len());
    for (i, entry) in seq.iter().enumerate() {
        let class_path = format!("{block_path}/classes/{i}");
        let name = entry
            .get("name")
            .and_then(|v| v.as_str())
            .ok_or(ParseError::Missing {
                path: class_path.clone(),
                field: "name",
            })?
            .to_string();
        let members_seq =
            entry
                .get("members")
                .and_then(|v| v.as_sequence())
                .ok_or(ParseError::Missing {
                    path: class_path.clone(),
                    field: "members",
                })?;
        if members_seq.is_empty() {
            return Err(ParseError::BadShape {
                path: class_path,
                field: "members",
                expected: "at least one `server.tool` id",
            });
        }
        let mut members = Vec::with_capacity(members_seq.len());
        for member in members_seq {
            let id = member.as_str().ok_or(ParseError::BadShape {
                path: class_path.clone(),
                field: "members",
                expected: "a list of `server.tool` string ids",
            })?;
            members.push(id.to_string());
        }
        classes.push(crate::eval::tool_selection_f1::EqualFunctionSet { name, members });
    }
    Ok(classes)
}

/// Parse the optional `distractors:` gate on an agent test (MCPAgentBench
/// tool-overload). Returns `None` when the field is absent.
///
/// The block is an object with a required `count:`, a required `source:` (an
/// object selecting `catalog` or `near_duplicate`), a required non-empty
/// `correct:` list of tool ids, an optional `complexity:` tag, and an optional
/// `expect:` list over the `distractors.*` targets. An empty or omitted
/// `expect:` falls back to the default gate (`distractors.accuracy >= 50`). A
/// block with no correct tool is a load error: there is nothing to score.
pub(super) fn parse_distractor_gate(
    entry: &YamlValue,
    path: &str,
) -> Result<Option<DistractorGate>, ParseError> {
    let Some(node) = entry.get("distractors") else {
        return Ok(None);
    };
    let block_path = format!("{path}/distractors");
    if node.as_mapping().is_none() {
        return Err(ParseError::BadShape {
            path: block_path,
            field: "distractors",
            expected: "an object with count:, source:, and correct: keys",
        });
    }
    let count = node
        .get("count")
        .and_then(|v| v.as_u64())
        .ok_or(ParseError::Missing {
            path: block_path.clone(),
            field: "count",
        })? as u32;
    let source = parse_distractor_source(node, &block_path)?;
    let complexity = parse_complexity(node, &block_path)?;
    let correct = parse_correct_tools(node, &block_path)?;
    let expect = parse_expectations(node, &block_path)?;
    let spec = DistractorSpec {
        count,
        source,
        complexity,
    };
    Ok(Some(DistractorGate {
        spec,
        correct,
        expect,
    }))
}

/// Parse the optional `golden_path:` gate on an agent test (WOR-1380).
/// Returns `None` when the field is absent.
///
/// The block is an object with a required non-empty `calls:` list (the ideal
/// tool names in order) and three optional boolean policy flags
/// (`allow_extra_steps` default false, `penalize_backtracking` and
/// `penalize_repeated_tools` default true), plus an optional `expect:` list
/// over the `golden_path.*` targets. An empty or omitted `expect:` falls back
/// to the default gate (`golden_path.passed >= 1`).
pub(super) fn parse_golden_path_gate(
    entry: &YamlValue,
    path: &str,
) -> Result<Option<GoldenPathGate>, ParseError> {
    let Some(node) = entry.get("golden_path") else {
        return Ok(None);
    };
    let block_path = format!("{path}/golden_path");
    if node.as_mapping().is_none() {
        return Err(ParseError::BadShape {
            path: block_path,
            field: "golden_path",
            expected: "an object with a calls: list",
        });
    }
    let calls = parse_string_list(node, &block_path, "calls")?;
    if calls.is_empty() {
        return Err(ParseError::BadShape {
            path: block_path,
            field: "calls",
            expected: "at least one tool name in the ideal path",
        });
    }
    let allow_extra_steps = parse_bool_flag(node, "allow_extra_steps", false);
    let penalize_backtracking = parse_bool_flag(node, "penalize_backtracking", true);
    let penalize_repeated_tools = parse_bool_flag(node, "penalize_repeated_tools", true);
    let expect = parse_expectations(node, &block_path)?;
    Ok(Some(GoldenPathGate {
        path: GoldenPath {
            calls,
            allow_extra_steps,
            penalize_backtracking,
            penalize_repeated_tools,
        },
        expect,
    }))
}

/// Read an optional boolean flag, falling back to `default` when the key is
/// absent or not a boolean. The golden-path policy flags share this.
fn parse_bool_flag(node: &YamlValue, field: &str, default: bool) -> bool {
    node.get(field).and_then(|v| v.as_bool()).unwrap_or(default)
}

/// Parse the optional `trajectory:` gate on an agent test (WOR-1380).
/// Returns `None` when the field is absent.
///
/// The block is an object with a required non-empty `calls:` list (each entry
/// an object with a required `name:` and an optional `args:` comparator) and a
/// required `mode:` (one of strict, exact_sequence, subsequence, unordered,
/// subset, superset), plus an optional `expect:` list over the `trajectory.*`
/// targets. An empty or omitted `expect:` falls back to the default gate
/// (`trajectory.passed >= 1`).
pub(super) fn parse_trajectory_gate(
    entry: &YamlValue,
    path: &str,
) -> Result<Option<TrajectoryGate>, ParseError> {
    let Some(node) = entry.get("trajectory") else {
        return Ok(None);
    };
    let block_path = format!("{path}/trajectory");
    if node.as_mapping().is_none() {
        return Err(ParseError::BadShape {
            path: block_path,
            field: "trajectory",
            expected: "an object with calls: and mode: keys",
        });
    }
    let mode = parse_match_mode(node, &block_path)?;
    let calls = parse_expected_calls(node, &block_path)?;
    let expect = parse_expectations(node, &block_path)?;
    Ok(Some(TrajectoryGate {
        expected: ExpectedTrace { calls, mode },
        expect,
    }))
}

/// Parse the optional `trajectory_axes:` gate on an agent test (WOR-1368).
/// Returns `None` when the field is absent.
///
/// The block is an object with optional `dependencies:` (list of
/// `{ producer, consumer }` pairs) and optional `order:` (list of
/// `{ first, second }` pairs), plus an optional `expect:` list over the
/// `trajectory.*` axis targets. An empty or omitted `expect:` falls back to the
/// default gate (both axes at 100).
pub(super) fn parse_trajectory_axes_gate(
    entry: &YamlValue,
    path: &str,
) -> Result<Option<TrajectoryAxesGate>, ParseError> {
    let Some(node) = entry.get("trajectory_axes") else {
        return Ok(None);
    };
    let block_path = format!("{path}/trajectory_axes");
    if node.as_mapping().is_none() {
        return Err(ParseError::BadShape {
            path: block_path,
            field: "trajectory_axes",
            expected: "an object with optional dependencies:, order:, and expect: keys",
        });
    }
    let dependencies = parse_dependency_edges(node, &block_path)?;
    let order = parse_order_pairs(node, &block_path)?;
    let expect = parse_expectations(node, &block_path)?;
    Ok(Some(TrajectoryAxesGate {
        dependencies,
        order,
        expect,
    }))
}

/// Parse the optional `dependencies:` list of `{ producer, consumer }` pairs.
/// Empty when absent. Each entry needs both names.
fn parse_dependency_edges(
    node: &YamlValue,
    block_path: &str,
) -> Result<Vec<DependencyEdge>, ParseError> {
    let Some(value) = node.get("dependencies") else {
        return Ok(Vec::new());
    };
    let seq = value.as_sequence().ok_or(ParseError::BadShape {
        path: format!("{block_path}/dependencies"),
        field: "dependencies",
        expected: "a list of { producer, consumer } objects",
    })?;
    let mut out = Vec::with_capacity(seq.len());
    for item in seq {
        let producer = item.get("producer").and_then(|v| v.as_str());
        let consumer = item.get("consumer").and_then(|v| v.as_str());
        match (producer, consumer) {
            (Some(p), Some(c)) => out.push(DependencyEdge::new(p, c)),
            _ => {
                return Err(ParseError::BadShape {
                    path: format!("{block_path}/dependencies"),
                    field: "dependencies",
                    expected: "each entry needs a `producer` and a `consumer` string",
                })
            }
        }
    }
    Ok(out)
}

/// Parse the optional `order:` list of `{ first, second }` pairs. Empty when
/// absent. Each entry needs both names.
fn parse_order_pairs(
    node: &YamlValue,
    block_path: &str,
) -> Result<Vec<(String, String)>, ParseError> {
    let Some(value) = node.get("order") else {
        return Ok(Vec::new());
    };
    let seq = value.as_sequence().ok_or(ParseError::BadShape {
        path: format!("{block_path}/order"),
        field: "order",
        expected: "a list of { first, second } objects",
    })?;
    let mut out = Vec::with_capacity(seq.len());
    for item in seq {
        let first = item.get("first").and_then(|v| v.as_str());
        let second = item.get("second").and_then(|v| v.as_str());
        match (first, second) {
            (Some(a), Some(b)) => out.push((a.to_string(), b.to_string())),
            _ => {
                return Err(ParseError::BadShape {
                    path: format!("{block_path}/order"),
                    field: "order",
                    expected: "each entry needs a `first` and a `second` string",
                })
            }
        }
    }
    Ok(out)
}

/// Parse the required `mode:` of a `trajectory:` block into a [`MatchMode`].
fn parse_match_mode(node: &YamlValue, block_path: &str) -> Result<MatchMode, ParseError> {
    let mode = node
        .get("mode")
        .and_then(|v| v.as_str())
        .ok_or(ParseError::Missing {
            path: block_path.to_string(),
            field: "mode",
        })?;
    match mode {
        "strict" => Ok(MatchMode::Strict),
        "exact_sequence" => Ok(MatchMode::ExactSequence),
        "subsequence" => Ok(MatchMode::Subsequence),
        "unordered" => Ok(MatchMode::Unordered),
        "subset" => Ok(MatchMode::Subset),
        "superset" => Ok(MatchMode::Superset),
        _ => Err(ParseError::BadShape {
            path: block_path.to_string(),
            field: "mode",
            expected: "one of strict, exact_sequence, subsequence, unordered, subset, superset",
        }),
    }
}

/// Parse the required non-empty `calls:` list of a `trajectory:` block into
/// [`ExpectedCall`]s. Each entry needs a `name:`; an absent `args:` means
/// [`ExpectedArgs::Any`], otherwise the value deserializes into the comparator
/// (`any` | `ignore` | `{exact: ...}` | `{subset: ...}` | `{schema: ...}`).
fn parse_expected_calls(
    node: &YamlValue,
    block_path: &str,
) -> Result<Vec<ExpectedCall>, ParseError> {
    let seq = node
        .get("calls")
        .and_then(|v| v.as_sequence())
        .ok_or(ParseError::Missing {
            path: block_path.to_string(),
            field: "calls",
        })?;
    if seq.is_empty() {
        return Err(ParseError::BadShape {
            path: block_path.to_string(),
            field: "calls",
            expected: "at least one expected call",
        });
    }
    let mut out = Vec::with_capacity(seq.len());
    for (i, item) in seq.iter().enumerate() {
        let call_path = format!("{block_path}/calls[{i}]");
        let name = item
            .get("name")
            .and_then(|v| v.as_str())
            .ok_or(ParseError::Missing {
                path: call_path.clone(),
                field: "name",
            })?;
        let args = match item.get("args") {
            None => ExpectedArgs::Any,
            // The args comparator is a serde enum whose Exact/Subset/Schema
            // variants hold a `serde_json::Value`, so bridge YAML to JSON first
            // and deserialize natively. A direct yaml-to-ExpectedArgs path
            // mis-handles the inner JSON value.
            Some(value) => {
                let json: serde_json::Value =
                    serde_yaml::from_value(value.clone()).map_err(|_| ParseError::BadShape {
                        path: call_path.clone(),
                        field: "args",
                        expected: "any | ignore | {exact: ...} | {subset: ...} | {schema: ...}",
                    })?;
                serde_json::from_value::<ExpectedArgs>(json).map_err(|_| ParseError::BadShape {
                    path: call_path.clone(),
                    field: "args",
                    expected: "any | ignore | {exact: ...} | {subset: ...} | {schema: ...}",
                })?
            }
        };
        out.push(ExpectedCall {
            name: name.to_string(),
            args,
        });
    }
    Ok(out)
}

/// Parse the required `source:` of a `distractors:` block.
///
/// Shape: `source: { from: catalog }` or
/// `source: { from: near_duplicate, of: [tool, ...] }`. The `near_duplicate`
/// form requires a non-empty `of:` list of real tool names to derive variants
/// from.
pub(super) fn parse_distractor_source(
    node: &YamlValue,
    block_path: &str,
) -> Result<DistractorSource, ParseError> {
    let source = node.get("source").ok_or(ParseError::Missing {
        path: block_path.to_string(),
        field: "source",
    })?;
    let from = source
        .get("from")
        .and_then(|v| v.as_str())
        .ok_or(ParseError::Missing {
            path: format!("{block_path}/source"),
            field: "from",
        })?;
    match from {
        "catalog" => Ok(DistractorSource::Catalog),
        "near_duplicate" => {
            let of = parse_string_list(source, &format!("{block_path}/source"), "of")?;
            Ok(DistractorSource::NearDuplicate { of })
        }
        _ => Err(ParseError::BadShape {
            path: format!("{block_path}/source"),
            field: "from",
            expected: "either `catalog` or `near_duplicate`",
        }),
    }
}

/// Parse the optional `complexity:` tag of a `distractors:` block.
///
/// Accepts `serial` or `parallel`; absent yields `None`. Any other value is a
/// load error so a typo fails fast rather than silently dropping the tag.
fn parse_complexity(node: &YamlValue, block_path: &str) -> Result<Option<Complexity>, ParseError> {
    let Some(value) = node.get("complexity") else {
        return Ok(None);
    };
    match value.as_str() {
        Some("serial") => Ok(Some(Complexity::Serial)),
        Some("parallel") => Ok(Some(Complexity::Parallel)),
        _ => Err(ParseError::BadShape {
            path: block_path.to_string(),
            field: "complexity",
            expected: "either `serial` or `parallel`",
        }),
    }
}

/// Parse the required non-empty `correct:` list of a `distractors:` block.
pub(super) fn parse_correct_tools(
    node: &YamlValue,
    block_path: &str,
) -> Result<Vec<String>, ParseError> {
    let correct = parse_string_list(node, block_path, "correct")?;
    if correct.is_empty() {
        return Err(ParseError::BadShape {
            path: block_path.to_string(),
            field: "correct",
            expected: "at least one correct tool id",
        });
    }
    Ok(correct)
}

/// Read a required list-of-strings field, erroring if it is missing or holds a
/// non-string entry. Shared by the source and correct-tool parsers.
fn parse_string_list(
    node: &YamlValue,
    block_path: &str,
    field: &'static str,
) -> Result<Vec<String>, ParseError> {
    let seq = node
        .get(field)
        .and_then(|v| v.as_sequence())
        .ok_or(ParseError::Missing {
            path: block_path.to_string(),
            field,
        })?;
    let mut out = Vec::with_capacity(seq.len());
    for item in seq {
        let value = item.as_str().ok_or(ParseError::BadShape {
            path: block_path.to_string(),
            field,
            expected: "a list of string ids",
        })?;
        out.push(value.to_string());
    }
    Ok(out)
}

/// Parse the optional `matrix_assert:` block on an agent test.
///
/// Returns `None` when the field is absent. The block carries an optional
/// `max_score` floor (0..1, the same range as `threshold:`) and an optional
/// `select_best` boolean (defaults `false`). A block that declares neither
/// is rejected: an empty `matrix_assert:` is almost always a typo, and
/// silently parsing it would make every cell carry a score for no reason.
pub(super) fn parse_matrix_assert(
    entry: &YamlValue,
    path: &str,
) -> Result<Option<super::MatrixAssert>, ParseError> {
    let Some(node) = entry.get("matrix_assert") else {
        return Ok(None);
    };
    let block_path = format!("{path}/matrix_assert");
    if node.as_mapping().is_none() {
        return Err(ParseError::BadShape {
            path: block_path,
            field: "matrix_assert",
            expected: "object with max_score and/or select_best",
        });
    }
    let max_score = super::test_parse::parse_unit_threshold(node, &block_path, "max_score")?;
    let select_best = match node.get("select_best") {
        None => false,
        Some(v) => v.as_bool().ok_or(ParseError::BadShape {
            path: block_path.clone(),
            field: "select_best",
            expected: "a boolean",
        })?,
    };
    if max_score.is_none() && !select_best {
        return Err(ParseError::BadShape {
            path: block_path,
            field: "matrix_assert",
            expected: "at least one of max_score or select_best",
        });
    }
    Ok(Some(super::MatrixAssert {
        max_score,
        select_best,
    }))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::eval::distractors::{Complexity, DistractorSource};
    use crate::eval::{ExpectedArgs, MatchMode};

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

    #[test]
    fn absent_distractors_block_parses_to_none() {
        let node = entry("name: x\nprompt: hi\n");
        assert!(parse_distractor_gate(&node, "/agents/0")
            .expect("parse ok")
            .is_none());
    }

    #[test]
    fn absent_token_efficiency_block_parses_to_none() {
        let node = entry("name: x\nprompt: hi\n");
        assert!(parse_token_efficiency_gate(&node, "/agents/0")
            .expect("parse ok")
            .is_none());
    }

    #[test]
    fn token_efficiency_gate_parses_classes_and_defaults_expect() {
        let node = entry(
            "token_efficiency:\n  classes:\n    - name: search\n      members: [search, find]\n",
        );
        let gate = parse_token_efficiency_gate(&node, "/agents/0")
            .expect("parse ok")
            .expect("gate present");
        assert_eq!(gate.classes.len(), 1);
        assert_eq!(gate.classes[0].name, "search");
        assert_eq!(gate.classes[0].members, vec!["search", "find"]);
        // An absent expect: list falls back to the default floor.
        assert!(gate.expect.is_empty());
    }

    #[test]
    fn token_efficiency_gate_without_classes_is_a_load_error() {
        let node = entry("token_efficiency:\n  expect: []\n");
        assert!(parse_token_efficiency_gate(&node, "/agents/0").is_err());
    }

    #[test]
    fn catalog_distractor_gate_parses_count_source_and_complexity() {
        let node = entry(
            "distractors:\n  count: 3\n  source:\n    from: catalog\n  correct: [google.search]\n  complexity: serial\n",
        );
        let gate = parse_distractor_gate(&node, "/agents/0")
            .expect("parse ok")
            .expect("gate present");
        assert_eq!(gate.spec.count, 3);
        assert_eq!(gate.spec.source, DistractorSource::Catalog);
        assert_eq!(gate.spec.complexity, Some(Complexity::Serial));
        assert_eq!(gate.correct, vec!["google.search".to_string()]);
    }

    #[test]
    fn near_duplicate_distractor_gate_parses_the_of_list() {
        let node = entry(
            "distractors:\n  count: 2\n  source:\n    from: near_duplicate\n    of: [search, get]\n  correct: [google.search]\n",
        );
        let gate = parse_distractor_gate(&node, "/agents/0")
            .expect("parse ok")
            .expect("gate present");
        assert_eq!(
            gate.spec.source,
            DistractorSource::NearDuplicate {
                of: vec!["search".to_string(), "get".to_string()],
            }
        );
    }

    #[test]
    fn distractor_gate_without_correct_is_a_load_error() {
        let node = entry("distractors:\n  count: 2\n  source:\n    from: catalog\n");
        assert!(parse_distractor_gate(&node, "/agents/0").is_err());
    }

    #[test]
    fn distractor_gate_with_unknown_source_is_a_load_error() {
        let node = entry(
            "distractors:\n  count: 1\n  source:\n    from: nonsense\n  correct: [google.search]\n",
        );
        assert!(parse_distractor_gate(&node, "/agents/0").is_err());
    }

    #[test]
    fn absent_golden_path_and_trajectory_parse_to_none() {
        let node = entry("name: x\nprompt: hi\n");
        assert!(parse_golden_path_gate(&node, "/agents/0")
            .expect("ok")
            .is_none());
        assert!(parse_trajectory_gate(&node, "/agents/0")
            .expect("ok")
            .is_none());
    }

    #[test]
    fn golden_path_gate_parses_calls_and_policy_flags() {
        let node = entry(
            "golden_path:\n  calls: [search, fetch]\n  allow_extra_steps: true\n  penalize_repeated_tools: false\n",
        );
        let gate = parse_golden_path_gate(&node, "/agents/0")
            .expect("ok")
            .expect("gate present");
        assert_eq!(
            gate.path.calls,
            vec!["search".to_string(), "fetch".to_string()]
        );
        assert!(gate.path.allow_extra_steps);
        // penalize_backtracking defaults to true when omitted.
        assert!(gate.path.penalize_backtracking);
        assert!(!gate.path.penalize_repeated_tools);
    }

    #[test]
    fn golden_path_gate_without_calls_is_a_load_error() {
        let node = entry("golden_path:\n  allow_extra_steps: true\n");
        assert!(parse_golden_path_gate(&node, "/agents/0").is_err());
    }

    #[test]
    fn trajectory_gate_parses_calls_mode_and_args() {
        let node = entry(
            "trajectory:\n  mode: subsequence\n  calls:\n    - name: search\n    - name: fetch\n      args:\n        subset:\n          q: hi\n",
        );
        let gate = parse_trajectory_gate(&node, "/agents/0")
            .expect("ok")
            .expect("gate present");
        assert_eq!(gate.expected.mode, MatchMode::Subsequence);
        assert_eq!(gate.expected.calls.len(), 2);
        assert_eq!(gate.expected.calls[0].name, "search");
        // An absent args comparator means "any".
        assert_eq!(gate.expected.calls[0].args, ExpectedArgs::Any);
        assert!(matches!(
            gate.expected.calls[1].args,
            ExpectedArgs::Subset(_)
        ));
    }

    #[test]
    fn trajectory_gate_with_unknown_mode_is_a_load_error() {
        let node = entry("trajectory:\n  mode: nonsense\n  calls:\n    - name: search\n");
        assert!(parse_trajectory_gate(&node, "/agents/0").is_err());
    }

    #[test]
    fn trajectory_gate_without_mode_is_a_load_error() {
        let node = entry("trajectory:\n  calls:\n    - name: search\n");
        assert!(parse_trajectory_gate(&node, "/agents/0").is_err());
    }

    #[test]
    fn absent_trajectory_axes_block_parses_to_none() {
        let node = entry("name: x\nprompt: hi\n");
        assert!(parse_trajectory_axes_gate(&node, "/agents/0")
            .expect("parse ok")
            .is_none());
    }

    #[test]
    fn trajectory_axes_gate_parses_dependencies_and_order() {
        let node = entry(
            "trajectory_axes:\n  dependencies:\n    - producer: search\n      consumer: fetch\n  order:\n    - first: auth\n      second: search\n",
        );
        let gate = parse_trajectory_axes_gate(&node, "/agents/0")
            .expect("parse ok")
            .expect("gate present");
        assert_eq!(
            gate.dependencies,
            vec![DependencyEdge::new("search", "fetch")]
        );
        assert_eq!(gate.order, vec![("auth".to_string(), "search".to_string())]);
        // An absent expect: list falls back to the default (both axes at 100).
        assert!(gate.expect.is_empty());
    }

    #[test]
    fn trajectory_axes_gate_with_only_order_parses() {
        let node = entry("trajectory_axes:\n  order:\n    - first: a\n      second: b\n");
        let gate = parse_trajectory_axes_gate(&node, "/agents/0")
            .expect("parse ok")
            .expect("gate present");
        assert!(gate.dependencies.is_empty());
        assert_eq!(gate.order, vec![("a".to_string(), "b".to_string())]);
    }

    #[test]
    fn trajectory_axes_dependency_without_consumer_is_a_load_error() {
        let node = entry("trajectory_axes:\n  dependencies:\n    - producer: search\n");
        assert!(parse_trajectory_axes_gate(&node, "/agents/0").is_err());
    }

    #[test]
    fn trajectory_axes_order_without_second_is_a_load_error() {
        let node = entry("trajectory_axes:\n  order:\n    - first: a\n");
        assert!(parse_trajectory_axes_gate(&node, "/agents/0").is_err());
    }
}
