//! Round-trip drift guard for `schemas/agent-cassette-v1.json` (WOR-1284).
//!
//! The agent cassette schema is a published contract: real cassettes written
//! by `mcptest run --record` must validate against it. The schema once
//! drifted behind the code (the `server` field on tool calls was missing
//! while `additionalProperties: false` rejected it), so these tests build
//! traces with the actual `mcptest-agent` types, serialize them through the
//! same serde path the recorder uses, and validate the JSON against the
//! published schema. Any future field added to the trace structs without a
//! matching schema update fails here, in the normal test gate.

use std::path::{Path, PathBuf};

use mcptest_agent::cassette::{from_run, Cassette};
use mcptest_agent::trace::{
    AgentCaller, AgentStopReason, AgentToolCall, AgentToolResult, ConversationSummary,
    ConversationTokens, ConversationTrace, ConversationTurn, ConversationTurnTrace,
};
use serde_json::{json, Value};

/// Locate the workspace root that holds the published `schemas/` directory.
fn workspace_root() -> PathBuf {
    // CARGO_MANIFEST_DIR points at crates/mcptest; two parents up is root.
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(Path::parent)
        .expect("workspace root above crates/mcptest")
        .to_path_buf()
}

fn schema_validator() -> jsonschema::Validator {
    let path = workspace_root()
        .join("schemas")
        .join("agent-cassette-v1.json");
    let raw =
        std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
    let schema: Value = serde_json::from_str(&raw).expect("agent-cassette-v1.json is valid JSON");
    jsonschema::validator_for(&schema).expect("agent cassette schema compiles")
}

fn assert_validates(cassette: &Cassette) {
    let validator = schema_validator();
    let value = serde_json::to_value(cassette).expect("cassette serializes");
    let errors: Vec<String> = validator
        .iter_errors(&value)
        .map(|e| format!("{e} at {}", e.instance_path))
        .collect();
    assert!(
        errors.is_empty(),
        "recorded cassette must validate against the published schema, got: {errors:#?}\ncassette JSON: {value:#}"
    );
}

/// A trace that exercises every field the recorder can serialize: namespaced
/// servers, a code-execution caller, hop indexes, sub-agent identity, input
/// digests, a non-default stop reason, diagnostic turns, and the per-turn
/// breakdown for multi-turn runs.
fn full_trace() -> ConversationTrace {
    let call = AgentToolCall {
        name: "get_weather".into(),
        server: "weather".into(),
        args: json!({ "city": "Sacramento" }),
        caller: AgentCaller::CodeExecution {
            tool_id: Some("code_execution_01abc".into()),
        },
        hop_index: 1,
        agent_id: Some("worker-1".into()),
        inputs_digest: Some("deadbeef".into()),
    };
    let result = AgentToolResult {
        call_index: 0,
        server: "weather".into(),
        is_error: false,
        result: json!({ "content": [{ "type": "text", "text": "72F" }] }),
    };
    ConversationTrace {
        tool_calls: vec![call.clone()],
        tool_results: vec![result.clone()],
        final_response: "It is 72F in Sacramento.".into(),
        conversation: ConversationSummary {
            tokens: ConversationTokens {
                prompt: 80,
                completion: 25,
                total: 105,
            },
            duration_ms: 1234,
            message_count: 4,
            stop_reason: AgentStopReason::TokenBudget,
            turns: vec![ConversationTurn {
                role: "assistant".into(),
                content: "calling get_weather".into(),
            }],
            per_turn: vec![ConversationTurnTrace {
                user: "What is the weather in Sacramento?".into(),
                tool_calls: vec![call],
                tool_results: vec![result],
                final_response: "It is 72F in Sacramento.".into(),
            }],
        },
    }
}

/// A trace using only defaults, mirroring what a plain single-prompt,
/// single-agent recording looks like (Direct caller skipped, no sub-agent
/// identity, empty per-turn breakdown).
fn minimal_trace() -> ConversationTrace {
    ConversationTrace {
        tool_calls: vec![AgentToolCall {
            name: "ping".into(),
            server: "server".into(),
            args: json!({}),
            ..Default::default()
        }],
        tool_results: vec![AgentToolResult {
            call_index: 0,
            server: "server".into(),
            is_error: false,
            result: json!({ "content": [] }),
        }],
        final_response: "pong".into(),
        conversation: ConversationSummary {
            tokens: ConversationTokens::default(),
            duration_ms: 7,
            message_count: 2,
            stop_reason: AgentStopReason::Completed,
            turns: vec![],
            per_turn: vec![],
        },
    }
}

#[test]
fn fully_populated_recorded_cassette_validates_against_published_schema() {
    let cassette = from_run(
        "weather query routes to get_weather",
        "claude-sonnet-4-5",
        "What is the weather in Sacramento?",
        Some("You are a weather assistant."),
        full_trace(),
    );
    assert_validates(&cassette);
}

#[test]
fn minimal_recorded_cassette_validates_against_published_schema() {
    let cassette = from_run("smoke", "claude-sonnet-4-5", "ping", None, minimal_trace());
    assert_validates(&cassette);
}

#[test]
fn fidelity_bearing_cassette_validates_against_published_schema() {
    // WOR-1321: a cassette recorded after the fidelity wiring carries the
    // tool-catalog fingerprint and the sampling params. The schema must
    // accept all three while keeping additionalProperties: false.
    let mut cassette = from_run("smoke", "claude-sonnet-4-5", "ping", None, minimal_trace());
    cassette.tools_fingerprint = Some("a1b2c3d4e5f60718".into());
    cassette.temperature = Some(0.0);
    cassette.max_tokens = Some(1024);
    assert_validates(&cassette);
}

#[test]
fn legacy_cassette_without_fidelity_fields_still_validates() {
    // WOR-1321: the fidelity fields are optional, so a cassette recorded
    // before the wiring (no tools_fingerprint, temperature, or max_tokens
    // keys at all) keeps validating and deserializing.
    let cassette = from_run("smoke", "claude-sonnet-4-5", "ping", None, minimal_trace());
    let value = serde_json::to_value(&cassette).expect("cassette serializes");
    assert!(
        value.get("tools_fingerprint").is_none(),
        "absent fidelity fields must not serialize: {value:#}"
    );
    assert_validates(&cassette);
}

#[test]
fn every_stop_reason_variant_validates_against_published_schema() {
    let variants = [
        AgentStopReason::Completed,
        AgentStopReason::TurnBudget,
        AgentStopReason::TokenBudget,
        AgentStopReason::ToolCallBudget,
        AgentStopReason::TimeBudget,
    ];
    for stop_reason in variants {
        let mut trace = minimal_trace();
        trace.conversation.stop_reason = stop_reason.clone();
        let cassette = from_run("budget", "m", "p", None, trace);
        let validator = schema_validator();
        let value = serde_json::to_value(&cassette).expect("cassette serializes");
        assert!(
            validator.is_valid(&value),
            "stop_reason {stop_reason:?} must be in the schema enum"
        );
    }
}

#[test]
fn legacy_cassette_without_newer_fields_still_validates() {
    // The shape a pre-WOR-1284 recording had: no server on calls or results,
    // no caller, hop_index, stop_reason, or per_turn. The schema additions
    // are deliberately optional so committed cassettes keep validating.
    let legacy = json!({
        "schema_version": "v1",
        "agent_name": "weather",
        "model": "claude-sonnet-4-5",
        "user_prompt": "What is the weather?",
        "system_prompt": null,
        "recorded_at": "2026-05-19T01:23:45Z",
        "trace": {
            "tool_calls": [{ "name": "get_weather", "args": { "city": "Sacramento" } }],
            "tool_results": [{ "call_index": 0, "is_error": false, "result": {} }],
            "final_response": "72F",
            "conversation": {
                "tokens": { "prompt": 1, "completion": 1, "total": 2 },
                "duration_ms": 5,
                "message_count": 1,
                "turns": []
            }
        }
    });
    let validator = schema_validator();
    let errors: Vec<String> = validator
        .iter_errors(&legacy)
        .map(|e| e.to_string())
        .collect();
    assert!(
        errors.is_empty(),
        "legacy cassette must validate: {errors:#?}"
    );
    // The same document must also deserialize through the runtime types, so
    // schema and serde agree on what "legacy" means.
    let _: Cassette = serde_json::from_value(legacy).expect("legacy cassette deserializes");
}

#[test]
fn unknown_field_on_a_tool_call_is_rejected_by_the_schema() {
    // The drift guard has teeth only while additionalProperties stays false:
    // a struct field added without a schema update must fail validation here
    // instead of silently passing.
    let cassette = from_run("smoke", "m", "p", None, minimal_trace());
    let mut value = serde_json::to_value(&cassette).expect("cassette serializes");
    value["trace"]["tool_calls"][0]["not_in_schema"] = json!(true);
    let validator = schema_validator();
    assert!(
        !validator.is_valid(&value),
        "schema must reject fields the trace structs do not serialize"
    );
}
