//! Integration test for SIGINT graceful shutdown (WOR-1434).
//!
//! Pins the user-facing contract: a `mcptest run` interrupted with Ctrl-C
//! (SIGINT) shuts the runner down cleanly, writes the partial result, and
//! exits with code 130 (`128 + SIGINT`). The signal path itself
//! (`tokio::select!` on `ctrl_c()`) lives in `cli::handlers::run_live`; this
//! test exercises it end to end against the real binary so the exit code and
//! the partial-result notice are a tested contract, not an internal detail.
//!
//! The signal-sending half is unix-only: SIGINT has no portable Windows
//! analogue and `assert_cmd`'s timeout sends SIGKILL (no graceful path). The
//! whole file still compiles on Windows because the unix-only test is gated
//! behind `#[cfg(unix)]`; nothing outside that gate references a unix symbol,
//! so the Windows CI matrix builds it as an empty test module.

#![allow(clippy::needless_doctest_main)]

/// A suite whose only server is a `mcptest mock` with a `slow` fault, so every
/// `tools/call` blocks long enough for the test to deliver a SIGINT while the
/// runner is mid-flight. Shared by the (currently sole) unix test; kept as a
/// free function so a future Windows analogue can reuse the manifest writing.
#[cfg(unix)]
fn write_slow_suite(dir: &std::path::Path) -> std::path::PathBuf {
    let bin = assert_cmd::cargo::cargo_bin("mcptest");
    let manifest = dir.join("tools.yaml");
    std::fs::write(
        &manifest,
        r#"mock_server:
  name: slow
  tools:
    - name: echo
      description: "Echo a message."
      input_schema:
        type: object
        required: [msg]
        properties:
          msg: { type: string }
      response:
        content:
          - type: text
            text: "echo: ${args.msg}"
"#,
    )
    .expect("write manifest");
    let cfg = dir.join("mcptest.yaml");
    // `slow:60000` holds each tools/call response for a full minute, so the
    // runner is reliably still in flight when the SIGINT lands. The run is
    // killed long before the delay elapses, so the wall-clock cost is the
    // few hundred milliseconds before the signal, not the configured delay.
    std::fs::write(
        &cfg,
        format!(
            r#"servers:
  slow:
    command: ['{bin}', 'mock', '--tools-from', '{manifest}', '--fault', 'slow:60000']
tools:
  - name: first slow call
    server: slow
    tool: echo
    args: {{ msg: one }}
    expect:
      - target: result.content[0].text
        matcher: {{ icontains: "echo: one" }}
  - name: second slow call
    server: slow
    tool: echo
    args: {{ msg: two }}
    expect:
      - target: result.content[0].text
        matcher: {{ icontains: "echo: two" }}
"#,
            bin = bin.display(),
            manifest = manifest.display(),
        ),
    )
    .expect("write suite");
    cfg
}

#[cfg(unix)]
#[test]
fn run_interrupted_by_sigint_exits_130_and_writes_a_partial_result() {
    use std::io::Read as _;
    use std::process::{Command, Stdio};
    use std::time::Duration;

    let dir = tempfile::tempdir().expect("tempdir");
    let cfg = write_slow_suite(dir.path());

    let bin = assert_cmd::cargo::cargo_bin("mcptest");
    let mut child = Command::new(bin)
        .args(["run", "--config", cfg.to_str().unwrap()])
        .current_dir(dir.path())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn mcptest run");

    // Give the run time to parse, spawn and connect the mock, and enter the
    // slow tools/call before signalling. The ctrl_c handler is only armed once
    // the run reaches the drive `select!`, so a SIGINT delivered during setup
    // would hit the default disposition and terminate the process (exit code
    // None) rather than the graceful path. The slow call holds for 60s, so once
    // setup is done the window is wide; we wait generously because a cold CI
    // runner spawns the mock subprocess slower than a warm local one.
    std::thread::sleep(Duration::from_millis(4000));

    // Deliver SIGINT exactly as a Ctrl-C at the terminal would. `kill -INT`
    // keeps the test free of a `libc`/`nix` dev-dependency; it is unix-only,
    // which is why the whole test is gated behind `#[cfg(unix)]`.
    let killed = Command::new("kill")
        .args(["-INT", &child.id().to_string()])
        .status()
        .expect("send SIGINT");
    assert!(killed.success(), "kill -INT should succeed");

    // The clean-shutdown contract: the process must exit on its own (not hang)
    // shortly after the signal. We bound the wait so a regression that ignores
    // the signal fails as a timeout rather than blocking the suite forever.
    let status = wait_with_timeout(&mut child, Duration::from_secs(20));

    let mut stderr = String::new();
    if let Some(mut handle) = child.stderr.take() {
        let _ = handle.read_to_string(&mut stderr);
    }

    let status = status.unwrap_or_else(|| {
        let _ = child.kill();
        panic!("mcptest run did not exit within 20s of SIGINT; stderr:\n{stderr}");
    });

    assert_eq!(
        status.code(),
        Some(130),
        "SIGINT must produce the interrupted exit code 130; stderr:\n{stderr}"
    );
    assert!(
        stderr.contains("interrupted by SIGINT"),
        "the run should announce the interrupt before exiting; stderr:\n{stderr}"
    );
}

/// Poll a child for exit up to `budget`, returning its status once it exits or
/// `None` on timeout. Avoids a hard dependency on the unstable
/// `Child::wait_timeout` while keeping the test from hanging on a regression.
#[cfg(unix)]
fn wait_with_timeout(
    child: &mut std::process::Child,
    budget: std::time::Duration,
) -> Option<std::process::ExitStatus> {
    use std::time::{Duration, Instant};
    let deadline = Instant::now() + budget;
    loop {
        match child.try_wait().expect("try_wait") {
            Some(status) => return Some(status),
            None if Instant::now() >= deadline => return None,
            None => std::thread::sleep(Duration::from_millis(50)),
        }
    }
}
