//! OpenAI Responses API リクエスト body 生成
//!
//! Chat Completions の `messages` と違い、Responses は `input[]` の
//! item 配列で reasoning / function_call / function_call_output が
//! first-class。`Item` を素に近い形で `input[]` に投影できる。

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

use crate::{
    llm_client::{
        Request,
        capability::{ModelCapability, ReasoningControl, ReasoningSupport},
        types::{ContentPart, Item, Role, ToolDefinition, image_data_url, parse_tool_arguments},
    },
    tool::Attachment,
};

use super::OpenAIResponsesScheme;

#[derive(Debug, Serialize)]
#[serde(untagged)]
pub(crate) enum FunctionCallOutputBody {
    Text(String),
    ContentItems(Vec<FunctionCallOutputContentItem>),
}

#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum FunctionCallOutputContentItem {
    InputText { text: String },
    InputImage { image_url: String },
}

/// `/v1/responses` のリクエスト body。
#[derive(Debug, Serialize)]
pub(crate) struct ResponsesRequest {
    pub model: String,
    /// システムプロンプト相当。`input[]` とは別フィールド。
    #[serde(skip_serializing_if = "Option::is_none")]
    pub instructions: Option<String>,
    pub input: Vec<InputItem>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub tools: Vec<ResponseTool>,
    /// 常時 `"auto"` を送る。scheme 固定値。
    pub tool_choice: &'static str,
    /// 常時 `true` を送る。scheme 固定値。
    pub parallel_tool_calls: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reasoning: Option<ReasoningConfig>,
    /// ZDR / stateless 運用では `false`。
    pub store: bool,
    /// 常時 `true`。
    pub stream: bool,
    /// `["reasoning.encrypted_content"]` 等。
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub include: Vec<&'static str>,
    /// 公式 OpenAI Responses API では受理されるが、互換 backend によっては
    /// 400 で弾く。scheme の `send_max_output_tokens` が `false` のときは
    /// `None` のまま送る (skip_serializing_if で除外)。
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_output_tokens: Option<u32>,
    /// 公式 OpenAI Responses API では受理されるが、互換 backend によっては
    /// `temperature` / `top_p` を 400 で弾く。scheme の
    /// `send_sampling_params` が `false` のときは `None` のまま送る。
    #[serde(skip_serializing_if = "Option::is_none")]
    pub temperature: Option<f32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub top_p: Option<f32>,
    /// 会話単位の安定キー。明示キーを必要とする backend では、
    /// 呼び出し側が安定した conversation identifier を渡す。
    /// `Request::cache_key` が `None` のときはキー自体を送らない。
    #[serde(skip_serializing_if = "Option::is_none")]
    pub prompt_cache_key: Option<String>,
}

/// reasoning 制御。
#[derive(Debug, Serialize)]
pub(crate) struct ReasoningConfig {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub effort: Option<String>,
    /// summary の出力制御。`"auto"` 固定で summary_text を受け取る。
    pub summary: &'static str,
}

/// `input[]` の 1 要素。
///
/// Responses API の item 型を素に近い形で投影する。未対応 type は
/// 無視（reasoning 送信時に `content: []` の場合は `None` として弾く）。
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum InputItem {
    /// 会話メッセージ。user / assistant / developer のいずれか。
    /// `Role::System` items は `developer` として投影する。OpenAI
    /// Responses 互換 backend の一部は `role: "system"` を拒否するため、
    /// system 相当の挿入には `role: "developer"` を使う。
    Message {
        role: &'static str,
        content: Vec<InputContent>,
    },
    /// 過去の function tool 呼び出し（assistant 側）。
    FunctionCall {
        call_id: String,
        name: String,
        /// JSON 文字列（object でなくても正規化済み）。
        arguments: String,
    },
    /// function tool の結果（user 側）。
    FunctionCallOutput {
        call_id: String,
        output: FunctionCallOutputBody,
    },
    /// reasoning item。`encrypted_content` があれば必ず添える。
    Reasoning {
        #[serde(skip_serializing_if = "Option::is_none")]
        id: Option<String>,
        /// Responses API は reasoning item に `summary` フィールドを必須で
        /// 要求する（中身が空でも `[]` として送る必要がある）。GPT-5 など
        /// summary を返さないモデル + reasoning effort 指定なしのターンでは
        /// summary text が一切付かないので、ここを skip すると 400
        /// "Missing required parameter: 'input[N].summary'" で弾かれる。
        summary: Vec<ReasoningSummaryPart>,
        #[serde(skip_serializing_if = "Vec::is_empty")]
        content: Vec<ReasoningContentPart>,
        #[serde(skip_serializing_if = "Option::is_none")]
        encrypted_content: Option<String>,
    },
}

/// メッセージ content_part。role で input/output を使い分ける。
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum InputContent {
    /// user / developer 側のテキスト
    InputText { text: String },
    /// user 側の画像
    /// assistant 側のテキスト
    OutputText { text: String },
}

#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum ReasoningSummaryPart {
    SummaryText { text: String },
}

#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum ReasoningContentPart {
    ReasoningText { text: String },
}

/// Responses 用 tool 定義。Chat と違い function キーでネストせず
/// トップレベルに `name` / `parameters` が載る。
#[derive(Debug, Serialize)]
pub(crate) struct ResponseTool {
    #[serde(rename = "type")]
    pub r#type: &'static str,
    pub name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// OpenAI Responses API は `type:"object"` のパラメータスキーマに
    /// `properties` が存在することを要求する。schemars は引数なし struct
    /// から `properties` を含まない最小スキーマを出すので、serialize
    /// 時に空オブジェクトを補う。
    #[serde(serialize_with = "serialize_parameters")]
    pub parameters: Value,
    /// Structured output モード制御。デフォルト false。
    pub strict: bool,
}

fn serialize_parameters<S: Serializer>(value: &Value, s: S) -> Result<S::Ok, S::Error> {
    if let Some(obj) = value.as_object()
        && obj.get("type").and_then(Value::as_str) == Some("object")
        && !obj.contains_key("properties")
    {
        let mut patched = obj.clone();
        patched.insert("properties".to_string(), Value::Object(Default::default()));
        return Value::Object(patched).serialize(s);
    }
    value.serialize(s)
}

impl OpenAIResponsesScheme {
    /// `Request` から wire 形式の body を組み立てる。
    pub(crate) fn build_request(
        &self,
        model: &str,
        request: &Request,
        capability: &ModelCapability,
    ) -> ResponsesRequest {
        let input = convert_items_to_input(&request.items, capability.vision);
        let tools = request.tools.iter().map(convert_tool).collect();

        // Reasoning 投影: capability が Effort / Both をサポートし、かつ
        // request 側で effort が指定されているときだけ reasoning を付ける。
        let supports_effort = matches!(
            capability.reasoning,
            Some(ReasoningSupport::Effort | ReasoningSupport::Both),
        );
        let reasoning = request
            .config
            .reasoning
            .as_ref()
            .filter(|_| supports_effort)
            .map(|effort| ReasoningConfig {
                effort: match effort {
                    ReasoningControl::Effort(effort) => Some(effort.as_str().to_string()),
                    ReasoningControl::BudgetTokens(_) => None,
                },
                summary: "auto",
            })
            .filter(|reasoning| reasoning.effort.is_some());

        let include: Vec<&'static str> = if self.include_encrypted_content {
            vec!["reasoning.encrypted_content"]
        } else {
            Vec::new()
        };

        ResponsesRequest {
            model: model.to_string(),
            instructions: request.system_prompt.clone(),
            input,
            tools,
            tool_choice: "auto",
            parallel_tool_calls: true,
            reasoning,
            store: self.store,
            stream: true,
            include,
            max_output_tokens: if self.send_max_output_tokens {
                request.config.max_tokens
            } else {
                None
            },
            temperature: if self.send_sampling_params {
                request.config.temperature
            } else {
                None
            },
            top_p: if self.send_sampling_params {
                request.config.top_p
            } else {
                None
            },
            prompt_cache_key: request.cache_key.clone(),
        }
    }
}

/// `Item` 列を `input[]` に変換する。
fn convert_items_to_input(items: &[Item], supports_images: bool) -> Vec<InputItem> {
    let mut out = Vec::with_capacity(items.len());
    for item in items {
        match item {
            Item::Message { role, content, .. } => {
                let (role_str, text_variant): (&'static str, fn(String) -> InputContent) =
                    match role {
                        Role::User => ("user", |t| InputContent::InputText { text: t }),
                        Role::Assistant => ("assistant", |t| InputContent::OutputText { text: t }),
                        Role::System => ("developer", |t| InputContent::InputText { text: t }),
                    };
                let parts: Vec<InputContent> = content
                    .iter()
                    .map(|part| match part {
                        ContentPart::Text { text } => text_variant(text.clone()),
                        ContentPart::Refusal { refusal } => text_variant(refusal.clone()),
                    })
                    .collect();
                out.push(InputItem::Message {
                    role: role_str,
                    content: parts,
                });
            }
            Item::ToolCall {
                call_id,
                name,
                arguments,
                ..
            } => {
                // 非 object / 旧形式の "null" を "{}" に正規化。
                let normalized = parse_tool_arguments(arguments).to_string();
                out.push(InputItem::FunctionCall {
                    call_id: call_id.clone(),
                    name: name.clone(),
                    arguments: normalized,
                });
            }
            Item::ToolResult {
                call_id,
                summary,
                content,
                attachments,
                ..
            } => {
                let text = match content {
                    Some(c) => format!("{summary}\n{c}"),
                    None => summary.clone(),
                };
                let output = if attachments.is_empty() {
                    FunctionCallOutputBody::Text(text)
                } else if supports_images {
                    let mut parts = vec![FunctionCallOutputContentItem::InputText { text }];
                    parts.extend(attachments.iter().map(|attachment| {
                        let Attachment::Image(image) = attachment;
                        FunctionCallOutputContentItem::InputImage {
                            image_url: image_data_url(image.mime_type(), image.data()),
                        }
                    }));
                    FunctionCallOutputBody::ContentItems(parts)
                } else {
                    FunctionCallOutputBody::Text(format!(
                        "{text}\n[{} image attachment(s) omitted: model does not support images]",
                        attachments.len()
                    ))
                };
                out.push(InputItem::FunctionCallOutput {
                    call_id: call_id.clone(),
                    output,
                });
            }
            Item::Reasoning {
                id,
                text,
                summary,
                encrypted_content,
                ..
            } => {
                let summary_parts = summary
                    .iter()
                    .filter(|s| !s.is_empty())
                    .map(|s| ReasoningSummaryPart::SummaryText { text: s.clone() })
                    .collect();
                let content_parts = if text.is_empty() {
                    Vec::new()
                } else {
                    vec![ReasoningContentPart::ReasoningText { text: text.clone() }]
                };
                out.push(InputItem::Reasoning {
                    id: id.clone(),
                    summary: summary_parts,
                    content: content_parts,
                    encrypted_content: encrypted_content.clone(),
                });
            }
        }
    }
    out
}

fn convert_tool(tool: &ToolDefinition) -> ResponseTool {
    ResponseTool {
        r#type: "function",
        name: tool.name.clone(),
        description: tool.description.clone(),
        parameters: tool.input_schema.clone(),
        strict: false,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::llm_client::capability::{
        CacheStrategy, ModelCapability, ReasoningControl, ReasoningEffort, ReasoningSupport,
        StructuredOutput, ToolCallingSupport,
    };

    fn cap_with_reasoning() -> ModelCapability {
        ModelCapability {
            tool_calling: ToolCallingSupport::Parallel,
            structured_output: StructuredOutput::JsonSchema,
            reasoning: Some(ReasoningSupport::Effort),
            vision: true,
            prompt_caching: CacheStrategy::Auto,
        }
    }

    fn cap_no_reasoning() -> ModelCapability {
        ModelCapability {
            reasoning: None,
            ..cap_with_reasoning()
        }
    }

    #[test]
    fn scheme_defaults_to_stateless_zdr() {
        let s = OpenAIResponsesScheme::new();
        assert!(!s.store);
        assert!(s.include_encrypted_content);
    }

    #[test]
    fn includes_encrypted_content_when_enabled() {
        let scheme = OpenAIResponsesScheme::new();
        let req = Request::new().user("hi");
        let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
        assert_eq!(body.include, vec!["reasoning.encrypted_content"]);
        assert!(!body.store);
        assert!(body.stream);
    }

    #[test]
    fn instructions_from_system_prompt() {
        let scheme = OpenAIResponsesScheme::new();
        let req = Request::new().system("be terse").user("hi");
        let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
        assert_eq!(body.instructions.as_deref(), Some("be terse"));
        assert_eq!(body.input.len(), 1);
    }

    #[test]
    fn tool_choice_and_parallel_are_fixed() {
        let scheme = OpenAIResponsesScheme::new();
        let req = Request::new().user("hi");
        let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
        assert_eq!(body.tool_choice, "auto");
        assert!(body.parallel_tool_calls);
    }

    #[test]
    fn user_message_uses_input_text() {
        let scheme = OpenAIResponsesScheme::new();
        let req = Request::new().user("hi");
        let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
        match &body.input[0] {
            InputItem::Message { role, content } => {
                assert_eq!(*role, "user");
                assert_eq!(content.len(), 1);
                assert!(matches!(&content[0], InputContent::InputText { text } if text == "hi"));
            }
            _ => panic!("expected message"),
        }
    }

    #[test]
    fn system_role_item_is_projected_as_developer() {
        // Some compatible backends reject `role: "system"` in input[].
        // Project in-conversation system notes as `role: "developer"` so
        // both official and compatible backends can accept them.
        let scheme = OpenAIResponsesScheme::new();
        let req = Request::new()
            .user("hi")
            .item(Item::system_message("[notify] hello"));
        let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
        match &body.input[1] {
            InputItem::Message { role, content } => {
                assert_eq!(*role, "developer");
                assert!(
                    matches!(&content[0], InputContent::InputText { text } if text == "[notify] hello"),
                );
            }
            _ => panic!("expected message"),
        }
    }

    #[test]
    fn assistant_message_uses_output_text() {
        let scheme = OpenAIResponsesScheme::new();
        let req = Request::new().user("hi").assistant("hello");
        let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
        match &body.input[1] {
            InputItem::Message { role, content } => {
                assert_eq!(*role, "assistant");
                assert!(
                    matches!(&content[0], InputContent::OutputText { text } if text == "hello")
                );
            }
            _ => panic!("expected message"),
        }
    }

    #[test]
    fn tool_call_and_result_become_function_items() {
        let scheme = OpenAIResponsesScheme::new();
        let req = Request::new()
            .user("run")
            .item(Item::tool_call("c1", "t", r#"{"a":1}"#))
            .item(Item::tool_result("c1", "ok"));
        let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
        assert!(matches!(body.input[1], InputItem::FunctionCall { .. }));
        assert!(matches!(
            body.input[2],
            InputItem::FunctionCallOutput { .. }
        ));
    }

    #[test]
    fn reasoning_item_round_trips_encrypted_content() {
        let scheme = OpenAIResponsesScheme::new();
        let item = Item::reasoning("inner")
            .with_reasoning_summary(vec!["s1".into()])
            .with_encrypted_content("ENC");
        let req = Request::new().user("hi").item(item);
        let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
        match &body.input[1] {
            InputItem::Reasoning {
                summary,
                content,
                encrypted_content,
                ..
            } => {
                assert_eq!(summary.len(), 1);
                assert_eq!(content.len(), 1);
                assert_eq!(encrypted_content.as_deref(), Some("ENC"));
            }
            _ => panic!("expected reasoning"),
        }
    }

    #[test]
    fn persisted_reasoning_items_are_preserved_across_user_turns() {
        let scheme = OpenAIResponsesScheme::new();
        let old_reasoning = Item::reasoning("old").with_encrypted_content("OLD_ENC");
        let current_reasoning = Item::reasoning("current").with_encrypted_content("CURRENT_ENC");
        let req = Request::new()
            .user("old prompt")
            .item(old_reasoning)
            .assistant("old answer")
            .user("new prompt")
            .item(current_reasoning);
        let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
        let encrypted: Vec<_> = body
            .input
            .iter()
            .filter_map(|item| match item {
                InputItem::Reasoning {
                    encrypted_content, ..
                } => encrypted_content.as_deref(),
                _ => None,
            })
            .collect();
        assert_eq!(encrypted, vec!["OLD_ENC", "CURRENT_ENC"]);
    }

    #[test]
    fn reasoning_is_kept_across_function_call_loop() {
        let scheme = OpenAIResponsesScheme::new();
        let req = Request::new()
            .user("run tool")
            .item(Item::reasoning("plan").with_encrypted_content("ENC"))
            .item(Item::tool_call("c1", "tool", "{}"))
            .item(Item::tool_result("c1", "ok"));
        let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
        assert!(matches!(body.input[1], InputItem::Reasoning { .. }));
        assert!(matches!(body.input[2], InputItem::FunctionCall { .. }));
        assert!(matches!(
            body.input[3],
            InputItem::FunctionCallOutput { .. }
        ));
    }

    #[test]
    fn reasoning_summary_field_is_always_serialized() {
        // Responses API は reasoning item に `summary` を必須で要求する。
        // summary が空でも wire 上に `summary: []` として残らないと、
        // backend によっては missing required parameter として拒否される。
        // reasoning effort 未指定のターンでは summary text が付かないことが
        // あるため、空のままでも skip しないこと。
        let scheme = OpenAIResponsesScheme::new();
        let item = Item::reasoning("").with_encrypted_content("ENC");
        let req = Request::new().user("hi").item(item);
        let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
        let json = serde_json::to_value(&body).unwrap();
        let reasoning_item = &json["input"][1];
        assert_eq!(reasoning_item["type"], "reasoning");
        assert!(
            reasoning_item.get("summary").is_some(),
            "summary key must be present even when empty, got: {reasoning_item}"
        );
        assert_eq!(reasoning_item["summary"], serde_json::json!([]));
    }

    #[test]
    fn reasoning_effort_projected_when_supported() {
        let scheme = OpenAIResponsesScheme::new();
        let mut req = Request::new().user("hi");
        req.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::High));
        let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
        let reasoning = body.reasoning.expect("reasoning should be set");
        assert_eq!(reasoning.effort.as_deref(), Some("high"));
        assert_eq!(reasoning.summary, "auto");
        let json = serde_json::to_value(reasoning).unwrap();
        assert!(
            json.get("context").is_none(),
            "reasoning.context must not be serialized, got: {json}"
        );
    }

    #[test]
    fn reasoning_omitted_when_unsupported() {
        let scheme = OpenAIResponsesScheme::new();
        let mut req = Request::new().user("hi");
        req.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::High));
        let body = scheme.build_request("gpt-4o", &req, &cap_no_reasoning());
        assert!(body.reasoning.is_none());
    }

    #[test]
    fn max_output_tokens_passed_through_by_default() {
        let scheme = OpenAIResponsesScheme::new();
        let req = Request::new().user("hi").max_tokens(100);
        let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
        assert_eq!(body.max_output_tokens, Some(100));
    }

    #[test]
    fn max_output_tokens_dropped_when_send_disabled() {
        let scheme = OpenAIResponsesScheme::new().with_send_max_output_tokens(false);
        let req = Request::new().user("hi").max_tokens(100);
        let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
        assert_eq!(body.max_output_tokens, None);
        let json = serde_json::to_value(&body).unwrap();
        assert!(
            json.get("max_output_tokens").is_none(),
            "max_output_tokens key must not appear in serialised body, got: {json}"
        );
    }

    #[test]
    fn sampling_params_passed_through_by_default() {
        let scheme = OpenAIResponsesScheme::new();
        let req = Request::new().user("hi").temperature(0.4).top_p(0.9);
        let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
        assert_eq!(body.temperature, Some(0.4));
        assert_eq!(body.top_p, Some(0.9));
    }

    #[test]
    fn sampling_params_dropped_when_send_disabled() {
        let scheme = OpenAIResponsesScheme::new().with_send_sampling_params(false);
        let req = Request::new().user("hi").temperature(0.4).top_p(0.9);
        let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
        assert_eq!(body.temperature, None);
        assert_eq!(body.top_p, None);
        let json = serde_json::to_value(&body).unwrap();
        assert!(
            json.get("temperature").is_none() && json.get("top_p").is_none(),
            "temperature/top_p keys must not appear in serialised body, got: {json}"
        );
    }

    #[test]
    fn prompt_cache_key_passed_through_when_set() {
        let scheme = OpenAIResponsesScheme::new();
        let req = Request::new().user("hi").cache_key("session-abc");
        let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
        assert_eq!(body.prompt_cache_key.as_deref(), Some("session-abc"));
        let json = serde_json::to_value(&body).unwrap();
        assert_eq!(json["prompt_cache_key"], "session-abc");
    }

    #[test]
    fn prompt_cache_key_omitted_when_none() {
        let scheme = OpenAIResponsesScheme::new();
        let req = Request::new().user("hi");
        let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
        assert!(body.prompt_cache_key.is_none());
        let json = serde_json::to_value(&body).unwrap();
        assert!(
            json.get("prompt_cache_key").is_none(),
            "prompt_cache_key key must not appear in serialised body, got: {json}"
        );
    }

    #[test]
    fn tool_schema_without_properties_is_normalized() {
        // schemars は引数なし struct から `type:"object"` だけのスキーマを
        // 吐く。OpenAI Responses は `properties` 欠落を 400 で拒否するので
        // 送る直前に空オブジェクトを補うのを確認。
        let scheme = OpenAIResponsesScheme::new();
        let raw_schema = serde_json::json!({ "type": "object" });
        let req = Request::new().tool(
            ToolDefinition::new("empty")
                .description("no args")
                .input_schema(raw_schema),
        );
        let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
        let json = serde_json::to_value(&body).unwrap();
        assert_eq!(json["tools"][0]["parameters"]["type"], "object");
        assert!(
            json["tools"][0]["parameters"]["properties"].is_object(),
            "properties must be present as an object, got: {}",
            json["tools"][0]["parameters"]
        );
    }

    #[test]
    fn tool_schema_with_properties_is_untouched() {
        let scheme = OpenAIResponsesScheme::new();
        let raw_schema = serde_json::json!({
            "type": "object",
            "properties": { "path": { "type": "string" } },
            "required": ["path"]
        });
        let req = Request::new().tool(
            ToolDefinition::new("t")
                .description("d")
                .input_schema(raw_schema.clone()),
        );
        let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
        let json = serde_json::to_value(&body).unwrap();
        assert_eq!(json["tools"][0]["parameters"], raw_schema);
    }

    #[test]
    fn serialized_body_has_expected_shape() {
        // wire 形式が崩れていないかのスモークテスト
        let scheme = OpenAIResponsesScheme::new();
        let req = Request::new()
            .system("sys")
            .user("hi")
            .tool(ToolDefinition::new("t").description("d"));
        let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
        let json = serde_json::to_value(&body).unwrap();
        assert_eq!(json["model"], "gpt-5");
        assert_eq!(json["instructions"], "sys");
        assert_eq!(json["tool_choice"], "auto");
        assert_eq!(json["parallel_tool_calls"], true);
        assert_eq!(json["store"], false);
        assert_eq!(json["stream"], true);
        assert_eq!(json["include"][0], "reasoning.encrypted_content");
        assert_eq!(json["tools"][0]["type"], "function");
        assert_eq!(json["tools"][0]["name"], "t");
    }

    #[test]
    fn durable_tool_image_uses_function_call_output_content_items() {
        let scheme = OpenAIResponsesScheme::new();
        let image = std::sync::Arc::<[u8]>::from(&b"\x89PNG\r\n\x1a\nbody"[..]);
        let item = Item::tool_result_item_with_attachments(
            "call_image",
            "Attached image",
            None,
            false,
            vec![crate::tool::Attachment::Image(
                crate::tool::ImageAttachment::new("image/png", image),
            )],
        );
        let persisted = serde_json::to_string(&item).unwrap();
        let restored: Item = serde_json::from_str(&persisted).unwrap();
        let req = Request::new()
            .item(Item::tool_call(
                "call_image",
                "ViewImage",
                r#"{"path":"a.png"}"#,
            ))
            .item(restored);
        let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
        let json = serde_json::to_value(&body).unwrap();

        assert_eq!(json["input"][1]["type"], "function_call_output");
        assert_eq!(json["input"].as_array().unwrap().len(), 2);
        assert_eq!(json["input"][1]["output"][0]["type"], "input_text");
        assert_eq!(json["input"][1]["output"][1]["type"], "input_image");
        assert!(
            json["input"][1]["output"][1]["image_url"]
                .as_str()
                .unwrap()
                .starts_with("data:image/png;base64,")
        );
        let rebuilt =
            serde_json::to_value(scheme.build_request("gpt-5", &req, &cap_with_reasoning()))
                .unwrap();
        assert_eq!(rebuilt["input"], json["input"]);

        let mut no_vision = cap_with_reasoning();
        no_vision.vision = false;
        let disabled =
            serde_json::to_string(&scheme.build_request("gpt-5", &req, &no_vision)).unwrap();
        assert!(!disabled.contains("data:image"));
    }
}
