//! Wire types for the subprocess plugin protocol.
//!
//! Why typed envelopes rather than `serde_json::Value` round-tripping:
//! plugins are a public extension surface, so pinning the shape in Rust
//! types gives the host, the spec doc, and the integration tests a
//! single source of truth. The contract is intentionally close to
//! JSON-RPC 2.0 (familiar to anyone who has written an MCP server) but
//! without the `jsonrpc: "2.0"` envelope tag, since the only peer is the
//! host that spawned the process.

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

/// Protocol version advertised in the host's first request so plugins
/// can refuse an incompatible peer.
///
/// New optional fields land additively under `v1`. Type changes or
/// removals roll `v2`.
pub const PROTOCOL_VERSION: &str = "v1";

/// One request the host sends to a plugin.
///
/// `id` is required and must be unique within the plugin's lifetime so
/// the host can correlate replies. We accept any JSON scalar so callers
/// in different languages can mint ids in whatever style fits (strings,
/// integers, UUIDs).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginRequest {
    /// Method name the plugin should dispatch on. Plugin-defined; the
    /// host treats it as an opaque string.
    pub method: String,
    /// Method-specific parameters. Omitted when there are none.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub params: Option<Value>,
    /// Correlation id echoed back in the matching [`PluginResponse`].
    pub id: Value,
}

/// One reply a plugin sends back to the host.
///
/// Exactly one of `result` and `error` is populated. Constructors below
/// enforce that invariant so callers cannot accidentally emit a half
/// response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginResponse {
    /// Echo of the matching request id.
    pub id: Value,
    /// Successful result payload, if the call succeeded.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub result: Option<Value>,
    /// Typed error payload, if the call failed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<PluginError>,
}

/// Typed error a plugin returns when it cannot satisfy a request.
///
/// The shape mirrors JSON-RPC's error object so plugins written against
/// existing JSON-RPC tooling can reuse their helpers. Code conventions:
/// negative integers in the JSON-RPC reserved range for protocol errors
/// (`-32700` parse, `-32600` invalid request, `-32601` method not
/// found, `-32602` invalid params, `-32603` internal), and any other
/// integer for plugin-defined errors.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PluginError {
    /// Integer error code. See module docs for conventions.
    pub code: i32,
    /// Short, human-readable message. Surfaced to the operator in run
    /// output.
    pub message: String,
    /// Optional structured payload for the host to attach to its own
    /// error report (for example a stack trace from the plugin's
    /// language runtime).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub data: Option<Value>,
}

impl PluginResponse {
    /// Build a successful response.
    pub fn success(id: Value, result: Value) -> Self {
        Self {
            id,
            result: Some(result),
            error: None,
        }
    }

    /// Build a failed response.
    pub fn failure(id: Value, error: PluginError) -> Self {
        Self {
            id,
            result: None,
            error: Some(error),
        }
    }
}

/// JSON-RPC reserved error codes the host emits when a plugin response
/// is missing or malformed.
///
/// Plugin authors should mirror these for protocol-level failures and
/// use any other integer for application-defined errors.
pub mod error_codes {
    /// `-32700`, the plugin sent a line that was not valid JSON.
    pub const PARSE_ERROR: i32 = -32700;
    /// `-32600`, the line parsed but does not satisfy [`super::PluginResponse`].
    pub const INVALID_RESPONSE: i32 = -32600;
    /// `-32601`, the plugin reported the requested method as unknown.
    pub const METHOD_NOT_FOUND: i32 = -32601;
    /// `-32602`, the plugin reported the params as malformed.
    pub const INVALID_PARAMS: i32 = -32602;
    /// `-32603`, the plugin reported an unexpected internal error.
    pub const INTERNAL_ERROR: i32 = -32603;
}

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

    #[test]
    fn success_response_serializes_with_result_only() {
        let resp = PluginResponse::success(json!(1), json!({"ok": true}));
        let raw = serde_json::to_string(&resp).expect("serialize");
        assert!(raw.contains("\"result\""));
        assert!(!raw.contains("\"error\""));
        assert!(raw.contains("\"id\":1"));
    }

    #[test]
    fn failure_response_serializes_with_error_only() {
        let resp = PluginResponse::failure(
            json!("abc"),
            PluginError {
                code: error_codes::METHOD_NOT_FOUND,
                message: "no such method".into(),
                data: None,
            },
        );
        let raw = serde_json::to_string(&resp).expect("serialize");
        assert!(raw.contains("\"error\""));
        assert!(!raw.contains("\"result\""));
    }

    #[test]
    fn request_round_trips_through_json() {
        let req = PluginRequest {
            method: "match".into(),
            params: Some(json!({"value": 42})),
            id: json!("req-1"),
        };
        let raw = serde_json::to_string(&req).expect("serialize");
        let back: PluginRequest = serde_json::from_str(&raw).expect("parse");
        assert_eq!(back.method, "match");
        assert_eq!(back.id, json!("req-1"));
        assert_eq!(back.params, Some(json!({"value": 42})));
    }

    #[test]
    fn request_without_params_omits_field() {
        let req = PluginRequest {
            method: "ping".into(),
            params: None,
            id: json!(7),
        };
        let raw = serde_json::to_string(&req).expect("serialize");
        assert!(!raw.contains("\"params\""));
    }
}
