//! Integration tests for the long-form `expect:` mapping (WOR-1280).
//!
//! The v1 schema accepts two shapes for a test's `expect:`: the short-form
//! assertion array and the long-form Expectation mapping (`assertions:` plus
//! the budget fields). The runtime parser used to read only the short form,
//! so a long-form test validated clean, ran with ZERO assertions, and
//! "passed". These tests pin the fix end to end against `mcptest mock`
//! spawned over stdio: the mapping form's assertions are evaluated, the
//! `max_duration_ms` and `max_response_tokens` budgets gate the step, and
//! header checks fail actionably on a stdio target that has no headers.

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

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

/// Absolute path to the built `mcptest` binary, used as the mock server
/// command inside generated suites.
fn mcptest_bin() -> &'static str {
    env!("CARGO_BIN_EXE_mcptest")
}

/// Write a mock manifest with a single `ping` tool that returns a fixed
/// five-word text block, and return the manifest path.
fn write_mock_manifest(dir: &std::path::Path) -> std::path::PathBuf {
    let manifest = r#"mock_server:
  name: expect-mapping-fixture
  tools:
    - name: ping
      description: "Return a fixed pong line."
      input_schema:
        type: object
      response:
        isError: false
        content:
          - type: text
            text: "pong alpha beta gamma delta"
"#;
    let path = dir.join("mock.yml");
    std::fs::write(&path, manifest).expect("write mock manifest");
    path
}

/// Render a suite whose single tool test uses the given `expect:` block
/// (caller provides the YAML lines under `    expect:`) against the mock,
/// optionally injecting a `--fault` into the mock server command.
fn write_suite(
    dir: &std::path::Path,
    expect_block: &str,
    fault: Option<&str>,
) -> std::path::PathBuf {
    let manifest = write_mock_manifest(dir);
    let fault_args = match fault {
        Some(kind) => format!(", '--fault', '{kind}'"),
        None => String::new(),
    };
    let suite = format!(
        r#"servers:
  fixture:
    command: ['{bin}', 'mock', '--tools-from', '{manifest}'{fault_args}]
tools:
  - name: ping under contract
    server: fixture
    tool: ping
    expect:
{expect_block}
"#,
        bin = mcptest_bin(),
        manifest = manifest.display(),
    );
    let path = dir.join("suite.yml");
    std::fs::write(&path, suite).expect("write suite");
    path
}

#[test]
fn long_form_mapping_with_a_failing_assertion_fails_the_run() {
    // The original WOR-1280 repro: before the fix this suite ran with zero
    // assertions and passed even though the assertion can never match.
    let dir = tempfile::tempdir().unwrap();
    let suite = write_suite(
        dir.path(),
        "      assertions:\n        - target: \"result.content[0].text\"\n          matcher: { contains: \"this text never appears\" }\n",
        None,
    );
    mcptest()
        .args(["run", "--config"])
        .arg(&suite)
        .assert()
        .code(1)
        .stdout(
            predicate::str::contains("1 failed")
                .and(predicate::str::contains("this text never appears")),
        );
}

#[test]
fn long_form_mapping_with_a_passing_assertion_passes() {
    let dir = tempfile::tempdir().unwrap();
    let suite = write_suite(
        dir.path(),
        "      assertions:\n        - target: \"result.content[0].text\"\n          matcher: { contains: \"pong\" }\n      max_duration_ms: 30000\n",
        None,
    );
    mcptest()
        .args(["run", "--config"])
        .arg(&suite)
        .assert()
        .code(0)
        .stdout(predicate::str::contains("1 passed"));
}

#[test]
fn tight_duration_budget_fails_against_a_slow_tool() {
    // The mock's `slow:300` fault delays every tools/call by 300 ms, so a
    // 50 ms budget must breach and the failure must name the field.
    let dir = tempfile::tempdir().unwrap();
    let suite = write_suite(
        dir.path(),
        "      assertions:\n        - target: \"result.content[0].text\"\n          matcher: { contains: \"pong\" }\n      max_duration_ms: 50\n",
        Some("slow:300"),
    );
    mcptest()
        .args(["run", "--config"])
        .arg(&suite)
        .assert()
        .code(1)
        .stdout(
            predicate::str::contains("1 failed")
                .and(predicate::str::contains("max_duration_ms"))
                .and(predicate::str::contains("over budget")),
        );
}

#[test]
fn generous_duration_budget_passes_against_the_same_slow_tool() {
    let dir = tempfile::tempdir().unwrap();
    let suite = write_suite(
        dir.path(),
        "      assertions:\n        - target: \"result.content[0].text\"\n          matcher: { contains: \"pong\" }\n      max_duration_ms: 30000\n",
        Some("slow:200"),
    );
    mcptest()
        .args(["run", "--config"])
        .arg(&suite)
        .assert()
        .code(0)
        .stdout(predicate::str::contains("1 passed"));
}

#[test]
fn token_budget_over_in_fail_mode_fails_the_run() {
    // The fixture response is five whitespace tokens; a budget of 3 with the
    // whitespace tokenizer must fail and name the field.
    let dir = tempfile::tempdir().unwrap();
    let suite = write_suite(
        dir.path(),
        "      assertions:\n        - target: \"result.content[0].text\"\n          matcher: { contains: \"pong\" }\n      max_response_tokens:\n        budget: 3\n        tokenizer: whitespace\n",
        None,
    );
    mcptest()
        .args(["run", "--config"])
        .arg(&suite)
        .assert()
        .code(1)
        .stdout(
            predicate::str::contains("1 failed")
                .and(predicate::str::contains("max_response_tokens"))
                .and(predicate::str::contains("over budget")),
        );
}

#[test]
fn token_budget_under_passes_and_warn_mode_never_fails() {
    let dir = tempfile::tempdir().unwrap();
    // Under budget passes.
    let under = write_suite(
        dir.path(),
        "      assertions:\n        - target: \"result.content[0].text\"\n          matcher: { contains: \"pong\" }\n      max_response_tokens:\n        budget: 50\n        tokenizer: whitespace\n",
        None,
    );
    mcptest()
        .args(["run", "--config"])
        .arg(&under)
        .assert()
        .code(0)
        .stdout(predicate::str::contains("1 passed"));
    // Over budget in warn mode still passes.
    let warn = write_suite(
        dir.path(),
        "      assertions:\n        - target: \"result.content[0].text\"\n          matcher: { contains: \"pong\" }\n      max_response_tokens:\n        budget: 2\n        tokenizer: whitespace\n        mode: warn\n",
        None,
    );
    mcptest()
        .args(["run", "--config"])
        .arg(&warn)
        .assert()
        .code(0)
        .stdout(predicate::str::contains("1 passed"));
}

#[test]
fn response_headers_on_a_stdio_target_fail_with_an_actionable_message() {
    let dir = tempfile::tempdir().unwrap();
    let suite = write_suite(
        dir.path(),
        "      assertions:\n        - target: \"result.content[0].text\"\n          matcher: { contains: \"pong\" }\n      response_headers:\n        content-type: application/json\n",
        None,
    );
    mcptest()
        .args(["run", "--config"])
        .arg(&suite)
        .assert()
        .code(1)
        .stdout(
            predicate::str::contains("1 failed")
                .and(predicate::str::contains("stdio"))
                .and(predicate::str::contains("response_headers")),
        );
}

#[test]
fn short_form_expect_array_behaves_exactly_as_before() {
    let dir = tempfile::tempdir().unwrap();
    // Passing short form.
    let pass = write_suite(
        dir.path(),
        "      - target: \"result.content[0].text\"\n        matcher: { contains: \"pong\" }\n",
        None,
    );
    mcptest()
        .args(["run", "--config"])
        .arg(&pass)
        .assert()
        .code(0)
        .stdout(predicate::str::contains("1 passed"));
    // Failing short form.
    let fail = write_suite(
        dir.path(),
        "      - target: \"result.content[0].text\"\n        matcher: { contains: \"absent needle\" }\n",
        None,
    );
    mcptest()
        .args(["run", "--config"])
        .arg(&fail)
        .assert()
        .code(1)
        .stdout(predicate::str::contains("1 failed"));
}

#[test]
fn unknown_tokenizer_in_the_token_budget_fails_the_suite_load() {
    let dir = tempfile::tempdir().unwrap();
    let suite = write_suite(
        dir.path(),
        "      assertions: []\n      max_response_tokens:\n        budget: 10\n        tokenizer: bogus\n",
        None,
    );
    mcptest()
        .args(["run", "--config"])
        .arg(&suite)
        .assert()
        .failure()
        .stderr(predicate::str::contains("tokenizer"));
}
