//! Integration tests for the subprocess plugin host.
//!
//! Each test spawns Python 3 as the plugin. Python ships with macOS and
//! every Linux CI image we target; the tests skip with a printed notice
//! on Windows runners where `python3` is not on PATH.

use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;

use mcptest_core::plugins::{HostError, PluginHost, PluginHostConfig};
use serde_json::json;

fn python_or_skip() -> Option<String> {
    for candidate in ["python3", "python"] {
        if which(candidate) {
            return Some(candidate.to_owned());
        }
    }
    eprintln!("skipping plugin host test: python interpreter not on PATH");
    None
}

fn which(program: &str) -> bool {
    let Ok(path) = std::env::var("PATH") else {
        return false;
    };
    for dir in std::env::split_paths(&path) {
        let full = dir.join(program);
        if full.is_file() {
            return true;
        }
    }
    false
}

fn workspace_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("crate parent")
        .parent()
        .expect("workspace root")
        .to_path_buf()
}

fn echo_plugin_script() -> PathBuf {
    workspace_root().join("examples/plugins/echo/echo.py")
}

fn config_for(python: &str, script: &str) -> PluginHostConfig {
    let mut env = HashMap::new();
    // Disable Python's stdout buffering so responses reach the host
    // promptly. We do not rely on `-u` (older PythonX builds vary).
    env.insert("PYTHONUNBUFFERED".to_owned(), "1".to_owned());
    PluginHostConfig {
        command: python.to_owned(),
        args: vec!["-c".to_owned(), script.to_owned()],
        env,
        working_dir: None,
        call_timeout: Duration::from_secs(5),
    }
}

#[tokio::test]
async fn round_trips_request_against_reference_plugin() {
    let Some(python) = python_or_skip() else {
        return;
    };
    let cfg = PluginHostConfig {
        command: python,
        args: vec![echo_plugin_script().to_string_lossy().into_owned()],
        env: {
            let mut m = HashMap::new();
            m.insert("PYTHONUNBUFFERED".to_owned(), "1".to_owned());
            m
        },
        working_dir: None,
        call_timeout: Duration::from_secs(5),
    };
    let mut host = PluginHost::new(cfg);

    let resp = host.call("ping", None).await.expect("ping should succeed");
    assert_eq!(resp.result, Some(json!({"pong": true})));
    assert!(resp.error.is_none());

    let resp = host
        .call("echo", Some(json!({"value": 42})))
        .await
        .expect("echo should succeed");
    assert_eq!(resp.result, Some(json!({"echoed": {"value": 42}})));

    // The host correlates ids internally. Different ids should land on
    // different responses so a stuck echo could not silently shadow a
    // later call.
    let first_id = resp.id.clone();
    let resp2 = host.call("ping", None).await.expect("second ping");
    assert_ne!(first_id, resp2.id);

    host.shutdown().await.expect("shutdown");
}

#[tokio::test]
async fn application_error_surfaces_as_response_error_not_host_error() {
    let Some(python) = python_or_skip() else {
        return;
    };
    let cfg = PluginHostConfig {
        command: python,
        args: vec![echo_plugin_script().to_string_lossy().into_owned()],
        env: HashMap::new(),
        working_dir: None,
        call_timeout: Duration::from_secs(5),
    };
    let mut host = PluginHost::new(cfg);
    let resp = host
        .call("nonsuch", None)
        .await
        .expect("unknown method should still produce a response");
    assert!(resp.result.is_none());
    let err = resp.error.expect("plugin returned typed error");
    assert_eq!(err.code, -32601);
    host.shutdown().await.expect("shutdown");
}

#[tokio::test]
async fn plugin_crash_surfaces_as_typed_host_error() {
    let Some(python) = python_or_skip() else {
        return;
    };
    // Script reads one line, then exits with code 1 without responding.
    let script = r#"
import sys
_ = sys.stdin.readline()
sys.exit(1)
"#;
    let cfg = config_for(&python, script);
    let mut host = PluginHost::new(cfg);
    let err = host
        .call("anything", None)
        .await
        .expect_err("crashed plugin must error");
    match err {
        HostError::PluginCrashed { .. } => {}
        other => panic!("expected PluginCrashed, got {other:?}"),
    }
}

#[tokio::test]
async fn plugin_timeout_surfaces_as_typed_host_error() {
    let Some(python) = python_or_skip() else {
        return;
    };
    // Script reads one line and then sleeps so the host's call timeout
    // fires before any response comes back.
    let script = r#"
import sys, time
_ = sys.stdin.readline()
time.sleep(30)
"#;
    let cfg = PluginHostConfig {
        call_timeout: Duration::from_millis(300),
        ..config_for(&python, script)
    };
    let mut host = PluginHost::new(cfg);
    let err = host
        .call("slow", None)
        .await
        .expect_err("slow plugin must time out");
    match err {
        HostError::Timeout { method, .. } => assert_eq!(method, "slow"),
        other => panic!("expected Timeout, got {other:?}"),
    }
    // Even after a timeout we must be able to drop the host without
    // leaking the child. `kill_on_drop` covers this; the assertion is
    // implicit (test does not hang).
    drop(host);
}

#[tokio::test]
async fn invalid_json_response_surfaces_as_typed_host_error() {
    let Some(python) = python_or_skip() else {
        return;
    };
    // Script reads one line then writes garbage to stdout.
    let script = r#"
import sys
_ = sys.stdin.readline()
sys.stdout.write("this is not json\n")
sys.stdout.flush()
"#;
    let cfg = config_for(&python, script);
    let mut host = PluginHost::new(cfg);
    let err = host
        .call("anything", None)
        .await
        .expect_err("garbage response must error");
    match err {
        HostError::InvalidJson { line, .. } => {
            assert!(line.contains("this is not json"));
        }
        other => panic!("expected InvalidJson, got {other:?}"),
    }
    host.shutdown().await.expect("shutdown");
}

#[tokio::test]
async fn stderr_noise_does_not_corrupt_the_stream() {
    let Some(python) = python_or_skip() else {
        return;
    };
    // Script writes a chatty log line to stderr before every response.
    // The host pipes stderr to /dev/null, so the wire stream on stdout
    // stays clean and the host parses one response per request.
    let script = r#"
import json, sys
for raw in sys.stdin:
    raw = raw.strip()
    if not raw:
        continue
    sys.stderr.write("noisy: about to handle " + raw + "\n")
    sys.stderr.flush()
    req = json.loads(raw)
    resp = {"id": req["id"], "result": {"got": req.get("method")}}
    sys.stdout.write(json.dumps(resp) + "\n")
    sys.stdout.flush()
"#;
    let cfg = config_for(&python, script);
    let mut host = PluginHost::new(cfg);
    for method in ["a", "b", "c"] {
        let resp = host.call(method, None).await.expect("noisy plugin call");
        assert_eq!(resp.result, Some(json!({"got": method})));
    }
    host.shutdown().await.expect("shutdown");
}

#[tokio::test]
async fn spawn_failure_surfaces_on_first_call() {
    let cfg = PluginHostConfig::new("/definitely/not/a/real/binary/mcptest-272");
    let mut host = PluginHost::new(cfg);
    let err = host
        .call("anything", None)
        .await
        .expect_err("missing binary must error");
    match err {
        HostError::SpawnFailed { command, .. } => {
            assert!(command.contains("mcptest-272"));
        }
        other => panic!("expected SpawnFailed, got {other:?}"),
    }
}

#[tokio::test]
async fn shutdown_is_idempotent() {
    let Some(python) = python_or_skip() else {
        return;
    };
    let cfg = config_for(&python, "import sys\nfor _ in sys.stdin: pass\n");
    let mut host = PluginHost::new(cfg);
    // Never call: shutdown on a never-spawned host is a no-op.
    host.shutdown().await.expect("shutdown without spawn");
    host.shutdown().await.expect("second shutdown");
}
