//! Proposed-assertion derivation from observed tool calls (WOR-1262).
//!
//! Why this exists: the most expensive step of authoring a tool test is
//! turning one real response into a defensible `expect:` list. This module is
//! the insta-style accept loop at authoring time: the caller executes the
//! tool (twice for stable tools, once otherwise), hands the observed results
//! to [`propose_test`], and gets back a complete runnable test block. Every
//! constraint is derived from observation, never invented: an `isError`
//! invariant, a structural schema of what actually came back, exact-value
//! assertions only on leaves that proved identical across both calls, and a
//! latency budget computed from the observed durations.
//!
//! The module is pure and transport-free. The `mcptest propose` CLI and the
//! `propose_assertions` mcp-server verb own the live calls and the safety
//! gating (`exec_policy`); this module only turns observations into YAML.

mod infer;
mod render;

use std::time::Duration;

use serde_json::{json, Value};

use infer::{diff_leaves, infer_schema, StableLeaf};
use render::ProposedAssertion;

/// Cap on proposed exact-value assertions. Five keeps the proposal reviewable
/// in one glance; a response with more stable leaves gets the shallowest five
/// and the structural schema covers the rest.
pub const EXACT_LEAF_CAP: usize = 5;

/// Longest string leaf (in characters) eligible for an exact-value
/// assertion. Long strings are likely prose or payloads whose exact bytes are
/// not the contract; the structural schema still pins their type.
pub const EXACT_STRING_MAX_LEN: usize = 80;

/// Floor of the proposed latency budget in milliseconds. A sub-50ms local
/// observation would otherwise propose a budget that flakes on any loaded CI
/// runner.
pub const LATENCY_FLOOR_MS: u64 = 100;

/// Granularity the latency budget is rounded up to, in milliseconds.
pub const LATENCY_STEP_MS: u64 = 50;

/// How many object levels the inferred schema descends from the target root.
/// Two levels satisfies the "one level of nesting deep minimum" contract
/// while keeping the proposal small; deeper structure stays unconstrained
/// rather than guessed.
const SCHEMA_OBJECT_DEPTH: usize = 2;

/// One observed `tools/call` execution. Proposals are derived only from what
/// was actually seen, so the full result value and the measured wall-clock
/// duration travel together.
#[derive(Debug, Clone)]
pub struct Observation {
    /// The complete `tools/call` result (the `result` member of the JSON-RPC
    /// response), untouched.
    pub result: Value,
    /// Wall-clock duration of the call as the caller measured it.
    pub duration: Duration,
}

/// The call a proposal describes: which tool, with which arguments, against
/// which `servers:` key. Carried separately from the observations so the
/// rendered test block references the caller's suite layout.
#[derive(Debug, Clone)]
pub struct ProposalInput {
    /// Server key the rendered test references. The caller declares this
    /// entry under `servers:` when pasting the block into a suite.
    pub server: String,
    /// Tool the observations came from.
    pub tool: String,
    /// Arguments object the calls were made with, echoed into the test.
    pub args: Value,
}

/// A proposed test derived from observation: the YAML block, the volatile
/// leaves that were deliberately not asserted, and human-readable caveats.
#[derive(Debug, Clone)]
pub struct ProposedTest {
    /// Complete test entry (name, server, tool, args, expect list) indented
    /// to paste directly under a suite's `tools:` key.
    pub yaml: String,
    /// Executor-syntax paths (`result.content[0].text` style) of leaves that
    /// differed across the two observed calls and were therefore excluded
    /// from exact-value assertions.
    pub excluded_volatile: Vec<String>,
    /// Caveats the caller should surface: single-observation fallback, an
    /// observed `isError: true`, the latency-budget formula, and so on.
    pub notes: Vec<String>,
}

/// Derive a proposed test from one or two observed calls.
///
/// With two observations the leaves equal across both are eligible for
/// exact-value assertions and the differing ones land in
/// `excluded_volatile`. With a single observation (single-call mode, or a
/// tool whose class forbids a second call) only the `isError` invariant and
/// the structural schema are proposed, with a note saying why.
///
/// ```
/// use std::time::Duration;
/// use serde_json::json;
/// use mcptest_core::propose::{propose_test, Observation, ProposalInput};
///
/// let input = ProposalInput {
///     server: "target".to_string(),
///     tool: "get_status".to_string(),
///     args: json!({}),
/// };
/// let seen = Observation {
///     result: json!({ "content": [{ "type": "text", "text": "ok" }] }),
///     duration: Duration::from_millis(12),
/// };
/// let proposal = propose_test(&input, &seen, Some(&seen));
/// assert!(proposal.yaml.contains("tool: \"get_status\""));
/// ```
pub fn propose_test(
    input: &ProposalInput,
    first: &Observation,
    second: Option<&Observation>,
) -> ProposedTest {
    let mut notes = Vec::new();
    let mut assertions = Vec::new();

    assertions.push(is_error_assertion(&first.result, &mut notes));

    let samples: Vec<&Value> = std::iter::once(first)
        .chain(second)
        .map(|o| &o.result)
        .collect();
    for field in ["content", "structuredContent"] {
        if let Some(assertion) = schema_assertion(field, &samples, &mut notes) {
            assertions.push(assertion);
        }
    }

    let mut excluded_volatile = Vec::new();
    match second {
        Some(second) => {
            let diff = diff_leaves(&first.result, &second.result);
            excluded_volatile = diff.volatile;
            let exacts = exact_assertions(&diff.stable);
            if exacts.is_empty() {
                notes.push(
                    "no stable short scalar leaves were observed, so no exact-value \
                     assertions are proposed"
                        .to_string(),
                );
            }
            assertions.extend(exacts);
        }
        None => notes.push(
            "single observation: stability across calls was not measured, so no \
             exact-value assertions are proposed"
                .to_string(),
        ),
    }
    if !excluded_volatile.is_empty() {
        notes.push(format!(
            "{} leaf path(s) differed across the two observed calls and were \
             excluded from exact-value assertions",
            excluded_volatile.len()
        ));
    }

    let durations: Vec<Duration> = std::iter::once(first)
        .chain(second)
        .map(|o| o.duration)
        .collect();
    let budget = latency_budget_ms(&durations);
    notes.push(format!(
        "latency budget max_duration_ms: {budget} (2x the slowest observed call, \
         rounded up to the nearest {LATENCY_STEP_MS} ms, floor {LATENCY_FLOOR_MS} ms); \
         emitted as a real field on the long-form expect block, which the engine \
         evaluates (WOR-1280)"
    ));

    let yaml = render::render_test(input, &assertions, &excluded_volatile, budget);
    ProposedTest {
        yaml,
        excluded_volatile,
        notes,
    }
}

/// The proposed latency budget: twice the slowest observed call, rounded up
/// to the nearest [`LATENCY_STEP_MS`], never below [`LATENCY_FLOOR_MS`].
///
/// The 2x headroom absorbs run-to-run jitter, the 50ms rounding keeps the
/// number stable across re-proposals, and the floor keeps a fast local
/// observation from proposing a budget that flakes on a loaded CI runner.
pub fn latency_budget_ms(durations: &[Duration]) -> u64 {
    let slowest_ms = durations
        .iter()
        .map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
        .max()
        .unwrap_or(0);
    let doubled = slowest_ms.saturating_mul(2);
    let rounded = doubled
        .div_ceil(LATENCY_STEP_MS)
        .saturating_mul(LATENCY_STEP_MS);
    rounded.max(LATENCY_FLOOR_MS)
}

/// The `isError` invariant, derived from what the observed call returned.
///
/// `isError: true` is pinned exactly (with a note, since the caller probably
/// expected success); `isError: false` is pinned exactly; an absent flag (the
/// common success shape on the wire) is asserted as "not true" so the test
/// passes whether the server omits the flag or sets it to `false`.
fn is_error_assertion(result: &Value, notes: &mut Vec<String>) -> ProposedAssertion {
    match result.get("isError") {
        Some(Value::Bool(true)) => {
            notes.push(
                "the observed call returned isError: true; the proposal pins the \
                 error shape (adjust the args if an error was not intended)"
                    .to_string(),
            );
            ProposedAssertion {
                target: "result.isError".to_string(),
                matcher: json!({ "exact": true }),
                message: "observed call returned isError: true".to_string(),
            }
        }
        Some(Value::Bool(false)) => ProposedAssertion {
            target: "result.isError".to_string(),
            matcher: json!({ "exact": false }),
            message: "tool call must succeed".to_string(),
        },
        _ => {
            notes.push(
                "isError was absent from the observed result; asserting it is \
                 never true"
                    .to_string(),
            );
            ProposedAssertion {
                target: "result.isError".to_string(),
                matcher: json!({ "not": { "exact": true } }),
                message: "tool call must not signal an error".to_string(),
            }
        }
    }
}

/// Structural schema assertion for one top-level result field (`content` or
/// `structuredContent`), inferred only from the observed samples. Returns
/// `None` (with a note where it matters) when the field was not present in
/// every observation, because a constraint on a sometimes-absent field would
/// be invented, not observed.
fn schema_assertion(
    field: &str,
    samples: &[&Value],
    notes: &mut Vec<String>,
) -> Option<ProposedAssertion> {
    let values: Vec<&Value> = samples.iter().filter_map(|s| s.get(field)).collect();
    if values.is_empty() {
        if field == "content" {
            notes.push(
                "result.content was absent from the observed result; no structural \
                 schema proposed for it"
                    .to_string(),
            );
        }
        return None;
    }
    if values.len() < samples.len() {
        notes.push(format!(
            "result.{field} was present in only one of the observed calls; no \
             structural schema proposed for it"
        ));
        return None;
    }
    let schema = infer_schema(&values, SCHEMA_OBJECT_DEPTH);
    Some(ProposedAssertion {
        target: format!("result.{field}"),
        matcher: json!({ "schema": schema }),
        message: format!("observed structure of result.{field}"),
    })
}

/// Exact-value assertions over the stable leaves: short scalars only
/// (strings under [`EXACT_STRING_MAX_LEN`] chars, numbers, bools), shallowest
/// paths first, capped at [`EXACT_LEAF_CAP`]. `result.isError` is excluded
/// because the invariant assertion already covers it.
fn exact_assertions(stable: &[StableLeaf]) -> Vec<ProposedAssertion> {
    let mut eligible: Vec<&StableLeaf> = stable
        .iter()
        .filter(|leaf| leaf.path != "result.isError")
        .filter(|leaf| exact_eligible(&leaf.value))
        // A quote inside a key cannot round-trip through the executor's
        // bracket-quoted path grammar; such a leaf stays schema-covered only.
        .filter(|leaf| !leaf.path.contains('"'))
        .collect();
    eligible.sort_by(|a, b| a.depth.cmp(&b.depth).then_with(|| a.path.cmp(&b.path)));
    eligible
        .into_iter()
        .take(EXACT_LEAF_CAP)
        .map(|leaf| ProposedAssertion {
            target: leaf.path.clone(),
            matcher: json!({ "exact": leaf.value }),
            message: "stable across both observed calls".to_string(),
        })
        .collect()
}

/// Whether a stable leaf value qualifies for an exact-value assertion: a
/// bool, a number, or a string shorter than [`EXACT_STRING_MAX_LEN`] chars.
/// Nulls are skipped (an exact null is indistinguishable from an absent
/// path under the executor's resolution rules).
fn exact_eligible(value: &Value) -> bool {
    match value {
        Value::Bool(_) | Value::Number(_) => true,
        Value::String(s) => s.chars().count() < EXACT_STRING_MAX_LEN,
        _ => false,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::{Path, PathBuf};

    fn input() -> ProposalInput {
        ProposalInput {
            server: "target".to_string(),
            tool: "get_status".to_string(),
            args: json!({}),
        }
    }

    fn obs(result: Value, ms: u64) -> Observation {
        Observation {
            result,
            duration: Duration::from_millis(ms),
        }
    }

    /// Parse the proposed block (wrapped under `tools:`) and return the
    /// long-form expect's assertion entries as JSON for structural
    /// assertions.
    fn expect_entries(yaml: &str) -> Vec<Value> {
        let doc = format!("tools:\n{yaml}");
        let parsed: serde_yaml::Value = serde_yaml::from_str(&doc).expect("proposal parses");
        let as_json: Value = serde_json::to_value(parsed).expect("yaml to json");
        as_json["tools"][0]["expect"]["assertions"]
            .as_array()
            .expect("assertions list")
            .clone()
    }

    #[test]
    fn volatile_leaves_are_excluded_from_exact_assertions_and_reported() {
        let first = obs(
            json!({
                "isError": false,
                "content": [{ "type": "text", "text": "ok" }],
                "structuredContent": { "status": "ok", "serial": 1 }
            }),
            5,
        );
        let second = obs(
            json!({
                "isError": false,
                "content": [{ "type": "text", "text": "ok" }],
                "structuredContent": { "status": "ok", "serial": 2 }
            }),
            6,
        );
        let proposal = propose_test(&input(), &first, Some(&second));
        assert_eq!(
            proposal.excluded_volatile,
            vec!["result.structuredContent.serial".to_string()]
        );
        let entries = expect_entries(&proposal.yaml);
        let exact_targets: Vec<&str> = entries
            .iter()
            .filter(|e| e["matcher"].get("exact").is_some())
            .filter_map(|e| e["target"].as_str())
            .collect();
        assert!(exact_targets.contains(&"result.content[0].text"));
        assert!(
            !exact_targets.contains(&"result.structuredContent.serial"),
            "volatile leaf must not be exact-asserted: {exact_targets:?}"
        );
        // The volatile leaf is named in a comment so a human reviewer sees
        // what was deliberately left out.
        assert!(proposal.yaml.contains("result.structuredContent.serial"));
    }

    #[test]
    fn exact_assertions_cap_at_five_preferring_shallow_paths() {
        let result = json!({
            "isError": false,
            "structuredContent": {
                "a": 1, "b": 2, "c": 3, "d": 4, "e": 5, "f": 6,
                "nested": { "deep": "value" }
            }
        });
        let proposal = propose_test(&input(), &obs(result.clone(), 5), Some(&obs(result, 5)));
        let entries = expect_entries(&proposal.yaml);
        let exacts: Vec<&Value> = entries
            .iter()
            .filter(|e| e["matcher"].get("exact").is_some() && e["target"] != "result.isError")
            .collect();
        assert_eq!(exacts.len(), EXACT_LEAF_CAP);
        // The deep leaf loses to the six shallower ones.
        assert!(exacts
            .iter()
            .all(|e| e["target"] != "result.structuredContent.nested.deep"));
    }

    #[test]
    fn single_observation_proposes_no_exact_assertions_and_says_why() {
        let first = obs(
            json!({ "isError": false, "content": [{ "type": "text", "text": "ok" }] }),
            5,
        );
        let proposal = propose_test(&input(), &first, None);
        let entries = expect_entries(&proposal.yaml);
        // Only the isError invariant carries an exact matcher.
        let exacts: Vec<&Value> = entries
            .iter()
            .filter(|e| e["matcher"].get("exact").is_some())
            .collect();
        assert_eq!(exacts.len(), 1);
        assert_eq!(exacts[0]["target"], "result.isError");
        assert!(proposal
            .notes
            .iter()
            .any(|n| n.contains("single observation")));
        assert!(proposal.excluded_volatile.is_empty());
    }

    #[test]
    fn long_string_leaves_are_not_exact_asserted() {
        let long = "x".repeat(200);
        let result = json!({ "isError": false, "structuredContent": { "blob": long } });
        let proposal = propose_test(&input(), &obs(result.clone(), 5), Some(&obs(result, 5)));
        let entries = expect_entries(&proposal.yaml);
        assert!(entries
            .iter()
            .all(|e| e["target"] != "result.structuredContent.blob"
                || e["matcher"].get("exact").is_none()));
    }

    #[test]
    fn latency_budget_doubles_rounds_up_to_fifty_and_floors_at_one_hundred() {
        // Sub-floor observations land on the floor.
        assert_eq!(latency_budget_ms(&[Duration::from_millis(1)]), 100);
        assert_eq!(latency_budget_ms(&[Duration::from_millis(0)]), 100);
        // 2x then round UP to the nearest 50.
        assert_eq!(latency_budget_ms(&[Duration::from_millis(120)]), 250);
        assert_eq!(latency_budget_ms(&[Duration::from_millis(75)]), 150);
        // Exact multiples do not over-round.
        assert_eq!(latency_budget_ms(&[Duration::from_millis(100)]), 200);
        // The slowest of several observations drives the budget.
        assert_eq!(
            latency_budget_ms(&[Duration::from_millis(10), Duration::from_millis(120)]),
            250
        );
    }

    #[test]
    fn observed_error_result_pins_is_error_true_with_a_note() {
        let first = obs(
            json!({ "isError": true, "content": [{ "type": "text", "text": "boom" }] }),
            5,
        );
        let proposal = propose_test(&input(), &first, None);
        let entries = expect_entries(&proposal.yaml);
        assert_eq!(entries[0]["target"], "result.isError");
        assert_eq!(entries[0]["matcher"]["exact"], true);
        assert!(proposal.notes.iter().any(|n| n.contains("isError: true")));
    }

    #[test]
    fn absent_is_error_is_asserted_as_not_true() {
        let first = obs(json!({ "content": [{ "type": "text", "text": "ok" }] }), 5);
        let proposal = propose_test(&input(), &first, None);
        let entries = expect_entries(&proposal.yaml);
        assert_eq!(entries[0]["target"], "result.isError");
        assert_eq!(entries[0]["matcher"]["not"]["exact"], true);
    }

    #[test]
    fn expect_list_orders_invariant_then_schema_then_exacts() {
        let result = json!({
            "isError": false,
            "content": [{ "type": "text", "text": "ok" }]
        });
        let proposal = propose_test(&input(), &obs(result.clone(), 5), Some(&obs(result, 5)));
        let entries = expect_entries(&proposal.yaml);
        assert_eq!(entries[0]["target"], "result.isError");
        assert_eq!(entries[1]["target"], "result.content");
        assert!(entries[1]["matcher"].get("schema").is_some());
        assert!(entries[2]["matcher"].get("exact").is_some());
    }

    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()
    }

    #[test]
    fn proposed_block_validates_against_the_v1_schema_in_a_minimal_suite() {
        let result = json!({
            "isError": false,
            "content": [{ "type": "text", "text": "ok" }],
            "structuredContent": { "status": "ok" }
        });
        let proposal = propose_test(
            &ProposalInput {
                server: "target".to_string(),
                tool: "get_status".to_string(),
                args: json!({ "verbose": true }),
            },
            &obs(result.clone(), 5),
            Some(&obs(result, 7)),
        );
        let suite = format!(
            "servers:\n  target:\n    command: [\"./srv\"]\ntools:\n{}",
            proposal.yaml
        );
        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(&suite).expect("suite 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(), "suite should validate, got: {errors:#?}");
    }

    #[test]
    fn proposal_is_deterministic_for_the_same_observations() {
        let result = json!({
            "isError": false,
            "content": [{ "type": "text", "text": "ok" }]
        });
        let a = propose_test(
            &input(),
            &obs(result.clone(), 5),
            Some(&obs(result.clone(), 5)),
        );
        let b = propose_test(&input(), &obs(result.clone(), 5), Some(&obs(result, 5)));
        assert_eq!(a.yaml, b.yaml);
        assert_eq!(a.excluded_volatile, b.excluded_volatile);
    }
}
