//! End-to-end MCP spec-compliance tests for `cordance serve`.
//!
//! These tests spawn the `cordance` binary as a child process, speak
//! newline-delimited JSON-RPC 2.0 over its stdio, and assert that the
//! responses match the MCP 2025-06-18 wire contract plus Cordance's
//! tool-surface and safety guarantees.
//!
//! Pure-function tests for `validate_target`, envelope shape, error codes,
//! and authority invariants live as unit tests in the corresponding
//! `src/mcp/*.rs` modules. The binary cannot expose its private modules to
//! integration tests, so we deliberately split coverage:
//!
//!   - `src/mcp/validation.rs`           — path canonicalisation edge cases
//!   - `src/mcp/error.rs`                — error → JSON-RPC code mapping
//!   - `src/mcp/envelope.rs`             — untrusted-content envelope shape
//!   - `src/mcp/tools/cortex.rs`         — authority-boundary invariants
//!   - `src/mcp/server.rs`               — tool list and exclusion guarantees
//!   - `tests/mcp.rs` (this file)        — end-to-end wire protocol
//!
//! Tests in this file run a real `cordance serve` subprocess and therefore
//! depend on the binary being built. `assert_cmd::Command::cargo_bin`
//! handles the build automatically.

use std::io::{BufRead, BufReader, Write};
use std::process::{ChildStdin, ChildStdout, Command, Stdio};
use std::time::Duration;

use assert_cmd::cargo::CommandCargoExt;
use serde_json::{Value, json};

/// Tool names this build of Cordance MUST register. Tests pin this list to
/// catch silent expansions and the adversarial-review exclusions.
const EXPECTED_TOOL_NAMES: &[&str] = &[
    "cordance_advise_by_rule",
    "cordance_advise_findings",
    "cordance_advise_run",
    "cordance_check_drift",
    "cordance_context_list_sources",
    "cordance_context_source_info",
    "cordance_context_summary",
    "cordance_cortex_receipt",
    "cordance_doctrine_lookup",
    "cordance_doctrine_topics",
    "cordance_evidence_lookup",
    "cordance_harness_target",
    "cordance_pack_dry_run",
];

/// Forbidden tools per `docs/design/MCP_ADVERSARIAL.md`. None of these may
/// appear in `tools/list`.
const FORBIDDEN_TOOL_NAMES: &[&str] = &[
    "cordance_doctrine_read",
    "cordance_cortex_push",
    "cordance_scan",
];

struct Server {
    child: std::process::Child,
    stdin: ChildStdin,
    stdout: BufReader<ChildStdout>,
}

impl Server {
    fn spawn(workdir: &std::path::Path) -> Self {
        Self::spawn_with_cwd(workdir, workdir)
    }

    /// Spawn `cordance serve` with `--target target_dir` but using
    /// `server_cwd` as the process working directory. The two directories
    /// are equal for normal tests but differ for the round-2 redteam-#1
    /// regression check that asserts the MCP allow-list comes from the
    /// server's launch CWD, not from `--target`.
    ///
    /// When `target_dir != server_cwd`, the CLI's CWD guard would otherwise
    /// reject the run with "target is outside the current working directory";
    /// pass `--allow-outside-cwd` so the server can start and we can exercise
    /// the MCP allow-list policy instead.
    fn spawn_with_cwd(target_dir: &std::path::Path, server_cwd: &std::path::Path) -> Self {
        let mut cmd = Command::cargo_bin("cordance").expect("locate cordance binary");
        cmd.arg("--target")
            .arg(target_dir);
        if target_dir != server_cwd {
            cmd.arg("--allow-outside-cwd");
        }
        cmd.arg("serve")
            .current_dir(server_cwd)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            // Stderr is the log channel; pipe it so test output doesn't
            // interleave with the captured JSON-RPC frames on stdout.
            .stderr(Stdio::piped())
            .env("CORDANCE_LOG", "warn");
        let mut child = cmd.spawn().expect("spawn cordance serve");
        let stdin = child.stdin.take().expect("take stdin");
        let stdout = BufReader::new(child.stdout.take().expect("take stdout"));
        Self {
            child,
            stdin,
            stdout,
        }
    }

    fn send(&mut self, payload: &Value) {
        let s = serde_json::to_string(payload).expect("encode request");
        writeln!(self.stdin, "{s}").expect("write request");
        self.stdin.flush().expect("flush request");
    }

    fn send_notification(&mut self, method: &str) {
        self.send(&json!({ "jsonrpc": "2.0", "method": method }));
    }

    fn recv(&mut self) -> Value {
        // Read one full JSON-RPC line. `BufReader::read_line` is blocking;
        // we accept that here because the test process is single-threaded
        // and the request was just written. If the server hangs, the test
        // harness eventually kills the suite — fine for local + CI.
        let mut line = String::new();
        let n = self.stdout.read_line(&mut line).expect("read response");
        assert!(n > 0, "server closed stdout unexpectedly");
        serde_json::from_str::<Value>(line.trim()).unwrap_or_else(|e| {
            panic!("server emitted non-JSON line: {line:?} ({e})");
        })
    }

    fn close(mut self) {
        drop(self.stdin); // signal EOF
        // Give the server a moment to drain and exit.
        let _ = self.child.wait_timeout(Duration::from_secs(5));
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

/// Tiny extension so we can timeout-wait on a child without pulling in
/// `wait-timeout` as a test dep.
trait ChildExt {
    fn wait_timeout(&mut self, dur: Duration) -> std::io::Result<Option<std::process::ExitStatus>>;
}

impl ChildExt for std::process::Child {
    fn wait_timeout(&mut self, dur: Duration) -> std::io::Result<Option<std::process::ExitStatus>> {
        // Poll-based wait. Good enough for tests.
        let deadline = std::time::Instant::now() + dur;
        loop {
            if let Some(status) = self.try_wait()? {
                return Ok(Some(status));
            }
            if std::time::Instant::now() >= deadline {
                return Ok(None);
            }
            std::thread::sleep(Duration::from_millis(50));
        }
    }
}

fn initialize_request(id: i64) -> Value {
    json!({
        "jsonrpc": "2.0",
        "id": id,
        "method": "initialize",
        "params": {
            "protocolVersion": "2025-06-18",
            "capabilities": {},
            "clientInfo": { "name": "cordance-test-client", "version": "0.0.1" }
        }
    })
}

fn list_tools_request(id: i64) -> Value {
    json!({ "jsonrpc": "2.0", "id": id, "method": "tools/list" })
}

fn call_tool_request(id: i64, name: &str, arguments: &Value) -> Value {
    json!({
        "jsonrpc": "2.0",
        "id": id,
        "method": "tools/call",
        "params": { "name": name, "arguments": arguments }
    })
}

#[test]
fn server_initializes_with_correct_protocol_version_and_capabilities() {
    let dir = tempfile::tempdir().expect("tempdir");
    let mut s = Server::spawn(dir.path());

    s.send(&initialize_request(1));
    let resp = s.recv();

    assert_eq!(resp["jsonrpc"], "2.0", "response must be JSON-RPC 2.0");
    assert_eq!(resp["id"], 1, "id must echo");
    assert!(resp.get("error").is_none(), "initialize errored: {resp}");
    let result = resp.get("result").expect("initialize result");
    assert_eq!(
        result["protocolVersion"], "2025-06-18",
        "Cordance pins to MCP 2025-06-18"
    );
    assert_eq!(
        result["serverInfo"]["name"], "cordance",
        "serverInfo.name must be 'cordance'"
    );
    assert!(
        result["capabilities"]["tools"].is_object(),
        "tools capability MUST be advertised"
    );

    s.close();
}

#[test]
fn tools_list_returns_exactly_thirteen_named_tools() {
    let dir = tempfile::tempdir().expect("tempdir");
    let mut s = Server::spawn(dir.path());

    s.send(&initialize_request(1));
    let _init = s.recv();
    s.send_notification("notifications/initialized");
    s.send(&list_tools_request(2));
    let resp = s.recv();

    assert!(resp.get("error").is_none(), "tools/list errored: {resp}");
    let tools = resp["result"]["tools"]
        .as_array()
        .expect("tools must be an array")
        .iter()
        .map(|t| {
            t["name"]
                .as_str()
                .expect("tool must have a name")
                .to_string()
        })
        .collect::<Vec<_>>();
    let mut sorted = tools.clone();
    sorted.sort();

    assert_eq!(
        sorted.len(),
        EXPECTED_TOOL_NAMES.len(),
        "expected exactly {} tools, got {}: {:?}",
        EXPECTED_TOOL_NAMES.len(),
        sorted.len(),
        sorted,
    );
    for (got, want) in sorted.iter().zip(EXPECTED_TOOL_NAMES.iter()) {
        assert_eq!(got, want, "tool list drifted: {sorted:?}");
    }

    for forbidden in FORBIDDEN_TOOL_NAMES {
        assert!(
            !tools.iter().any(|n| n == forbidden),
            "forbidden tool {forbidden} is registered"
        );
    }

    // Each tool MUST carry a name, description, and inputSchema (MCP spec).
    for tool in resp["result"]["tools"].as_array().expect("array") {
        assert!(tool["name"].is_string(), "tool.name must be string");
        assert!(
            tool["description"].is_string(),
            "tool.description required ({:?})",
            tool["name"]
        );
        assert!(
            tool["inputSchema"].is_object(),
            "tool.inputSchema required ({:?})",
            tool["name"]
        );
    }

    s.close();
}

#[test]
fn unknown_tool_returns_invalid_params_error() {
    let dir = tempfile::tempdir().expect("tempdir");
    let mut s = Server::spawn(dir.path());

    s.send(&initialize_request(1));
    let _init = s.recv();
    s.send_notification("notifications/initialized");
    s.send(&call_tool_request(
        2,
        "cordance_nonexistent_tool",
        &json!({}),
    ));
    let resp = s.recv();
    let err = resp
        .get("error")
        .expect("calling an unknown tool MUST yield a JSON-RPC error");
    assert_eq!(
        err["code"], -32_602,
        "unknown tool MUST be INVALID_PARAMS, got {err}"
    );

    s.close();
}

#[test]
fn context_summary_returns_metadata_without_file_content() {
    let dir = tempfile::tempdir().expect("tempdir");
    let mut s = Server::spawn(dir.path());

    s.send(&initialize_request(1));
    let _init = s.recv();
    s.send_notification("notifications/initialized");
    s.send(&call_tool_request(
        2,
        "cordance_context_summary",
        &json!({}),
    ));
    let resp = s.recv();
    assert!(
        resp.get("error").is_none(),
        "context_summary errored: {resp}"
    );
    let result = &resp["result"];
    let structured = result["structuredContent"].clone();
    assert!(
        structured.is_object(),
        "structuredContent must be an object for a Json<T> tool"
    );
    assert_eq!(
        structured["schema"], "cordance-context-summary.v1",
        "summary schema literal must be pinned",
    );
    // No content body — only metadata fields.
    assert!(structured["project_name"].is_string());
    assert!(structured["source_count"].is_number());
    let body_str = serde_json::to_string(&structured).expect("serialise summary");
    // The summary names sources by id/sha but never includes file body.
    assert!(
        !body_str.contains("```rust"),
        "summary leaked code-fenced content: {body_str}"
    );

    s.close();
}

#[test]
fn cortex_receipt_authority_boundary_is_locked_to_candidate_only() {
    let dir = tempfile::tempdir().expect("tempdir");
    let mut s = Server::spawn(dir.path());

    s.send(&initialize_request(1));
    let _init = s.recv();
    s.send_notification("notifications/initialized");
    s.send(&call_tool_request(
        2,
        "cordance_cortex_receipt",
        &json!({ "dry_run": true }),
    ));
    let resp = s.recv();
    assert!(
        resp.get("error").is_none(),
        "cortex_receipt errored: {resp}"
    );
    let receipt = resp["result"]["structuredContent"]["receipt"].clone();
    let boundary = receipt["authority_boundary"].clone();
    assert_eq!(
        boundary["candidate_only"], true,
        "candidate_only must always be true"
    );
    for flag in [
        "cortex_truth_allowed",
        "cortex_admission_allowed",
        "durable_promotion_allowed",
        "memory_promotion_allowed",
        "doctrine_promotion_allowed",
        "trusted_history_allowed",
        "release_acceptance_allowed",
        "runtime_authority_allowed",
    ] {
        assert_eq!(
            boundary[flag], false,
            "authority_boundary.{flag} must be false (Cordance never grants Cortex authority)"
        );
    }
    assert!(
        resp["result"]["structuredContent"]["written_path"].is_null(),
        "dry_run=true must not write a file"
    );

    s.close();
}

#[test]
fn path_traversal_via_tool_argument_is_rejected() {
    let dir = tempfile::tempdir().expect("tempdir");
    let mut s = Server::spawn(dir.path());

    s.send(&initialize_request(1));
    let _init = s.recv();
    s.send_notification("notifications/initialized");
    // Send a value that *resolves* to a real path (the parent of the
    // tempdir) but lies outside every allowed root. The validator must
    // reject this with INVALID_PARAMS.
    s.send(&call_tool_request(
        2,
        "cordance_context_summary",
        &json!({ "target": "../.." }),
    ));
    let resp = s.recv();
    let err = resp.get("error").expect("traversal must error");
    assert_eq!(
        err["code"], -32_602,
        "path traversal MUST be INVALID_PARAMS"
    );

    s.close();
}

/// Round-2 redteam #1 regression: a hostile `--target/cordance.toml`
/// with `[mcp].allowed_roots = ["<sibling>"]` MUST NOT widen the
/// server's allow-list. The MCP allow-list is policy and must be
/// sourced exclusively from the operator's launch CWD.
#[test]
fn mcp_target_allow_list_ignores_target_cordance_toml() {
    let parent = tempfile::tempdir().expect("parent tempdir");
    let target_dir = parent.path().join("hostile-target");
    let server_cwd = parent.path().join("operator-cwd");
    let sibling = parent.path().join("offlimits-sibling");
    std::fs::create_dir_all(&target_dir).expect("mkdir target");
    std::fs::create_dir_all(&server_cwd).expect("mkdir server cwd");
    std::fs::create_dir_all(&sibling).expect("mkdir sibling");

    // Hostile cordance.toml in the --target directory: declares `sibling`
    // as an allowed root. If serve_cmd were to honour this, the MCP peer
    // could reach `sibling` from outside the target tree.
    let toml = format!(
        "[mcp]\nallowed_roots = [{:?}]\n",
        sibling.to_string_lossy(),
    );
    std::fs::write(target_dir.join("cordance.toml"), toml)
        .expect("write hostile cordance.toml");

    // Server's launch CWD has *no* cordance.toml — so server-side policy
    // is the bare default (allow-list contains only --target).
    let mut s = Server::spawn_with_cwd(&target_dir, &server_cwd);

    s.send(&initialize_request(1));
    let _init = s.recv();
    s.send_notification("notifications/initialized");

    // Ask the server to operate on the sibling that the target's
    // cordance.toml claimed to allow. The server MUST reject.
    let sibling_str = sibling
        .to_str()
        .expect("sibling path utf8");
    s.send(&call_tool_request(
        2,
        "cordance_context_summary",
        &json!({ "target": sibling_str }),
    ));
    let resp = s.recv();
    let err = resp
        .get("error")
        .expect("target-cordance.toml widening must error");
    assert_eq!(
        err["code"], -32_602,
        "hostile widening MUST be INVALID_PARAMS, got {err}"
    );

    s.close();
}

/// Round-2 redteam #3 regression: the wire error returned to the MCP
/// peer must not echo the offending path (raw input or canonical).
#[test]
fn mcp_validation_error_omits_paths() {
    let dir = tempfile::tempdir().expect("tempdir");
    let mut s = Server::spawn(dir.path());

    s.send(&initialize_request(1));
    let _init = s.recv();
    s.send_notification("notifications/initialized");

    // A target that canonicalises to a real path (the parent of the
    // tempdir) but lies outside every allowed root.
    let parent_str = dir
        .path()
        .parent()
        .expect("tempdir parent")
        .to_str()
        .expect("parent utf8");
    let marker = "leak-marker-aBcDeF123456";
    // Embed a leak-marker in the input to be extra-confident the server
    // doesn't echo it back. The marker is appended after the absolute
    // parent path so canonicalisation either fails (not-found path) or
    // succeeds at the parent — either way, the wire error must not
    // contain the marker.
    let raw_with_marker = format!("{parent_str}/{marker}");
    s.send(&call_tool_request(
        2,
        "cordance_context_summary",
        &json!({ "target": raw_with_marker }),
    ));
    let resp = s.recv();
    let err = resp.get("error").expect("invalid target must error");
    let msg = err["message"]
        .as_str()
        .expect("error.message must be a string");
    assert!(
        !msg.contains(marker),
        "wire error leaked input marker {marker}: {msg:?}"
    );
    // Also assert the parent path itself isn't echoed.
    assert!(
        !msg.contains(parent_str),
        "wire error leaked parent path: {msg:?}"
    );
    assert!(
        !msg.contains(dir.path().to_str().expect("dir utf8")),
        "wire error leaked cwd path: {msg:?}"
    );

    s.close();
}
