//! Test-section parsing for the suite loader.
//!
//! Holds the `tools:` / `resources:` / `prompts:` per-entry parsers plus the
//! YAML-to-JSON coercion the matchers and tool args share. Pulled out of
//! [`super`] so the parsing half stays under the AGENTS module-size ceiling;
//! the matcher grammar lives in the [`super::matcher_parse`] sibling for the
//! same reason. All functions are crate-private; [`super::parse`] drives them.

use serde_json::Value;
use serde_yaml::Value as YamlValue;

use super::agent_types::{AgentTurns, SimulatedUser};
use super::matcher_parse::parse_matcher;
use super::robustness_parse::{parse_fuzz, parse_metamorphic, parse_negative_path};
use super::types::{
    Action, AssertSet, Assertion, TransformCommand, TransformSpec, TransformStep, TypedTest,
};
use super::ParseError;

pub(super) fn parse_test_section<F>(
    root: &YamlValue,
    section: &'static str,
    mut per_test: F,
    out: &mut Vec<TypedTest>,
) -> Result<(), ParseError>
where
    F: FnMut(&YamlValue, &str) -> Result<TypedTest, ParseError>,
{
    let Some(seq) = root.get(section).and_then(|v| v.as_sequence()) else {
        return Ok(());
    };
    for (i, entry) in seq.iter().enumerate() {
        let path = format!("/{section}/{i}");
        out.push(per_test(entry, &path)?);
    }
    Ok(())
}

pub(super) fn parse_tool_test(entry: &YamlValue, path: &str) -> Result<TypedTest, ParseError> {
    let name = require_str(entry, path, "name")?.to_string();
    let server = require_str(entry, path, "server")?.to_string();
    let tool = require_str(entry, path, "tool")?.to_string();
    let args = entry
        .get("args")
        .map(yaml_to_json)
        .transpose()?
        .unwrap_or(Value::Object(Default::default()));
    let expectations = parse_expectations(entry, path)?;
    let budgets = super::budgets_parse::parse_expect_budgets(entry, path)?;
    let tags = parse_tags(entry, path)?;
    let (skip, only) = parse_skip_only(entry, path)?;
    let assert_sets = parse_assert_sets(entry, path)?;
    let threshold = parse_test_threshold(entry, path)?;
    let derived_metrics = super::derived_parse::parse_derived_metrics(entry, path)?;
    let transform = parse_transform(entry, path)?;
    let metamorphic = parse_metamorphic(entry, path)?;
    let fuzz = parse_fuzz(entry, path)?;
    let negative_path = parse_negative_path(entry, path)?;
    // Optional named-error fixture reference. Validated against
    // the suite's fixtures.errors[] list in `suite::parse` after every
    // test is parsed.
    let inject_error = entry
        .get("inject_error")
        .map(|node| {
            node.as_str()
                .map(str::to_string)
                .ok_or(ParseError::BadShape {
                    path: path.to_string(),
                    field: "inject_error",
                    expected: "string (name of an entry under `fixtures.errors[]`)",
                })
        })
        .transpose()?;
    let (input_responses, input_responder) =
        super::elicitation_parse::parse_test_input_config(entry, path)?;
    Ok(TypedTest {
        name,
        server,
        action: Action::ToolCall { tool, args },
        expectations,
        budgets,
        tags,
        skip,
        only,
        assert_sets,
        threshold,
        derived_metrics,
        transform,
        inject_error,
        metamorphic,
        fuzz,
        negative_path,
        input_responses,
        input_responder,
    })
}

pub(super) fn parse_resource_test(entry: &YamlValue, path: &str) -> Result<TypedTest, ParseError> {
    let name = require_str(entry, path, "name")?.to_string();
    let server = require_str(entry, path, "server")?.to_string();
    let uri = require_str(entry, path, "uri")?.to_string();
    let expectations = parse_expectations(entry, path)?;
    let budgets = super::budgets_parse::parse_expect_budgets(entry, path)?;
    let tags = parse_tags(entry, path)?;
    let (skip, only) = parse_skip_only(entry, path)?;
    let assert_sets = parse_assert_sets(entry, path)?;
    let threshold = parse_test_threshold(entry, path)?;
    let derived_metrics = super::derived_parse::parse_derived_metrics(entry, path)?;
    Ok(TypedTest {
        name,
        server,
        action: Action::ResourceRead { uri },
        expectations,
        budgets,
        tags,
        skip,
        only,
        assert_sets,
        threshold,
        derived_metrics,
        // Transforms are tool-test-only; resource tests carry none.
        transform: None,
        // `inject_error` is tool-test-only.
        inject_error: None,
        // `metamorphic` is tool-test-only.
        metamorphic: None,
        // `fuzz` is tool-test-only.
        fuzz: None,
        // `negative_path` is tool-test-only.
        negative_path: None,
        // Elicitation answers are tool-test-only.
        input_responses: None,
        input_responder: None,
    })
}

pub(super) fn parse_prompt_test(entry: &YamlValue, path: &str) -> Result<TypedTest, ParseError> {
    let name = require_str(entry, path, "name")?.to_string();
    let server = require_str(entry, path, "server")?.to_string();
    let prompt = require_str(entry, path, "prompt")?.to_string();
    let args = entry.get("args").map(yaml_to_json).transpose()?;
    let expectations = parse_expectations(entry, path)?;
    let budgets = super::budgets_parse::parse_expect_budgets(entry, path)?;
    let tags = parse_tags(entry, path)?;
    let (skip, only) = parse_skip_only(entry, path)?;
    let assert_sets = parse_assert_sets(entry, path)?;
    let threshold = parse_test_threshold(entry, path)?;
    let derived_metrics = super::derived_parse::parse_derived_metrics(entry, path)?;
    Ok(TypedTest {
        name,
        server,
        action: Action::PromptGet { name: prompt, args },
        expectations,
        budgets,
        tags,
        skip,
        only,
        assert_sets,
        threshold,
        derived_metrics,
        // Transforms are tool-test-only; prompt tests carry none.
        transform: None,
        // `inject_error` is tool-test-only.
        inject_error: None,
        // `metamorphic` is tool-test-only.
        metamorphic: None,
        // `fuzz` is tool-test-only.
        fuzz: None,
        // `negative_path` is tool-test-only.
        negative_path: None,
        // Elicitation answers are tool-test-only.
        input_responses: None,
        input_responder: None,
    })
}

/// Parse the optional `skip:` and `only:` authoring markers on any test
/// entry (WOR-1267). `skip` must be a non-empty reason string; a bare
/// boolean or an empty string is rejected so a parked test always carries
/// an explanation. `only` must be a boolean. Returns `(skip, only)` so
/// every test kind wires both fields through one code path.
pub(super) fn parse_skip_only(
    entry: &YamlValue,
    path: &str,
) -> Result<(Option<String>, bool), ParseError> {
    let skip = match entry.get("skip") {
        None => None,
        Some(node) => {
            let reason = node.as_str().map(str::trim).unwrap_or("");
            if reason.is_empty() {
                return Err(ParseError::BadShape {
                    path: format!("{path}/skip"),
                    field: "skip",
                    expected: "a non-empty reason string explaining why the test is parked",
                });
            }
            Some(reason.to_string())
        }
    };
    let only = match entry.get("only") {
        None => false,
        Some(node) => node.as_bool().ok_or(ParseError::BadShape {
            path: format!("{path}/only"),
            field: "only",
            expected: "a boolean",
        })?,
    };
    Ok((skip, only))
}

/// Parse the optional `tags: [...]` block on any test entry.
/// Returns an empty Vec when the field is missing. Rejects non-string
/// entries with a clear ParseError so the user does not silently get
/// a tagless test from `tags: [123]`.
pub(super) fn parse_tags(entry: &YamlValue, path: &str) -> Result<Vec<String>, ParseError> {
    let Some(node) = entry.get("tags") else {
        return Ok(Vec::new());
    };
    let Some(seq) = node.as_sequence() else {
        return Err(ParseError::BadShape {
            path: format!("{path}/tags"),
            field: "tags",
            expected: "a list of strings",
        });
    };
    let mut out = Vec::with_capacity(seq.len());
    for (i, item) in seq.iter().enumerate() {
        let Some(s) = item.as_str() else {
            return Err(ParseError::BadShape {
                path: format!("{path}/tags/{i}"),
                field: "tags",
                expected: "string",
            });
        };
        out.push(s.to_string());
    }
    Ok(out)
}

/// Parse the optional `turns:` block on an agent test. Returns
/// `None` when absent (the legacy single-`prompt` run). Two shapes:
///
/// - Scripted: a sequence of `{ user: "..." }` turns the driver replays.
/// - Simulated: a `{ simulate: { goal, persona?, max_turns? } }` block where
///   an LLM plays the user.
pub(super) fn parse_agent_turns(
    entry: &YamlValue,
    path: &str,
) -> Result<Option<AgentTurns>, ParseError> {
    let Some(node) = entry.get("turns") else {
        return Ok(None);
    };
    let tpath = format!("{path}/turns");
    // Simulated form: a mapping carrying `simulate`.
    if let Some(sim) = node.get("simulate") {
        let spath = format!("{tpath}/simulate");
        let goal = require_str(sim, &spath, "goal")?.to_string();
        let persona = sim
            .get("persona")
            .and_then(|v| v.as_str())
            .map(str::to_string);
        let max_turns = sim
            .get("max_turns")
            .and_then(|v| v.as_u64())
            .map(|n| n as u32)
            .unwrap_or(6)
            .max(1);
        return Ok(Some(AgentTurns::Simulated(SimulatedUser {
            goal,
            persona,
            max_turns,
        })));
    }
    // Scripted form: a sequence of `{ user: "..." }`.
    let seq = node.as_sequence().ok_or_else(|| ParseError::BadShape {
        path: tpath.clone(),
        field: "turns",
        expected: "a list of { user: ... } turns or a { simulate: ... } block",
    })?;
    if seq.is_empty() {
        return Err(ParseError::BadShape {
            path: tpath,
            field: "turns",
            expected: "at least one user turn",
        });
    }
    let mut turns = Vec::with_capacity(seq.len());
    for (i, t) in seq.iter().enumerate() {
        let upath = format!("{tpath}/{i}");
        turns.push(require_str(t, &upath, "user")?.to_string());
    }
    Ok(Some(AgentTurns::Scripted(turns)))
}

/// Resolve the assertion list inside an `expect:` node, whichever of the
/// two schema shapes the YAML used (WOR-1280). The short form is the bare
/// array; the long form is an Expectation mapping whose `assertions:` key
/// carries the same array. Returns the sequence plus the path fragment
/// naming it, so error paths point at the right YAML location. `None`
/// when there is no `expect:` or the long form declares no assertions.
fn expect_assertion_seq(entry: &YamlValue) -> Option<(&Vec<YamlValue>, &'static str)> {
    let node = entry.get("expect")?;
    if let Some(seq) = node.as_sequence() {
        return Some((seq, "expect"));
    }
    node.get("assertions")
        .and_then(|v| v.as_sequence())
        .map(|seq| (seq, "expect/assertions"))
}

pub(super) fn parse_expectations(
    entry: &YamlValue,
    path: &str,
) -> Result<Vec<Assertion>, ParseError> {
    let mut out = Vec::new();
    let Some((seq, expect_path)) = expect_assertion_seq(entry) else {
        return Ok(out);
    };
    for (i, item) in seq.iter().enumerate() {
        // Assert-set entries (a `assert-set:` key) are collected separately
        // by [`parse_assert_sets`]; skip them here so the assertion list
        // stays a flat list of single-target assertions.
        if item.get("assert-set").is_some() {
            continue;
        }
        let item_path = format!("{path}/{expect_path}/{i}");
        out.push(parse_one_assertion(item, &item_path)?);
    }
    Ok(out)
}

/// Parse one assertion node (`target` + `matcher` plus the optional
///  `weight` and `name`). Shared by the top-level `expect:` list
/// and the per-set assertion list so the two cannot drift apart.
fn parse_one_assertion(item: &YamlValue, item_path: &str) -> Result<Assertion, ParseError> {
    let target = item
        .get("target")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();
    let matcher_node = item.get("matcher").ok_or_else(|| ParseError::Missing {
        path: item_path.to_string(),
        field: "matcher",
    })?;
    let matcher = parse_matcher(matcher_node, item_path)?;
    let weight = parse_weight(item, item_path, "weight")?;
    let name = item
        .get("name")
        .and_then(|v| v.as_str())
        .map(str::to_string);
    let transform = parse_assertion_transform(item, item_path)?;
    Ok(Assertion {
        target,
        matcher,
        weight,
        name,
        transform,
    })
}

/// Parse the optional per-assertion `transform:` command. The
/// value is a single shell command string (`jq ascii_downcase`,
/// `node ./normalize.js`) split with the same POSIX rules the
/// request/response transform and the hooks use, so an unterminated quote
/// fails at load time. Returns `None` when the key is absent, which keeps
/// the no-transform path: the matcher judges the value as extracted.
fn parse_assertion_transform(
    item: &YamlValue,
    item_path: &str,
) -> Result<Option<TransformCommand>, ParseError> {
    let Some(value) = item.get("transform") else {
        return Ok(None);
    };
    let raw = value.as_str().ok_or(ParseError::BadShape {
        path: format!("{item_path}/transform"),
        field: "transform",
        expected: "a shell command string",
    })?;
    let parts = shell_words::split(raw).map_err(|_| ParseError::BadShape {
        path: format!("{item_path}/transform"),
        field: "transform",
        expected: "a parseable shell command (check your quoting)",
    })?;
    let mut iter = parts.into_iter();
    let program = iter.next().ok_or(ParseError::BadShape {
        path: format!("{item_path}/transform"),
        field: "transform",
        expected: "a non-empty command",
    })?;
    Ok(Some(TransformCommand {
        program,
        args: iter.collect(),
    }))
}

/// Parse an optional positive `field` weight, defaulting to `1.0`
///. A non-numeric, zero, or negative value is a user error the
/// parser rejects so a typo cannot silently zero out an item's
/// contribution to the combined score.
pub(super) fn parse_weight(
    entry: &YamlValue,
    path: &str,
    field: &'static str,
) -> Result<f64, ParseError> {
    let Some(node) = entry.get(field) else {
        return Ok(1.0);
    };
    let value = node.as_f64().ok_or(ParseError::BadShape {
        path: path.to_string(),
        field,
        expected: "a positive number",
    })?;
    if value <= 0.0 {
        return Err(ParseError::BadShape {
            path: path.to_string(),
            field,
            expected: "a number greater than zero",
        });
    }
    Ok(value)
}

/// Parse a 0..1 `field` threshold. Returns `None` when absent.
/// A value outside `[0, 1]` is rejected so a threshold of `5` (meant as
/// `0.5`) fails loudly rather than making every test impossible to pass.
pub(super) fn parse_unit_threshold(
    entry: &YamlValue,
    path: &str,
    field: &'static str,
) -> Result<Option<f64>, ParseError> {
    let Some(node) = entry.get(field) else {
        return Ok(None);
    };
    let value = node.as_f64().ok_or(ParseError::BadShape {
        path: path.to_string(),
        field,
        expected: "a number between 0 and 1",
    })?;
    if !(0.0..=1.0).contains(&value) {
        return Err(ParseError::BadShape {
            path: path.to_string(),
            field,
            expected: "a number between 0 and 1",
        });
    }
    Ok(Some(value))
}

/// Parse the test-level `threshold:` combined-score gate.
/// Returns `None` when omitted, which preserves the legacy
/// "every assertion must pass" behavior at the executor.
pub(super) fn parse_test_threshold(
    entry: &YamlValue,
    path: &str,
) -> Result<Option<f64>, ParseError> {
    parse_unit_threshold(entry, path, "threshold")
}

/// Parse the assert-set bundles found in a test's `expect:` list
///. Each entry that carries an `assert-set:` key is lifted into
/// an [`AssertSet`] with its own threshold, optional weight, and nested
/// assertions. Entries without the key are plain assertions handled by
/// [`parse_expectations`], so the two passes partition the list.
pub(super) fn parse_assert_sets(
    entry: &YamlValue,
    path: &str,
) -> Result<Vec<AssertSet>, ParseError> {
    let mut out = Vec::new();
    let Some((seq, expect_path)) = expect_assertion_seq(entry) else {
        return Ok(out);
    };
    for (i, item) in seq.iter().enumerate() {
        let Some(block) = item.get("assert-set") else {
            continue;
        };
        let set_path = format!("{path}/{expect_path}/{i}/assert-set");
        out.push(parse_one_assert_set(item, block, &set_path)?);
    }
    Ok(out)
}

/// Build one [`AssertSet`] from the `assert-set:` block on an `expect:`
/// entry. The threshold lives on the block (`assert-set: { threshold,
/// assertions }`); the optional `weight` and `name` may sit on the block
/// or on the entry itself, with the block winning.
fn parse_one_assert_set(
    entry: &YamlValue,
    block: &YamlValue,
    set_path: &str,
) -> Result<AssertSet, ParseError> {
    let threshold =
        parse_unit_threshold(block, set_path, "threshold")?.ok_or(ParseError::Missing {
            path: set_path.to_string(),
            field: "threshold",
        })?;
    let weight = match block.get("weight") {
        Some(_) => parse_weight(block, set_path, "weight")?,
        None => parse_weight(entry, set_path, "weight")?,
    };
    let name = block
        .get("name")
        .or_else(|| entry.get("name"))
        .and_then(|v| v.as_str())
        .map(str::to_string);
    let mut assertions = Vec::new();
    let Some(seq) = block.get("assertions").and_then(|v| v.as_sequence()) else {
        return Err(ParseError::Missing {
            path: set_path.to_string(),
            field: "assertions",
        });
    };
    for (i, item) in seq.iter().enumerate() {
        let item_path = format!("{set_path}/assertions/{i}");
        assertions.push(parse_one_assertion(item, &item_path)?);
    }
    Ok(AssertSet {
        name,
        threshold,
        weight,
        assertions,
    })
}

/// Parse the optional `transform:` block on a tool test or `defaultTest`
///. The block is `{ request?, response? }` where each phase is a
/// shell command string split with POSIX rules. Returns `None` when the key
/// is absent so the no-transform path is preserved, and `None` when the
/// block declares neither phase (an empty `transform:` is inert).
pub(super) fn parse_transform(
    entry: &YamlValue,
    path: &str,
) -> Result<Option<TransformSpec>, ParseError> {
    let Some(node) = entry.get("transform") else {
        return Ok(None);
    };
    let block_path = format!("{path}/transform");
    if node.as_mapping().is_none() {
        return Err(ParseError::BadShape {
            path: block_path,
            field: "transform",
            expected: "an object with request and/or response commands",
        });
    }
    let request = parse_transform_command(node, &block_path, "request")?;
    let response = parse_transform_command(node, &block_path, "response")?;
    let spec = TransformSpec { request, response };
    Ok(if spec.is_empty() { None } else { Some(spec) })
}

/// Parse one transform phase. The value is either a bare
/// shell command string (the unchanged subprocess form) or a `{ jq: "<prog>" }`
/// mapping (the inline jq form). A string splits into program plus argv with
/// the POSIX splitter, so an unterminated quote fails at load time; a jq
/// program is compiled at load time so a jq syntax error also fails the parse.
fn parse_transform_command(
    node: &YamlValue,
    path: &str,
    field: &'static str,
) -> Result<Option<TransformStep>, ParseError> {
    let Some(value) = node.get(field) else {
        return Ok(None);
    };
    if let Some(jq) = value.get("jq") {
        return parse_jq_transform(jq, path, field).map(Some);
    }
    parse_command_transform(value, path, field).map(Some)
}

/// Parse the bare-string subprocess form of a transform phase. Splits the
/// command with the same POSIX rules the  hooks use; a non-string or
/// empty command is a typed error.
fn parse_command_transform(
    value: &YamlValue,
    path: &str,
    field: &'static str,
) -> Result<TransformStep, ParseError> {
    let raw = value.as_str().ok_or(ParseError::BadShape {
        path: format!("{path}/{field}"),
        field,
        expected: "a shell command string or a { jq: \"<program>\" } object",
    })?;
    let parts = shell_words::split(raw).map_err(|_| ParseError::BadShape {
        path: format!("{path}/{field}"),
        field,
        expected: "a parseable shell command (check your quoting)",
    })?;
    let mut iter = parts.into_iter();
    let program = iter.next().ok_or(ParseError::BadShape {
        path: format!("{path}/{field}"),
        field,
        expected: "a non-empty command",
    })?;
    Ok(TransformStep::Command(TransformCommand {
        program,
        args: iter.collect(),
    }))
}

/// Parse the inline `{ jq: "<program>" }` form of a transform phase
///. The program is compiled here so a jq syntax error fails the
/// suite load with a message pointing at the test, mirroring the unterminated
/// quote a bad subprocess command raises.
fn parse_jq_transform(
    value: &YamlValue,
    path: &str,
    field: &'static str,
) -> Result<TransformStep, ParseError> {
    let program = value.as_str().ok_or(ParseError::BadShape {
        path: format!("{path}/{field}/jq"),
        field,
        expected: "a jq program string",
    })?;
    crate::transform_jq::compile(program).map_err(|e| ParseError::BadJq {
        path: format!("{path}/{field}/jq"),
        field,
        detail: e.to_string(),
    })?;
    Ok(TransformStep::Jq(program.to_string()))
}

pub(super) fn require_str<'a>(
    entry: &'a YamlValue,
    path: &str,
    field: &'static str,
) -> Result<&'a str, ParseError> {
    entry
        .get(field)
        .and_then(|v| v.as_str())
        .ok_or(ParseError::Missing {
            path: path.to_string(),
            field,
        })
}

/// Convert a [`YamlValue`] to a [`Value`] for downstream JSON-RPC use.
/// Maps that have non-string keys collapse to a best-effort string form
/// so the executor never panics on unexpected YAML; the schema already
/// rejects those at load time, this is a defensive fallback.
pub(super) fn yaml_to_json(value: &YamlValue) -> Result<Value, ParseError> {
    Ok(match value {
        YamlValue::Null => Value::Null,
        YamlValue::Bool(b) => Value::Bool(*b),
        YamlValue::Number(n) => {
            if let Some(i) = n.as_i64() {
                Value::Number(i.into())
            } else if let Some(u) = n.as_u64() {
                Value::Number(u.into())
            } else if let Some(f) = n.as_f64() {
                serde_json::Number::from_f64(f)
                    .map(Value::Number)
                    .unwrap_or(Value::Null)
            } else {
                Value::Null
            }
        }
        YamlValue::String(s) => Value::String(s.clone()),
        YamlValue::Sequence(seq) => {
            let mut out = Vec::with_capacity(seq.len());
            for item in seq {
                out.push(yaml_to_json(item)?);
            }
            Value::Array(out)
        }
        YamlValue::Mapping(map) => {
            let mut out = serde_json::Map::new();
            for (k, v) in map {
                let key = k
                    .as_str()
                    .map(|s| s.to_string())
                    .unwrap_or_else(|| format!("{k:?}"));
                out.insert(key, yaml_to_json(v)?);
            }
            Value::Object(out)
        }
        YamlValue::Tagged(t) => yaml_to_json(&t.value)?,
    })
}
