//! Integration tests for `mcptest diff --suggest-regen` (WOR-1275).
//!
//! Drives the diff-to-regenerate flow end to end with file fixtures: a
//! baseline catalog, a current catalog where one tool's schema changed
//! (an enum value swapped), one tool was removed, and one was added, and
//! a suite referencing the changed and removed tools. The tests pin which
//! test names are flagged per category, that drafts regenerate from the
//! CURRENT schema, that draft names never collide with suite names, that
//! the combined YAML passes the suite loader, and that hand-authored
//! tests touching unaffected tools are never mentioned.

use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::tempdir;

fn mcptest() -> Command {
    Command::cargo_bin("mcptest").expect("binary builds")
}

fn write(path: &std::path::Path, body: &str) {
    std::fs::write(path, body).unwrap();
}

/// Baseline `tools/list` snapshot: `create_issue` (enum priority),
/// `archive_issue` (will be removed), `list_issues` (never changes).
const BASELINE: &str = r#"{
  "tools": [
    {
      "name": "create_issue",
      "inputSchema": {
        "type": "object",
        "properties": {
          "title": { "type": "string" },
          "priority": { "type": "string", "enum": ["low", "medium", "high"] }
        },
        "required": ["title", "priority"]
      }
    },
    {
      "name": "archive_issue",
      "inputSchema": {
        "type": "object",
        "properties": { "id": { "type": "string" } },
        "required": ["id"]
      }
    },
    { "name": "list_issues", "inputSchema": { "type": "object" } }
  ]
}"#;

/// Current snapshot: `create_issue` swapped enum value `low` for
/// `urgent`, `archive_issue` is gone, `add_comment` is new.
const CURRENT: &str = r#"{
  "tools": [
    {
      "name": "create_issue",
      "inputSchema": {
        "type": "object",
        "properties": {
          "title": { "type": "string" },
          "priority": { "type": "string", "enum": ["urgent", "medium", "high"] }
        },
        "required": ["title", "priority"]
      }
    },
    {
      "name": "add_comment",
      "inputSchema": {
        "type": "object",
        "properties": { "body": { "type": "string" } },
        "required": ["body"]
      }
    },
    { "name": "list_issues", "inputSchema": { "type": "object" } }
  ]
}"#;

/// Suite referencing the changed tool (twice), the removed tool, the
/// unaffected tool, and a name that collides with the added tool's
/// natural draft name.
const SUITE: &str = r#"servers:
  main:
    command: ["echo", "hi"]
tools:
  - name: "create: hand authored"
    server: main
    tool: create_issue
    args:
      title: "x"
      priority: "low"
    expect:
      - target: result
        matcher:
          contains: ok
  - name: "archive: hand authored"
    server: main
    tool: archive_issue
    args:
      id: "1"
    expect:
      - target: result
        matcher:
          contains: ok
  - name: "list: untouched"
    server: main
    tool: list_issues
    args: {}
    expect:
      - target: result
        matcher:
          contains: ok
  - name: "add_comment: valid arguments"
    server: main
    tool: add_comment
    args:
      body: "pre-written"
    expect:
      - target: result
        matcher:
          contains: ok
"#;

struct Fixture {
    dir: tempfile::TempDir,
}

impl Fixture {
    fn new() -> Self {
        let dir = tempdir().unwrap();
        write(&dir.path().join("baseline.json"), BASELINE);
        write(&dir.path().join("current.json"), CURRENT);
        write(&dir.path().join("suite.yml"), SUITE);
        Fixture { dir }
    }

    fn path(&self, name: &str) -> String {
        self.dir.path().join(name).to_str().unwrap().to_string()
    }
}

#[test]
fn changed_tool_flags_its_referencing_tests_and_regenerates_from_current_schema() {
    let fx = Fixture::new();
    let assertion = mcptest()
        .args([
            "diff",
            &fx.path("baseline.json"),
            &fx.path("current.json"),
            "--suggest-regen",
            "--suite-file",
            &fx.path("suite.yml"),
        ])
        .assert()
        // Exit code is unchanged from plain diff: the removed tool and
        // the dropped enum value are breaking, fail-on-breaking defaults
        // to true.
        .code(1);
    let out = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
    let err = String::from_utf8(assertion.get_output().stderr.clone()).unwrap();
    // The hand-authored create_issue test is flagged, on stderr.
    assert!(err.contains("tool `create_issue` changed"));
    assert!(err.contains("create: hand authored"));
    // The draft regenerates from the CURRENT schema: the new enum value
    // appears, the dropped one does not.
    assert!(out.contains("# regenerated by mcptest diff"));
    assert!(out.contains("priority: \"urgent\""));
    assert!(!out.contains("\"low\""));
    assert!(out.contains("create_issue: regenerated valid arguments"));
}

#[test]
fn removed_tool_names_its_tests_with_a_delete_or_rewrite_note_and_no_draft() {
    let fx = Fixture::new();
    let assertion = mcptest()
        .args([
            "diff",
            &fx.path("baseline.json"),
            &fx.path("current.json"),
            "--suggest-regen",
            "--suite-file",
            &fx.path("suite.yml"),
            "--fail-on-breaking=false",
        ])
        .assert()
        .success();
    let out = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
    let err = String::from_utf8(assertion.get_output().stderr.clone()).unwrap();
    assert!(err.contains("archive: hand authored"));
    assert!(err.contains("delete or rewrite"));
    // The removed tool appears in the combined YAML only as a comment;
    // there is never a draft (or a deletion) for it.
    assert!(
        out.contains("# removed tool `archive_issue`: delete or rewrite: archive: hand authored")
    );
    assert!(!out.contains("tool: archive_issue"));
}

#[test]
fn added_tool_draft_dedups_against_an_existing_suite_test_name() {
    let fx = Fixture::new();
    let assertion = mcptest()
        .args([
            "diff",
            &fx.path("baseline.json"),
            &fx.path("current.json"),
            "--suggest-regen",
            "--suite-file",
            &fx.path("suite.yml"),
            "--fail-on-breaking=false",
        ])
        .assert()
        .success();
    let out = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
    // The suite already has "add_comment: valid arguments", so the fresh
    // draft takes the dedup suffix instead of colliding.
    assert!(out.contains("add_comment: valid arguments (suggested)"));
    assert!(out.contains("body: \"sample\""));
}

#[test]
fn unaffected_hand_authored_tests_are_never_mentioned() {
    let fx = Fixture::new();
    let assertion = mcptest()
        .args([
            "diff",
            &fx.path("baseline.json"),
            &fx.path("current.json"),
            "--suggest-regen",
            "--suite-file",
            &fx.path("suite.yml"),
            "--fail-on-breaking=false",
        ])
        .assert()
        .success();
    let out = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
    let err = String::from_utf8(assertion.get_output().stderr.clone()).unwrap();
    assert!(!out.contains("list: untouched"));
    assert!(!err.contains("list: untouched"));
}

#[test]
fn combined_yaml_passes_the_suite_loader_when_wrapped_with_a_servers_block() {
    let fx = Fixture::new();
    let assertion = mcptest()
        .args([
            "diff",
            &fx.path("baseline.json"),
            &fx.path("current.json"),
            "--suggest-regen",
            "--suite-file",
            &fx.path("suite.yml"),
            "--fail-on-breaking=false",
        ])
        .assert()
        .success();
    let out = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
    // The combined block starts at the column-zero `tools:` line that
    // follows the drift report.
    let start = out.find("\ntools:\n").expect("a combined tools block") + 1;
    let combined = &out[start..];
    // Drafts reference the suite's first server key, so the wrapper
    // defines that server and nothing else.
    let wrapped = format!("servers:\n  main:\n    command: [\"echo\", \"hi\"]\n{combined}");
    let merged = fx.dir.path().join("merged.yml");
    write(&merged, &wrapped);
    mcptest()
        .args(["--config", merged.to_str().unwrap(), "validate"])
        .assert()
        .success()
        .stdout(predicate::str::contains("ok"));
}

#[test]
fn without_a_suite_only_added_tool_drafts_are_emitted_with_a_note() {
    let fx = Fixture::new();
    let assertion = mcptest()
        .args([
            "diff",
            &fx.path("baseline.json"),
            &fx.path("current.json"),
            "--suggest-regen",
            "--fail-on-breaking=false",
        ])
        .assert()
        .success();
    let out = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
    let err = String::from_utf8(assertion.get_output().stderr.clone()).unwrap();
    assert!(err.contains("added tools only"));
    // The added tool gets its fresh draft; without a suite there is no
    // referencing-test analysis, so no changed-tool regen and no removed
    // comment.
    assert!(out.contains("add_comment: valid arguments"));
    assert!(!out.contains("regenerated valid arguments"));
    assert!(!out.contains("# removed tool"));
}

#[test]
fn suite_flag_requires_suggest_regen() {
    let fx = Fixture::new();
    mcptest()
        .args([
            "diff",
            &fx.path("baseline.json"),
            &fx.path("current.json"),
            "--suite-file",
            &fx.path("suite.yml"),
        ])
        .assert()
        .failure()
        .stderr(predicate::str::contains("suggest-regen"));
}

#[test]
fn json_format_embeds_the_regen_plan_under_a_stable_key() {
    let fx = Fixture::new();
    let assertion = mcptest()
        .args([
            "diff",
            &fx.path("baseline.json"),
            &fx.path("current.json"),
            "--format",
            "json",
            "--suggest-regen",
            "--suite-file",
            &fx.path("suite.yml"),
            "--fail-on-breaking=false",
        ])
        .assert()
        .success();
    let out = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&out).expect("stdout is one JSON doc");
    let items = parsed["regen"]["items"].as_array().expect("regen items");
    let kinds: Vec<&str> = items.iter().map(|i| i["kind"].as_str().unwrap()).collect();
    assert_eq!(kinds, vec!["changed", "removed", "added"]);
    assert_eq!(parsed["regen"]["items"][1]["tool"], "archive_issue");
    assert!(parsed["regen"]["yaml"]
        .as_str()
        .unwrap()
        .contains("# regenerated by mcptest diff"));
    assert!(parsed["report"]["changes"].is_array());
}

#[test]
fn missing_suite_file_fails_with_exit_one() {
    let fx = Fixture::new();
    mcptest()
        .args([
            "diff",
            &fx.path("baseline.json"),
            &fx.path("current.json"),
            "--suggest-regen",
            "--suite-file",
            &fx.path("nope.yml"),
        ])
        .assert()
        .code(1)
        .stderr(predicate::str::contains("cannot read suite"));
}
