use async_trait::async_trait;
use futures_util::stream;
use reqwest::Client;
use serde_json::{Value, json};
use std::collections::{HashMap, VecDeque};
use std::time::Duration;
use thiserror::Error;

use super::stream::SseFramer;
use super::{
    ChatCompletionRequest, DynProviderError, FinishReason, LLMProvider, Message, ProviderChunk,
    ProviderStream, Usage,
};
use crate::bus::message::ContentBlock;
use crate::storage::Storage;
use std::sync::Arc;

const LLM_REQUEST_TIMEOUT_SECS: u64 = 300;

fn convert_content_blocks(blocks: &[ContentBlock]) -> Value {
    if blocks.len() == 1
        && let ContentBlock::Text { text } = &blocks[0]
    {
        return Value::String(text.clone());
    }
    Value::Array(
        blocks
            .iter()
            .map(|b| match b {
                ContentBlock::Text { text } => json!({ "type": "text", "text": text }),
                ContentBlock::ImageUrl { image_url } => {
                    json!({ "type": "image_url", "image_url": { "url": image_url.url } })
                }
            })
            .collect(),
    )
}

fn text_content(blocks: &[ContentBlock]) -> String {
    blocks
        .iter()
        .filter_map(|block| match block {
            ContentBlock::Text { text } => Some(text.as_str()),
            ContentBlock::ImageUrl { .. } => None,
        })
        .collect::<Vec<_>>()
        .join("\n")
}

fn role_safe_content(role: &str, blocks: &[ContentBlock]) -> Value {
    if role == "user" {
        convert_content_blocks(blocks)
    } else {
        // OpenAI-compatible endpoints commonly reject native image parts on
        // system/assistant messages. AgentLoop already supplies a textual
        // attachment manifest for persisted assistant media; keep this final
        // provider boundary defensive for all callers.
        Value::String(text_content(blocks))
    }
}

fn regular_message_json(message: &Message) -> Value {
    if message.role == "tool" {
        json!({
            "role": message.role,
            "content": text_content(&message.content),
            "tool_call_id": message.tool_call_id,
            "name": message.name,
        })
    } else if message.role == "assistant"
        && message
            .tool_calls
            .as_ref()
            .is_some_and(|calls| !calls.is_empty())
    {
        let mut value = json!({
            "role": message.role,
            "content": role_safe_content(&message.role, &message.content),
            "tool_calls": message.tool_calls.as_ref().map(|calls| {
                calls.iter().map(|call| json!({
                    "id": call.id,
                    "type": "function",
                    "function": {
                        "name": call.name,
                        "arguments": serde_json::to_string(&call.arguments).unwrap_or_else(|_| "null".to_string())
                    }
                })).collect::<Vec<_>>()
            })
        });
        if let Some(ref reasoning_content) = message.reasoning_content {
            value["reasoning_content"] = json!(reasoning_content);
        }
        value
    } else {
        let mut value = json!({
            "role": message.role,
            "content": role_safe_content(&message.role, &message.content)
        });
        if message.role == "assistant"
            && let Some(ref reasoning_content) = message.reasoning_content
        {
            value["reasoning_content"] = json!(reasoning_content);
        }
        value
    }
}

/// OpenAI-compatible APIs do not consistently accept image parts on `tool`
/// messages. Keep the tool-call contract textual and append one temporary user
/// message containing media returned by the complete contiguous tool batch.
fn convert_messages(messages: &[Message]) -> Vec<Value> {
    let mut converted = Vec::with_capacity(messages.len());
    let mut tool_media = Vec::new();
    let mut tool_media_labels = Vec::new();

    let flush_tool_media =
        |converted: &mut Vec<Value>, tool_media: &mut Vec<Value>, labels: &mut Vec<String>| {
            if tool_media.is_empty() {
                return;
            }
            let mut content = vec![json!({
                "type": "text",
                "text": format!("[以下媒体由本轮工具调用返回]\n{}", labels.join("\n")),
            })];
            content.append(tool_media);
            converted.push(json!({
                "role": "user",
                "content": content,
            }));
            labels.clear();
        };

    for message in messages {
        if message.role != "tool" {
            flush_tool_media(&mut converted, &mut tool_media, &mut tool_media_labels);
        }
        converted.push(regular_message_json(message));
        if message.role == "tool" {
            for block in &message.content {
                if let ContentBlock::ImageUrl { image_url } = block {
                    let label = format!(
                        "- {} / {}",
                        message.name.as_deref().unwrap_or("tool"),
                        message.tool_call_id.as_deref().unwrap_or("unknown")
                    );
                    if tool_media_labels.last() != Some(&label) {
                        tool_media_labels.push(label);
                    }
                    tool_media.push(json!({
                        "type": "image_url",
                        "image_url": { "url": image_url.url },
                    }));
                }
            }
        }
    }
    flush_tool_media(&mut converted, &mut tool_media, &mut tool_media_labels);
    converted
}

pub struct OpenAIProvider {
    client: Client,
    name: String,
    api_key: String,
    base_url: String,
    extra_headers: HashMap<String, String>,
    model_id: String,
    temperature: Option<f32>,
    max_tokens: Option<u32>,
    model_extra: HashMap<String, serde_json::Value>,
    storage: Option<Arc<Storage>>,
}

impl OpenAIProvider {
    // Provider construction mirrors the independently configurable fields in
    // LLMProviderConfig; grouping them again would only duplicate that API.
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        name: String,
        api_key: String,
        base_url: String,
        extra_headers: HashMap<String, String>,
        model_id: String,
        temperature: Option<f32>,
        max_tokens: Option<u32>,
        model_extra: HashMap<String, serde_json::Value>,
    ) -> Self {
        Self {
            client: Client::builder()
                .timeout(Duration::from_secs(LLM_REQUEST_TIMEOUT_SECS))
                .build()
                .unwrap_or_else(|_| Client::new()),
            name,
            api_key,
            base_url,
            extra_headers,
            model_id,
            temperature,
            max_tokens,
            model_extra,
            storage: None,
        }
    }

    pub fn set_storage(&mut self, storage: Arc<Storage>) {
        self.storage = Some(storage);
    }

    fn build_request_body(&self, request: &ChatCompletionRequest) -> Value {
        let mut body = json!({
            "model": self.model_id,
            "messages": convert_messages(&request.messages),
            "temperature": request.temperature.or(self.temperature).unwrap_or(0.7),
            "max_tokens": request.max_tokens.or(self.max_tokens),
        });

        for (key, value) in &self.model_extra {
            body[key] = value.clone();
        }

        if let Some(tools) = &request.tools {
            body["tools"] = json!(tools);
        }

        body
    }

    fn build_stream_request_body(&self, request: &ChatCompletionRequest) -> Value {
        let mut body = self.build_request_body(request);
        body["stream"] = Value::Bool(true);
        body["stream_options"] = json!({ "include_usage": true });
        body
    }
}

#[derive(Debug, Error)]
enum OpenAIStreamError {
    #[error("invalid UTF-8 in SSE event: {0}")]
    Utf8(#[from] std::string::FromUtf8Error),
    #[error("invalid OpenAI-compatible SSE payload: {0}")]
    Json(#[from] serde_json::Error),
    #[error("OpenAI-compatible stream ended without a finish marker")]
    MissingFinish,
}

#[derive(Clone, Copy, PartialEq, Eq)]
enum InlineMode {
    Text,
    Reasoning,
}

struct InlineReasoningParser {
    mode: InlineMode,
    pending: String,
}

impl Default for InlineReasoningParser {
    fn default() -> Self {
        Self {
            mode: InlineMode::Text,
            pending: String::new(),
        }
    }
}

impl InlineReasoningParser {
    const TAGS: [(&'static str, InlineMode); 4] = [
        ("<think>", InlineMode::Reasoning),
        ("<reasoning>", InlineMode::Reasoning),
        ("</think>", InlineMode::Text),
        ("</reasoning>", InlineMode::Text),
    ];

    fn push(&mut self, delta: &str) -> Vec<ProviderChunk> {
        self.pending.push_str(delta);
        self.drain(false)
    }

    fn finish(&mut self) -> Vec<ProviderChunk> {
        self.drain(true)
    }

    fn drain(&mut self, finish: bool) -> Vec<ProviderChunk> {
        let mut chunks = Vec::new();
        loop {
            let next_tag = Self::TAGS
                .iter()
                .filter_map(|(tag, mode)| self.pending.find(tag).map(|index| (index, *tag, *mode)))
                .min_by_key(|(index, _, _)| *index);
            if let Some((index, tag, mode)) = next_tag {
                let text = self.pending[..index].to_string();
                self.emit_text(text, &mut chunks);
                self.pending.drain(..index + tag.len());
                self.mode = mode;
                continue;
            }

            let retained = if finish {
                0
            } else {
                longest_tag_prefix_suffix(&self.pending, &Self::TAGS)
            };
            let emit_len = self.pending.len() - retained;
            if emit_len > 0 {
                let text = self.pending[..emit_len].to_string();
                self.pending.drain(..emit_len);
                self.emit_text(text, &mut chunks);
            }
            break;
        }
        chunks
    }

    fn emit_text(&self, text: String, chunks: &mut Vec<ProviderChunk>) {
        if text.is_empty() {
            return;
        }
        chunks.push(match self.mode {
            InlineMode::Text => ProviderChunk::Text(text),
            InlineMode::Reasoning => ProviderChunk::Reasoning(text),
        });
    }
}

fn longest_tag_prefix_suffix(value: &str, tags: &[(&str, InlineMode)]) -> usize {
    let mut best = 0;
    for boundary in value
        .char_indices()
        .map(|(index, _)| index)
        .chain([value.len()])
    {
        let suffix = &value[boundary..];
        if tags.iter().any(|(tag, _)| tag.starts_with(suffix)) {
            best = best.max(suffix.len());
        }
    }
    best
}

#[derive(Default)]
struct PartialStreamTool {
    id: Option<String>,
    name: Option<String>,
    started: bool,
}

#[derive(Default)]
struct OpenAISseDecoder {
    framer: SseFramer,
    inline_reasoning: InlineReasoningParser,
    tools: HashMap<usize, PartialStreamTool>,
    metadata_emitted: bool,
    done_emitted: bool,
}

impl OpenAISseDecoder {
    fn push(&mut self, bytes: &[u8]) -> Result<Vec<ProviderChunk>, OpenAIStreamError> {
        let frames = self.framer.push(bytes)?;
        self.decode_frames(frames)
    }

    fn finish(&mut self) -> Result<Vec<ProviderChunk>, OpenAIStreamError> {
        let frames = self.framer.finish()?;
        let mut chunks = self.decode_frames(frames)?;
        chunks.extend(self.inline_reasoning.finish());
        if !self.done_emitted {
            return Err(OpenAIStreamError::MissingFinish);
        }
        Ok(chunks)
    }

    fn decode_frames(
        &mut self,
        frames: Vec<String>,
    ) -> Result<Vec<ProviderChunk>, OpenAIStreamError> {
        let mut chunks = Vec::new();
        for data in frames {
            if data == "[DONE]" {
                chunks.extend(self.inline_reasoning.finish());
                if !self.done_emitted {
                    chunks.push(ProviderChunk::Done(FinishReason::Stop));
                    self.done_emitted = true;
                }
                continue;
            }
            let payload: Value = serde_json::from_str(&data)?;
            if !self.metadata_emitted {
                let id = payload
                    .get("id")
                    .and_then(Value::as_str)
                    .unwrap_or_default();
                let model = payload
                    .get("model")
                    .and_then(Value::as_str)
                    .unwrap_or_default();
                if !id.is_empty() || !model.is_empty() {
                    chunks.push(ProviderChunk::Metadata {
                        id: id.to_string(),
                        model: model.to_string(),
                    });
                    self.metadata_emitted = true;
                }
            }
            if let Some(usage) = payload.get("usage").filter(|value| !value.is_null()) {
                chunks.push(ProviderChunk::Usage(parse_openai_usage(usage)));
            }
            let Some(choice) = payload
                .get("choices")
                .and_then(Value::as_array)
                .and_then(|choices| choices.first())
            else {
                continue;
            };
            if let Some(delta) = choice.get("delta") {
                if let Some(reasoning) = delta
                    .get("reasoning_content")
                    .and_then(Value::as_str)
                    .or_else(|| delta.get("reasoning").and_then(Value::as_str))
                    .filter(|value| !value.is_empty())
                {
                    chunks.push(ProviderChunk::Reasoning(reasoning.to_string()));
                }
                if let Some(content) = delta
                    .get("content")
                    .and_then(Value::as_str)
                    .filter(|value| !value.is_empty())
                {
                    chunks.extend(self.inline_reasoning.push(content));
                }
                if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) {
                    self.decode_tool_calls(tool_calls, &mut chunks);
                }
            }
            if let Some(reason) = choice.get("finish_reason").and_then(Value::as_str) {
                chunks.extend(self.inline_reasoning.finish());
                self.flush_unstarted_tools(&mut chunks);
                if !self.done_emitted {
                    chunks.push(ProviderChunk::Done(FinishReason::from_provider(reason)));
                    self.done_emitted = true;
                }
            }
        }
        Ok(chunks)
    }

    fn decode_tool_calls(&mut self, calls: &[Value], chunks: &mut Vec<ProviderChunk>) {
        for (fallback_index, call) in calls.iter().enumerate() {
            let index = call
                .get("index")
                .and_then(Value::as_u64)
                .and_then(|value| usize::try_from(value).ok())
                .unwrap_or(fallback_index);
            let tool = self.tools.entry(index).or_default();
            if let Some(id) = call.get("id").and_then(Value::as_str) {
                tool.id = Some(id.to_string());
            }
            let function = call.get("function");
            if let Some(name) = function
                .and_then(|value| value.get("name"))
                .and_then(Value::as_str)
                .filter(|value| !value.is_empty())
            {
                tool.name = Some(name.to_string());
            }
            if !tool.started && tool.name.is_some() {
                chunks.push(ProviderChunk::ToolCallStart {
                    index,
                    id: tool.id.clone(),
                    name: tool.name.clone(),
                });
                tool.started = true;
            }
            if let Some(arguments) = function
                .and_then(|value| value.get("arguments"))
                .and_then(Value::as_str)
                .filter(|value| !value.is_empty())
            {
                chunks.push(ProviderChunk::ToolCallArguments {
                    index,
                    delta: arguments.to_string(),
                });
            }
        }
    }

    fn flush_unstarted_tools(&mut self, chunks: &mut Vec<ProviderChunk>) {
        let mut indexes = self.tools.keys().copied().collect::<Vec<_>>();
        indexes.sort_unstable();
        for index in indexes {
            let tool = self
                .tools
                .get_mut(&index)
                .expect("tool index came from map");
            if !tool.started {
                chunks.push(ProviderChunk::ToolCallStart {
                    index,
                    id: tool.id.clone(),
                    name: tool.name.clone(),
                });
                tool.started = true;
            }
        }
    }
}

fn parse_openai_usage(value: &Value) -> Usage {
    let direct_cached = value.get("cached_tokens").and_then(Value::as_u64);
    let nested_cached = value
        .get("prompt_tokens_details")
        .and_then(|details| details.get("cached_tokens"))
        .and_then(Value::as_u64);
    Usage {
        prompt_tokens: json_u32(value, "prompt_tokens"),
        completion_tokens: json_u32(value, "completion_tokens"),
        total_tokens: json_u32(value, "total_tokens"),
        cached_tokens: nested_cached
            .or(direct_cached)
            .and_then(|tokens| u32::try_from(tokens).ok()),
        cache_read_input_tokens: None,
        cache_creation_input_tokens: None,
    }
}

fn json_u32(value: &Value, key: &str) -> u32 {
    value
        .get(key)
        .and_then(Value::as_u64)
        .and_then(|number| u32::try_from(number).ok())
        .unwrap_or_default()
}

struct OpenAIHttpStream {
    response: reqwest::Response,
    decoder: OpenAISseDecoder,
    pending: VecDeque<ProviderChunk>,
    reached_eof: bool,
}

async fn next_openai_chunk(
    mut state: OpenAIHttpStream,
) -> Result<Option<(ProviderChunk, OpenAIHttpStream)>, DynProviderError> {
    loop {
        if let Some(chunk) = state.pending.pop_front() {
            return Ok(Some((chunk, state)));
        }
        if state.reached_eof {
            return Ok(None);
        }
        match state.response.chunk().await? {
            Some(bytes) => state.pending.extend(state.decoder.push(&bytes)?),
            None => {
                state.pending.extend(state.decoder.finish()?);
                state.reached_eof = true;
            }
        }
    }
}

#[async_trait]
impl LLMProvider for OpenAIProvider {
    async fn stream(
        &self,
        request: ChatCompletionRequest,
    ) -> Result<ProviderStream, DynProviderError> {
        let start = std::time::Instant::now();
        let url = format!("{}/chat/completions", self.base_url);

        let body = self.build_stream_request_body(&request);

        // Debug: Log LLM request summary (only in debug builds)
        #[cfg(debug_assertions)]
        {
            // Log messages summary
            let msg_count = body["messages"].as_array().map(|a| a.len()).unwrap_or(0);
            tracing::debug!(msg_count = msg_count, "LLM request messages count");

            // Log first 20 bytes of base64 images (don't log full base64)
            if let Some(msgs) = body["messages"].as_array() {
                for (i, msg) in msgs.iter().enumerate() {
                    if let Some(content) = msg.get("content").and_then(|c| c.as_array()) {
                        for (j, item) in content.iter().enumerate() {
                            if item.get("type").and_then(|t| t.as_str()) == Some("image_url")
                                && let Some(url_str) = item
                                    .get("image_url")
                                    .and_then(|u| u.get("url"))
                                    .and_then(|v| v.as_str())
                            {
                                let prefix: String = url_str.chars().take(20).collect();
                                tracing::debug!(msg_idx = i, item_idx = j, image_prefix = %prefix, image_url_len = %url_str.len(), "Image in LLM request (first 20 bytes shown)");
                            }
                        }
                    }
                }
            }
        }

        let mut req_builder = self
            .client
            .post(&url)
            .header("Authorization", format!("Bearer {}", self.api_key))
            .header("Content-Type", "application/json");

        for (key, value) in &self.extra_headers {
            req_builder = req_builder.header(key.as_str(), value.as_str());
        }

        let request_summary = super::stream::diagnostic_request_summary(
            &self.model_id,
            body["messages"].as_array().map_or(0, Vec::len),
            body["tools"].as_array().map_or(0, Vec::len),
        );

        let resp = req_builder.json(&body).send().await.inspect_err(|e| {
            let is_timeout = e.is_timeout();
            tracing::error!(
                provider = %self.name,
                model = %self.model_id,
                url = %url,
                timeout = is_timeout,
                error = %e,
                elapsed_ms = %start.elapsed().as_millis(),
                "LLM API request failed"
            );
        })?;

        let status = resp.status();
        if !status.is_success() {
            let text = resp.text().await?;
            let error = format!("API error {}: {}", status, text);
            tracing::error!(
                provider = %self.name,
                model = %self.model_id,
                http_status = %status,
                error = %error,
                elapsed_ms = %start.elapsed().as_millis(),
                "LLM API returned error"
            );
            if let Some(ref storage) = self.storage
                && let Err(e) = storage
                    .append_llm_call(
                        &self.name,
                        &self.model_id,
                        &request_summary,
                        Some(&text),
                        Some(&error),
                        start.elapsed().as_millis() as u64,
                    )
                    .await
            {
                tracing::warn!("failed to persist LLM call: {}", e);
            }
            return Err(error.into());
        }

        let state = OpenAIHttpStream {
            response: resp,
            decoder: OpenAISseDecoder::default(),
            pending: VecDeque::new(),
            reached_eof: false,
        };
        Ok(Box::pin(stream::try_unfold(state, next_openai_chunk)))
    }

    fn ptype(&self) -> &str {
        "openai"
    }

    fn name(&self) -> &str {
        &self.name
    }

    fn model_id(&self) -> &str {
        &self.model_id
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::providers::{Message, ToolCall};

    #[test]
    fn test_build_request_body_includes_assistant_tool_calls() {
        let provider = OpenAIProvider::new(
            "test".to_string(),
            "key".to_string(),
            "https://example.com/v1".to_string(),
            HashMap::new(),
            "gpt-test".to_string(),
            None,
            None,
            HashMap::new(),
        );

        let request = ChatCompletionRequest {
            messages: vec![Message {
                role: "assistant".to_string(),
                content: vec![ContentBlock::text("calling tool")],
                reasoning_content: None,
                provider_state: None,
                tool_call_id: None,
                name: None,
                tool_calls: Some(vec![ToolCall {
                    id: "call_1".to_string(),
                    name: "calculator".to_string(),
                    arguments: json!({"expression": "1+1"}),
                }]),
            }],
            temperature: None,
            max_tokens: None,
            tools: None,
        };

        let body = provider.build_request_body(&request);
        let messages = body["messages"].as_array().unwrap();
        let tool_calls = messages[0]["tool_calls"].as_array().unwrap();

        assert_eq!(tool_calls.len(), 1);
        assert_eq!(tool_calls[0]["id"], "call_1");
        assert_eq!(tool_calls[0]["type"], "function");
        assert_eq!(tool_calls[0]["function"]["name"], "calculator");
        assert_eq!(
            tool_calls[0]["function"]["arguments"],
            "{\"expression\":\"1+1\"}"
        );

        let stream_body = provider.build_stream_request_body(&request);
        assert_eq!(stream_body["stream"], true);
        assert_eq!(stream_body["stream_options"]["include_usage"], true);
    }

    #[test]
    fn tool_images_are_appended_after_the_complete_tool_batch() {
        let messages = vec![
            Message::tool("call_1", "file_read", "first image"),
            Message {
                role: "tool".to_string(),
                content: vec![
                    ContentBlock::text("second image"),
                    ContentBlock::image_url("data:image/png;base64,AAAA"),
                ],
                reasoning_content: None,
                provider_state: None,
                tool_call_id: Some("call_2".to_string()),
                name: Some("file_read".to_string()),
                tool_calls: None,
            },
        ];

        let converted = convert_messages(&messages);

        assert_eq!(converted.len(), 3);
        assert_eq!(converted[0]["role"], "tool");
        assert_eq!(converted[1]["role"], "tool");
        assert_eq!(converted[2]["role"], "user");
        assert_eq!(converted[2]["content"][1]["type"], "image_url");
        assert_eq!(
            converted[2]["content"][1]["image_url"]["url"],
            "data:image/png;base64,AAAA"
        );
        assert_eq!(converted[1]["content"], "second image");
    }

    #[test]
    fn assistant_tools_precede_all_tool_results_and_following_steering() {
        let messages = vec![
            Message {
                role: "assistant".to_string(),
                content: vec![ContentBlock::text("calling tools")],
                reasoning_content: None,
                provider_state: None,
                tool_call_id: None,
                name: None,
                tool_calls: Some(vec![
                    ToolCall {
                        id: "call_1".to_string(),
                        name: "lookup".to_string(),
                        arguments: json!({"q": "one"}),
                    },
                    ToolCall {
                        id: "call_2".to_string(),
                        name: "lookup".to_string(),
                        arguments: json!({"q": "two"}),
                    },
                ]),
            },
            Message::tool("call_1", "lookup", "result"),
            Message::tool("call_2", "lookup", "second result"),
            Message::user("用户补充指令"),
        ];

        let converted = convert_messages(&messages);

        assert_eq!(converted.len(), 4);
        assert_eq!(converted[0]["role"], "assistant");
        assert_eq!(converted[1]["role"], "tool");
        assert_eq!(converted[1]["tool_call_id"], "call_1");
        assert_eq!(converted[2]["role"], "tool");
        assert_eq!(converted[2]["tool_call_id"], "call_2");
        assert_eq!(converted[3]["role"], "user");
        assert_eq!(converted[3]["content"], "用户补充指令");
    }

    #[test]
    fn assistant_images_are_never_serialized_as_native_content_parts() {
        let converted = convert_messages(&[Message {
            role: "assistant".to_string(),
            content: vec![
                ContentBlock::text("screenshot delivered"),
                ContentBlock::image_url("data:image/png;base64,AAAA"),
            ],
            reasoning_content: None,
            provider_state: None,
            tool_call_id: None,
            name: None,
            tool_calls: None,
        }]);

        assert_eq!(converted.len(), 1);
        assert_eq!(converted[0]["role"], "assistant");
        assert_eq!(converted[0]["content"], "screenshot delivered");
    }

    #[tokio::test]
    async fn sse_decoder_handles_byte_boundaries_reasoning_content_and_usage() {
        let input = concat!(
            "data: {\"id\":\"r1\",\"model\":\"m1\",\"choices\":[{\"delta\":{\"reasoning_content\":\"why\",\"content\":\"你好\"},\"finish_reason\":null}]}\r\n\r\n",
            "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
            "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15,\"prompt_tokens_details\":{\"cached_tokens\":3}}}\n\n",
            "data: [DONE]\n\n"
        );
        let mut decoder = OpenAISseDecoder::default();
        let mut chunks = Vec::new();
        for byte in input.as_bytes() {
            chunks.extend(decoder.push(std::slice::from_ref(byte)).unwrap());
        }
        chunks.extend(decoder.finish().unwrap());

        let response = crate::providers::collect_provider_stream(Box::pin(
            futures_util::stream::iter(chunks.into_iter().map(Ok)),
        ))
        .await
        .unwrap();
        assert_eq!(response.id, "r1");
        assert_eq!(response.model, "m1");
        assert_eq!(response.reasoning_content.as_deref(), Some("why"));
        assert_eq!(response.content, "你好");
        assert_eq!(response.usage.total_tokens, 15);
        assert_eq!(response.usage.cached_tokens, Some(3));
    }

    #[test]
    fn inline_reasoning_tags_may_span_sse_chunks() {
        let input = concat!(
            "data: {\"choices\":[{\"delta\":{\"content\":\"<thi\"},\"finish_reason\":null}]}\n\n",
            "data: {\"choices\":[{\"delta\":{\"content\":\"nk>secret</th\"},\"finish_reason\":null}]}\n\n",
            "data: {\"choices\":[{\"delta\":{\"content\":\"ink>answer\"},\"finish_reason\":\"stop\"}]}\n\n",
            "data: [DONE]\n\n"
        );
        let mut decoder = OpenAISseDecoder::default();
        let chunks = decoder.push(input.as_bytes()).unwrap();
        assert_eq!(
            chunks
                .iter()
                .filter_map(|chunk| match chunk {
                    ProviderChunk::Reasoning(value) => Some(value.as_str()),
                    _ => None,
                })
                .collect::<String>(),
            "secret"
        );
        assert_eq!(
            chunks
                .iter()
                .filter_map(|chunk| match chunk {
                    ProviderChunk::Text(value) => Some(value.as_str()),
                    _ => None,
                })
                .collect::<String>(),
            "answer"
        );
    }

    #[test]
    fn reasoning_alias_is_used_when_reasoning_content_is_null() {
        let input = concat!(
            "data: {\"choices\":[{\"delta\":{\"reasoning_content\":null,\"reasoning\":\"alias\"},\"finish_reason\":\"stop\"}]}\n\n",
            "data: [DONE]\n\n"
        );
        let mut decoder = OpenAISseDecoder::default();
        let chunks = decoder.push(input.as_bytes()).unwrap();
        assert!(
            chunks
                .iter()
                .any(|chunk| matches!(chunk, ProviderChunk::Reasoning(value) if value == "alias"))
        );
    }

    #[tokio::test]
    async fn tool_call_arguments_are_assembled_across_sse_events() {
        let input = concat!(
            "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"calculator\",\"arguments\":\"{\\\"expression\\\":\"}}]},\"finish_reason\":null}]}\n\n",
            "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"1+1\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
            "data: [DONE]\n\n"
        );
        let mut decoder = OpenAISseDecoder::default();
        let chunks = decoder.push(input.as_bytes()).unwrap();
        let response = crate::providers::collect_provider_stream(Box::pin(
            futures_util::stream::iter(chunks.into_iter().map(Ok)),
        ))
        .await
        .unwrap();
        assert_eq!(response.tool_calls.len(), 1);
        assert_eq!(response.tool_calls[0].id, "call_1");
        assert_eq!(response.tool_calls[0].name, "calculator");
        assert_eq!(
            response.tool_calls[0].arguments,
            serde_json::json!({"expression":"1+1"})
        );
    }

    #[test]
    fn missing_finish_marker_is_an_error() {
        let mut decoder = OpenAISseDecoder::default();
        decoder
            .push(b"data: {\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n\n")
            .unwrap();
        assert!(matches!(
            decoder.finish(),
            Err(OpenAIStreamError::MissingFinish)
        ));
    }
}
