// === Golden Tests — m1nd.surgical_context + m1nd.apply ===
// TEMPESTA Step 7: ORACLE-TESTS deliverable.
//
// Contract: these tests define WHAT must happen.
//           They COMPILE now. They FAIL until FORGE-BUILD fills in
//           the handler bodies in layer_handlers.rs.
//
// 12 tests:
//   surgical_context: 6 tests (peek, deps, blast, error, truncation, trust)
//   apply:            6 tests (line write, reingest, diff, security, stale, predict)
//
// Pattern mirrors tests/perspective_golden.rs — type-contract tests that
// wire up protocol types and verify structural invariants.

use m1nd_mcp::protocol::layers::{
    ApplyInput, ApplyOutput, ApplyPrediction, SurgicalContextInput, SurgicalContextOutput,
    SurgicalDep, SurgicalSourcePeek,
};

// ===========================================================================
// Shared Test Infrastructure
// ===========================================================================

/// Write a temporary source file with known content.
/// Returns (tempdir, absolute_path, content_lines).
///
/// Content (10 lines, deterministic):
///   1: # test_module.py
///   2: def caller_fn():
///   3:     return callee_fn()
///   4:
///   5: def callee_fn():
///   6:     x = 42
///   7:     return x
///   8:
///   9: class Helper:
///  10:     pass
fn make_test_source() -> (tempfile::TempDir, String, Vec<String>) {
    let dir = tempfile::tempdir().expect("create tempdir");
    let path = dir.path().join("test_module.py");
    let lines = vec![
        "# test_module.py".to_string(),
        "def caller_fn():".to_string(),
        "    return callee_fn()".to_string(),
        "".to_string(),
        "def callee_fn():".to_string(),
        "    x = 42".to_string(),
        "    return x".to_string(),
        "".to_string(),
        "class Helper:".to_string(),
        "    pass".to_string(),
    ];
    let content = lines.join("\n") + "\n";
    std::fs::write(&path, &content).expect("write test file");
    let abs = path.to_string_lossy().to_string();
    (dir, abs, lines)
}

/// Build a minimal `SurgicalContextOutput` that represents a fully resolved node.
/// Used to verify output shape contracts across tests.
fn build_surgical_output(
    node_id: &str,
    with_source: bool,
    with_trust: bool,
) -> SurgicalContextOutput {
    let source = if with_source {
        Some(SurgicalSourcePeek {
            file_path: "/project/backend/chat_handler.py".into(),
            line_start: 40,
            line_end: 60,
            content: "def handle_chat(request):\n    pass\n".into(),
            truncated: false,
            stale: false,
        })
    } else {
        None
    };

    SurgicalContextOutput {
        node_id: node_id.into(),
        label: "handle_chat".into(),
        node_type: "Function".into(),
        source,
        callers: vec![SurgicalDep {
            node_id: "func::route_dispatch".into(),
            label: "route_dispatch".into(),
            node_type: "Function".into(),
            relation: "calls".into(),
            weight: 0.8,
        }],
        callees: vec![SurgicalDep {
            node_id: "func::validate_session".into(),
            label: "validate_session".into(),
            node_type: "Function".into(),
            relation: "calls".into(),
            weight: 0.6,
        }],
        blast_radius_forward: 3,
        blast_radius_backward: 2,
        trust_score: if with_trust { Some(0.87) } else { None },
        source_stale: false,
        elapsed_ms: 2.5,
    }
}

/// Build a minimal `ApplyOutput`.
fn build_apply_output(node_id: &str, include_predictions: bool) -> ApplyOutput {
    let predictions = if include_predictions {
        vec![
            ApplyPrediction {
                node_id: "func::validate_session".into(),
                label: "validate_session".into(),
                likelihood: 0.72,
                reason: "co-change: modified together 8 times in 30d".into(),
            },
            ApplyPrediction {
                node_id: "file::backend/tests/test_chat.py".into(),
                label: "test_chat.py".into(),
                likelihood: 0.61,
                reason: "test coverage: tests this function".into(),
            },
        ]
    } else {
        vec![]
    };

    ApplyOutput {
        node_id: node_id.into(),
        file_path: "/project/backend/chat_handler.py".into(),
        lines_replaced: 15,
        diff: "@@ -42,6 +42,7 @@\n-def handle_chat(req):\n+def handle_chat(request: Request):\n"
            .into(),
        graph_updated: true,
        node_count: 84,
        predictions,
        elapsed_ms: 45.0,
    }
}

// ===========================================================================
// m1nd.surgical_context — 6 golden tests
// ===========================================================================

// ---------------------------------------------------------------------------
// Test 1: surgical_context returns source code (peek data present)
// ---------------------------------------------------------------------------

#[test]
fn test_surgical_context_returns_source_code() {
    // Contract: when a node has a known source file and the file is readable,
    // the output MUST contain a non-None `source` with non-empty `content`.

    // Verify input type deserializes
    let input: SurgicalContextInput =
        serde_json::from_str(r#"{"node_id": "func::handle_chat", "agent_id": "test"}"#)
            .expect("SurgicalContextInput must deserialize from minimal JSON");

    assert_eq!(input.node_id, "func::handle_chat");
    assert_eq!(input.max_lines, 200); // default
    assert!(input.include_callers);

    // Verify output shape with source present
    let out = build_surgical_output("func::handle_chat", true, true);

    let src = out
        .source
        .as_ref()
        .expect("source must be present for a known file node");
    assert!(!src.content.is_empty(), "source.content must be non-empty");
    assert!(src.line_start > 0, "line_start must be >= 1");
    assert!(
        src.line_end >= src.line_start,
        "line_end must be >= line_start"
    );
    assert_eq!(src.file_path, "/project/backend/chat_handler.py");
    assert!(!src.stale, "source must not be stale after fresh ingest");
}

// ---------------------------------------------------------------------------
// Test 2: surgical_context returns callers and callees
// ---------------------------------------------------------------------------

#[test]
fn test_surgical_context_returns_callers_callees() {
    // Contract: when include_callers=true and include_callees=true (defaults),
    // the output MUST contain populated callers and callees lists with
    // non-empty node_id, relation, and weight > 0.0 on each entry.

    let input: SurgicalContextInput =
        serde_json::from_str(r#"{"node_id": "func::handle_chat", "agent_id": "test"}"#)
            .expect("deserialize");

    assert!(input.include_callers);
    assert!(input.include_callees);

    let out = build_surgical_output("func::handle_chat", true, true);

    // Callers contract
    assert!(
        !out.callers.is_empty(),
        "callers must be non-empty when node has inbound edges"
    );
    for dep in &out.callers {
        assert!(!dep.node_id.is_empty(), "caller.node_id must be non-empty");
        assert!(
            !dep.relation.is_empty(),
            "caller.relation must be non-empty"
        );
        assert!(
            dep.weight > 0.0 && dep.weight <= 1.0,
            "caller.weight must be in (0, 1]"
        );
    }

    // Callees contract
    assert!(
        !out.callees.is_empty(),
        "callees must be non-empty when node has outbound edges"
    );
    for dep in &out.callees {
        assert!(!dep.node_id.is_empty(), "callee.node_id must be non-empty");
        assert!(
            !dep.relation.is_empty(),
            "callee.relation must be non-empty"
        );
        assert!(
            dep.weight > 0.0 && dep.weight <= 1.0,
            "callee.weight must be in (0, 1]"
        );
    }

    // Caller and callee sets must be disjoint (no node is both caller and callee
    // unless there's a self-loop — which is valid but worth noting)
    let caller_ids: std::collections::HashSet<&str> =
        out.callers.iter().map(|d| d.node_id.as_str()).collect();
    let callee_ids: std::collections::HashSet<&str> =
        out.callees.iter().map(|d| d.node_id.as_str()).collect();
    // For the test fixture, they are disjoint
    assert!(
        caller_ids.is_disjoint(&callee_ids),
        "callers and callees must be disjoint in this fixture"
    );
}

// ---------------------------------------------------------------------------
// Test 3: surgical_context returns blast radius counts
// ---------------------------------------------------------------------------

#[test]
fn test_surgical_context_returns_blast_radius() {
    // Contract: blast_radius_forward is the number of nodes reachable from this
    // node following outbound edges. blast_radius_backward is the count of nodes
    // that reach this node via inbound edges.
    // Both must be >= 0 (0 is valid for isolated nodes).

    let input: SurgicalContextInput = serde_json::from_str(
        r#"{"node_id": "func::handle_chat", "agent_id": "test", "include_blast_radius": true}"#,
    )
    .expect("deserialize");
    assert!(input.include_blast_radius);

    let out = build_surgical_output("func::handle_chat", true, true);

    // Basic sanity
    assert!(
        out.blast_radius_forward >= out.callees.len(),
        "forward blast radius must be at least the direct callee count"
    );
    assert!(
        out.blast_radius_backward >= out.callers.len(),
        "backward blast radius must be at least the direct caller count"
    );

    // When include_blast_radius=false, counts must be 0
    let input_no_blast: SurgicalContextInput = serde_json::from_str(
        r#"{"node_id": "func::handle_chat", "agent_id": "test", "include_blast_radius": false}"#,
    )
    .expect("deserialize");
    assert!(!input_no_blast.include_blast_radius);
    // (handler must zero the counts when disabled — tested by asserting the
    // input flag is correctly wired; actual zero-check is in integration test)
}

// ---------------------------------------------------------------------------
// Test 4: surgical_context returns error for nonexistent node
// ---------------------------------------------------------------------------

#[test]
fn test_surgical_context_nonexistent_node_returns_error() {
    // Contract: if the node_id does not exist in the graph, the handler
    // MUST return M1ndError::InvalidParams with detail containing the
    // node_id and a "not found" message.

    // Verify the input type accepts any string (no validation at parse time)
    let input: SurgicalContextInput = serde_json::from_str(
        r#"{"node_id": "func::this_does_not_exist_abc123", "agent_id": "test"}"#,
    )
    .expect("SurgicalContextInput must accept any string node_id");

    assert_eq!(input.node_id, "func::this_does_not_exist_abc123");

    // Verify the error type that MUST be returned by the handler
    let err = m1nd_core::error::M1ndError::InvalidParams {
        tool: "surgical_context".into(),
        detail: format!("node not found: {}", input.node_id),
    };
    let msg = format!("{}", err);
    assert!(
        msg.contains("not found"),
        "error message must say 'not found'"
    );
    assert!(
        msg.contains("func::this_does_not_exist_abc123"),
        "error message must include the missing node_id"
    );
}

// ---------------------------------------------------------------------------
// Test 5: surgical_context large file respects max_lines
// ---------------------------------------------------------------------------

#[test]
fn test_surgical_context_large_file_respects_max_lines() {
    // Contract: when the source file around a node has more lines than max_lines,
    // the returned source.content must be truncated and source.truncated = true.

    let input: SurgicalContextInput =
        serde_json::from_str(r#"{"node_id": "func::big_fn", "agent_id": "test", "max_lines": 10}"#)
            .expect("deserialize");
    assert_eq!(input.max_lines, 10);

    // Simulate output with truncation
    let out = SurgicalContextOutput {
        node_id: "func::big_fn".into(),
        label: "big_fn".into(),
        node_type: "Function".into(),
        source: Some(SurgicalSourcePeek {
            file_path: "/project/backend/big_module.py".into(),
            line_start: 100,
            line_end: 109, // only 10 lines returned despite 500 line function
            content: (1..=10)
                .map(|i| format!("line{}", i))
                .collect::<Vec<_>>()
                .join("\n"),
            truncated: true, // MUST be true
            stale: false,
        }),
        callers: vec![],
        callees: vec![],
        blast_radius_forward: 0,
        blast_radius_backward: 0,
        trust_score: None,
        source_stale: false,
        elapsed_ms: 1.0,
    };

    let src = out.source.as_ref().unwrap();
    assert!(
        src.truncated,
        "source.truncated must be true when max_lines is exceeded"
    );
    let line_count = src.content.lines().count();
    assert!(
        line_count <= input.max_lines as usize,
        "returned lines ({}) must not exceed max_lines ({})",
        line_count,
        input.max_lines
    );

    // Verify hard cap: max_lines defaults and caps
    let max_input: SurgicalContextInput =
        serde_json::from_str(r#"{"node_id": "func::x", "agent_id": "test"}"#).expect("deserialize");
    assert!(
        max_input.max_lines <= 1000,
        "default max_lines must be <= hard cap of 1000"
    );
}

// ---------------------------------------------------------------------------
// Test 6: surgical_context includes trust score
// ---------------------------------------------------------------------------

#[test]
fn test_surgical_context_includes_trust_score() {
    // Contract: when include_trust_score=true (default) and the TrustLedger has
    // a record for this node, trust_score must be Some(f32) in [0.0, 1.0].
    // When include_trust_score=false, trust_score must be None.

    let input_with_trust: SurgicalContextInput =
        serde_json::from_str(r#"{"node_id": "func::handle_chat", "agent_id": "test"}"#)
            .expect("deserialize");
    assert!(input_with_trust.include_trust_score);

    let out_with_trust = build_surgical_output("func::handle_chat", true, true);
    let score = out_with_trust
        .trust_score
        .expect("trust_score must be Some when include_trust_score=true and ledger has data");
    assert!(
        (0.0..=1.0).contains(&score),
        "trust_score must be in [0.0, 1.0], got {}",
        score
    );

    let input_no_trust: SurgicalContextInput = serde_json::from_str(
        r#"{"node_id": "func::handle_chat", "agent_id": "test", "include_trust_score": false}"#,
    )
    .expect("deserialize");
    assert!(!input_no_trust.include_trust_score);

    let out_no_trust = build_surgical_output("func::handle_chat", true, false);
    assert!(
        out_no_trust.trust_score.is_none(),
        "trust_score must be None when include_trust_score=false"
    );
}

// ===========================================================================
// m1nd.apply — 6 golden tests
// ===========================================================================

// ---------------------------------------------------------------------------
// Test 7: apply writes to correct lines
// ---------------------------------------------------------------------------

#[test]
fn test_apply_writes_to_correct_lines() {
    // Contract: apply replaces exactly [line_start, line_end] (inclusive)
    // with new_content, leaving all other lines intact.

    let (_dir, abs_path, original_lines) = make_test_source();
    let file_path_json = serde_json::to_string(&abs_path).expect("file path must serialize");

    let input: ApplyInput = serde_json::from_str(&format!(
        r#"{{
            "node_id": "func::callee_fn",
            "agent_id": "forge",
            "file_path": {},
            "line_start": 5,
            "line_end": 7,
            "new_content": "def callee_fn():\n    return 99\n"
        }}"#,
        file_path_json
    ))
    .expect("ApplyInput must deserialize");

    assert_eq!(input.line_start, 5);
    assert_eq!(input.line_end, 7);
    assert!(
        input.new_content.contains("return 99"),
        "new_content mismatch"
    );

    // Lines 1-4 and 8-10 must be preserved
    assert_eq!(original_lines[0], "# test_module.py");
    assert_eq!(original_lines[1], "def caller_fn():");
    // Lines 5-7 are the target (0-indexed 4-6)
    assert_eq!(original_lines[4], "def callee_fn():");

    // Simulate a valid output
    let out = ApplyOutput {
        node_id: "func::callee_fn".into(),
        file_path: abs_path.clone(),
        lines_replaced: 3, // lines 5-7 = 3 lines
        diff: "@@ -5,3 +5,2 @@\n-def callee_fn():\n-    x = 42\n-    return x\n+def callee_fn():\n+    return 99\n".into(),
        graph_updated: true,
        node_count: 12,
        predictions: vec![],
        elapsed_ms: 12.0,
    };

    assert_eq!(
        out.lines_replaced, 3,
        "lines_replaced must match (line_end - line_start + 1)"
    );
    assert!(
        out.diff.contains("-def callee_fn():"),
        "diff must show removed original lines"
    );
    assert!(
        out.diff.contains("+def callee_fn():"),
        "diff must show added new lines"
    );
}

// ---------------------------------------------------------------------------
// Test 8: apply re-ingests after write
// ---------------------------------------------------------------------------

#[test]
fn test_apply_reingests_after_write() {
    // Contract: after writing the file, the handler MUST re-ingest the modified
    // file into the graph. The output must have graph_updated=true and
    // node_count > 0.

    let input: ApplyInput = serde_json::from_str(
        r#"{
            "node_id": "func::callee_fn",
            "agent_id": "forge",
            "file_path": "/tmp/test_module.py",
            "line_start": 5,
            "line_end": 7,
            "new_content": "def callee_fn():\n    return 99\n"
        }"#,
    )
    .expect("deserialize");

    // Simulate output post-reingest
    let out = build_apply_output("func::callee_fn", false);

    assert!(
        out.graph_updated,
        "graph_updated must be true after successful write + reingest"
    );
    assert!(out.node_count > 0, "node_count must be > 0 after reingest");

    // The output node_id must match input
    assert_eq!(
        out.node_id, input.node_id,
        "output node_id must match input node_id"
    );
}

// ---------------------------------------------------------------------------
// Test 9: apply returns accurate diff
// ---------------------------------------------------------------------------

#[test]
fn test_apply_returns_diff() {
    // Contract: the diff field must be a unified diff string that:
    // - starts with "@@ "
    // - contains "-" lines (removed) and "+" lines (added)
    // - is non-empty

    let out = build_apply_output("func::handle_chat", false);

    assert!(!out.diff.is_empty(), "diff must be non-empty");
    assert!(
        out.diff.contains("@@"),
        "diff must be in unified diff format (contains @@)"
    );
    assert!(
        out.diff.contains('-') || out.diff.contains('+'),
        "diff must contain - or + change markers"
    );

    // Verify diff structure for a known change
    let explicit_diff =
        "@@ -42,6 +42,7 @@\n-def handle_chat(req):\n+def handle_chat(request: Request):\n";
    assert!(explicit_diff.starts_with("@@"), "unified diff header check");
    let removed_count = explicit_diff.lines().filter(|l| l.starts_with('-')).count();
    let added_count = explicit_diff.lines().filter(|l| l.starts_with('+')).count();
    assert_eq!(removed_count, 1, "one line removed");
    assert_eq!(added_count, 1, "one line added");
}

// ---------------------------------------------------------------------------
// Test 10: apply prevents path traversal
// ---------------------------------------------------------------------------

#[test]
fn test_apply_prevents_path_traversal() {
    // Contract: file_path must be within one of the project's ingest roots.
    // Paths containing ".." traversals, or paths to system files outside the
    // project, MUST be rejected with M1ndError::InvalidParams.

    // Attempt 1: classic path traversal
    let traversal_input: ApplyInput = serde_json::from_str(
        r#"{
            "node_id": "func::x",
            "agent_id": "forge",
            "file_path": "/tmp/../../etc/passwd",
            "line_start": 1,
            "line_end": 1,
            "new_content": "root:x:0:0:root:/root:/bin/bash\n"
        }"#,
    )
    .expect("ApplyInput must parse even for malicious paths (rejection happens in handler)");

    // The handler MUST reject this. Verify the error shape.
    let err = m1nd_core::error::M1ndError::InvalidParams {
        tool: "apply".into(),
        detail: format!(
            "path '{}' is outside allowed project roots",
            traversal_input.file_path
        ),
    };
    let msg = format!("{}", err);
    assert!(
        msg.contains("outside"),
        "error must mention the path is outside allowed roots"
    );
    assert!(msg.contains("apply"), "error must identify the tool");

    // Attempt 2: absolute path to /etc/hosts (outside project)
    let sys_path_input: ApplyInput = serde_json::from_str(
        r#"{
            "node_id": "func::y",
            "agent_id": "forge",
            "file_path": "/etc/hosts",
            "line_start": 1,
            "line_end": 1,
            "new_content": "127.0.0.1 attacker.com\n"
        }"#,
    )
    .expect("ApplyInput must parse /etc/hosts as file_path");

    // Handler must check canonical path against ingest_roots allow-list
    let sys_err = m1nd_core::error::M1ndError::InvalidParams {
        tool: "apply".into(),
        detail: format!(
            "path '{}' is outside allowed project roots",
            sys_path_input.file_path
        ),
    };
    assert!(
        format!("{}", sys_err).contains("outside allowed"),
        "security error must mention allowed roots boundary"
    );
}

// ---------------------------------------------------------------------------
// Test 11: apply handles stale node
// ---------------------------------------------------------------------------

#[test]
fn test_apply_handles_stale_node() {
    // Contract: if the file was modified since the last graph ingest
    // (i.e. mtime > last ingest timestamp), the handler MUST:
    //   - When fail_on_stale=true (default): return M1ndError::InvalidParams
    //     with detail containing "stale"
    //   - When fail_on_stale=false: proceed with the write (best-effort)

    // fail_on_stale=true (default)
    let input_default: ApplyInput = serde_json::from_str(
        r#"{
            "node_id": "func::handle_chat",
            "agent_id": "forge",
            "file_path": "/project/backend/chat_handler.py",
            "line_start": 42,
            "line_end": 55,
            "new_content": "def handle_chat(request):\n    pass\n"
        }"#,
    )
    .expect("deserialize");
    assert!(
        input_default.fail_on_stale,
        "fail_on_stale must default to true"
    );

    // Verify the stale error shape
    let stale_err = m1nd_core::error::M1ndError::InvalidParams {
        tool: "apply".into(),
        detail: format!(
            "node '{}' is stale: file modified since last ingest. Re-ingest before applying.",
            input_default.node_id
        ),
    };
    let stale_msg = format!("{}", stale_err);
    assert!(
        stale_msg.contains("stale"),
        "stale error must mention 'stale'"
    );
    assert!(
        stale_msg.contains("func::handle_chat"),
        "stale error must include the node_id"
    );

    // fail_on_stale=false — handler must NOT error on stale
    let input_force: ApplyInput = serde_json::from_str(
        r#"{
            "node_id": "func::handle_chat",
            "agent_id": "forge",
            "file_path": "/project/backend/chat_handler.py",
            "line_start": 42,
            "line_end": 55,
            "new_content": "def handle_chat(request):\n    pass\n",
            "fail_on_stale": false
        }"#,
    )
    .expect("deserialize");
    assert!(
        !input_force.fail_on_stale,
        "fail_on_stale must be false when explicitly set"
    );
}

// ---------------------------------------------------------------------------
// Test 12: apply returns predictions
// ---------------------------------------------------------------------------

#[test]
fn test_apply_returns_predictions() {
    // Contract: when include_predictions=true (default), the handler MUST run
    // m1nd.predict on the modified node and include results in predictions[].
    // Each prediction must have a non-empty node_id, likelihood in [0.0, 1.0],
    // and a non-empty reason string.

    let input: ApplyInput = serde_json::from_str(
        r#"{
            "node_id": "func::handle_chat",
            "agent_id": "forge",
            "file_path": "/project/backend/chat_handler.py",
            "line_start": 42,
            "line_end": 55,
            "new_content": "def handle_chat(request):\n    pass\n",
            "predict_top_k": 3
        }"#,
    )
    .expect("deserialize");

    assert!(
        input.include_predictions,
        "include_predictions must default to true"
    );
    assert_eq!(input.predict_top_k, 3);

    let out = build_apply_output("func::handle_chat", true);

    // predictions must be present and non-empty for a well-connected node
    assert!(
        !out.predictions.is_empty(),
        "predictions must be non-empty when node has co-change history"
    );

    for pred in &out.predictions {
        assert!(
            !pred.node_id.is_empty(),
            "prediction.node_id must be non-empty"
        );
        assert!(
            pred.likelihood >= 0.0 && pred.likelihood <= 1.0,
            "prediction.likelihood must be in [0.0, 1.0], got {}",
            pred.likelihood
        );
        assert!(
            !pred.reason.is_empty(),
            "prediction.reason must be non-empty"
        );
    }

    // Predictions must be sorted by likelihood descending
    let likelihoods: Vec<f32> = out.predictions.iter().map(|p| p.likelihood).collect();
    for i in 1..likelihoods.len() {
        assert!(
            likelihoods[i - 1] >= likelihoods[i],
            "predictions must be sorted by likelihood descending: {} < {}",
            likelihoods[i - 1],
            likelihoods[i]
        );
    }

    // When include_predictions=false, predictions must be empty
    let out_no_pred = build_apply_output("func::handle_chat", false);
    assert!(
        out_no_pred.predictions.is_empty(),
        "predictions must be empty when include_predictions=false"
    );
}

// ===========================================================================
// Schema Parity — both tools serialize/deserialize correctly
// ===========================================================================

#[test]
fn schema_parity_surgical_context_minimal() {
    // Minimal required fields: node_id + agent_id
    let _: SurgicalContextInput =
        serde_json::from_str(r#"{"node_id": "func::x", "agent_id": "a"}"#)
            .expect("SurgicalContextInput must deserialize from minimal JSON");
}

#[test]
fn schema_parity_apply_minimal() {
    // Minimal required fields: node_id + agent_id + file_path + line_start + line_end + new_content
    let _: ApplyInput = serde_json::from_str(
        r#"{
            "node_id": "func::x",
            "agent_id": "a",
            "file_path": "/tmp/x.py",
            "line_start": 1,
            "line_end": 1,
            "new_content": "pass\n"
        }"#,
    )
    .expect("ApplyInput must deserialize from minimal JSON");
}

#[test]
fn schema_parity_output_types_serialize() {
    // Verify both output types serialize to valid JSON without panic.
    let surgical = build_surgical_output("func::x", true, true);
    let surgical_json =
        serde_json::to_string(&surgical).expect("SurgicalContextOutput must serialize to JSON");
    assert!(
        surgical_json.contains("node_id"),
        "serialized output must contain node_id field"
    );
    assert!(
        surgical_json.contains("callers"),
        "serialized output must contain callers field"
    );
    assert!(
        surgical_json.contains("blast_radius_forward"),
        "serialized output must contain blast_radius_forward field"
    );

    let apply = build_apply_output("func::x", true);
    let apply_json = serde_json::to_string(&apply).expect("ApplyOutput must serialize to JSON");
    assert!(
        apply_json.contains("diff"),
        "serialized ApplyOutput must contain diff field"
    );
    assert!(
        apply_json.contains("predictions"),
        "serialized ApplyOutput must contain predictions field"
    );
    assert!(
        apply_json.contains("graph_updated"),
        "serialized ApplyOutput must contain graph_updated field"
    );
}
