//! Probe tier for the stub generators (WOR-1260, `--probe`).
//!
//! Why this exists: the default stubs cover one happy path and the obvious
//! negative cases, but servers most often break on the edges of their own
//! declared contract. This module derives deterministic boundary and negative
//! probe cases from a tool's `inputSchema`: values sitting exactly on declared
//! bounds (expect success), values one step outside them (expect rejection),
//! empty-value probes, and an unexpected-extra-property probe. Probes whose
//! outcome the schema does not decide assert only "no crash". Generation makes
//! no tool calls; running the emitted YAML is a plain `mcptest run`.
//!
//! Probe value generation lives here rather than reusing
//! [`crate::eval::fuzz`]: the fuzzer's generators are private and produce
//! malformed-input sweeps, not verdict-classified boundary cases. The small
//! overlap (wrong-size values) is flagged for a follow-up consolidation.

use serde_json::{json, Map, Value};

use super::resolve::resolve;
use super::synthesis::{self, SynthesisResult};
use crate::exec_policy::REVIEW_MARKER;

/// Default seed for probe generation. Probes are deterministic by
/// construction; the seed is only consumed when the per-tool cap forces a
/// category to drop cases, to pick which window survives (mirroring the
/// fuzz module's budget windows). Fixed so `--check` never drifts.
pub const DEFAULT_PROBE_SEED: u64 = 1260;

/// Cap on probe cases emitted per tool. Twelve keeps a property-heavy tool
/// from drowning the suite; selection prefers one case of each category over
/// many of one, so coverage stays broad as the cap bites.
pub const PROBE_CASE_CAP: usize = 12;

/// Longest string the boundary probes will materialize. A `maxLength` past
/// this emits no string-bound probes instead of megabytes of YAML.
const STRING_LEN_CAP: u64 = 4096;

/// Most array elements the boundary probes will materialize, matching the
/// synthesis cap so `minItems`/`maxItems` probes stay readable.
const ARRAY_LEN_CAP: u64 = 64;

/// Knobs for the probe tier.
///
/// Held as a struct (mirroring [`super::StubOptions`]) so `--probe-seed` or a
/// cap override can land later without churning the render signatures.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProbeOptions {
    /// Seed for cap-truncation windows. See [`DEFAULT_PROBE_SEED`].
    pub seed: u64,
    /// Per-tool case cap. See [`PROBE_CASE_CAP`].
    pub max_cases_per_tool: usize,
}

impl Default for ProbeOptions {
    fn default() -> Self {
        Self {
            seed: DEFAULT_PROBE_SEED,
            max_cases_per_tool: PROBE_CASE_CAP,
        }
    }
}

/// What a probe asserts. Three-valued on purpose: a probe whose outcome the
/// schema does not decide must not fabricate a strict expectation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Verdict {
    /// The args satisfy the schema; expect a structured result.
    Accept,
    /// The args violate the schema; expect the tool to surface an error.
    Reject,
    /// The schema is silent; assert only that the call does not crash.
    NoCrash,
}

/// Probe category, used by the cap selection to keep one of each kind in
/// preference to many of one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Category {
    Boundary,
    Violation,
    Empty,
    ExtraProperty,
}

/// One candidate probe case before capping and rendering.
#[derive(Debug, Clone)]
struct ProbeCase {
    category: Category,
    name: String,
    args: Map<String, Value>,
    verdict: Verdict,
}

/// Builds verdict-checked probe cases for one tool. The optional validator is
/// the ground truth: an `Accept` probe that does not actually validate (or a
/// `Reject` probe that does) is dropped rather than emitted with a wrong
/// expectation.
struct ProbeBuilder<'a> {
    tool: &'a str,
    happy: &'a Map<String, Value>,
    validator: Option<jsonschema::Validator>,
}

impl ProbeBuilder<'_> {
    /// Build one probe with `prop` set to `value`, or `None` when the case is
    /// uninformative (identical to the happy path) or its verdict is wrong
    /// against the real validator.
    fn case(
        &self,
        category: Category,
        name: String,
        prop: &str,
        value: Value,
        verdict: Verdict,
    ) -> Option<ProbeCase> {
        let mut args = self.happy.clone();
        args.insert(prop.to_string(), value);
        match verdict {
            Verdict::Accept => {
                if args == *self.happy {
                    return None;
                }
                if !self.validates(&args)? {
                    return None;
                }
            }
            Verdict::Reject => {
                if self.validates(&args)? {
                    return None;
                }
            }
            Verdict::NoCrash => {}
        }
        Some(ProbeCase {
            category,
            name,
            args,
            verdict,
        })
    }

    /// Whether `args` satisfy the tool's input schema. `Some(true)` when no
    /// validator compiled: generation degrades optimistically, mirroring the
    /// branch check in [`super::synthesis`].
    fn validates(&self, args: &Map<String, Value>) -> Option<bool> {
        match &self.validator {
            Some(v) => Some(v.is_valid(&Value::Object(args.clone()))),
            None => Some(true),
        }
    }
}

/// Render the probe section for one tool: a header comment, the review marker
/// for destructive tools, the capped probe cases, and the
/// additional-properties note. Returns a skip comment when no happy-path args
/// exist (every probe perturbs them).
pub(crate) fn render_probe_section(
    tool: &str,
    server: &str,
    input_schema: &Value,
    happy: &SynthesisResult,
    destructive: bool,
    opts: &ProbeOptions,
) -> String {
    let Some(happy_args) = happy.value.as_ref().and_then(Value::as_object) else {
        return format!(
            "  # note: probe cases skipped for `{tool}`: happy-path arguments could not be synthesized\n\n"
        );
    };
    let mut notes = Vec::new();
    let resolved_root = match resolve(input_schema, input_schema, 0, &mut notes) {
        Ok((schema, _)) => schema,
        Err(_) => input_schema.clone(),
    };
    let builder = ProbeBuilder {
        tool,
        happy: happy_args,
        validator: jsonschema::validator_for(input_schema).ok(),
    };
    let (groups, extra_note) = collect_probes(&builder, &resolved_root, input_schema);
    let cases = select_capped(groups, opts.seed, opts.max_cases_per_tool.max(1));

    let mut body = String::new();
    if destructive && !cases.is_empty() {
        for line in REVIEW_MARKER.lines() {
            body.push_str(&format!("  {line}\n"));
        }
    }
    for case in &cases {
        body.push_str(&render_case(case, server, tool));
    }
    if let Some(note) = extra_note {
        body.push_str(&note);
    }
    let mut s = format!(
        "  # probe tier: schema-derived boundary and negative cases (seed {}, cap {}).\n",
        opts.seed, opts.max_cases_per_tool
    );
    if body.is_empty() {
        // A header with nothing under it still ends the block cleanly.
        s.push('\n');
    }
    s.push_str(&body);
    s
}

/// Gather every candidate probe, grouped by category in the fixed order the
/// cap selection prefers. Also returns the note emitted when the schema does
/// not reject unexpected extra properties.
fn collect_probes(
    builder: &ProbeBuilder<'_>,
    resolved_root: &Value,
    input_schema: &Value,
) -> (Vec<Vec<ProbeCase>>, Option<String>) {
    let mut cases = Vec::new();
    for (name, raw_prop) in ordered_properties(resolved_root) {
        let prop = match resolve(&raw_prop, input_schema, 0, &mut Vec::new()) {
            Ok((schema, _)) => schema,
            Err(_) => continue,
        };
        property_probes(builder, &name, &prop, input_schema, &mut cases);
    }
    let mut extra_note = None;
    if resolved_root.get("additionalProperties") == Some(&Value::Bool(false)) {
        cases.extend(builder.case(
            Category::ExtraProperty,
            format!("{}: probe rejects unexpected extra property", builder.tool),
            "mcptest_probe_unexpected",
            json!(true),
            Verdict::Reject,
        ));
    } else {
        extra_note = Some(format!(
            "  # note: tool `{}` does not declare additionalProperties: false; the schema accepts unexpected extra properties, so no rejection probe is emitted\n\n",
            builder.tool
        ));
    }
    (dedupe(group_by_category(cases)), extra_note)
}

/// Split the flat candidate list into one group per category, in the fixed
/// order the round-robin cap selection prefers.
fn group_by_category(cases: Vec<ProbeCase>) -> Vec<Vec<ProbeCase>> {
    const ORDER: [Category; 4] = [
        Category::Boundary,
        Category::Violation,
        Category::Empty,
        Category::ExtraProperty,
    ];
    ORDER
        .iter()
        .map(|category| {
            cases
                .iter()
                .filter(|case| case.category == *category)
                .cloned()
                .collect()
        })
        .collect()
}

/// Candidate probes for one resolved property, dispatched on its effective
/// type. Booleans and untyped properties have no usable edges.
fn property_probes(
    builder: &ProbeBuilder<'_>,
    name: &str,
    prop: &Value,
    root: &Value,
    out: &mut Vec<ProbeCase>,
) {
    match synthesis::effective_type(prop).as_deref() {
        Some("integer") => numeric_probes(builder, name, prop, true, out),
        Some("number") => numeric_probes(builder, name, prop, false, out),
        Some("string") => {
            string_probes(builder, name, prop, out);
            out.extend(empty_string_probe(builder, name, prop));
        }
        Some("array") => {
            array_probes(builder, name, prop, root, out);
            out.extend(empty_array_probe(builder, name, prop));
        }
        Some("object") => out.extend(empty_object_probe(builder, name, prop)),
        _ => {}
    }
}

/// Boundary and just-outside probes for `minimum`/`maximum`. Exclusive
/// bounds are left to the validator guard: a candidate that lands on an
/// exclusive bound fails its `Accept` check and is dropped, never emitted
/// with a wrong verdict.
fn numeric_probes(
    builder: &ProbeBuilder<'_>,
    name: &str,
    prop: &Value,
    integer: bool,
    out: &mut Vec<ProbeCase>,
) {
    let tool = builder.tool;
    if let Some(min) = prop.get("minimum").and_then(Value::as_f64) {
        out.extend(number_json(min, integer).and_then(|v| {
            builder.case(
                Category::Boundary,
                format!("{tool}: probe {name} at declared minimum"),
                name,
                v,
                Verdict::Accept,
            )
        }));
        out.extend(number_json(min - 1.0, integer).and_then(|v| {
            builder.case(
                Category::Violation,
                format!("{tool}: probe rejects {name} below declared minimum"),
                name,
                v,
                Verdict::Reject,
            )
        }));
    }
    if let Some(max) = prop.get("maximum").and_then(Value::as_f64) {
        out.extend(number_json(max, integer).and_then(|v| {
            builder.case(
                Category::Boundary,
                format!("{tool}: probe {name} at declared maximum"),
                name,
                v,
                Verdict::Accept,
            )
        }));
        out.extend(number_json(max + 1.0, integer).and_then(|v| {
            builder.case(
                Category::Violation,
                format!("{tool}: probe rejects {name} above declared maximum"),
                name,
                v,
                Verdict::Reject,
            )
        }));
    }
}

/// Length-bound probes: strings exactly at `minLength`/`maxLength` (valid)
/// and one character outside each (violations).
fn string_probes(builder: &ProbeBuilder<'_>, name: &str, prop: &Value, out: &mut Vec<ProbeCase>) {
    let tool = builder.tool;
    let min_len = prop.get("minLength").and_then(Value::as_u64);
    let max_len = prop.get("maxLength").and_then(Value::as_u64);
    if let Some(min) = min_len.filter(|m| *m > 0 && *m <= STRING_LEN_CAP) {
        out.extend(builder.case(
            Category::Boundary,
            format!("{tool}: probe {name} at minLength"),
            name,
            json!("x".repeat(min as usize)),
            Verdict::Accept,
        ));
        out.extend(builder.case(
            Category::Violation,
            format!("{tool}: probe rejects {name} shorter than minLength"),
            name,
            json!("x".repeat((min - 1) as usize)),
            Verdict::Reject,
        ));
    }
    if let Some(max) = max_len.filter(|m| *m < STRING_LEN_CAP) {
        out.extend(builder.case(
            Category::Boundary,
            format!("{tool}: probe {name} at maxLength"),
            name,
            json!("x".repeat(max as usize)),
            Verdict::Accept,
        ));
        out.extend(builder.case(
            Category::Violation,
            format!("{tool}: probe rejects {name} longer than maxLength"),
            name,
            json!("x".repeat((max + 1) as usize)),
            Verdict::Reject,
        ));
    }
}

/// Item-count probes: arrays exactly at `minItems`/`maxItems` (valid) and
/// one element outside each (violations). Elements come from the shared
/// synthesis engine so they satisfy the declared `items` schema.
fn array_probes(
    builder: &ProbeBuilder<'_>,
    name: &str,
    prop: &Value,
    root: &Value,
    out: &mut Vec<ProbeCase>,
) {
    let tool = builder.tool;
    let item_schema = prop.get("items").cloned().unwrap_or(Value::Null);
    let Some(item) = synthesis::synthesize_value(&item_schema, root).value else {
        return;
    };
    let array_of = |n: u64| Value::Array(vec![item.clone(); n as usize]);
    let min_items = prop.get("minItems").and_then(Value::as_u64);
    let max_items = prop.get("maxItems").and_then(Value::as_u64);
    if let Some(min) = min_items.filter(|m| *m > 0 && *m <= ARRAY_LEN_CAP) {
        out.extend(builder.case(
            Category::Boundary,
            format!("{tool}: probe {name} at minItems"),
            name,
            array_of(min),
            Verdict::Accept,
        ));
        out.extend(builder.case(
            Category::Violation,
            format!("{tool}: probe rejects {name} with fewer than minItems"),
            name,
            array_of(min - 1),
            Verdict::Reject,
        ));
    }
    if let Some(max) = max_items.filter(|m| *m < ARRAY_LEN_CAP) {
        out.extend(builder.case(
            Category::Boundary,
            format!("{tool}: probe {name} at maxItems"),
            name,
            array_of(max),
            Verdict::Accept,
        ));
        out.extend(builder.case(
            Category::Violation,
            format!("{tool}: probe rejects {name} with more than maxItems"),
            name,
            array_of(max + 1),
            Verdict::Reject,
        ));
    }
}

/// The empty-string probe. Verdict comes from the declared keywords, not the
/// validator: a schema that says nothing about `minLength` accepts `""`
/// structurally, but whether the server accepts it is undecidable, so the
/// probe asserts only "no crash" per the three-valued semantics.
fn empty_string_probe(builder: &ProbeBuilder<'_>, name: &str, prop: &Value) -> Option<ProbeCase> {
    let (verdict, label) = match prop.get("minLength").and_then(Value::as_u64) {
        None => (Verdict::NoCrash, "(schema is silent)"),
        Some(0) => (Verdict::Accept, "is accepted"),
        Some(_) => (Verdict::Reject, "is rejected"),
    };
    builder.case(
        Category::Empty,
        format!("{}: probe empty {name} {label}", builder.tool),
        name,
        json!(""),
        verdict,
    )
}

/// The empty-array probe, with the same keyword-driven verdict rule as
/// [`empty_string_probe`] applied to `minItems`.
fn empty_array_probe(builder: &ProbeBuilder<'_>, name: &str, prop: &Value) -> Option<ProbeCase> {
    let (verdict, label) = match prop.get("minItems").and_then(Value::as_u64) {
        None => (Verdict::NoCrash, "(schema is silent)"),
        Some(0) => (Verdict::Accept, "is accepted"),
        Some(_) => (Verdict::Reject, "is rejected"),
    };
    builder.case(
        Category::Empty,
        format!("{}: probe empty {name} {label}", builder.tool),
        name,
        json!([]),
        verdict,
    )
}

/// The empty-object probe. Required members forbid `{}`; declared-but-all
/// optional members explicitly permit it; a bare `object` type is silent and
/// gets the no-crash verdict.
fn empty_object_probe(builder: &ProbeBuilder<'_>, name: &str, prop: &Value) -> Option<ProbeCase> {
    let has_required = prop
        .get("required")
        .and_then(Value::as_array)
        .is_some_and(|r| !r.is_empty());
    let has_properties = prop
        .get("properties")
        .and_then(Value::as_object)
        .is_some_and(|p| !p.is_empty());
    let (verdict, label) = if has_required {
        (Verdict::Reject, "is rejected")
    } else if has_properties {
        (Verdict::Accept, "is accepted")
    } else {
        (Verdict::NoCrash, "(schema is silent)")
    };
    builder.case(
        Category::Empty,
        format!("{}: probe empty {name} {label}", builder.tool),
        name,
        json!({}),
        verdict,
    )
}

/// Properties in a stable, informative order: required ones first (in
/// `required` order), then the rest in map order. serde_json maps are
/// BTree-backed, so map order is alphabetical and deterministic.
fn ordered_properties(schema: &Value) -> Vec<(String, Value)> {
    let props = schema
        .get("properties")
        .and_then(Value::as_object)
        .cloned()
        .unwrap_or_default();
    let required: Vec<String> = schema
        .get("required")
        .and_then(Value::as_array)
        .map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str().map(str::to_string))
                .collect()
        })
        .unwrap_or_default();
    let mut out = Vec::new();
    for name in &required {
        if let Some(prop) = props.get(name) {
            out.push((name.clone(), prop.clone()));
        }
    }
    for (name, prop) in &props {
        if !required.contains(name) {
            out.push((name.clone(), prop.clone()));
        }
    }
    out
}

/// A JSON number for a probe value. Integer bounds with a fractional part
/// (legal but pathological) produce no probe rather than a lie.
fn number_json(value: f64, integer: bool) -> Option<Value> {
    if integer {
        return (value.fract() == 0.0).then(|| json!(value as i64));
    }
    serde_json::Number::from_f64(value).map(Value::Number)
}

/// Drop candidates whose args duplicate an earlier candidate (for example an
/// empty-string probe that equals the shorter-than-minLength violation when
/// `minLength` is 1). First occurrence wins, in category order.
fn dedupe(groups: Vec<Vec<ProbeCase>>) -> Vec<Vec<ProbeCase>> {
    let mut seen: Vec<String> = Vec::new();
    groups
        .into_iter()
        .map(|group| {
            group
                .into_iter()
                .filter(|case| {
                    let key = Value::Object(case.args.clone()).to_string();
                    if seen.contains(&key) {
                        return false;
                    }
                    seen.push(key);
                    true
                })
                .collect()
        })
        .collect()
}

/// Apply the per-tool cap, preferring one case of each category over many of
/// one: allotments grow round-robin across the category groups, and a group
/// that must drop cases keeps a seed-rotated window (the same scheme as the
/// fuzz module's budget) so the choice is deterministic for a seed.
fn select_capped(mut groups: Vec<Vec<ProbeCase>>, seed: u64, cap: usize) -> Vec<ProbeCase> {
    let total: usize = groups.iter().map(Vec::len).sum();
    if total > cap {
        let mut take = vec![0usize; groups.len()];
        let mut remaining = cap;
        while remaining > 0 {
            let mut progressed = false;
            for (i, group) in groups.iter().enumerate() {
                if remaining == 0 {
                    break;
                }
                if take[i] < group.len() {
                    take[i] += 1;
                    remaining -= 1;
                    progressed = true;
                }
            }
            if !progressed {
                break;
            }
        }
        for (i, group) in groups.iter_mut().enumerate() {
            if take[i] < group.len() {
                let start = (seed as usize) % group.len();
                group.rotate_left(start);
                group.truncate(take[i]);
            }
        }
    }
    groups.into_iter().flatten().collect()
}

/// Render one probe case as a YAML test entry, choosing the expectation block
/// by verdict.
fn render_case(case: &ProbeCase, server: &str, tool: &str) -> String {
    let mut s = String::new();
    if case.verdict == Verdict::NoCrash {
        s.push_str("  # probe: the input schema is silent here, so this case asserts only\n");
        s.push_str(
            "  # that the call does not crash; acceptance or a clean tool error both pass.\n",
        );
    }
    s.push_str(&format!("  - name: \"{}\"\n", case.name));
    s.push_str(&format!("    server: {server}\n"));
    s.push_str(&format!("    tool: {tool}\n"));
    s.push_str(&super::emit::render_args_block(&case.args));
    s.push_str(&match case.verdict {
        Verdict::Accept => accept_expectation(),
        Verdict::Reject => reject_expectation(),
        Verdict::NoCrash => no_crash_expectation(),
    });
    s
}

fn accept_expectation() -> String {
    let mut s = String::new();
    s.push_str("    expect:\n");
    s.push_str("      - target: result\n");
    s.push_str("        matcher:\n");
    s.push_str("          schema:\n");
    s.push_str("            type: object\n");
    s.push_str(
        "        message: \"a bound-respecting call should return a structured result\"\n\n",
    );
    s
}

/// Mirrors the error expectation the existing violation cases assert, so a
/// probe rejection reads the same as a generated schema-violation test.
fn reject_expectation() -> String {
    let mut s = String::new();
    s.push_str("    expect:\n");
    s.push_str("      - target: result.isError\n");
    s.push_str("        matcher:\n");
    s.push_str("          exact: true\n");
    s.push_str("        message: \"a schema-violating call should surface an error\"\n\n");
    s
}

/// The inconclusive-tolerant expectation: an `anyOf` composition (the same
/// mechanism examples/inconclusive.yml leans on) that passes for a normal
/// result and for a clean `isError: true` rejection alike. Only a crash, a
/// hang, or a malformed envelope fails it.
fn no_crash_expectation() -> String {
    let mut s = String::new();
    s.push_str("    expect:\n");
    s.push_str("      - target: result\n");
    s.push_str("        matcher:\n");
    s.push_str("          anyOf:\n");
    s.push_str(
        "            - schema: {\"type\":\"object\",\"properties\":{\"isError\":{\"const\":false}}}\n",
    );
    s.push_str(
        "            - schema: {\"type\":\"object\",\"required\":[\"isError\"],\"properties\":{\"isError\":{\"const\":true}}}\n",
    );
    s.push_str("        message: \"no crash: acceptance or a clean tool error both pass\"\n\n");
    s
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::stub_gen::{generate_with, render_suite, StubOptions, SuiteOptions};
    use std::path::{Path, PathBuf};

    fn workspace_root() -> PathBuf {
        // CARGO_MANIFEST_DIR is crates/mcptest-core; two parents up is root.
        Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .and_then(Path::parent)
            .expect("workspace root above crates/mcptest-core")
            .to_path_buf()
    }

    fn validate_against_v1(yaml: &str) {
        let schema_path = workspace_root().join("schemas").join("v1.json");
        let raw = std::fs::read_to_string(&schema_path)
            .unwrap_or_else(|e| panic!("read {}: {e}", schema_path.display()));
        let schema: Value = serde_json::from_str(&raw).expect("v1.json is valid JSON");
        let validator = jsonschema::validator_for(&schema).expect("schema compiles");
        let as_yaml: serde_yaml::Value = serde_yaml::from_str(yaml).expect("document parses");
        let as_json: Value = serde_json::to_value(as_yaml).expect("YAML converts to JSON");
        let errors: Vec<String> = validator
            .iter_errors(&as_json)
            .map(|e| e.to_string())
            .collect();
        assert!(errors.is_empty(), "should validate, got: {errors:#?}");
    }

    fn probe_suite_opts() -> SuiteOptions {
        SuiteOptions {
            probe: Some(ProbeOptions::default()),
            ..SuiteOptions::default()
        }
    }

    fn bounded_tool() -> Value {
        json!({
            "name": "page",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "n": { "type": "integer", "minimum": 1, "maximum": 9, "default": 5 }
                },
                "required": ["n"]
            }
        })
    }

    #[test]
    fn probe_output_is_byte_identical_across_invocations() {
        let tools = vec![bounded_tool()];
        let first = render_suite(&tools, &probe_suite_opts());
        let second = render_suite(&tools, &probe_suite_opts());
        assert_eq!(first, second, "probe output must be deterministic");
    }

    #[test]
    fn bounded_numeric_gets_boundary_and_just_outside_probes() {
        let yaml = render_suite(&[bounded_tool()], &probe_suite_opts());
        // The default (5) is the happy value, so both bounds are informative.
        assert!(yaml.contains("page: probe n at declared minimum"));
        assert!(yaml.contains("page: probe n at declared maximum"));
        assert!(yaml.contains("page: probe rejects n below declared minimum"));
        assert!(yaml.contains("page: probe rejects n above declared maximum"));
        assert!(yaml.contains("n: 0"));
        assert!(yaml.contains("n: 10"));
        validate_against_v1(&yaml);
    }

    #[test]
    fn string_and_array_bounds_get_length_probes() {
        // minLength/maxLength leave room around the happy value ("sample",
        // six characters), so both boundary probes stay informative.
        let tool = json!({
            "name": "tagger",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "label": { "type": "string", "minLength": 2, "maxLength": 8 },
                    "tags": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 2 }
                },
                "required": ["label"]
            }
        });
        let yaml = render_suite(&[tool], &probe_suite_opts());
        assert!(yaml.contains("tagger: probe label at minLength"));
        assert!(yaml.contains("tagger: probe label at maxLength"));
        assert!(yaml.contains("tagger: probe rejects label shorter than minLength"));
        assert!(yaml.contains("tagger: probe rejects label longer than maxLength"));
        assert!(yaml.contains("tagger: probe tags at minItems"));
        assert!(yaml.contains("tagger: probe rejects tags with more than maxItems"));
        validate_against_v1(&yaml);
    }

    #[test]
    fn boundary_probe_identical_to_happy_args_is_skipped() {
        // Synthesis sits on the minimum, so the at-minimum probe would
        // duplicate the valid case and is dropped as uninformative.
        let tool = json!({
            "name": "page",
            "inputSchema": {
                "type": "object",
                "properties": { "n": { "type": "integer", "minimum": 1 } },
                "required": ["n"]
            }
        });
        let yaml = render_suite(&[tool], &probe_suite_opts());
        assert!(!yaml.contains("page: probe n at declared minimum"));
        assert!(yaml.contains("page: probe rejects n below declared minimum"));
    }

    #[test]
    fn silent_empty_string_probe_asserts_only_no_crash() {
        let tool = json!({
            "name": "say",
            "inputSchema": {
                "type": "object",
                "properties": { "text": { "type": "string" } },
                "required": ["text"]
            }
        });
        let yaml = render_suite(&[tool], &probe_suite_opts());
        assert!(yaml.contains("say: probe empty text (schema is silent)"));
        assert!(yaml.contains("anyOf:"));
        assert!(yaml.contains("asserts only"));
        assert!(yaml.contains("no crash: acceptance or a clean tool error both pass"));
        validate_against_v1(&yaml);
    }

    #[test]
    fn forbidden_empty_string_probe_expects_rejection() {
        let tool = json!({
            "name": "say",
            "inputSchema": {
                "type": "object",
                "properties": { "text": { "type": "string", "minLength": 2 } },
                "required": ["text"]
            }
        });
        let yaml = render_suite(&[tool], &probe_suite_opts());
        assert!(yaml.contains("say: probe empty text is rejected"));
        validate_against_v1(&yaml);
    }

    #[test]
    fn explicitly_permitted_empty_array_probe_expects_success() {
        let tool = json!({
            "name": "filter",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "tags": { "type": "array", "minItems": 0, "items": { "type": "string" } }
                },
                "required": []
            }
        });
        let yaml = render_suite(&[tool], &probe_suite_opts());
        assert!(yaml.contains("filter: probe empty tags is accepted"));
        validate_against_v1(&yaml);
    }

    #[test]
    fn additional_properties_false_gets_an_extra_property_rejection_probe() {
        let tool = json!({
            "name": "strict",
            "inputSchema": {
                "type": "object",
                "additionalProperties": false,
                "properties": { "q": { "type": "string" } },
                "required": ["q"]
            }
        });
        let yaml = render_suite(&[tool], &probe_suite_opts());
        assert!(yaml.contains("strict: probe rejects unexpected extra property"));
        assert!(yaml.contains("mcptest_probe_unexpected: true"));
        validate_against_v1(&yaml);
    }

    #[test]
    fn permissive_additional_properties_gets_a_note_not_a_probe() {
        let tool = json!({
            "name": "loose",
            "inputSchema": {
                "type": "object",
                "properties": { "q": { "type": "string" } },
                "required": ["q"]
            }
        });
        let yaml = render_suite(&[tool], &probe_suite_opts());
        assert!(!yaml.contains("loose: probe rejects unexpected extra property"));
        assert!(yaml.contains("tool `loose` does not declare additionalProperties: false"));
    }

    #[test]
    fn probe_cap_is_respected_on_a_property_heavy_schema() {
        let mut props = Map::new();
        let mut required = Vec::new();
        for i in 0..10 {
            props.insert(
                format!("p{i}"),
                json!({ "type": "integer", "minimum": 0, "maximum": 9 }),
            );
            required.push(json!(format!("p{i}")));
        }
        let tool = json!({
            "name": "big",
            "inputSchema": {
                "type": "object",
                "properties": props,
                "required": required
            }
        });
        let yaml = render_suite(&[tool], &probe_suite_opts());
        let probes = yaml.matches("- name: \"big: probe ").count();
        assert_eq!(probes, PROBE_CASE_CAP, "cap must bound probe cases");
        // One of each category survives in preference to many of one: both
        // boundary and violation probes are present after capping.
        assert!(yaml.contains("at declared maximum"));
        assert!(yaml.contains("below declared minimum"));
        validate_against_v1(&yaml);
    }

    #[test]
    fn destructive_named_tool_probes_carry_the_review_marker() {
        let tool = json!({
            "name": "delete_item",
            "inputSchema": {
                "type": "object",
                "properties": { "id": { "type": "integer", "minimum": 1, "maximum": 5 } },
                "required": ["id"]
            }
        });
        let yaml = render_suite(&[tool], &probe_suite_opts());
        assert!(yaml.contains("  # review before first run\n"));
        assert!(yaml.contains("developer's responsibility"));
        validate_against_v1(&yaml);
    }

    #[test]
    fn non_destructive_tool_probes_have_no_review_marker() {
        let yaml = render_suite(&[bounded_tool()], &probe_suite_opts());
        assert!(!yaml.contains("# review before first run"));
    }

    #[test]
    fn probe_off_renders_no_probe_section() {
        let tools = vec![bounded_tool()];
        let yaml = render_suite(&tools, &SuiteOptions::default());
        assert!(!yaml.contains("probe tier"));
        assert!(!yaml.contains(": probe "));
    }

    #[test]
    fn stub_generator_emits_probe_section_per_tool() {
        let catalog = json!({ "tools": [bounded_tool()] });
        let opts = StubOptions {
            probe: Some(ProbeOptions::default()),
            ..StubOptions::default()
        };
        let stubs = generate_with(&catalog, &opts).unwrap();
        assert!(stubs[0].yaml.contains("probe tier"));
        assert!(stubs[0]
            .yaml
            .contains("page: probe rejects n below declared minimum"));
        validate_against_v1(&stubs[0].yaml);
    }

    #[test]
    fn unsatisfiable_happy_path_skips_probes_with_a_note() {
        let tool = json!({
            "name": "broken",
            "inputSchema": {
                "type": "object",
                "properties": { "s": { "type": "string", "minLength": 9, "maxLength": 2 } },
                "required": ["s"]
            }
        });
        let yaml = render_suite(&[tool], &probe_suite_opts());
        assert!(yaml.contains("probe cases skipped for `broken`"));
        assert!(!yaml.contains("broken: probe "));
        validate_against_v1(&yaml);
    }

    #[test]
    fn wrong_verdict_candidates_are_dropped_by_the_validator_guard() {
        // Both bounds are odd while multipleOf demands even, so the
        // at-bound values do not actually validate: emitting them as Accept
        // probes would be a lie and the guard must drop them. The
        // just-outside values still violate the schema, so the rejection
        // probes survive.
        let tool = json!({
            "name": "stepper",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "n": { "type": "integer", "minimum": 1, "maximum": 9, "multipleOf": 2 }
                },
                "required": ["n"]
            }
        });
        let yaml = render_suite(&[tool], &probe_suite_opts());
        assert!(!yaml.contains("stepper: probe n at declared minimum"));
        assert!(!yaml.contains("stepper: probe n at declared maximum"));
        assert!(yaml.contains("stepper: probe rejects n below declared minimum"));
        assert!(yaml.contains("stepper: probe rejects n above declared maximum"));
    }
}
