use codex_core::config_types::SandboxMode;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use serde::Deserialize;
use serde::Serialize;
use strum_macros::Display;
use uuid::Uuid;

use mcp_types::CallToolResult;
use mcp_types::ContentBlock;
use mcp_types::RequestId;
use mcp_types::TextContent;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ConversationId(pub Uuid);

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct MessageId(pub Uuid);

// Requests
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallRequest {
    #[serde(rename = "jsonrpc")]
    pub jsonrpc: &'static str,
    pub id: RequestId,
    pub method: &'static str,
    pub params: ToolCallRequestParams,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "name", content = "arguments", rename_all = "camelCase")]
pub enum ToolCallRequestParams {
    ConversationCreate(ConversationCreateArgs),
    ConversationStream(ConversationStreamArgs),
    ConversationSendMessage(ConversationSendMessageArgs),
    ConversationsList(ConversationsListArgs),
}

impl ToolCallRequestParams {
    /// Wrap this request in a JSON-RPC request.
    #[allow(dead_code)]
    pub fn into_request(self, id: RequestId) -> ToolCallRequest {
        ToolCallRequest {
            jsonrpc: "2.0",
            id,
            method: "tools/call",
            params: self,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConversationCreateArgs {
    pub prompt: String,
    pub model: String,
    pub cwd: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub approval_policy: Option<AskForApproval>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sandbox: Option<SandboxMode>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub config: Option<serde_json::Value>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub profile: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub base_instructions: Option<String>,
}

/// Optional overrides for an existing conversation's execution context when sending a message.
/// Fields left as `None` inherit the current conversation/session settings.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConversationOverrides {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub approval_policy: Option<AskForApproval>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sandbox: Option<SandboxMode>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub config: Option<serde_json::Value>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub profile: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub base_instructions: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConversationStreamArgs {
    pub conversation_id: ConversationId,
}

/// If omitted, the message continues from the latest turn.
/// Set to resume/edit from an earlier parent message in the thread.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConversationSendMessageArgs {
    pub conversation_id: ConversationId,
    pub content: Vec<InputItem>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent_message_id: Option<MessageId>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[serde(flatten)]
    pub conversation_overrides: Option<ConversationOverrides>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConversationsListArgs {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub limit: Option<u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cursor: Option<String>,
}

// Responses
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolCallResponse {
    pub request_id: RequestId,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub is_error: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none", flatten)]
    pub result: Option<ToolCallResponseResult>,
}

impl From<ToolCallResponse> for CallToolResult {
    fn from(val: ToolCallResponse) -> Self {
        let ToolCallResponse {
            request_id: _request_id,
            is_error,
            result,
        } = val;
        match result {
            Some(res) => match serde_json::to_value(&res) {
                Ok(v) => CallToolResult {
                    content: vec![ContentBlock::TextContent(TextContent {
                        r#type: "text".to_string(),
                        text: v.to_string(),
                        annotations: None,
                    })],
                    is_error,
                    structured_content: Some(v),
                },
                Err(e) => CallToolResult {
                    content: vec![ContentBlock::TextContent(TextContent {
                        r#type: "text".to_string(),
                        text: format!("Failed to serialize tool result: {e}"),
                        annotations: None,
                    })],
                    is_error: Some(true),
                    structured_content: None,
                },
            },
            None => CallToolResult {
                content: vec![],
                is_error,
                structured_content: None,
            },
        }
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ToolCallResponseResult {
    ConversationCreate(ConversationCreateResult),
    ConversationStream(ConversationStreamResult),
    ConversationSendMessage(ConversationSendMessageResult),
    ConversationsList(ConversationsListResult),
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ConversationCreateResult {
    Ok {
        conversation_id: ConversationId,
        model: String,
    },
    Error {
        message: String,
    },
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConversationStreamResult {}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
// TODO: remove this status because we have is_error field in the response.
#[serde(tag = "status", rename_all = "camelCase")]
pub enum ConversationSendMessageResult {
    Ok,
    Error { message: String },
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConversationsListResult {
    pub conversations: Vec<ConversationSummary>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub next_cursor: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConversationSummary {
    pub conversation_id: ConversationId,
    pub title: String,
}

// Notifications
#[derive(Debug, Clone, Deserialize, Display)]
pub enum ServerNotification {
    InitialState(InitialStateNotificationParams),
    StreamDisconnected(StreamDisconnectedNotificationParams),
    CodexEvent(Box<CodexEventNotificationParams>),
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NotificationMeta {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub conversation_id: Option<ConversationId>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub request_id: Option<RequestId>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InitialStateNotificationParams {
    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
    pub meta: Option<NotificationMeta>,
    pub initial_state: InitialStatePayload,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InitialStatePayload {
    #[serde(default)]
    pub events: Vec<CodexEventNotificationParams>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StreamDisconnectedNotificationParams {
    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
    pub meta: Option<NotificationMeta>,
    pub reason: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodexEventNotificationParams {
    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
    pub meta: Option<NotificationMeta>,
    pub msg: EventMsg,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CancelNotificationParams {
    pub request_id: RequestId,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
}

impl Serialize for ServerNotification {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        use serde::ser::SerializeMap;

        let mut map = serializer.serialize_map(Some(2))?;
        match self {
            ServerNotification::CodexEvent(p) => {
                map.serialize_entry("method", &format!("notifications/{}", p.msg))?;
                map.serialize_entry("params", p)?;
            }
            ServerNotification::InitialState(p) => {
                map.serialize_entry("method", "notifications/initial_state")?;
                map.serialize_entry("params", p)?;
            }
            ServerNotification::StreamDisconnected(p) => {
                map.serialize_entry("method", "notifications/stream_disconnected")?;
                map.serialize_entry("params", p)?;
            }
        }
        map.end()
    }
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "method", content = "params", rename_all = "camelCase")]
pub enum ClientNotification {
    #[serde(rename = "notifications/cancelled")]
    Cancelled(CancelNotificationParams),
}

#[cfg(test)]
#[allow(clippy::expect_used)]
#[allow(clippy::unwrap_used)]
mod tests {
    use std::path::PathBuf;

    use super::*;
    use codex_core::protocol::McpInvocation;
    use codex_core::protocol::McpToolCallBeginEvent;
    use pretty_assertions::assert_eq;
    use serde::Serialize;
    use serde_json::Value;
    use serde_json::json;
    use uuid::uuid;

    fn to_val<T: Serialize>(v: &T) -> Value {
        serde_json::to_value(v).expect("serialize to Value")
    }

    // ----- Requests -----

    #[test]
    fn serialize_tool_call_request_params_conversation_create_minimal() {
        let req = ToolCallRequestParams::ConversationCreate(ConversationCreateArgs {
            prompt: "".into(),
            model: "o3".into(),
            cwd: "/repo".into(),
            approval_policy: None,
            sandbox: None,
            config: None,
            profile: None,
            base_instructions: None,
        });

        let observed = to_val(&req.into_request(mcp_types::RequestId::Integer(2)));
        let expected = json!({
            "jsonrpc": "2.0",
            "id": 2,
            "method": "tools/call",
            "params": {
                "name": "conversationCreate",
                "arguments": {
                    "prompt": "",
                    "model": "o3",
                    "cwd": "/repo"
                }
            }
        });
        assert_eq!(observed, expected);
    }

    #[test]
    fn serialize_tool_call_request_params_conversation_send_message_with_overrides_and_parent_message_id()
     {
        let req = ToolCallRequestParams::ConversationSendMessage(ConversationSendMessageArgs {
            conversation_id: ConversationId(uuid!("d0f6ecbe-84a2-41c1-b23d-b20473b25eab")),
            content: vec![
                InputItem::Text { text: "Hi".into() },
                InputItem::Image {
                    image_url: "https://example.com/cat.jpg".into(),
                },
                InputItem::LocalImage {
                    path: "notes.txt".into(),
                },
            ],
            parent_message_id: Some(MessageId(uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8"))),
            conversation_overrides: Some(ConversationOverrides {
                model: Some("o4-mini".into()),
                cwd: Some("/workdir".into()),
                approval_policy: None,
                sandbox: Some(SandboxMode::DangerFullAccess),
                config: Some(json!({"temp": 0.2})),
                profile: Some("eng".into()),
                base_instructions: Some("Be terse".into()),
            }),
        });

        let observed = to_val(&req.into_request(mcp_types::RequestId::Integer(2)));
        let expected = json!({
            "jsonrpc": "2.0",
            "id": 2,
            "method": "tools/call",
            "params": {
                "name": "conversationSendMessage",
                "arguments": {
                    "conversation_id": "d0f6ecbe-84a2-41c1-b23d-b20473b25eab",
                    "content": [
                        { "type": "text", "text": "Hi" },
                        { "type": "image", "image_url": "https://example.com/cat.jpg" },
                        { "type": "local_image", "path": "notes.txt" }
                    ],
                    "parent_message_id": "67e55044-10b1-426f-9247-bb680e5fe0c8",
                    "model": "o4-mini",
                    "cwd": "/workdir",
                    "sandbox": "danger-full-access",
                    "config": { "temp": 0.2 },
                    "profile": "eng",
                    "base_instructions": "Be terse"
                }
            }
        });
        assert_eq!(observed, expected);
    }

    #[test]
    fn serialize_tool_call_request_params_conversations_list_with_opts() {
        let req = ToolCallRequestParams::ConversationsList(ConversationsListArgs {
            limit: Some(50),
            cursor: Some("abc".into()),
        });

        let observed = to_val(&req.into_request(RequestId::Integer(2)));
        let expected = json!({
            "jsonrpc": "2.0",
            "id": 2,
            "method": "tools/call",
            "params": {
                "name": "conversationsList",
                "arguments": {
                    "limit": 50,
                    "cursor": "abc"
                }
            }
        });
        assert_eq!(observed, expected);
    }

    #[test]
    fn serialize_tool_call_request_params_conversation_stream() {
        let req = ToolCallRequestParams::ConversationStream(ConversationStreamArgs {
            conversation_id: ConversationId(uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8")),
        });

        let observed = to_val(&req.into_request(mcp_types::RequestId::Integer(2)));
        let expected = json!({
            "jsonrpc": "2.0",
            "id": 2,
            "method": "tools/call",
            "params": {
                "name": "conversationStream",
                "arguments": {
                    "conversation_id": "67e55044-10b1-426f-9247-bb680e5fe0c8"
                }
            }
        });
        assert_eq!(observed, expected);
    }

    // ----- Message inputs / sources -----

    #[test]
    fn serialize_message_input_image_url() {
        let item = InputItem::Image {
            image_url: "https://example.com/x.png".into(),
        };
        let observed = to_val(&item);
        let expected = json!({
            "type": "image",
            "image_url": "https://example.com/x.png"
        });
        assert_eq!(observed, expected);
    }

    #[test]
    fn serialize_message_input_local_image_path() {
        let url = InputItem::LocalImage {
            path: PathBuf::from("https://example.com/a.pdf"),
        };
        let id = InputItem::LocalImage {
            path: PathBuf::from("file_456"),
        };
        let observed_url = to_val(&url);
        let expected_url = json!({"type":"local_image","path":"https://example.com/a.pdf"});
        assert_eq!(
            observed_url, expected_url,
            "LocalImage with URL path should serialize as image_url"
        );
        let observed_id = to_val(&id);
        let expected_id = json!({"type":"local_image","path":"file_456"});
        assert_eq!(
            observed_id, expected_id,
            "LocalImage with file id should serialize as image_url"
        );
    }

    #[test]
    fn serialize_message_input_image_url_without_detail() {
        let item = InputItem::Image {
            image_url: "https://example.com/x.png".into(),
        };
        let observed = to_val(&item);
        let expected = json!({
            "type": "image",
            "image_url": "https://example.com/x.png"
        });
        assert_eq!(observed, expected);
    }

    // ----- Responses -----

    #[test]
    fn response_success_conversation_create_full_schema() {
        let env = ToolCallResponse {
            request_id: RequestId::Integer(1),
            is_error: None,
            result: Some(ToolCallResponseResult::ConversationCreate(
                ConversationCreateResult::Ok {
                    conversation_id: ConversationId(uuid!("d0f6ecbe-84a2-41c1-b23d-b20473b25eab")),
                    model: "o3".into(),
                },
            )),
        };
        let req_id = env.request_id.clone();
        let observed = to_val(&CallToolResult::from(env));
        let expected = json!({
            "content": [
                { "type": "text", "text": "{\"conversation_id\":\"d0f6ecbe-84a2-41c1-b23d-b20473b25eab\",\"model\":\"o3\"}" }
            ],
            "structuredContent": {
                "conversation_id": "d0f6ecbe-84a2-41c1-b23d-b20473b25eab",
                "model": "o3"
            }
        });
        assert_eq!(
            observed, expected,
            "response (ConversationCreate) must match"
        );
        assert_eq!(req_id, RequestId::Integer(1));
    }

    #[test]
    fn response_error_conversation_create_full_schema() {
        let env = ToolCallResponse {
            request_id: RequestId::Integer(2),
            is_error: Some(true),
            result: Some(ToolCallResponseResult::ConversationCreate(
                ConversationCreateResult::Error {
                    message: "Failed to initialize session".into(),
                },
            )),
        };
        let req_id = env.request_id.clone();
        let observed = to_val(&CallToolResult::from(env));
        let expected = json!({
            "content": [
                { "type": "text", "text": "{\"message\":\"Failed to initialize session\"}" }
            ],
            "isError": true,
            "structuredContent": {
                "message": "Failed to initialize session"
            }
        });
        assert_eq!(
            observed, expected,
            "error response (ConversationCreate) must match"
        );
        assert_eq!(req_id, RequestId::Integer(2));
    }

    #[test]
    fn response_success_conversation_stream_empty_result_object() {
        let env = ToolCallResponse {
            request_id: RequestId::Integer(2),
            is_error: None,
            result: Some(ToolCallResponseResult::ConversationStream(
                ConversationStreamResult {},
            )),
        };
        let req_id = env.request_id.clone();
        let observed = to_val(&CallToolResult::from(env));
        let expected = json!({
            "content": [ { "type": "text", "text": "{}" } ],
            "structuredContent": {}
        });
        assert_eq!(
            observed, expected,
            "response (ConversationStream) must have empty object result"
        );
        assert_eq!(req_id, RequestId::Integer(2));
    }

    #[test]
    fn response_success_send_message_accepted_full_schema() {
        let env = ToolCallResponse {
            request_id: RequestId::Integer(3),
            is_error: None,
            result: Some(ToolCallResponseResult::ConversationSendMessage(
                ConversationSendMessageResult::Ok,
            )),
        };
        let req_id = env.request_id.clone();
        let observed = to_val(&CallToolResult::from(env));
        let expected = json!({
            "content": [ { "type": "text", "text": "{\"status\":\"ok\"}" } ],
            "structuredContent": { "status": "ok" }
        });
        assert_eq!(
            observed, expected,
            "response (ConversationSendMessageAccepted) must match"
        );
        assert_eq!(req_id, RequestId::Integer(3));
    }

    #[test]
    fn response_success_conversations_list_with_next_cursor_full_schema() {
        let env = ToolCallResponse {
            request_id: RequestId::Integer(4),
            is_error: None,
            result: Some(ToolCallResponseResult::ConversationsList(
                ConversationsListResult {
                    conversations: vec![ConversationSummary {
                        conversation_id: ConversationId(uuid!(
                            "67e55044-10b1-426f-9247-bb680e5fe0c8"
                        )),
                        title: "Refactor config loader".into(),
                    }],
                    next_cursor: Some("next123".into()),
                },
            )),
        };
        let req_id = env.request_id.clone();
        let observed = to_val(&CallToolResult::from(env));
        let expected = json!({
            "content": [
                { "type": "text", "text": "{\"conversations\":[{\"conversation_id\":\"67e55044-10b1-426f-9247-bb680e5fe0c8\",\"title\":\"Refactor config loader\"}],\"next_cursor\":\"next123\"}" }
            ],
            "structuredContent": {
                "conversations": [
                    {
                        "conversation_id": "67e55044-10b1-426f-9247-bb680e5fe0c8",
                        "title": "Refactor config loader"
                    }
                ],
                "next_cursor": "next123"
            }
        });
        assert_eq!(
            observed, expected,
            "response (ConversationsList with cursor) must match"
        );
        assert_eq!(req_id, RequestId::Integer(4));
    }

    #[test]
    fn response_error_only_is_error_and_request_id_string() {
        let env = ToolCallResponse {
            request_id: RequestId::Integer(4),
            is_error: Some(true),
            result: None,
        };
        let req_id = env.request_id.clone();
        let observed = to_val(&CallToolResult::from(env));
        let expected = json!({
            "content": [],
            "isError": true
        });
        assert_eq!(
            observed, expected,
            "error response must omit `result` and include `isError`"
        );
        assert_eq!(req_id, RequestId::Integer(4));
    }

    // ----- Notifications -----

    #[test]
    fn serialize_notification_initial_state_minimal() {
        let params = InitialStateNotificationParams {
            meta: Some(NotificationMeta {
                conversation_id: Some(ConversationId(uuid!(
                    "67e55044-10b1-426f-9247-bb680e5fe0c8"
                ))),
                request_id: Some(RequestId::Integer(44)),
            }),
            initial_state: InitialStatePayload {
                events: vec![
                    CodexEventNotificationParams {
                        meta: None,
                        msg: EventMsg::TaskStarted,
                    },
                    CodexEventNotificationParams {
                        meta: None,
                        msg: EventMsg::AgentMessageDelta(
                            codex_core::protocol::AgentMessageDeltaEvent {
                                delta: "Loading...".into(),
                            },
                        ),
                    },
                ],
            },
        };

        let observed = to_val(&ServerNotification::InitialState(params.clone()));
        let expected = json!({
            "method": "notifications/initial_state",
            "params": {
                "_meta": {
                    "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8",
                    "requestId": 44
                },
                "initial_state": {
                    "events": [
                        { "msg": { "type": "task_started" } },
                        { "msg": { "type": "agent_message_delta", "delta": "Loading..." } }
                    ]
                }
            }
        });
        assert_eq!(observed, expected);
    }

    #[test]
    fn serialize_notification_initial_state_omits_empty_events_full_json() {
        let params = InitialStateNotificationParams {
            meta: None,
            initial_state: InitialStatePayload { events: vec![] },
        };

        let observed = to_val(&ServerNotification::InitialState(params));
        let expected = json!({
            "method": "notifications/initial_state",
            "params": {
                "initial_state": { "events": [] }
            }
        });
        assert_eq!(observed, expected);
    }

    #[test]
    fn serialize_notification_stream_disconnected() {
        let params = StreamDisconnectedNotificationParams {
            meta: Some(NotificationMeta {
                conversation_id: Some(ConversationId(uuid!(
                    "67e55044-10b1-426f-9247-bb680e5fe0c8"
                ))),
                request_id: None,
            }),
            reason: "New stream() took over".into(),
        };

        let observed = to_val(&ServerNotification::StreamDisconnected(params));
        let expected = json!({
            "method": "notifications/stream_disconnected",
            "params": {
                "_meta": { "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8" },
                "reason": "New stream() took over"
            }
        });
        assert_eq!(observed, expected);
    }

    #[test]
    fn serialize_notification_codex_event_uses_eventmsg_type_in_method() {
        let params = CodexEventNotificationParams {
            meta: Some(NotificationMeta {
                conversation_id: Some(ConversationId(uuid!(
                    "67e55044-10b1-426f-9247-bb680e5fe0c8"
                ))),
                request_id: Some(RequestId::Integer(44)),
            }),
            msg: EventMsg::AgentMessage(codex_core::protocol::AgentMessageEvent {
                message: "hi".into(),
            }),
        };

        let observed = to_val(&ServerNotification::CodexEvent(Box::new(params)));
        let expected = json!({
            "method": "notifications/agent_message",
            "params": {
                "_meta": {
                    "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8",
                    "requestId": 44
                },
                "msg": { "type": "agent_message", "message": "hi" }
            }
        });
        assert_eq!(observed, expected);
    }

    #[test]
    fn serialize_notification_codex_event_task_started_full_json() {
        let params = CodexEventNotificationParams {
            meta: Some(NotificationMeta {
                conversation_id: Some(ConversationId(uuid!(
                    "67e55044-10b1-426f-9247-bb680e5fe0c8"
                ))),
                request_id: Some(RequestId::Integer(7)),
            }),
            msg: EventMsg::TaskStarted,
        };

        let observed = to_val(&ServerNotification::CodexEvent(Box::new(params)));
        let expected = json!({
            "method": "notifications/task_started",
            "params": {
                "_meta": {
                    "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8",
                    "requestId": 7
                },
                "msg": { "type": "task_started" }
            }
        });
        assert_eq!(observed, expected);
    }

    #[test]
    fn serialize_notification_codex_event_agent_message_delta_full_json() {
        let params = CodexEventNotificationParams {
            meta: None,
            msg: EventMsg::AgentMessageDelta(codex_core::protocol::AgentMessageDeltaEvent {
                delta: "stream...".into(),
            }),
        };

        let observed = to_val(&ServerNotification::CodexEvent(Box::new(params)));
        let expected = json!({
            "method": "notifications/agent_message_delta",
            "params": {
                "msg": { "type": "agent_message_delta", "delta": "stream..." }
            }
        });
        assert_eq!(observed, expected);
    }

    #[test]
    fn serialize_notification_codex_event_agent_message_full_json() {
        let params = CodexEventNotificationParams {
            meta: Some(NotificationMeta {
                conversation_id: Some(ConversationId(uuid!(
                    "67e55044-10b1-426f-9247-bb680e5fe0c8"
                ))),
                request_id: Some(RequestId::Integer(44)),
            }),
            msg: EventMsg::AgentMessage(codex_core::protocol::AgentMessageEvent {
                message: "hi".into(),
            }),
        };

        let observed = to_val(&ServerNotification::CodexEvent(Box::new(params)));
        let expected = json!({
            "method": "notifications/agent_message",
            "params": {
                "_meta": {
                    "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8",
                    "requestId": 44
                },
                "msg": { "type": "agent_message", "message": "hi" }
            }
        });
        assert_eq!(observed, expected);
    }

    #[test]
    fn serialize_notification_codex_event_agent_reasoning_full_json() {
        let params = CodexEventNotificationParams {
            meta: None,
            msg: EventMsg::AgentReasoning(codex_core::protocol::AgentReasoningEvent {
                text: "thinking…".into(),
            }),
        };

        let observed = to_val(&ServerNotification::CodexEvent(Box::new(params)));
        let expected = json!({
            "method": "notifications/agent_reasoning",
            "params": {
                "msg": { "type": "agent_reasoning", "text": "thinking…" }
            }
        });
        assert_eq!(observed, expected);
    }

    #[test]
    fn serialize_notification_codex_event_token_count_full_json() {
        let usage = codex_core::protocol::TokenUsage {
            input_tokens: 10,
            cached_input_tokens: Some(2),
            output_tokens: 5,
            reasoning_output_tokens: Some(1),
            total_tokens: 16,
        };
        let params = CodexEventNotificationParams {
            meta: None,
            msg: EventMsg::TokenCount(usage),
        };

        let observed = to_val(&ServerNotification::CodexEvent(Box::new(params)));
        let expected = json!({
            "method": "notifications/token_count",
            "params": {
                "msg": {
                    "type": "token_count",
                    "input_tokens": 10,
                    "cached_input_tokens": 2,
                    "output_tokens": 5,
                    "reasoning_output_tokens": 1,
                    "total_tokens": 16
                }
            }
        });
        assert_eq!(observed, expected);
    }

    #[test]
    fn serialize_notification_codex_event_session_configured_full_json() {
        let params = CodexEventNotificationParams {
            meta: Some(NotificationMeta {
                conversation_id: Some(ConversationId(uuid!(
                    "67e55044-10b1-426f-9247-bb680e5fe0c8"
                ))),
                request_id: None,
            }),
            msg: EventMsg::SessionConfigured(codex_core::protocol::SessionConfiguredEvent {
                session_id: uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8"),
                model: "codex-mini-latest".into(),
                history_log_id: 42,
                history_entry_count: 3,
            }),
        };

        let observed = to_val(&ServerNotification::CodexEvent(Box::new(params)));
        let expected = json!({
            "method": "notifications/session_configured",
            "params": {
                "_meta": { "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8" },
                "msg": {
                    "type": "session_configured",
                    "session_id": "67e55044-10b1-426f-9247-bb680e5fe0c8",
                    "model": "codex-mini-latest",
                    "history_log_id": 42,
                    "history_entry_count": 3
                }
            }
        });
        assert_eq!(observed, expected);
    }

    #[test]
    fn serialize_notification_codex_event_exec_command_begin_full_json() {
        let params = CodexEventNotificationParams {
            meta: None,
            msg: EventMsg::ExecCommandBegin(codex_core::protocol::ExecCommandBeginEvent {
                call_id: "c1".into(),
                command: vec!["bash".into(), "-lc".into(), "echo hi".into()],
                cwd: std::path::PathBuf::from("/work"),
            }),
        };

        let observed = to_val(&ServerNotification::CodexEvent(Box::new(params)));
        let expected = json!({
            "method": "notifications/exec_command_begin",
            "params": {
                "msg": {
                    "type": "exec_command_begin",
                    "call_id": "c1",
                    "command": ["bash", "-lc", "echo hi"],
                    "cwd": "/work"
                }
            }
        });
        assert_eq!(observed, expected);
    }

    #[test]
    fn serialize_notification_codex_event_mcp_tool_call_begin_full_json() {
        let params = CodexEventNotificationParams {
            meta: None,
            msg: EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
                call_id: "m1".into(),
                invocation: McpInvocation {
                    server: "calc".into(),
                    tool: "add".into(),
                    arguments: Some(json!({"a":1,"b":2})),
                },
            }),
        };

        let observed = to_val(&ServerNotification::CodexEvent(Box::new(params)));
        let expected = json!({
            "method": "notifications/mcp_tool_call_begin",
            "params": {
                "msg": {
                    "type": "mcp_tool_call_begin",
                    "call_id": "m1",
                    "invocation": {
                        "server": "calc",
                        "tool": "add",
                        "arguments": { "a": 1, "b": 2 }
                    }
                }
            }
        });
        assert_eq!(observed, expected);
    }

    #[test]
    fn serialize_notification_codex_event_patch_apply_end_full_json() {
        let params = CodexEventNotificationParams {
            meta: None,
            msg: EventMsg::PatchApplyEnd(codex_core::protocol::PatchApplyEndEvent {
                call_id: "p1".into(),
                stdout: "ok".into(),
                stderr: "".into(),
                success: true,
            }),
        };

        let observed = to_val(&ServerNotification::CodexEvent(Box::new(params)));
        let expected = json!({
            "method": "notifications/patch_apply_end",
            "params": {
                "msg": {
                    "type": "patch_apply_end",
                    "call_id": "p1",
                    "stdout": "ok",
                    "stderr": "",
                    "success": true
                }
            }
        });
        assert_eq!(observed, expected);
    }

    // ----- Cancelled notifications -----

    #[test]
    fn serialize_notification_cancelled_with_reason_full_json() {
        let params = CancelNotificationParams {
            request_id: RequestId::String("r-123".into()),
            reason: Some("user_cancelled".into()),
        };

        let observed = to_val(&ClientNotification::Cancelled(params));
        let expected = json!({
            "method": "notifications/cancelled",
            "params": {
                "requestId": "r-123",
                "reason": "user_cancelled"
            }
        });
        assert_eq!(observed, expected);
    }

    #[test]
    fn serialize_notification_cancelled_without_reason_full_json() {
        let params = CancelNotificationParams {
            request_id: RequestId::Integer(77),
            reason: None,
        };

        let observed = to_val(&ClientNotification::Cancelled(params));

        // Check exact structure: reason must be omitted.
        assert_eq!(observed["method"], "notifications/cancelled");
        assert_eq!(observed["params"]["requestId"], 77);
        assert!(
            observed["params"].get("reason").is_none(),
            "reason must be omitted when None"
        );
    }
}
