//! SSE stream generators for mock inference endpoints.
//!
//! These produce the exact wire format that the kigi sampling client expects,
//! validated against the real sampling client.

use axum::response::sse::Event;
use serde_json::json;

use crate::scripted::SseEvent;

/// Generate Anthropic Messages SSE events: one text block streamed as a
/// single delta, terminated by a `message_delta` carrying `stop_reason`.
pub fn messages_api_events(text: &str, model: &str, stop_reason: &str) -> Vec<Event> {
    vec![
        Event::default().data(
            json!({
                "type": "message_start",
                "message": {
                    "id": "msg_test", "type": "message", "role": "assistant",
                    "content": [], "model": model, "stop_reason": null,
                    "usage": {
                        "input_tokens": 10, "output_tokens": 0,
                        "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0
                    }
                }
            })
            .to_string(),
        ),
        Event::default().data(
            json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}})
                .to_string(),
        ),
        Event::default().data(
            json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":text}})
                .to_string(),
        ),
        Event::default().data(json!({"type":"content_block_stop","index":0}).to_string()),
        Event::default().data(
            json!({"type":"message_delta","delta":{"stop_reason":stop_reason},"usage":{"output_tokens":5,"input_tokens":10}})
                .to_string(),
        ),
        Event::default().data(json!({"type":"message_stop"}).to_string()),
    ]
}

/// Generate ChatCompletions SSE events that stream `text` word-by-word
/// (whitespace-collapsing; use [`chat_completion_events_exact`] when the
/// receiver must reconstruct `text` byte-for-byte).
pub fn chat_completion_events(text: &str, model: &str) -> Vec<Event> {
    chat_completion_events_from_deltas(&space_prefixed_deltas(text.split_whitespace()), model)
}

/// Like [`chat_completion_events`] but byte-exact: concatenating the deltas
/// reproduces `text` byte-for-byte (newlines and whitespace runs preserved).
/// Fenced code blocks (mermaid etc.) need their newlines to parse as a block,
/// which `split_whitespace` would destroy.
pub fn chat_completion_events_exact(text: &str, model: &str) -> Vec<Event> {
    chat_completion_events_from_deltas(&chat_completion_deltas(text), model)
}

/// Split `text` into deltas that reconstruct it byte-for-byte: the first
/// carries no leading space; each subsequent one is ` {word}` (split on
/// single spaces only, so newlines/tabs stay inside the words).
fn chat_completion_deltas(text: &str) -> Vec<String> {
    space_prefixed_deltas(text.split(' '))
}

/// Shape words into chat deltas: first word bare, each subsequent one ` {word}`
/// — the source iterator decides collapsing (echo) vs byte-exact (fixed).
fn space_prefixed_deltas<'a>(words: impl Iterator<Item = &'a str>) -> Vec<String> {
    words
        .enumerate()
        .map(|(i, word)| {
            if i == 0 {
                word.to_owned()
            } else {
                format!(" {word}")
            }
        })
        .collect()
}

fn chat_completion_events_from_deltas(deltas: &[String], model: &str) -> Vec<Event> {
    let n = deltas.len();
    let mut events = Vec::new();

    for (i, content) in deltas.iter().enumerate() {
        let finish_reason = if i + 1 == n {
            json!("stop")
        } else {
            json!(null)
        };

        let chunk = if i == 0 {
            json!({
                "id": "chatcmpl-test",
                "object": "chat.completion.chunk",
                "created": 1234567890,
                "model": model,
                "choices": [{
                    "index": 0,
                    "delta": { "role": "assistant", "content": content },
                    "finish_reason": finish_reason
                }]
            })
        } else {
            json!({
                "id": "chatcmpl-test",
                "object": "chat.completion.chunk",
                "created": 1234567890,
                "model": model,
                "choices": [{
                    "index": 0,
                    "delta": { "content": content },
                    "finish_reason": finish_reason
                }]
            })
        };
        events.push(Event::default().data(chunk.to_string()));
    }

    // Usage chunk
    events.push(
        Event::default().data(
            json!({
                "id": "chatcmpl-test",
                "object": "chat.completion.chunk",
                "created": 1234567890,
                "model": model,
                "choices": [],
                "usage": {
                    "prompt_tokens": 10,
                    "completion_tokens": n,
                    "total_tokens": 10 + n
                }
            })
            .to_string(),
        ),
    );
    events.push(Event::default().data("[DONE]"));
    events
}

/// Generate Responses API SSE events that stream `text` word-by-word
/// (whitespace-collapsing; use [`responses_api_events_exact`] when the
/// receiver must reconstruct `text` byte-for-byte).
pub fn responses_api_events(text: &str, model: &str) -> Vec<Event> {
    let deltas: Vec<String> = text
        .split_whitespace()
        .map(|word| format!("{word} "))
        .collect();
    responses_api_events_from_deltas(&deltas, text, model)
}

/// Like [`responses_api_events`] but byte-exact: concatenating the deltas
/// reproduces `text` byte-for-byte (newlines and whitespace runs preserved).
pub fn responses_api_events_exact(text: &str, model: &str) -> Vec<Event> {
    responses_api_events_from_deltas(&responses_api_deltas(text), text, model)
}

/// `split_inclusive(' ')` keeps each chunk's trailing space, so concatenating
/// the chunks reconstructs `text` byte-for-byte (newlines included).
fn responses_api_deltas(text: &str) -> Vec<String> {
    text.split_inclusive(' ').map(str::to_owned).collect()
}

// `deltas` and `text` deliberately disagree in echo mode: collapsed deltas, uncollapsed
// `response.completed` text — inherited load-bearing shell behavior, do not unify.
fn responses_api_events_from_deltas(deltas: &[String], text: &str, model: &str) -> Vec<Event> {
    let mut events = Vec::new();
    let mut seq = 0;

    // response.created
    events.push(
        Event::default().data(
            json!({
                "type": "response.created",
                "sequence_number": seq,
                "response": {
                    "id": "resp_test",
                    "object": "response",
                    "created_at": 1234567890,
                    "model": model,
                    "status": "in_progress",
                    "output": []
                }
            })
            .to_string(),
        ),
    );
    seq += 1;

    // Text deltas
    for chunk in deltas {
        events.push(
            Event::default().data(
                json!({
                    "type": "response.output_text.delta",
                    "sequence_number": seq,
                    "item_id": "item_test",
                    "output_index": 0,
                    "content_index": 0,
                    "delta": chunk
                })
                .to_string(),
            ),
        );
        seq += 1;
    }

    // response.completed
    events.push(
        Event::default().data(
            json!({
                "type": "response.completed",
                "sequence_number": seq,
                "response": {
                    "id": "resp_test",
                    "object": "response",
                    "created_at": 1234567890,
                    "model": model,
                    "status": "completed",
                    "output": [{
                        "type": "message",
                        "id": "msg_test",
                        "role": "assistant",
                        "status": "completed",
                        "content": [{
                            "type": "output_text",
                            "text": text,
                            "annotations": []
                        }]
                    }],
                    "usage": {
                        "input_tokens": 10,
                        "output_tokens": 5,
                        "total_tokens": 15,
                        "input_tokens_details": { "cached_tokens": 0 },
                        "output_tokens_details": { "reasoning_tokens": 0 }
                    }
                }
            })
            .to_string(),
        ),
    );
    events.push(Event::default().data("[DONE]"));
    events
}

/// Generate Responses API SSE events for a reasoning-only completion: the
/// model streams reasoning summary deltas and finishes with a `reasoning`
/// output item but NO message / output-text and no tool call. The shell's
/// collector synthesizes an empty assistant, so the response classifies as
/// `EmptyReason::ReasoningOnly` — the trigger that makes the sampler resample
/// (the model doomloop).
///
/// Returns [`SseEvent`]s (not axum `Event`s) for direct use with
/// [`crate::ScriptedResponse::sse`] / `enqueue_response`: reasoning-only is a
/// scripted scenario, not an echo/fixed response mode, so it is not wired into
/// the `mock_server` mode handlers.
pub fn responses_api_reasoning_only_events(reasoning: &str, model: &str) -> Vec<SseEvent> {
    let mut events = Vec::new();
    let mut seq = 0;

    // response.created
    events.push(SseEvent::data(
        json!({
            "type": "response.created",
            "sequence_number": seq,
            "response": {
                "id": "resp_test",
                "object": "response",
                "created_at": 1234567890,
                "model": model,
                "status": "in_progress",
                "output": []
            }
        })
        .to_string(),
    ));
    seq += 1;

    // Reasoning summary deltas — the only content the model streams.
    for word in reasoning.split_whitespace() {
        events.push(SseEvent::data(
            json!({
                "type": "response.reasoning_summary_text.delta",
                "sequence_number": seq,
                "item_id": "reasoning_item_1",
                "output_index": 0,
                "summary_index": 0,
                "delta": format!("{word} ")
            })
            .to_string(),
        ));
        seq += 1;
    }

    // response.completed: a single `reasoning` output item carrying the full
    // summary and NO message item. `response_to_conversation_items` appends an
    // empty assistant, yielding `[Reasoning, Assistant("")]` → reasoning_only.
    events.push(SseEvent::data(
        json!({
            "type": "response.completed",
            "sequence_number": seq,
            "response": {
                "id": "resp_test",
                "object": "response",
                "created_at": 1234567890,
                "model": model,
                "status": "completed",
                "output": [{
                    "type": "reasoning",
                    "id": "reasoning_item_1",
                    "summary": [{
                        "type": "summary_text",
                        "text": reasoning
                    }],
                    "status": "completed"
                }],
                "usage": {
                    "input_tokens": 10,
                    "output_tokens": 5,
                    "total_tokens": 15,
                    "input_tokens_details": { "cached_tokens": 0 },
                    "output_tokens_details": { "reasoning_tokens": 5 }
                }
            }
        })
        .to_string(),
    ));
    events.push(SseEvent::data("[DONE]"));
    events
}

/// Generate Responses API SSE events for a completion that streams reasoning
/// summary deltas FIRST and then a normal text answer: the shape a
/// reasoning-capable model produces on an ordinary turn. `response.completed`
/// carries both output items (`reasoning` + `message`), so the collector
/// yields `[Reasoning, Assistant(text)]` — a full, non-empty turn.
///
/// Returns [`SseEvent`]s for direct use with [`crate::ScriptedResponse::sse`]
/// / `enqueue_response`, mirroring [`responses_api_reasoning_only_events`].
pub fn responses_api_reasoning_and_text_events(
    reasoning: &str,
    text: &str,
    model: &str,
) -> Vec<SseEvent> {
    let mut events = Vec::new();
    let mut seq = 0;

    // response.created
    events.push(SseEvent::data(
        json!({
            "type": "response.created",
            "sequence_number": seq,
            "response": {
                "id": "resp_test",
                "object": "response",
                "created_at": 1234567890,
                "model": model,
                "status": "in_progress",
                "output": []
            }
        })
        .to_string(),
    ));
    seq += 1;

    // Reasoning summary deltas stream before any answer text.
    for word in reasoning.split_whitespace() {
        events.push(SseEvent::data(
            json!({
                "type": "response.reasoning_summary_text.delta",
                "sequence_number": seq,
                "item_id": "reasoning_item_1",
                "output_index": 0,
                "summary_index": 0,
                "delta": format!("{word} ")
            })
            .to_string(),
        ));
        seq += 1;
    }

    // Then the visible answer.
    for word in text.split_whitespace() {
        events.push(SseEvent::data(
            json!({
                "type": "response.output_text.delta",
                "sequence_number": seq,
                "item_id": "item_test",
                "output_index": 1,
                "content_index": 0,
                "delta": format!("{word} ")
            })
            .to_string(),
        ));
        seq += 1;
    }

    // response.completed with BOTH items: reasoning + the assistant message.
    events.push(SseEvent::data(
        json!({
            "type": "response.completed",
            "sequence_number": seq,
            "response": {
                "id": "resp_test",
                "object": "response",
                "created_at": 1234567890,
                "model": model,
                "status": "completed",
                "output": [
                    {
                        "type": "reasoning",
                        "id": "reasoning_item_1",
                        "summary": [{
                            "type": "summary_text",
                            "text": reasoning
                        }],
                        "status": "completed"
                    },
                    {
                        "type": "message",
                        "id": "msg_test",
                        "role": "assistant",
                        "status": "completed",
                        "content": [{
                            "type": "output_text",
                            "text": text,
                            "annotations": []
                        }]
                    }
                ],
                "usage": {
                    "input_tokens": 10,
                    "output_tokens": 10,
                    "total_tokens": 20,
                    "input_tokens_details": { "cached_tokens": 0 },
                    "output_tokens_details": { "reasoning_tokens": 5 }
                }
            }
        })
        .to_string(),
    ));
    events.push(SseEvent::data("[DONE]"));
    events
}

/// SSE `event:` name and payload `type` of the non-standard doom-loop check
/// event (`kigi_sampling_types::DOOM_LOOP_CHECK_EVENT_TYPE`). Hardcoded
/// like every other wire string in this file; the shell integration tests
/// pin the two spellings against each other by absorbing built frames
/// through the real client.
const DOOM_LOOP_CHECK_EVENT: &str = "response.doom_loop_check";

/// One named `response.doom_loop_check` frame carrying the (cumulative)
/// trigger set, in the inference API's wire shape.
fn doom_loop_check_frame(triggers: &[&str], seq: u64) -> SseEvent {
    SseEvent::with_event(
        DOOM_LOOP_CHECK_EVENT,
        json!({
            "sequence_number": seq,
            "type": DOOM_LOOP_CHECK_EVENT,
            "doom_loop_check": { "triggers": triggers }
        })
        .to_string(),
    )
}

/// Inject `doom_loop_check.triggers` into a turn's terminal
/// `response.completed` object — the single home for terminal-field emission,
/// the dual of the mid-stream [`doom_loop_check_frame`]. Composes over any
/// turn builder (re-serialization may reorder JSON keys; clients and shape
/// tests parse, never byte-compare, these frames). Panics when the turn has
/// no completed frame: every builder emits one, so a miss is a script bug.
fn with_terminal_doom_loop_field(mut events: Vec<SseEvent>, triggers: &[&str]) -> Vec<SseEvent> {
    let patched = events.iter_mut().any(|e| {
        if e.data == "[DONE]" {
            return false;
        }
        let Ok(mut value) = serde_json::from_str::<serde_json::Value>(&e.data) else {
            return false;
        };
        if value["type"] != "response.completed" {
            return false;
        }
        value["response"]["doom_loop_check"] = json!({ "triggers": triggers });
        e.data = value.to_string();
        true
    });
    assert!(
        patched,
        "turn builders always emit a response.completed frame"
    );
    events
}

/// Generate Responses API SSE events for a server-detected doom loop: a
/// reasoning-only stream (the doomed signature — the model loops in its
/// thinking and never answers) followed by named `response.doom_loop_check`
/// frames re-sent with the growing **cumulative** trigger set (one frame per
/// prefix of `triggers`, mirroring how the server re-emits as new triggers
/// appear), and a terminal `response.completed` whose response object carries
/// the full set under `doom_loop_check.triggers`.
///
/// Returns [`SseEvent`]s for direct use with [`crate::ScriptedResponse::sse`]
/// / `enqueue_response`, mirroring [`responses_api_reasoning_only_events`].
pub fn responses_api_doom_loop_check_events(
    triggers: &[&str],
    reasoning: &str,
    model: &str,
) -> Vec<SseEvent> {
    let mut events = responses_api_reasoning_only_events(reasoning, model);
    // Cumulative frames land between the deltas and the terminal event; the
    // frame seq roughly continues the stream (clients never validate it).
    for prefix_len in 1..=triggers.len() {
        let at = events.len() - 2;
        events.insert(
            at,
            doom_loop_check_frame(&triggers[..prefix_len], at as u64),
        );
    }
    with_terminal_doom_loop_field(events, triggers)
}

/// Generate Responses API SSE events for an ordinary reasoning + text turn
/// (mirroring [`responses_api_reasoning_and_text_events`]) whose terminal
/// `response.completed` object carries `doom_loop_check.triggers` with NO
/// mid-stream check frame — the terminal-only copy of the signal.
pub fn responses_api_doom_loop_terminal_only_events(
    triggers: &[&str],
    reasoning: &str,
    text: &str,
    model: &str,
) -> Vec<SseEvent> {
    with_terminal_doom_loop_field(
        responses_api_reasoning_and_text_events(reasoning, text, model),
        triggers,
    )
}

/// Splice ONE named `response.doom_loop_check` frame with an arbitrary
/// `data:` payload — a byte-exact wire fixture or a malformed variant — into
/// an otherwise-normal reasoning + text turn, right after `response.created`.
/// The payload's own `sequence_number` (if any) is its business; clients
/// never validate sequence continuity.
pub fn responses_api_with_doom_loop_frame(
    check_frame_data: &str,
    reasoning: &str,
    text: &str,
    model: &str,
) -> Vec<SseEvent> {
    let mut events = responses_api_reasoning_and_text_events(reasoning, text, model);
    events.insert(
        1,
        SseEvent::with_event(DOOM_LOOP_CHECK_EVENT, check_frame_data),
    );
    events
}

/// Generate Responses API SSE events for a turn that streams reasoning
/// summary deltas FIRST and then issues one `function_call` — the shape a
/// reasoning-capable model produces when it thinks before its first tool
/// call. `response.completed` carries both output items (`reasoning` +
/// `function_call`) and no message, so the collector yields
/// `[Reasoning, ToolCall]` (tool calls keep the turn non-empty — no
/// `EmptyReason::ReasoningOnly` resample).
///
/// Returns [`SseEvent`]s for direct use with [`crate::ScriptedResponse::sse`]
/// / `enqueue_response`, mirroring [`responses_api_reasoning_only_events`].
pub fn responses_api_reasoning_then_tool_call_events(
    reasoning: &str,
    call_id: &str,
    name: &str,
    arguments: &str,
    model: &str,
) -> Vec<SseEvent> {
    let mut events = Vec::new();
    let mut seq = 0;

    // response.created
    events.push(SseEvent::data(
        json!({
            "type": "response.created",
            "sequence_number": seq,
            "response": {
                "id": "resp_test",
                "object": "response",
                "created_at": 1234567890,
                "model": model,
                "status": "in_progress",
                "output": []
            }
        })
        .to_string(),
    ));
    seq += 1;

    // Reasoning summary deltas stream before the tool call.
    for word in reasoning.split_whitespace() {
        events.push(SseEvent::data(
            json!({
                "type": "response.reasoning_summary_text.delta",
                "sequence_number": seq,
                "item_id": "reasoning_item_1",
                "output_index": 0,
                "summary_index": 0,
                "delta": format!("{word} ")
            })
            .to_string(),
        ));
        seq += 1;
    }

    // Then the tool invocation.
    events.push(SseEvent::data(
        json!({
            "type": "response.function_call_arguments.delta",
            "sequence_number": seq,
            "item_id": call_id,
            "output_index": 1,
            "delta": arguments
        })
        .to_string(),
    ));
    seq += 1;

    // response.completed: the reasoning item plus the function_call item.
    events.push(SseEvent::data(
        json!({
            "type": "response.completed",
            "sequence_number": seq,
            "response": {
                "id": "resp_test",
                "object": "response",
                "created_at": 1234567890,
                "model": model,
                "status": "completed",
                "output": [
                    {
                        "type": "reasoning",
                        "id": "reasoning_item_1",
                        "summary": [{
                            "type": "summary_text",
                            "text": reasoning
                        }],
                        "status": "completed"
                    },
                    {
                        "type": "function_call",
                        "call_id": call_id,
                        "name": name,
                        "arguments": arguments
                    }
                ],
                "usage": {
                    "input_tokens": 10,
                    "output_tokens": 20,
                    "total_tokens": 30,
                    "input_tokens_details": { "cached_tokens": 0 },
                    "output_tokens_details": { "reasoning_tokens": 5 }
                }
            }
        })
        .to_string(),
    ));
    events.push(SseEvent::data("[DONE]"));
    events
}

/// Chat Completions twin of [`responses_api_reasoning_then_tool_call_events`]:
/// `reasoning_content` deltas, then one `tool_calls` delta, then a
/// `finish_reason: "tool_calls"` chunk with usage.
pub fn chat_completions_reasoning_then_tool_call_events(
    reasoning: &str,
    call_id: &str,
    name: &str,
    arguments: &str,
    model: &str,
) -> Vec<SseEvent> {
    let mut events = Vec::new();
    for word in reasoning.split_whitespace() {
        events.push(SseEvent::data(
            json!({
                "id": "chatcmpl-test",
                "object": "chat.completion.chunk",
                "created": 1234567890,
                "model": model,
                "choices": [{
                    "index": 0,
                    "delta": { "reasoning_content": format!("{word} ") },
                    "finish_reason": null
                }]
            })
            .to_string(),
        ));
    }
    events.push(SseEvent::data(
        json!({
            "id": "chatcmpl-test",
            "object": "chat.completion.chunk",
            "created": 1234567890,
            "model": model,
            "choices": [{
                "index": 0,
                "delta": {
                    "role": "assistant",
                    "content": null,
                    "tool_calls": [{
                        "index": 0,
                        "id": call_id,
                        "type": "function",
                        "function": { "name": name, "arguments": arguments }
                    }]
                },
                "finish_reason": null
            }]
        })
        .to_string(),
    ));
    events.push(SseEvent::data(
        json!({
            "id": "chatcmpl-test",
            "object": "chat.completion.chunk",
            "created": 1234567890,
            "model": model,
            "choices": [{
                "index": 0,
                "delta": {},
                "finish_reason": "tool_calls"
            }],
            "usage": {
                "prompt_tokens": 10,
                "completion_tokens": 20,
                "total_tokens": 30
            }
        })
        .to_string(),
    ));
    events.push(SseEvent::data("[DONE]"));
    events
}

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

    /// Both byte-exact delta encoders must reconstruct a multi-line response
    /// (incl. a ```mermaid fence) byte-for-byte. This is load-bearing:
    /// `split_whitespace` would collapse the fence's newlines onto one line,
    /// so a client would never parse it as a code block and diagram detection
    /// would silently fail.
    #[test]
    fn deltas_reconstruct_multiline_response_byte_for_byte() {
        let text = "Here is a flow:\n\n```mermaid\nflowchart TD\n  A --> B\n  B --> C\n```\n\nDone rendering.\n";

        assert_eq!(chat_completion_deltas(text).concat(), text);
        assert_eq!(responses_api_deltas(text).concat(), text);

        // The reconstruction preserves the fence as a real, newline-delimited
        // code block (the property diagram detection depends on).
        assert!(
            chat_completion_deltas(text)
                .concat()
                .contains("```mermaid\nflowchart TD\n")
        );
    }

    /// Multiple consecutive spaces and a trailing newline survive too (no
    /// `split_whitespace`-style collapsing).
    #[test]
    fn deltas_preserve_runs_of_whitespace() {
        let text = "a  b\tc\n";
        assert_eq!(chat_completion_deltas(text).concat(), text);
        assert_eq!(responses_api_deltas(text).concat(), text);
    }

    /// Shape guard for the reasoning-only builder: parse each event back to JSON
    /// and assert the structural tags/fields the shell collector keys on — at
    /// least one `response.reasoning_summary_text.delta` carrying text, no
    /// `response.output_text.delta`, and a `response.completed` whose output
    /// holds a `reasoning` item (with summary text) and no `message` item,
    /// terminated by `[DONE]`. A full round-trip through `rs::ResponseStreamEvent`
    /// would pin the async-openai types directly, but that crate is not a
    /// dependency here; the integration test deserializes these events through
    /// the real client, covering the wire contract end-to-end.
    #[test]
    fn reasoning_only_events_carry_reasoning_and_no_output_text() {
        let events = responses_api_reasoning_only_events("alpha beta gamma", "m");
        assert_eq!(events.last().map(|e| e.data.as_str()), Some("[DONE]"));

        // Parse every non-terminal event into JSON and key off the `type` tag.
        let parsed: Vec<serde_json::Value> = events
            .iter()
            .filter(|e| e.data != "[DONE]")
            .map(|e| serde_json::from_str(&e.data).expect("each event is valid JSON"))
            .collect();
        let types: Vec<&str> = parsed
            .iter()
            .map(|v| v["type"].as_str().expect("each event has a type tag"))
            .collect();

        let reasoning_delta = parsed
            .iter()
            .find(|v| v["type"] == "response.reasoning_summary_text.delta")
            .expect("must stream a reasoning summary delta");
        assert!(
            !reasoning_delta["delta"]
                .as_str()
                .unwrap_or_default()
                .is_empty(),
            "the reasoning delta must carry text"
        );
        assert!(
            !types.contains(&"response.output_text.delta"),
            "reasoning-only must not stream output text"
        );

        let completed = parsed
            .iter()
            .find(|v| v["type"] == "response.completed")
            .expect("must emit a completed event");
        let output = completed["response"]["output"]
            .as_array()
            .expect("completed carries an output array");
        let reasoning_item = output
            .iter()
            .find(|o| o["type"] == "reasoning")
            .expect("completed output must carry a reasoning item");
        assert!(
            !reasoning_item["summary"][0]["text"]
                .as_str()
                .unwrap_or_default()
                .is_empty(),
            "the reasoning item must carry summary text"
        );
        assert!(
            !output.iter().any(|o| o["type"] == "message"),
            "completed output must have no message item (no visible text)"
        );
    }

    /// Shape guard for the reasoning+text builder: reasoning summary deltas
    /// stream before the output-text deltas, and `response.completed` carries
    /// BOTH a `reasoning` item and a `message` item, terminated by `[DONE]` —
    /// the ordinary reasoning-model turn (never `EmptyReason::ReasoningOnly`).
    #[test]
    fn reasoning_and_text_events_carry_both_items() {
        let events = responses_api_reasoning_and_text_events("alpha beta", "the answer", "m");
        assert_eq!(events.last().map(|e| e.data.as_str()), Some("[DONE]"));

        let parsed: Vec<serde_json::Value> = events
            .iter()
            .filter(|e| e.data != "[DONE]")
            .map(|e| serde_json::from_str(&e.data).expect("each event is valid JSON"))
            .collect();
        let types: Vec<&str> = parsed
            .iter()
            .map(|v| v["type"].as_str().expect("each event has a type tag"))
            .collect();

        // Reasoning streams strictly before the visible answer.
        let first_reasoning = types
            .iter()
            .position(|t| *t == "response.reasoning_summary_text.delta")
            .expect("must stream reasoning summary deltas");
        let first_text = types
            .iter()
            .position(|t| *t == "response.output_text.delta")
            .expect("must stream output text deltas");
        assert!(
            first_reasoning < first_text,
            "reasoning deltas must precede text deltas"
        );

        let completed = parsed
            .iter()
            .find(|v| v["type"] == "response.completed")
            .expect("must emit a completed event");
        let output = completed["response"]["output"]
            .as_array()
            .expect("completed carries an output array");
        assert_eq!(
            output[0]["summary"][0]["text"].as_str(),
            Some("alpha beta"),
            "completed output must carry the reasoning item first"
        );
        assert_eq!(
            output[1]["content"][0]["text"].as_str(),
            Some("the answer"),
            "completed output must carry the assistant message"
        );
    }

    /// Shape guard for the reasoning+tool-call builder: reasoning summary
    /// deltas stream before the function-call args delta, no output text
    /// anywhere, and `response.completed` carries a `reasoning` item plus a
    /// `function_call` item (no `message`), terminated by `[DONE]` — the
    /// think-then-call turn whose tool call keeps it non-empty.
    #[test]
    fn reasoning_then_tool_call_events_carry_reasoning_and_function_call() {
        let events = responses_api_reasoning_then_tool_call_events(
            "alpha beta",
            "call_1",
            "read_file",
            "{\"target_file\":\"a.rs\"}",
            "m",
        );
        assert_eq!(events.last().map(|e| e.data.as_str()), Some("[DONE]"));

        let parsed: Vec<serde_json::Value> = events
            .iter()
            .filter(|e| e.data != "[DONE]")
            .map(|e| serde_json::from_str(&e.data).expect("each event is valid JSON"))
            .collect();
        let types: Vec<&str> = parsed
            .iter()
            .map(|v| v["type"].as_str().expect("each event has a type tag"))
            .collect();

        // Reasoning streams strictly before the tool invocation; no text.
        let first_reasoning = types
            .iter()
            .position(|t| *t == "response.reasoning_summary_text.delta")
            .expect("must stream reasoning summary deltas");
        let args_delta = types
            .iter()
            .position(|t| *t == "response.function_call_arguments.delta")
            .expect("must stream a function-call arguments delta");
        assert!(
            first_reasoning < args_delta,
            "reasoning deltas must precede the tool call"
        );
        assert!(
            !types.contains(&"response.output_text.delta"),
            "a think-then-call turn must not stream output text"
        );

        let completed = parsed
            .iter()
            .find(|v| v["type"] == "response.completed")
            .expect("must emit a completed event");
        let output = completed["response"]["output"]
            .as_array()
            .expect("completed carries an output array");
        assert_eq!(
            output[0]["summary"][0]["text"].as_str(),
            Some("alpha beta"),
            "completed output must carry the reasoning item first"
        );
        assert_eq!(output[1]["type"].as_str(), Some("function_call"));
        assert_eq!(output[1]["call_id"].as_str(), Some("call_1"));
        assert_eq!(output[1]["name"].as_str(), Some("read_file"));
        assert!(
            !output.iter().any(|o| o["type"] == "message"),
            "completed output must have no message item (no visible text)"
        );
    }

    /// Shape guard for the Chat Completions twin: `reasoning_content` deltas
    /// stream first, then exactly one `tool_calls` delta carrying the call
    /// id/name/arguments, then a `finish_reason: "tool_calls"` chunk, with no
    /// visible `content` anywhere, terminated by `[DONE]`.
    #[test]
    fn chat_reasoning_then_tool_call_events_carry_reasoning_then_tool_call() {
        let events = chat_completions_reasoning_then_tool_call_events(
            "alpha beta",
            "call_1",
            "read_file",
            "{\"target_file\":\"a.rs\"}",
            "m",
        );
        assert_eq!(events.last().map(|e| e.data.as_str()), Some("[DONE]"));

        let parsed: Vec<serde_json::Value> = events
            .iter()
            .filter(|e| e.data != "[DONE]")
            .map(|e| serde_json::from_str(&e.data).expect("each event is valid JSON"))
            .collect();
        let delta_at = |v: &serde_json::Value| v["choices"][0]["delta"].clone();

        let first_reasoning = parsed
            .iter()
            .position(|v| !delta_at(v)["reasoning_content"].is_null())
            .expect("must stream reasoning_content deltas");
        let tool_call = parsed
            .iter()
            .position(|v| !delta_at(v)["tool_calls"].is_null())
            .expect("must stream a tool_calls delta");
        assert!(
            first_reasoning < tool_call,
            "reasoning deltas must precede the tool call"
        );
        let call = delta_at(&parsed[tool_call])["tool_calls"][0].clone();
        assert_eq!(call["id"].as_str(), Some("call_1"));
        assert_eq!(call["function"]["name"].as_str(), Some("read_file"));
        assert!(
            parsed.iter().all(|v| delta_at(v)["content"]
                .as_str()
                .unwrap_or_default()
                .is_empty()),
            "a think-then-call turn must not stream visible content"
        );
        assert!(
            parsed
                .iter()
                .any(|v| v["choices"][0]["finish_reason"] == "tool_calls"),
            "the stream must finish with finish_reason tool_calls"
        );
    }

    /// Shape guard for the doom-loop builder: one NAMED check frame per
    /// cumulative prefix of `triggers` (each frame re-sends every trigger so
    /// far), a reasoning-only output (no message item — the doomed
    /// signature), and the terminal response object carrying the full set.
    #[test]
    fn doom_loop_check_events_send_growing_named_frames_and_terminal_field() {
        let events = responses_api_doom_loop_check_events(
            &["tail_repetition:4@response", "tail_repetition:2@response"],
            "looping thought",
            "m",
        );
        assert_eq!(events.last().map(|e| e.data.as_str()), Some("[DONE]"));

        let frames: Vec<&SseEvent> = events
            .iter()
            .filter(|e| e.event.as_deref() == Some(DOOM_LOOP_CHECK_EVENT))
            .collect();
        assert_eq!(frames.len(), 2, "one frame per cumulative prefix");
        let first: serde_json::Value = serde_json::from_str(&frames[0].data).unwrap();
        assert_eq!(first["type"], DOOM_LOOP_CHECK_EVENT);
        assert!(first["sequence_number"].is_u64());
        assert_eq!(
            first["doom_loop_check"]["triggers"],
            json!(["tail_repetition:4@response"])
        );
        let second: serde_json::Value = serde_json::from_str(&frames[1].data).unwrap();
        assert_eq!(
            second["doom_loop_check"]["triggers"],
            json!(["tail_repetition:4@response", "tail_repetition:2@response"])
        );

        let completed = events
            .iter()
            .filter(|e| e.data != "[DONE]")
            .map(|e| serde_json::from_str::<serde_json::Value>(&e.data).unwrap())
            .find(|v| v["type"] == "response.completed")
            .expect("must emit a completed event");
        assert_eq!(
            completed["response"]["doom_loop_check"]["triggers"],
            json!(["tail_repetition:4@response", "tail_repetition:2@response"])
        );
        let output = completed["response"]["output"].as_array().unwrap();
        assert!(
            !output.iter().any(|o| o["type"] == "message"),
            "a doomed turn is reasoning-only (no message item)"
        );
    }

    /// Shape guard for the terminal-only variant: no named check frame
    /// anywhere; the completed response carries both output items (the turn
    /// is a normal answer) plus `doom_loop_check.triggers`.
    #[test]
    fn doom_loop_terminal_only_events_carry_field_without_mid_stream_frame() {
        let events = responses_api_doom_loop_terminal_only_events(
            &["low_logprob@thinking"],
            "brief thought",
            "the answer",
            "m",
        );
        assert!(
            events.iter().all(|e| e.event.is_none()),
            "terminal-only variant must not emit a named check frame"
        );

        let completed = events
            .iter()
            .filter(|e| e.data != "[DONE]")
            .map(|e| serde_json::from_str::<serde_json::Value>(&e.data).unwrap())
            .find(|v| v["type"] == "response.completed")
            .expect("must emit a completed event");
        assert_eq!(
            completed["response"]["doom_loop_check"]["triggers"],
            json!(["low_logprob@thinking"])
        );
        let output = completed["response"]["output"].as_array().unwrap();
        assert!(output.iter().any(|o| o["type"] == "message"));
        assert!(output.iter().any(|o| o["type"] == "reasoning"));
    }

    /// Shape guard for the splice helper: the named frame lands right after
    /// `response.created` with the caller's payload byte-for-byte (this is
    /// how byte-exact fixtures and malformed variants ride a normal turn).
    #[test]
    fn with_doom_loop_frame_splices_payload_verbatim() {
        let payload = r#"{"type":"response.doom_loop_check","doom_loop_check":{"triggers":42}}"#;
        let events = responses_api_with_doom_loop_frame(payload, "hm", "hi", "m");
        assert_eq!(events[1].event.as_deref(), Some(DOOM_LOOP_CHECK_EVENT));
        assert_eq!(events[1].data, payload);
        let created: serde_json::Value = serde_json::from_str(&events[0].data).unwrap();
        assert_eq!(created["type"], "response.created");
    }
}
