//! Check-block parsing for the suite loader.
//!
//! Holds the parsers for the computed-metric check blocks: the top-level
//! `calibration:` and `tool_quality:` lists, and the compliance-object
//! `grade_delta:` and `spec_version_check:` blocks. Pulled out of [`super`] so
//! the loader stays under the module-size ceiling; all functions are
//! crate-private and [`super::parse`] drives them. The shared assertion and
//! field helpers (`parse_expectations`, `require_str`) live in
//! [`super::test_parse`].

use serde_yaml::Value as YamlValue;

use super::test_parse::{parse_expectations, require_str};
use super::types::{ConformanceBlock, CoverageBlock, ModelCompatBlock, PerformanceBlock};
use super::ParseError;
use crate::coverage::{Threshold, ThresholdKind, Thresholds};

/// Parse the optional top-level `model_compat:` block (WOR-1486).
///
/// Returns `None` when the key is absent, so a suite that declares no
/// model-compat lane is unchanged. The block is an object with two required
/// string paths, `baseline` and `candidate`, each a path to a model-compat
/// snapshot JSON. A non-object shape or a missing path is a typed error so a
/// typo fails at load time rather than at run time.
pub(super) fn parse_model_compat(root: &YamlValue) -> Result<Option<ModelCompatBlock>, ParseError> {
    let Some(node) = root.get("model_compat") else {
        return Ok(None);
    };
    let path = "/model_compat";
    if node.as_mapping().is_none() {
        return Err(ParseError::BadShape {
            path: path.to_string(),
            field: "model_compat",
            expected: "an object with `baseline` and `candidate` paths",
        });
    }
    Ok(Some(ModelCompatBlock {
        baseline: require_str(node, path, "baseline")?.into(),
        candidate: require_str(node, path, "candidate")?.into(),
    }))
}

/// Parse the optional top-level `conformance:` block (WOR-1484).
///
/// Returns `None` when the key is absent, so a suite that declares no
/// conformance lane is unchanged. The presence of the key opts the suite into
/// the lane; the only field is an optional `target_version` string. A
/// non-object shape, or a non-string `target_version`, is a typed error so a
/// typo fails at load time rather than at run time.
pub(super) fn parse_conformance(root: &YamlValue) -> Result<Option<ConformanceBlock>, ParseError> {
    let Some(node) = root.get("conformance") else {
        return Ok(None);
    };
    let path = "/conformance";
    if node.as_mapping().is_none() {
        return Err(ParseError::BadShape {
            path: path.to_string(),
            field: "conformance",
            expected: "an object",
        });
    }
    let target_version = match node.get("target_version") {
        None => None,
        Some(v) => Some(
            v.as_str()
                .ok_or(ParseError::BadShape {
                    path: format!("{path}/target_version"),
                    field: "target_version",
                    expected: "a protocol-version string (e.g. `2026-07-28`)",
                })?
                .to_string(),
        ),
    };
    Ok(Some(ConformanceBlock { target_version }))
}

/// Parse the optional top-level `coverage:` block (WOR-1488).
///
/// Returns `None` when the key is absent, so a suite that declares no coverage
/// gate is unchanged. The block is an object of dimension thresholds; each
/// recognized key (`tools`, `args`, `error_paths`, `transports`) is a minimum
/// percentage between 0 and 100. A non-object shape, a non-number value, or a
/// value out of range is a typed error so a typo fails at load time. An empty
/// object enables coverage instrumentation without gating any dimension.
pub(super) fn parse_coverage(root: &YamlValue) -> Result<Option<CoverageBlock>, ParseError> {
    let Some(node) = root.get("coverage") else {
        return Ok(None);
    };
    let path = "/coverage";
    if node.as_mapping().is_none() {
        return Err(ParseError::BadShape {
            path: path.to_string(),
            field: "coverage",
            expected: "an object of dimension thresholds (tools, args, error_paths, transports)",
        });
    }
    let mut entries: Vec<Threshold> = Vec::new();
    for (key, kind) in [
        ("tools", ThresholdKind::Tools),
        ("args", ThresholdKind::Args),
        ("error_paths", ThresholdKind::ErrorPaths),
        ("transports", ThresholdKind::Transports),
    ] {
        let Some(value) = node.get(key) else {
            continue;
        };
        let pct = value.as_f64().ok_or(ParseError::BadShape {
            path: format!("{path}/{key}"),
            field: key,
            expected: "a percentage number between 0 and 100",
        })?;
        if !(0.0..=100.0).contains(&pct) {
            return Err(ParseError::BadShape {
                path: format!("{path}/{key}"),
                field: key,
                expected: "a percentage number between 0 and 100",
            });
        }
        entries.push(Threshold { kind, min_pct: pct });
    }
    Ok(Some(CoverageBlock {
        thresholds: Thresholds { entries },
    }))
}

/// Parse the optional top-level `performance:` block (WOR-1487).
///
/// Returns `None` when the key is absent, so a suite that declares no
/// performance budgets is unchanged. The block is an object with two optional
/// integer fields, `default_timeout_ms` and `p95_latency_ms`, each a positive
/// millisecond count. A non-object shape, or a non-positive-integer value, is a
/// typed error so a typo fails at load time rather than at run time.
pub(super) fn parse_performance(root: &YamlValue) -> Result<Option<PerformanceBlock>, ParseError> {
    let Some(node) = root.get("performance") else {
        return Ok(None);
    };
    let path = "/performance";
    if node.as_mapping().is_none() {
        return Err(ParseError::BadShape {
            path: path.to_string(),
            field: "performance",
            expected: "an object with optional `default_timeout_ms` and `p95_latency_ms`",
        });
    }
    Ok(Some(PerformanceBlock {
        default_timeout_ms: positive_ms(node, path, "default_timeout_ms")?,
        p95_latency_ms: positive_ms(node, path, "p95_latency_ms")?,
    }))
}

/// Read an optional positive-millisecond integer field from a block. Returns
/// `None` when the field is absent; a zero, negative, fractional, or non-number
/// value is a typed error (the schema floor is 1).
fn positive_ms(
    node: &YamlValue,
    path: &str,
    field: &'static str,
) -> Result<Option<u64>, ParseError> {
    let Some(value) = node.get(field) else {
        return Ok(None);
    };
    let ms = value
        .as_u64()
        .filter(|n| *n >= 1)
        .ok_or(ParseError::BadShape {
            path: format!("{path}/{field}"),
            field,
            expected: "a positive integer (milliseconds)",
        })?;
    Ok(Some(ms))
}

/// Parse the optional compliance `grade_delta:` block.
///
/// Reads `compliance.grade_delta` from the compliance object form. Returns
/// `None` when the suite has no `compliance:` object, or when that object
/// declares no `grade_delta`. The block carries an optional `against:`
/// scorecard path and an optional `expect:` of assertions over the `grade`,
/// `score`, `grade_regressed`, and `regressed_sections` targets.
pub(super) fn parse_grade_delta(
    root: &YamlValue,
) -> Result<Option<crate::checks::grade_delta::GradeDelta>, ParseError> {
    let Some(compliance) = root.get("compliance") else {
        return Ok(None);
    };
    // Only the object form carries grade_delta; the bare-array form has none.
    let Some(node) = compliance.get("grade_delta") else {
        return Ok(None);
    };
    let path = "/compliance/grade_delta";
    if node.as_mapping().is_none() {
        return Err(ParseError::BadShape {
            path: path.to_string(),
            field: "grade_delta",
            expected: "an object",
        });
    }
    let against = node
        .get("against")
        .and_then(|v| v.as_str())
        .map(std::path::PathBuf::from);
    let expect = parse_expectations(node, path)?;
    Ok(Some(crate::checks::grade_delta::GradeDelta {
        against,
        expect,
    }))
}

/// Parse the optional compliance `spec_version_check:` block.
///
/// Reads `compliance.spec_version_check` from the compliance object form.
/// Returns `None` when the suite has no `compliance:` object, or when that
/// object declares no `spec_version_check`. The block carries an optional
/// `against:` target spec revision and an optional `expect:` of assertions over
/// the `spec_clean` and `spec_breaking_changes` targets.
pub(super) fn parse_spec_version_check(
    root: &YamlValue,
) -> Result<Option<crate::checks::spec_version_check::SpecVersionCheck>, ParseError> {
    let Some(compliance) = root.get("compliance") else {
        return Ok(None);
    };
    // Only the object form carries spec_version_check; the bare-array form
    // (a list of tests) has no place for it.
    let Some(node) = compliance.get("spec_version_check") else {
        return Ok(None);
    };
    let path = "/compliance/spec_version_check";
    if node.as_mapping().is_none() {
        return Err(ParseError::BadShape {
            path: path.to_string(),
            field: "spec_version_check",
            expected: "an object",
        });
    }
    let against = node
        .get("against")
        .and_then(|v| v.as_str())
        .map(str::to_string);
    let expect = parse_expectations(node, path)?;
    Ok(Some(crate::checks::spec_version_check::SpecVersionCheck {
        against,
        expect,
    }))
}

/// Parse the optional top-level `tool_quality:` list.
///
/// Returns an empty vector when the key is absent. Each entry carries a
/// `name`, a `server` key, and an optional `expect:` of assertions over the
/// `min_score` / `mean_score` / `tool["<name>"].score` targets.
pub(super) fn parse_tool_quality(
    root: &YamlValue,
) -> Result<Vec<crate::checks::tool_quality::ToolQualityCheck>, ParseError> {
    let Some(seq) = root.get("tool_quality") else {
        return Ok(Vec::new());
    };
    let Some(items) = seq.as_sequence() else {
        return Err(ParseError::BadShape {
            path: "/tool_quality".to_string(),
            field: "tool_quality",
            expected: "a list of tool-quality checks",
        });
    };
    let mut out = Vec::with_capacity(items.len());
    for (i, item) in items.iter().enumerate() {
        let path = format!("/tool_quality/{i}");
        out.push(crate::checks::tool_quality::ToolQualityCheck {
            name: require_str(item, &path, "name")?.to_string(),
            server: require_str(item, &path, "server")?.to_string(),
            expect: parse_expectations(item, &path)?,
        });
    }
    Ok(out)
}

/// Parse the optional top-level `calibration:` list.
///
/// Returns an empty vector when the key is absent, so a suite that declares no
/// calibration block is unchanged. Each entry carries a `name`, a `labels`
/// path, an optional `reliability` confusion matrix, an optional
/// `observed_positive_rate`, and an optional `expect:` of assertions over the
/// `ece` / `brier` / `corrected_rate` targets.
pub(super) fn parse_calibration(
    root: &YamlValue,
) -> Result<Vec<crate::checks::calibration::CalibrationCheck>, ParseError> {
    let Some(seq) = root.get("calibration") else {
        return Ok(Vec::new());
    };
    let Some(items) = seq.as_sequence() else {
        return Err(ParseError::BadShape {
            path: "/calibration".to_string(),
            field: "calibration",
            expected: "a list of calibration checks",
        });
    };
    let mut out = Vec::with_capacity(items.len());
    for (i, item) in items.iter().enumerate() {
        out.push(parse_one_calibration(item, &format!("/calibration/{i}"))?);
    }
    Ok(out)
}

/// Parse one `calibration:` entry into a [`crate::checks::calibration::CalibrationCheck`].
fn parse_one_calibration(
    entry: &YamlValue,
    path: &str,
) -> Result<crate::checks::calibration::CalibrationCheck, ParseError> {
    use crate::checks::calibration::CalibrationCheck;
    let name = require_str(entry, path, "name")?.to_string();
    let labels = require_str(entry, path, "labels")?.into();
    let reliability = parse_confusion_matrix(entry, path)?;
    let observed_positive_rate = parse_opt_unit(entry, path, "observed_positive_rate")?;
    let expect = parse_expectations(entry, path)?;
    Ok(CalibrationCheck {
        name,
        labels,
        reliability,
        observed_positive_rate,
        expect,
    })
}

/// Parse the optional `reliability:` confusion matrix on a calibration entry.
/// Returns `None` when absent; a present-but-malformed block (missing a count)
/// is a typed error so a typo surfaces at load time.
fn parse_confusion_matrix(
    entry: &YamlValue,
    path: &str,
) -> Result<Option<crate::checks::calibration::ConfusionMatrix>, ParseError> {
    use crate::checks::calibration::ConfusionMatrix;
    let Some(node) = entry.get("reliability") else {
        return Ok(None);
    };
    let rel_path = format!("{path}/reliability");
    if node.as_mapping().is_none() {
        return Err(ParseError::BadShape {
            path: rel_path,
            field: "reliability",
            expected: "an object with tp, fn, tn, fp counts",
        });
    }
    Ok(Some(ConfusionMatrix {
        tp: require_u32(node, &rel_path, "tp")?,
        fn_: require_u32(node, &rel_path, "fn")?,
        tn: require_u32(node, &rel_path, "tn")?,
        fp: require_u32(node, &rel_path, "fp")?,
    }))
}

/// Read a required non-negative integer count from a confusion-matrix block.
fn require_u32(node: &YamlValue, path: &str, field: &'static str) -> Result<u32, ParseError> {
    node.get(field)
        .and_then(|v| v.as_u64())
        .map(|n| n as u32)
        .ok_or(ParseError::Missing {
            path: path.to_string(),
            field,
        })
}

/// Read an optional 0..1 number field, returning `None` when absent. An
/// out-of-range value is a typed error so a `1.5` rate does not slip through.
fn parse_opt_unit(
    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 in 0..1",
    })?;
    if !(0.0..=1.0).contains(&value) {
        return Err(ParseError::BadShape {
            path: path.to_string(),
            field,
            expected: "a number in 0..1",
        });
    }
    Ok(Some(value))
}

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

    fn yaml(src: &str) -> YamlValue {
        serde_yaml::from_str(src).unwrap()
    }

    #[test]
    fn absent_conformance_block_is_none() {
        let root = yaml("servers:\n  s: { command: [\"echo\"] }\n");
        assert!(parse_conformance(&root).unwrap().is_none());
    }

    #[test]
    fn empty_conformance_block_opts_in_with_no_target_version() {
        // The key's presence alone opts in; an empty object leaves the target
        // version unset so the lane accepts any negotiated version.
        let root = yaml("conformance: {}\n");
        let block = parse_conformance(&root).unwrap().expect("present");
        assert!(block.target_version.is_none());
    }

    #[test]
    fn conformance_block_round_trips_a_target_version() {
        let root = yaml("conformance:\n  target_version: \"2026-07-28\"\n");
        let block = parse_conformance(&root).unwrap().expect("present");
        assert_eq!(block.target_version.as_deref(), Some("2026-07-28"));
    }

    #[test]
    fn non_object_conformance_is_a_load_error() {
        let root = yaml("conformance: 7\n");
        assert!(parse_conformance(&root).is_err());
    }

    #[test]
    fn non_string_target_version_is_a_load_error() {
        let root = yaml("conformance:\n  target_version: 20260728\n");
        assert!(parse_conformance(&root).is_err());
    }

    #[test]
    fn absent_coverage_block_is_none() {
        let root = yaml("servers:\n  s: { command: [\"echo\"] }\n");
        assert!(parse_coverage(&root).unwrap().is_none());
    }

    #[test]
    fn coverage_block_parses_declared_dimensions_in_order() {
        let root =
            yaml("coverage:\n  tools: 80\n  args: 60\n  error_paths: 50\n  transports: 100\n");
        let block = parse_coverage(&root).unwrap().expect("present");
        let kinds: Vec<_> = block.thresholds.entries.iter().map(|t| t.kind).collect();
        assert_eq!(
            kinds,
            vec![
                ThresholdKind::Tools,
                ThresholdKind::Args,
                ThresholdKind::ErrorPaths,
                ThresholdKind::Transports,
            ]
        );
        assert_eq!(block.thresholds.entries[0].min_pct, 80.0);
        assert_eq!(block.thresholds.entries[3].min_pct, 100.0);
    }

    #[test]
    fn coverage_block_keeps_only_declared_dimensions() {
        let root = yaml("coverage:\n  tools: 75\n");
        let block = parse_coverage(&root).unwrap().expect("present");
        assert_eq!(block.thresholds.entries.len(), 1);
        assert_eq!(block.thresholds.entries[0].kind, ThresholdKind::Tools);
    }

    #[test]
    fn empty_coverage_block_enables_instrumentation_without_gating() {
        // An empty object opts the run into coverage but gates no dimension.
        let root = yaml("coverage: {}\n");
        let block = parse_coverage(&root).unwrap().expect("present");
        assert!(block.thresholds.entries.is_empty());
    }

    #[test]
    fn non_object_coverage_is_a_load_error() {
        let root = yaml("coverage: 7\n");
        assert!(parse_coverage(&root).is_err());
    }

    #[test]
    fn out_of_range_coverage_percentage_is_a_load_error() {
        let root = yaml("coverage:\n  tools: 140\n");
        assert!(parse_coverage(&root).is_err());
    }

    #[test]
    fn non_number_coverage_percentage_is_a_load_error() {
        let root = yaml("coverage:\n  tools: \"lots\"\n");
        assert!(parse_coverage(&root).is_err());
    }

    #[test]
    fn absent_performance_block_is_none() {
        let root = yaml("servers:\n  s: { command: [\"echo\"] }\n");
        assert!(parse_performance(&root).unwrap().is_none());
    }

    #[test]
    fn performance_block_round_trips_both_fields() {
        let root = yaml("performance:\n  default_timeout_ms: 30000\n  p95_latency_ms: 1500\n");
        let block = parse_performance(&root).unwrap().expect("present");
        assert_eq!(block.default_timeout_ms, Some(30000));
        assert_eq!(block.p95_latency_ms, Some(1500));
    }

    #[test]
    fn empty_performance_block_leaves_both_fields_unset() {
        let root = yaml("performance: {}\n");
        let block = parse_performance(&root).unwrap().expect("present");
        assert_eq!(block.default_timeout_ms, None);
        assert_eq!(block.p95_latency_ms, None);
    }

    #[test]
    fn non_object_performance_is_a_load_error() {
        let root = yaml("performance: 7\n");
        assert!(parse_performance(&root).is_err());
    }

    #[test]
    fn zero_performance_millis_is_a_load_error() {
        // The schema floor is 1; zero is not a valid timeout or budget.
        let root = yaml("performance:\n  p95_latency_ms: 0\n");
        assert!(parse_performance(&root).is_err());
    }

    #[test]
    fn non_integer_performance_millis_is_a_load_error() {
        let root = yaml("performance:\n  default_timeout_ms: \"soon\"\n");
        assert!(parse_performance(&root).is_err());
    }
}
