//! Newline-delimited JSON-RPC 2.0 envelope types for the exec IPC
//! channel.
//!
//! Why typed envelopes instead of `serde_json::Value` round-tripping: a
//! contract sketch lives in ADR-0018 §"IPC envelope shapes" and the
//! schema is published at `schemas/ipc/v1.json`. Pinning the wire shape
//! in Rust types lets the dispatcher and the tests share one definition
//! and lets `cargo doc` show the contract next to the code.

use serde::{Deserialize, Serialize};
use serde_json::Value;

/// Current IPC envelope version advertised in the `mcp.initialize`
/// response.
///
/// New optional fields are allowed inside a `v1` line; type changes and
/// removals roll a `v2`. The version is exposed so SDKs can pin a
/// compatible co-process.
pub const IPC_VERSION: &str = "v1";

/// Coprocess protocol version this binary's exec loop speaks (WOR-1318).
///
/// Why an integer and why here: SDKs open every session with a
/// `coprocess/handshake` request carrying the version they were built
/// against, so a drifted contract fails fast instead of misparsing under
/// serde leniency. The Rust SDK pins the same literal in
/// `sdks/rust/mcptest-runner/src/coprocess.rs`; it cannot import this
/// crate (the SDK stays a small sync crate, and its only shared
/// workspace dependency is a proc-macro crate that cannot export runtime
/// consts), so a cross-check test in `crates/mcptest-core/tests/exec_ipc.rs`
/// reads that file and fails when the two literals diverge. Bump both
/// together whenever a wire shape changes.
pub const COPROCESS_PROTOCOL_VERSION: u32 = 2;

/// Params of the `coprocess/handshake` request (WOR-1318).
///
/// Strict (`deny_unknown_fields`) on purpose: extra fields mean the SDK
/// was built against a newer contract, and the handshake is exactly the
/// place where that should surface as a loud error.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HandshakeParams {
    /// Coprocess protocol version the SDK speaks.
    pub protocol_version: u32,
    /// SDK family (for example `"rust"`, `"python"`). Diagnostic only;
    /// it names the peer in mismatch messages.
    #[serde(default)]
    pub sdk: Option<String>,
    /// SDK package version. Diagnostic only.
    #[serde(default)]
    pub sdk_version: Option<String>,
}

/// Result payload of a successful `coprocess/handshake` (WOR-1318).
///
/// Strict (`deny_unknown_fields`) so a reshaped reply reads as drift on
/// the SDK side rather than silently collapsing into defaults.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HandshakeReply {
    /// Version this binary speaks; always [`COPROCESS_PROTOCOL_VERSION`].
    pub protocol_version: u32,
    /// Binary semver, so SDK diagnostics can name the exact build.
    pub binary_version: String,
}

/// Set of JSON-RPC methods the SDK can call against the co-process.
///
/// The string form is the wire name. Held as an enum so the dispatcher
/// matches on a closed set instead of a free-form string and unknown
/// methods bubble up as a typed `MethodNotFound` error.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IpcMethod {
    /// Coprocess version handshake (WOR-1318). The first request a v2
    /// SDK sends after spawn; legacy (v1) SDKs never send it and the
    /// session must keep serving them, so the version is enforced only
    /// when the request actually arrives.
    Handshake,
    /// Handshake. Returns the IPC version, the server's MCP version,
    /// and a `ready` flag once the upstream MCP connection has settled.
    Initialize,
    /// Forward a `tools/call` request.
    Call,
    /// Forward a `tools/list` request.
    ListTools,
    /// Forward a `resources/list` request.
    ListResources,
    /// Forward a `resources/read` request.
    ReadResource,
    /// Forward a `prompts/get` request.
    CallPrompt,
    /// Configure the upload reporter (URL plus optional token).
    ReporterSetUpload,
    /// Flush the upload reporter at session end.
    ReporterFlush,
    /// Switch the cache between `read`, `write`, `readwrite`, and `off`.
    CacheSetMode,
    /// Switch the cassette recorder between `record`, `replay`, and
    /// `off`.
    CassetteSetMode,
    /// Graceful shutdown. Drains in-flight requests, flushes reporters,
    /// then exits.
    Shutdown,
}

impl IpcMethod {
    /// Parse the wire string into a typed method.
    ///
    /// Returns `None` for unknown method names so the dispatcher can
    /// surface a `-32601 method not found` response.
    pub fn from_wire(s: &str) -> Option<Self> {
        Some(match s {
            "coprocess/handshake" => Self::Handshake,
            "mcp.initialize" => Self::Initialize,
            "mcp.call" => Self::Call,
            "mcp.listTools" => Self::ListTools,
            "mcp.listResources" => Self::ListResources,
            "mcp.readResource" => Self::ReadResource,
            "mcp.callPrompt" => Self::CallPrompt,
            "mcp.reporter.setUpload" => Self::ReporterSetUpload,
            "mcp.reporter.flush" => Self::ReporterFlush,
            "mcp.cache.set_mode" => Self::CacheSetMode,
            "mcp.cassette.set_mode" => Self::CassetteSetMode,
            "mcp.shutdown" => Self::Shutdown,
            _ => return None,
        })
    }

    /// Render the method as a wire string.
    pub fn as_wire(&self) -> &'static str {
        match self {
            Self::Handshake => "coprocess/handshake",
            Self::Initialize => "mcp.initialize",
            Self::Call => "mcp.call",
            Self::ListTools => "mcp.listTools",
            Self::ListResources => "mcp.listResources",
            Self::ReadResource => "mcp.readResource",
            Self::CallPrompt => "mcp.callPrompt",
            Self::ReporterSetUpload => "mcp.reporter.setUpload",
            Self::ReporterFlush => "mcp.reporter.flush",
            Self::CacheSetMode => "mcp.cache.set_mode",
            Self::CassetteSetMode => "mcp.cassette.set_mode",
            Self::Shutdown => "mcp.shutdown",
        }
    }
}

/// JSON-RPC request envelope received over stdin.
///
/// `id` is `serde_json::Value` so the SDK can use a number or a string
/// (Vitest workers tend to mint string ids; pytest workers use ints).
/// `params` is optional because lifecycle methods like `mcp.shutdown`
/// carry none.
///
/// Strict (`deny_unknown_fields`, WOR-1318): the JSON-RPC envelope is a
/// closed four-field shape, so an extra field means SDK drift and must
/// surface as a parse error, not vanish under serde leniency.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IpcRequest {
    /// Always the literal string `"2.0"`. Validated at dispatch time.
    pub jsonrpc: String,
    /// Caller-assigned request id. Echoed in the matching response.
    #[serde(default)]
    pub id: Option<Value>,
    /// Wire method name. Parsed into [`IpcMethod`] by the dispatcher.
    pub method: String,
    /// Free-form parameter object. The dispatcher pulls the fields it
    /// needs and forwards the rest unchanged.
    #[serde(default)]
    pub params: Option<Value>,
}

/// JSON-RPC response envelope written to stdout.
///
/// Exactly one of `result` and `error` is populated. The dispatcher
/// builds the envelope via [`IpcResponse::success`] or
/// [`IpcResponse::failure`] so the invariant is enforced in one place.
///
/// Not strict: serde cannot combine `deny_unknown_fields` with the
/// flattened body, so unknown-field detection for responses lives on the
/// SDK side, which parses the envelope without flattening (WOR-1318).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IpcResponse {
    /// Always `"2.0"`.
    pub jsonrpc: String,
    /// Echoed from the matching request. `null` for parse errors that
    /// could not recover an id.
    pub id: Value,
    /// Outcome body. Either `{result: ...}` or `{error: ...}`.
    #[serde(flatten)]
    pub body: IpcResponseBody,
}

/// Outcome body of an [`IpcResponse`]. Flattened on the wire so the
/// envelope reads as `{jsonrpc, id, result}` or `{jsonrpc, id, error}`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum IpcResponseBody {
    /// Successful response. Carries the method-specific result payload.
    Success {
        /// Method-specific result payload. See `schemas/ipc/v1.json` for
        /// per-method shapes.
        result: Value,
    },
    /// Failed response. Carries a typed error.
    Failure {
        /// Typed error body following JSON-RPC conventions.
        error: IpcError,
    },
}

/// JSON-RPC error body returned for failed requests.
///
/// Codes follow the JSON-RPC spec (`-32700` parse error, `-32600`
/// invalid request, `-32601` method not found, `-32602` invalid params,
/// `-32603` internal error) plus an application range from `-32000`
/// downward for MCP-specific failures.
///
/// Strict (`deny_unknown_fields`, WOR-1318): every legal field is
/// declared, so an undeclared one is contract drift.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IpcError {
    /// JSON-RPC error code. See [`error_codes`].
    pub code: i32,
    /// Short, human-readable message.
    pub message: String,
    /// Optional structured payload (for example the upstream MCP
    /// server's error body).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub data: Option<Value>,
}

impl IpcResponse {
    /// Build a successful response.
    pub fn success(id: Value, result: Value) -> Self {
        Self {
            jsonrpc: "2.0".to_owned(),
            id,
            body: IpcResponseBody::Success { result },
        }
    }

    /// Build a failed response.
    pub fn failure(id: Value, error: IpcError) -> Self {
        Self {
            jsonrpc: "2.0".to_owned(),
            id,
            body: IpcResponseBody::Failure { error },
        }
    }
}

/// JSON-RPC error code constants the dispatcher emits.
///
/// Grouped as a module so callers reference them by name (for example
/// `error_codes::METHOD_NOT_FOUND`) instead of pasting magic numbers.
pub mod error_codes {
    /// `-32700`, malformed JSON on the wire.
    pub const PARSE_ERROR: i32 = -32700;
    /// `-32600`, JSON parsed but envelope is invalid.
    pub const INVALID_REQUEST: i32 = -32600;
    /// `-32601`, method name not recognised.
    pub const METHOD_NOT_FOUND: i32 = -32601;
    /// `-32602`, params do not satisfy the method's contract.
    pub const INVALID_PARAMS: i32 = -32602;
    /// `-32603`, dispatcher hit an unexpected internal error.
    pub const INTERNAL_ERROR: i32 = -32603;
    /// `-32000`, the upstream MCP server returned an error.
    pub const MCP_SERVER_ERROR: i32 = -32000;
    /// `-32001`, the upstream MCP connection dropped.
    pub const MCP_DISCONNECTED: i32 = -32001;
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_known_methods_from_wire() {
        assert_eq!(
            IpcMethod::from_wire("mcp.initialize"),
            Some(IpcMethod::Initialize)
        );
        assert_eq!(IpcMethod::from_wire("mcp.call"), Some(IpcMethod::Call));
        assert_eq!(
            IpcMethod::from_wire("mcp.shutdown"),
            Some(IpcMethod::Shutdown)
        );
        assert_eq!(IpcMethod::from_wire("mcp.bogus"), None);
    }

    #[test]
    fn round_trips_method_names() {
        for m in [
            IpcMethod::Handshake,
            IpcMethod::Initialize,
            IpcMethod::Call,
            IpcMethod::ListTools,
            IpcMethod::ListResources,
            IpcMethod::ReadResource,
            IpcMethod::CallPrompt,
            IpcMethod::ReporterSetUpload,
            IpcMethod::ReporterFlush,
            IpcMethod::CacheSetMode,
            IpcMethod::CassetteSetMode,
            IpcMethod::Shutdown,
        ] {
            let wire = m.as_wire();
            assert_eq!(IpcMethod::from_wire(wire), Some(m));
        }
    }

    #[test]
    fn request_with_unknown_envelope_field_fails_to_parse() {
        let raw = r#"{"jsonrpc":"2.0","id":1,"method":"mcp.call","params":{},"extra":true}"#;
        assert!(serde_json::from_str::<IpcRequest>(raw).is_err());
    }

    #[test]
    fn handshake_params_reject_unknown_fields() {
        let raw = r#"{"protocol_version":2,"sdk":"rust","sdk_version":"1.0.1","bogus":1}"#;
        assert!(serde_json::from_str::<HandshakeParams>(raw).is_err());
        let raw = r#"{"protocol_version":2}"#;
        let parsed: HandshakeParams = serde_json::from_str(raw).unwrap();
        assert_eq!(parsed.protocol_version, 2);
        assert_eq!(parsed.sdk, None);
    }

    #[test]
    fn handshake_reply_rejects_unknown_fields() {
        let raw = r#"{"protocol_version":2,"binary_version":"1.0.1","note":"x"}"#;
        assert!(serde_json::from_str::<HandshakeReply>(raw).is_err());
    }

    #[test]
    fn success_envelope_serializes_with_result_field() {
        let resp = IpcResponse::success(serde_json::json!(1), serde_json::json!({"ok": true}));
        let raw = serde_json::to_string(&resp).unwrap();
        assert!(raw.contains("\"result\""));
        assert!(!raw.contains("\"error\""));
        assert!(raw.contains("\"jsonrpc\":\"2.0\""));
    }

    #[test]
    fn failure_envelope_serializes_with_error_field() {
        let resp = IpcResponse::failure(
            serde_json::json!(2),
            IpcError {
                code: error_codes::METHOD_NOT_FOUND,
                message: "method not found".into(),
                data: None,
            },
        );
        let raw = serde_json::to_string(&resp).unwrap();
        assert!(raw.contains("\"error\""));
        assert!(!raw.contains("\"result\""));
    }
}
