//! OpenAI-compatible chat-completions client with SSE streaming, retries and
//! model failover. Works against OpenRouter, OpenAI, Ollama, vLLM, llama.cpp,
//! SGLang — anything speaking the /chat/completions protocol.

use crate::codex_auth::{self, CodexTokens};
use anyhow::{anyhow, bail, Context, Result};
use futures_util::StreamExt;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolCall {
    pub id: String,
    pub name: String,
    /// Raw JSON string of the arguments, as streamed by the API.
    pub arguments: String,
}

#[derive(Debug, Clone)]
pub struct ChatMessage {
    pub role: &'static str, // "system" | "user" | "assistant" | "tool"
    pub content: String,
    pub tool_calls: Vec<ToolCall>,
    pub tool_call_id: Option<String>,
    /// Attached images as `data:` URLs — sent as multimodal content to
    /// vision-capable models (OpenRouter Gemini/Claude/GPT, local llava/vl).
    pub images: Vec<String>,
}

impl ChatMessage {
    pub fn system(content: impl Into<String>) -> Self {
        ChatMessage { role: "system", content: content.into(), tool_calls: vec![], tool_call_id: None, images: vec![] }
    }
    pub fn user(content: impl Into<String>) -> Self {
        ChatMessage { role: "user", content: content.into(), tool_calls: vec![], tool_call_id: None, images: vec![] }
    }
    pub fn user_with_images(content: impl Into<String>, images: Vec<String>) -> Self {
        ChatMessage { role: "user", content: content.into(), tool_calls: vec![], tool_call_id: None, images }
    }
    pub fn assistant(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
        ChatMessage { role: "assistant", content: content.into(), tool_calls, tool_call_id: None, images: vec![] }
    }
    pub fn tool(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
        ChatMessage { role: "tool", content: content.into(), tool_calls: vec![], tool_call_id: Some(tool_call_id.into()), images: vec![] }
    }

    fn to_wire(&self) -> Value {
        let mut m = json!({ "role": self.role, "content": self.content });
        // Multimodal content array when images are attached.
        if !self.images.is_empty() {
            let mut parts: Vec<Value> = Vec::new();
            if !self.content.is_empty() {
                parts.push(json!({ "type": "text", "text": self.content }));
            }
            for url in &self.images {
                parts.push(json!({ "type": "image_url", "image_url": { "url": url } }));
            }
            m["content"] = Value::Array(parts);
        }
        if !self.tool_calls.is_empty() {
            m["tool_calls"] = Value::Array(
                self.tool_calls
                    .iter()
                    .map(|tc| {
                        json!({
                            "id": tc.id,
                            "type": "function",
                            "function": { "name": tc.name, "arguments": tc.arguments }
                        })
                    })
                    .collect(),
            );
        }
        if let Some(id) = &self.tool_call_id {
            m["tool_call_id"] = json!(id);
        }
        m
    }
}

#[derive(Debug, Clone, Copy, Default)]
pub struct Usage {
    pub prompt_tokens: u32,
    pub completion_tokens: u32,
}

#[derive(Debug, Default)]
pub struct TurnOutput {
    pub content: String,
    pub reasoning: String,
    pub tool_calls: Vec<ToolCall>,
    pub finish_reason: String,
    pub usage: Option<Usage>,
    /// Model that actually served the request (after failover).
    pub served_by: String,
}

/// Streaming deltas surfaced to the UI while a response is generating.
#[derive(Debug, Clone)]
pub enum LlmDelta {
    Content(String),
    Reasoning(String),
    /// A tool call started streaming (name arrives before its arguments).
    ToolCallStarted,
}

const THINK_OPEN: [&str; 2] = ["<think>", "<thinking>"];
const THINK_CLOSE: [&str; 2] = ["</think>", "</thinking>"];

/// Earliest `<think>`/`<thinking>`/`</think>`/`</thinking>` in `buf` (case-
/// insensitive) as (byte start, byte len, is_opening).
fn think_find_tag(buf: &str) -> Option<(usize, usize, bool)> {
    let lower = buf.to_ascii_lowercase();
    let mut best: Option<(usize, usize, bool)> = None;
    for (t, is_open) in THINK_OPEN.iter().map(|t| (t, true)).chain(THINK_CLOSE.iter().map(|t| (t, false))) {
        if let Some(p) = lower.find(t) {
            if best.map_or(true, |(bp, ..)| p < bp) {
                best = Some((p, t.len(), is_open));
            }
        }
    }
    best
}

/// Length of the trailing slice of `buf` that is a *proper prefix* of some tag,
/// i.e. the bytes we must hold back in case the tag is split across chunks.
fn think_partial_len(buf: &str) -> usize {
    let lower = buf.to_ascii_lowercase();
    let n = lower.len();
    let upper = 10usize.min(n); // longest tag "</thinking>" is 11 bytes
    for k in (1..=upper).rev() {
        if !buf.is_char_boundary(n - k) {
            continue;
        }
        let suffix = &lower[n - k..];
        if THINK_OPEN.iter().chain(THINK_CLOSE.iter()).any(|t| t.len() > k && t.starts_with(suffix)) {
            return k;
        }
    }
    0
}

/// Streaming splitter that pulls inline `<think>…</think>` reasoning out of the
/// content stream and routes it to reasoning deltas — robust to tags split
/// across chunk boundaries and to a stray closing tag with no opening one.
/// (Models that expose reasoning via the separate `reasoning`/`reasoning_content`
/// field never hit this; it only cleans up models that inline the tags.)
#[derive(Default)]
struct ThinkSplit {
    inside: bool,
    hold: String,
}

impl ThinkSplit {
    fn emit(inside: bool, s: &str, out: &mut TurnOutput, on: &mut impl FnMut(LlmDelta)) {
        if s.is_empty() {
            return;
        }
        if inside {
            out.reasoning.push_str(s);
            on(LlmDelta::Reasoning(s.to_string()));
        } else {
            out.content.push_str(s);
            on(LlmDelta::Content(s.to_string()));
        }
    }

    fn feed(&mut self, text: &str, out: &mut TurnOutput, on: &mut impl FnMut(LlmDelta)) {
        self.hold.push_str(text);
        loop {
            match think_find_tag(&self.hold) {
                Some((start, len, is_open)) => {
                    let before = self.hold[..start].to_string();
                    Self::emit(self.inside, &before, out, on);
                    self.hold.replace_range(..start + len, "");
                    self.inside = is_open; // opening -> reasoning, closing -> content
                }
                None => {
                    let cut = self.hold.len() - think_partial_len(&self.hold);
                    if cut > 0 {
                        let piece = self.hold[..cut].to_string();
                        Self::emit(self.inside, &piece, out, on);
                        self.hold.replace_range(..cut, "");
                    }
                    break;
                }
            }
        }
    }

    /// Flush any held-back partial at end of stream (it never became a tag).
    fn finish(&mut self, out: &mut TurnOutput, on: &mut impl FnMut(LlmDelta)) {
        if !self.hold.is_empty() {
            let s = std::mem::take(&mut self.hold);
            Self::emit(self.inside, &s, out, on);
        }
    }
}

/// Which wire protocol the endpoint speaks.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApiFlavor {
    /// OpenAI-compatible `/chat/completions` (OpenRouter, OpenAI, Ollama, …).
    ChatCompletions,
    /// The Responses API used by the Codex subscription endpoint.
    Responses,
}

pub struct LlmClient {
    http: reqwest::Client,
    pub base_url: String,
    pub api_key: String,
    pub temperature: Option<f64>,
    flavor: ApiFlavor,
    /// Codex OAuth credentials, refreshed in-place when the access token nears
    /// expiry or the endpoint returns 401. Only set for `ApiFlavor::Responses`.
    codex: Option<Arc<Mutex<CodexTokens>>>,
    /// Reasoning effort for the Responses API (low|medium|high|xhigh).
    reasoning_effort: String,
}

#[derive(Debug)]
pub enum LlmError {
    /// Context window exceeded — caller should trigger emergency compaction.
    ContextOverflow(String),
    /// The provider doesn't know this model id (HTTP 404 / not_found).
    ModelNotFound(String),
    /// The provider rejected the credentials (HTTP 401/403).
    Unauthorized(String),
    Other(anyhow::Error),
}

impl std::fmt::Display for LlmError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            LlmError::ContextOverflow(m) => write!(f, "context overflow: {m}"),
            LlmError::ModelNotFound(m) => write!(f, "model not found: {m}"),
            LlmError::Unauthorized(m) => write!(f, "unauthorized: {m}"),
            LlmError::Other(e) => write!(f, "{e:#}"),
        }
    }
}
impl std::error::Error for LlmError {}

fn is_model_not_found(msg: &str) -> bool {
    let l = msg.to_ascii_lowercase();
    l.contains("404")
        || l.contains("not_found_error")
        || l.contains("model not found")
        || l.contains("no endpoints found")
        || l.contains("is not a valid model")
}

fn is_unauthorized(msg: &str) -> bool {
    let l = msg.to_ascii_lowercase();
    l.contains("401") || l.contains("403") || l.contains("invalid api key") || l.contains("no auth credentials")
}

const CONTEXT_OVERFLOW_MARKERS: &[&str] = &[
    "maximum context length",
    "context length exceeded",
    "context window",
    "prompt is too long",
    "prompt too long",
    "input is too long",
    "token limit exceeded",
    "context_length_exceeded",
];

fn is_context_overflow(msg: &str) -> bool {
    let lower = msg.to_ascii_lowercase();
    CONTEXT_OVERFLOW_MARKERS.iter().any(|m| lower.contains(m))
}

impl LlmClient {
    fn build_http() -> reqwest::Client {
        reqwest::Client::builder()
            .connect_timeout(Duration::from_secs(20))
            .timeout(Duration::from_secs(1200))
            .build()
            .expect("reqwest client")
    }

    pub fn new(base_url: &str, api_key: &str, temperature: Option<f64>) -> Self {
        LlmClient {
            http: Self::build_http(),
            base_url: base_url.trim_end_matches('/').to_string(),
            api_key: api_key.to_string(),
            temperature,
            flavor: ApiFlavor::ChatCompletions,
            codex: None,
            reasoning_effort: "medium".to_string(),
        }
    }

    /// Client for the Codex subscription endpoint (Responses API + OAuth token).
    pub fn new_codex(base_url: &str, tokens: CodexTokens, temperature: Option<f64>, effort: &str) -> Self {
        LlmClient {
            http: Self::build_http(),
            base_url: base_url.trim_end_matches('/').to_string(),
            api_key: String::new(),
            temperature,
            flavor: ApiFlavor::Responses,
            codex: Some(Arc::new(Mutex::new(tokens))),
            reasoning_effort: normalize_effort(effort),
        }
    }

    /// Stream one chat completion. Retries transient failures, then walks the
    /// failover model list. Deltas are pushed through `on_delta`.
    pub async fn chat_stream(
        &self,
        models: &[String],
        messages: &[ChatMessage],
        tools: &Value,
        cancel: &CancellationToken,
        mut on_delta: impl FnMut(LlmDelta),
    ) -> std::result::Result<TurnOutput, LlmError> {
        let mut last_err: Option<anyhow::Error> = None;
        for (mi, model) in models.iter().enumerate() {
            let max_attempts = 3;
            for attempt in 1..=max_attempts {
                if cancel.is_cancelled() {
                    return Err(LlmError::Other(anyhow!("cancelled")));
                }
                match self.try_stream(model, messages, tools, cancel, &mut on_delta).await {
                    Ok(mut out) => {
                        out.served_by = model.clone();
                        return Ok(out);
                    }
                    Err(e) => {
                        let msg = format!("{e:#}");
                        if is_context_overflow(&msg) {
                            return Err(LlmError::ContextOverflow(msg));
                        }
                        // A bad model id / missing credentials won't be fixed
                        // by retrying or failover — surface immediately so the
                        // UI can guide recovery.
                        if is_model_not_found(&msg) {
                            return Err(LlmError::ModelNotFound(msg));
                        }
                        if is_unauthorized(&msg) {
                            return Err(LlmError::Unauthorized(msg));
                        }
                        if cancel.is_cancelled() {
                            return Err(LlmError::Other(anyhow!("cancelled")));
                        }
                        let retryable = msg.contains("429")
                            || msg.contains("500")
                            || msg.contains("502")
                            || msg.contains("503")
                            || msg.contains("504")
                            || msg.contains("timed out")
                            || msg.contains("connection")
                            || msg.contains("error sending request")
                            || msg.contains("stream ended unexpectedly");
                        last_err = Some(e);
                        if retryable && attempt < max_attempts {
                            let backoff = Duration::from_millis(800 * 2u64.pow(attempt - 1));
                            tokio::select! {
                                _ = tokio::time::sleep(backoff) => {},
                                _ = cancel.cancelled() => return Err(LlmError::Other(anyhow!("cancelled"))),
                            }
                            continue;
                        }
                        break; // move to next failover model
                    }
                }
            }
            if mi + 1 < models.len() {
                on_delta(LlmDelta::Reasoning(format!(
                    "\n[failover] {} unavailable, trying {}\n",
                    model,
                    models[mi + 1]
                )));
            }
        }
        Err(LlmError::Other(last_err.unwrap_or_else(|| anyhow!("no models configured"))))
    }

    async fn try_stream(
        &self,
        model: &str,
        messages: &[ChatMessage],
        tools: &Value,
        cancel: &CancellationToken,
        on_delta: &mut impl FnMut(LlmDelta),
    ) -> Result<TurnOutput> {
        match self.flavor {
            ApiFlavor::ChatCompletions => self.try_stream_chat(model, messages, tools, cancel, on_delta).await,
            ApiFlavor::Responses => self.try_stream_responses(model, messages, tools, cancel, on_delta).await,
        }
    }

    async fn try_stream_chat(
        &self,
        model: &str,
        messages: &[ChatMessage],
        tools: &Value,
        cancel: &CancellationToken,
        on_delta: &mut impl FnMut(LlmDelta),
    ) -> Result<TurnOutput> {
        let wire: Vec<Value> = messages.iter().map(|m| m.to_wire()).collect();
        let mut body = json!({
            "model": model,
            "messages": wire,
            "stream": true,
            "stream_options": { "include_usage": true },
        });
        if tools.as_array().map(|a| !a.is_empty()).unwrap_or(false) {
            body["tools"] = tools.clone();
            body["tool_choice"] = json!("auto");
        }
        if let Some(t) = self.temperature {
            body["temperature"] = json!(t);
        }

        let url = format!("{}/chat/completions", self.base_url);
        let resp = self
            .http
            .post(&url)
            .bearer_auth(&self.api_key)
            .header("HTTP-Referer", "https://github.com/lucid-train")
            .header("X-Title", "Lucid Train")
            .json(&body)
            .send()
            .await
            .context("error sending request")?;

        let status = resp.status();
        if !status.is_success() {
            let text = resp.text().await.unwrap_or_default();
            // Lucid Cloud gates usage on login + an active plan + credits.
            // Surface those as actionable hints instead of a raw HTTP dump.
            if status.as_u16() == 402 {
                bail!("HTTP 402: no active plan or out of credits — subscribe at lucidtrain.com/pricing (or top up). {}", text.chars().take(300).collect::<String>());
            }
            if status.as_u16() == 401 {
                bail!("HTTP 401: not signed in or token expired — run `lucid-train login`. {}", text.chars().take(300).collect::<String>());
            }
            if let Some(m) = usage_limit_message(status, &text) {
                bail!("{m}");
            }
            bail!("HTTP {status}: {}", text.chars().take(800).collect::<String>());
        }

        let mut out = TurnOutput::default();
        // index -> partial tool call
        let mut partial_calls: Vec<ToolCall> = Vec::new();
        let mut think = ThinkSplit::default();
        let mut buf = String::new();
        let mut stream = resp.bytes_stream();

        loop {
            let chunk = tokio::select! {
                c = stream.next() => c,
                _ = cancel.cancelled() => bail!("cancelled"),
            };
            let Some(chunk) = chunk else { break };
            let bytes = chunk.context("stream read error")?;
            buf.push_str(&String::from_utf8_lossy(&bytes));

            // Process complete SSE lines.
            while let Some(pos) = buf.find('\n') {
                let line: String = buf.drain(..=pos).collect();
                let line = line.trim();
                if line.is_empty() || line.starts_with(':') {
                    continue;
                }
                let Some(data) = line.strip_prefix("data:").map(str::trim) else {
                    continue;
                };
                if data == "[DONE]" {
                    think.finish(&mut out, on_delta);
                    out.tool_calls = finalize_calls(partial_calls);
                    return Ok(out);
                }
                let Ok(v) = serde_json::from_str::<Value>(data) else {
                    continue;
                };
                if let Some(err) = v.get("error") {
                    bail!("API error: {err}");
                }
                if let Some(usage) = v.get("usage").filter(|u| !u.is_null()) {
                    out.usage = Some(Usage {
                        prompt_tokens: usage["prompt_tokens"].as_u64().unwrap_or(0) as u32,
                        completion_tokens: usage["completion_tokens"].as_u64().unwrap_or(0) as u32,
                    });
                }
                let Some(choice) = v["choices"].as_array().and_then(|c| c.first()) else {
                    continue;
                };
                if let Some(fr) = choice["finish_reason"].as_str() {
                    out.finish_reason = fr.to_string();
                }
                let delta = &choice["delta"];
                if let Some(text) = delta["content"].as_str() {
                    if !text.is_empty() {
                        think.feed(text, &mut out, on_delta);
                    }
                }
                // DeepSeek uses reasoning_content; OpenRouter normalizes to reasoning.
                for key in ["reasoning_content", "reasoning"] {
                    if let Some(text) = delta[key].as_str() {
                        if !text.is_empty() {
                            out.reasoning.push_str(text);
                            on_delta(LlmDelta::Reasoning(text.to_string()));
                        }
                    }
                }
                if let Some(calls) = delta["tool_calls"].as_array() {
                    for tc in calls {
                        let idx = tc["index"].as_u64().unwrap_or(0) as usize;
                        while partial_calls.len() <= idx {
                            partial_calls.push(ToolCall::default());
                        }
                        let slot = &mut partial_calls[idx];
                        if let Some(id) = tc["id"].as_str() {
                            slot.id = id.to_string();
                        }
                        if let Some(name) = tc["function"]["name"].as_str() {
                            if slot.name.is_empty() {
                                on_delta(LlmDelta::ToolCallStarted);
                            }
                            slot.name.push_str(name);
                        }
                        if let Some(args) = tc["function"]["arguments"].as_str() {
                            slot.arguments.push_str(args);
                        }
                    }
                }
            }
        }

        think.finish(&mut out, on_delta);
        // Stream ended without [DONE]; accept what we have if non-empty.
        if out.content.is_empty() && partial_calls.is_empty() && out.reasoning.is_empty() {
            bail!("stream ended unexpectedly");
        }
        out.tool_calls = finalize_calls(partial_calls);
        Ok(out)
    }

    // ---------- Codex / Responses API ----------

    /// Current Codex access token + account id, refreshing first if it's near
    /// expiry. Errors if this isn't a Codex client.
    async fn codex_credentials(&self) -> Result<(String, String)> {
        let arc = self.codex.as_ref().ok_or_else(|| anyhow!("not a codex client"))?;
        let mut guard = arc.lock().await;
        if codex_auth::is_expired(&guard) {
            // Adopt any fresher token the Codex CLI wrote before spending ours.
            codex_auth::reload(&mut guard);
            if codex_auth::is_expired(&guard) {
                codex_auth::refresh(&mut guard, &self.http).await?;
            }
        }
        Ok((guard.access_token.clone(), guard.account_id.clone()))
    }

    /// Force a token refresh (used after a 401) and return the new credentials.
    async fn codex_force_refresh(&self) -> Result<(String, String)> {
        let arc = self.codex.as_ref().ok_or_else(|| anyhow!("not a codex client"))?;
        let mut guard = arc.lock().await;
        let dead = guard.access_token.clone();
        // The Codex CLI may have already refreshed — adopt a fresher on-disk
        // token instead of spending our (single-use) refresh token again.
        codex_auth::reload(&mut guard);
        if guard.access_token != dead && !codex_auth::is_expired(&guard) {
            return Ok((guard.access_token.clone(), guard.account_id.clone()));
        }
        codex_auth::refresh(&mut guard, &self.http).await?;
        Ok((guard.access_token.clone(), guard.account_id.clone()))
    }

    fn send_responses(
        &self,
        access: &str,
        account: &str,
        body: &Value,
    ) -> reqwest::RequestBuilder {
        let url = format!("{}/responses", self.base_url);
        let mut req = self
            .http
            .post(&url)
            .bearer_auth(access)
            .header("OpenAI-Beta", "responses=experimental")
            .header("originator", "codex_cli_rs")
            .header("User-Agent", concat!("codex_cli_rs/", env!("CARGO_PKG_VERSION")))
            .header("Accept", "text/event-stream")
            .header("session_id", codex_auth::session_id())
            .json(body);
        if !account.is_empty() {
            req = req.header("chatgpt-account-id", account);
        }
        req
    }

    /// Stream one turn through the Codex subscription Responses endpoint,
    /// translating its event stream into the same `TurnOutput`/`LlmDelta`
    /// shapes the rest of the app consumes.
    async fn try_stream_responses(
        &self,
        model: &str,
        messages: &[ChatMessage],
        tools: &Value,
        cancel: &CancellationToken,
        on_delta: &mut impl FnMut(LlmDelta),
    ) -> Result<TurnOutput> {
        let body = build_responses_body(model, messages, tools, self.temperature, &self.reasoning_effort);

        // Tag credential/refresh failures as 401 so the caller treats them as a
        // lapsed session (→ Codex re-auth flow), never an OpenRouter key prompt.
        let (mut access, mut account) = self
            .codex_credentials()
            .await
            .map_err(|e| anyhow!("401 Unauthorized: Codex session unusable: {e:#}"))?;
        let mut resp = self
            .send_responses(&access, &account, &body)
            .send()
            .await
            .context("error sending request")?;
        // Reactive refresh: a 401 means the token died mid-flight; refresh once.
        if resp.status().as_u16() == 401 {
            let (a, c) = self
                .codex_force_refresh()
                .await
                .map_err(|e| anyhow!("401 Unauthorized: Codex token refresh failed: {e:#}"))?;
            access = a;
            account = c;
            resp = self
                .send_responses(&access, &account, &body)
                .send()
                .await
                .context("error sending request")?;
        }

        let status = resp.status();
        if !status.is_success() {
            let text = resp.text().await.unwrap_or_default();
            if let Some(m) = usage_limit_message(status, &text) {
                bail!("{m}");
            }
            bail!("HTTP {status}: {}", text.chars().take(800).collect::<String>());
        }

        let mut out = TurnOutput::default();
        let mut think = ThinkSplit::default();
        // (item_id -> partial function call) preserving stream order.
        let mut calls: Vec<(String, ToolCall)> = Vec::new();
        let mut buf = String::new();
        let mut stream = resp.bytes_stream();

        loop {
            let chunk = tokio::select! {
                c = stream.next() => c,
                _ = cancel.cancelled() => bail!("cancelled"),
            };
            let Some(chunk) = chunk else { break };
            let bytes = chunk.context("stream read error")?;
            buf.push_str(&String::from_utf8_lossy(&bytes));

            while let Some(pos) = buf.find('\n') {
                let line: String = buf.drain(..=pos).collect();
                let line = line.trim();
                if line.is_empty() || line.starts_with(':') {
                    continue;
                }
                // The Responses stream sends `event:` and `data:` lines; the
                // JSON payload carries its own `type`, so we key off that.
                let Some(data) = line.strip_prefix("data:").map(str::trim) else {
                    continue;
                };
                if data == "[DONE]" {
                    break;
                }
                let Ok(v) = serde_json::from_str::<Value>(data) else {
                    continue;
                };
                if handle_responses_event(&v, &mut out, &mut think, &mut calls, on_delta)? {
                    // `response.completed` — finalize and return.
                    think.finish(&mut out, on_delta);
                    out.tool_calls = finalize_calls(calls.into_iter().map(|(_, c)| c).collect());
                    out.finish_reason =
                        if out.tool_calls.is_empty() { "stop".into() } else { "tool_calls".into() };
                    return Ok(out);
                }
            }
        }

        think.finish(&mut out, on_delta);
        out.tool_calls = finalize_calls(calls.into_iter().map(|(_, c)| c).collect());
        if out.content.is_empty() && out.tool_calls.is_empty() && out.reasoning.is_empty() {
            bail!("stream ended unexpectedly");
        }
        Ok(out)
    }

    /// One-shot non-streaming completion (used for compaction summaries).
    pub async fn complete_once(&self, model: &str, messages: &[ChatMessage]) -> Result<String> {
        // Codex has no chat/completions endpoint — collect a streamed turn.
        if self.flavor == ApiFlavor::Responses {
            let cancel = CancellationToken::new();
            let mut sink = |_: LlmDelta| {};
            let out = self.try_stream_responses(model, messages, &json!([]), &cancel, &mut sink).await?;
            return Ok(out.content);
        }
        let wire: Vec<Value> = messages.iter().map(|m| m.to_wire()).collect();
        let body = json!({ "model": model, "messages": wire, "stream": false });
        let url = format!("{}/chat/completions", self.base_url);
        let resp = self
            .http
            .post(&url)
            .bearer_auth(&self.api_key)
            .json(&body)
            .send()
            .await?;
        let status = resp.status();
        let v: Value = resp.json().await?;
        if !status.is_success() {
            if let Some(m) = usage_limit_message(status, &v.to_string()) {
                bail!("{m}");
            }
            bail!("HTTP {status}: {v}");
        }
        Ok(v["choices"][0]["message"]["content"].as_str().unwrap_or_default().to_string())
    }
}

/// Apply one Responses-API stream event to the accumulating turn state.
/// Returns `Ok(true)` when the turn is complete (`response.completed`),
/// `Ok(false)` to keep reading, or an error on `response.failed`/`error`.
fn handle_responses_event(
    v: &Value,
    out: &mut TurnOutput,
    think: &mut ThinkSplit,
    calls: &mut Vec<(String, ToolCall)>,
    on_delta: &mut impl FnMut(LlmDelta),
) -> Result<bool> {
    match v["type"].as_str().unwrap_or("") {
        "response.output_text.delta" => {
            if let Some(t) = v["delta"].as_str() {
                if !t.is_empty() {
                    think.feed(t, out, on_delta);
                }
            }
        }
        "response.reasoning_summary_text.delta" | "response.reasoning_text.delta" => {
            if let Some(t) = v["delta"].as_str() {
                if !t.is_empty() {
                    out.reasoning.push_str(t);
                    on_delta(LlmDelta::Reasoning(t.to_string()));
                }
            }
        }
        "response.output_item.added" => {
            let item = &v["item"];
            if item["type"] == "function_call" {
                let id = item["id"].as_str().unwrap_or("").to_string();
                on_delta(LlmDelta::ToolCallStarted);
                calls.push((
                    id,
                    ToolCall {
                        id: item["call_id"].as_str().unwrap_or("").to_string(),
                        name: item["name"].as_str().unwrap_or("").to_string(),
                        arguments: String::new(),
                    },
                ));
            }
        }
        "response.function_call_arguments.delta" => {
            let item_id = v["item_id"].as_str().unwrap_or("");
            if let Some(d) = v["delta"].as_str() {
                if let Some((_, c)) = calls.iter_mut().find(|(id, _)| id == item_id) {
                    c.arguments.push_str(d);
                }
            }
        }
        "response.output_item.done" => {
            let item = &v["item"];
            if item["type"] == "function_call" {
                let id = item["id"].as_str().unwrap_or("");
                let call_id = item["call_id"].as_str().unwrap_or("").to_string();
                let name = item["name"].as_str().unwrap_or("").to_string();
                // The done event carries the authoritative arguments.
                let args = item["arguments"].as_str().unwrap_or("").to_string();
                if let Some((_, c)) = calls.iter_mut().find(|(cid, _)| cid == id) {
                    if !call_id.is_empty() {
                        c.id = call_id;
                    }
                    if !name.is_empty() {
                        c.name = name;
                    }
                    if !args.is_empty() {
                        c.arguments = args;
                    }
                } else {
                    calls.push((id.to_string(), ToolCall { id: call_id, name, arguments: args }));
                }
            }
        }
        "response.completed" => {
            let u = &v["response"]["usage"];
            if u.is_object() {
                out.usage = Some(Usage {
                    prompt_tokens: u["input_tokens"].as_u64().unwrap_or(0) as u32,
                    completion_tokens: u["output_tokens"].as_u64().unwrap_or(0) as u32,
                });
            }
            return Ok(true);
        }
        "response.failed" => {
            bail!("API error: {}", v["response"]["error"]);
        }
        "error" | "response.error" => {
            bail!("API error: {}", v["message"].as_str().unwrap_or(&v.to_string()));
        }
        _ => {}
    }
    Ok(false)
}

/// Clamp a reasoning-effort string to a value GPT-5.x accepts. "minimal" is
/// explicitly rejected by GPT-5.x ("minimal configs are not supported"), and an
/// empty/unknown value falls back to a safe default.
pub fn normalize_effort(effort: &str) -> String {
    match effort.trim().to_ascii_lowercase().as_str() {
        "low" => "low",
        "high" => "high",
        "xhigh" | "extra-high" | "max" => "xhigh",
        _ => "medium", // medium, minimal (unsupported), empty, or anything else
    }
    .to_string()
}

/// Build a Responses-API request body from chat-style messages: system text
/// becomes `instructions`, the rest become `input` items, and tool schemas are
/// flattened from the chat-completions `{function:{…}}` wrapper.
fn build_responses_body(
    model: &str,
    messages: &[ChatMessage],
    tools: &Value,
    temperature: Option<f64>,
    effort: &str,
) -> Value {
    let mut instructions = String::new();
    let mut input: Vec<Value> = Vec::new();
    for m in messages {
        match m.role {
            "system" => {
                if !instructions.is_empty() {
                    instructions.push_str("\n\n");
                }
                instructions.push_str(&m.content);
            }
            "user" => {
                let mut content: Vec<Value> = Vec::new();
                if !m.content.is_empty() {
                    content.push(json!({ "type": "input_text", "text": m.content }));
                }
                for url in &m.images {
                    content.push(json!({ "type": "input_image", "image_url": url }));
                }
                if content.is_empty() {
                    content.push(json!({ "type": "input_text", "text": "" }));
                }
                input.push(json!({ "type": "message", "role": "user", "content": content }));
            }
            "assistant" => {
                if !m.content.is_empty() {
                    input.push(json!({
                        "type": "message",
                        "role": "assistant",
                        "content": [{ "type": "output_text", "text": m.content }],
                    }));
                }
                for tc in &m.tool_calls {
                    input.push(json!({
                        "type": "function_call",
                        "call_id": tc.id,
                        "name": tc.name,
                        "arguments": tc.arguments,
                    }));
                }
            }
            "tool" => {
                input.push(json!({
                    "type": "function_call_output",
                    "call_id": m.tool_call_id.clone().unwrap_or_default(),
                    "output": m.content,
                }));
            }
            _ => {}
        }
    }

    let mut body = json!({
        "model": model,
        "instructions": instructions,
        "input": input,
        "stream": true,
        "store": false,
        "parallel_tool_calls": true,
        "reasoning": { "effort": normalize_effort(effort), "summary": "auto" },
        "include": [],
    });

    if let Some(arr) = tools.as_array() {
        let conv: Vec<Value> = arr
            .iter()
            .filter_map(|t| {
                let f = &t["function"];
                if !f.is_object() {
                    return None;
                }
                Some(json!({
                    "type": "function",
                    "name": f["name"],
                    "description": f["description"],
                    "parameters": f["parameters"],
                }))
            })
            .collect();
        if !conv.is_empty() {
            body["tools"] = json!(conv);
            body["tool_choice"] = json!("auto");
        }
    }
    if let Some(t) = temperature {
        body["temperature"] = json!(t);
    }
    body
}

fn finalize_calls(calls: Vec<ToolCall>) -> Vec<ToolCall> {
    calls
        .into_iter()
        .filter(|c| !c.name.is_empty())
        .enumerate()
        .map(|(i, mut c)| {
            if c.id.is_empty() {
                c.id = format!("call_{i}");
            }
            c
        })
        .collect()
}

/// Render a `secs`-second window as a coarse "~N days / hours / minutes" string.
fn human_duration(secs: u64) -> String {
    let plural = |n: u64, unit: &str| format!("{n} {unit}{}", if n == 1 { "" } else { "s" });
    if secs >= 86_400 {
        plural(secs / 86_400, "day")
    } else if secs >= 3_600 {
        plural(secs / 3_600, "hour")
    } else {
        plural((secs / 60).max(1), "minute")
    }
}

/// Recognise the ChatGPT/Codex `usage_limit_reached` (HTTP 429) error body and
/// turn it into a concise, actionable note with the reset window, instead of
/// dumping raw JSON at the user. Returns `None` for unrelated errors so callers
/// fall back to their normal HTTP message.
fn usage_limit_message(status: reqwest::StatusCode, body: &str) -> Option<String> {
    // Only the rate-limit code, or a body that explicitly names the usage limit.
    if status.as_u16() != 429 && !body.contains("usage_limit_reached") {
        return None;
    }
    let v: Value = serde_json::from_str(body).ok()?;
    let err = &v["error"];
    if err["type"].as_str() != Some("usage_limit_reached") && status.as_u16() != 429 {
        return None;
    }
    // Prefer the server-provided countdown; else derive it from resets_at (epoch).
    let secs = err["resets_in_seconds"].as_u64().or_else(|| {
        let at = err["resets_at"].as_u64()?;
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .ok()?
            .as_secs();
        Some(at.saturating_sub(now))
    });
    let window = secs
        .map(|s| format!(" — resets in ~{}", human_duration(s)))
        .unwrap_or_default();
    Some(format!(
        "ChatGPT/Codex usage limit reached{window}. Switch model with /model (e.g. a local Ollama model) or add an OpenRouter key with /apikey."
    ))
}

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

    #[test]
    fn usage_limit_message_is_friendly() {
        // The real body ChatGPT/Codex returns on 429 (resets_in_seconds ≈ 27 days).
        let body = r#"{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached","plan_type":"enterprise_cbp_usage_based","resets_at":1785542399,"resets_in_seconds":2337146}}"#;
        let m = usage_limit_message(reqwest::StatusCode::TOO_MANY_REQUESTS, body)
            .expect("usage-limit body should produce a friendly message");
        assert!(m.contains("usage limit reached"), "got: {m}");
        assert!(m.contains("resets in ~27 days"), "reset window wrong: {m}");
        assert!(m.contains("/model") && m.contains("/apikey"), "missing next steps: {m}");
        assert!(!m.contains('{'), "must not leak raw JSON: {m}");
    }

    #[test]
    fn non_usage_errors_pass_through() {
        assert!(usage_limit_message(reqwest::StatusCode::INTERNAL_SERVER_ERROR, "boom").is_none());
        // A 429 without a parseable body still yields a generic friendly note.
        assert!(usage_limit_message(reqwest::StatusCode::TOO_MANY_REQUESTS, "{}").is_some());
    }

    #[test]
    fn human_duration_units() {
        assert_eq!(human_duration(2_337_146), "27 days");
        assert_eq!(human_duration(7_200), "2 hours");
        assert_eq!(human_duration(60), "1 minute");
    }
}

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

    fn run(chunks: &[&str]) -> (String, String) {
        let mut split = ThinkSplit::default();
        let mut out = TurnOutput::default();
        let mut on = |_: LlmDelta| {};
        for c in chunks {
            split.feed(c, &mut out, &mut on);
        }
        split.finish(&mut out, &mut on);
        (out.content, out.reasoning)
    }

    #[test]
    fn inline_think_routed_to_reasoning() {
        let (c, r) = run(&["<think>planning</think>Hello world"]);
        assert_eq!(c, "Hello world");
        assert_eq!(r, "planning");
    }

    #[test]
    fn tag_split_across_chunks_never_leaks() {
        let (c, r) = run(&["<think>abc", "</thi", "nk>", "answer"]);
        assert_eq!(c, "answer");
        assert_eq!(r, "abc");
        assert!(!c.contains("think"));
    }

    #[test]
    fn stray_closing_tag_dropped() {
        let (c, _) = run(&["</think>just the answer"]);
        assert_eq!(c, "just the answer");
        assert!(!c.contains("think"));
    }

    #[test]
    fn stray_closing_tag_split_dropped() {
        let (c, _) = run(&["</thi", "nk> answer"]);
        assert_eq!(c, " answer");
        assert!(!c.contains("think"));
    }

    #[test]
    fn less_than_in_content_preserved() {
        let (c, _) = run(&["1 < 2 and 3 > 2"]);
        assert_eq!(c, "1 < 2 and 3 > 2");
    }

    #[test]
    fn trailing_lt_then_text() {
        let (c, _) = run(&["price <", " $5"]);
        assert_eq!(c, "price < $5");
    }

    #[test]
    fn thinking_variant_case_insensitive() {
        let (c, r) = run(&["<Thinking>deep</Thinking>done"]);
        assert_eq!(c, "done");
        assert_eq!(r, "deep");
    }

    #[test]
    fn plain_content_unchanged() {
        let (c, r) = run(&["just text"]);
        assert_eq!(c, "just text");
        assert!(r.is_empty());
    }

    #[test]
    fn incomplete_tag_at_end_flushed() {
        let (c, _) = run(&["hi <th"]);
        assert_eq!(c, "hi <th");
    }
}

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

    fn collect(events: &[Value]) -> (TurnOutput, Vec<LlmDelta>) {
        let mut out = TurnOutput::default();
        let mut think = ThinkSplit::default();
        let mut calls: Vec<(String, ToolCall)> = Vec::new();
        let mut deltas = Vec::new();
        let mut done = false;
        for v in events {
            done = handle_responses_event(v, &mut out, &mut think, &mut calls, &mut |d| deltas.push(d)).unwrap();
            if done {
                break;
            }
        }
        out.tool_calls = finalize_calls(calls.into_iter().map(|(_, c)| c).collect());
        if done {
            out.finish_reason =
                if out.tool_calls.is_empty() { "stop".into() } else { "tool_calls".into() };
        }
        (out, deltas)
    }

    #[test]
    fn parses_text_reasoning_and_usage() {
        let (out, deltas) = collect(&[
            json!({"type":"response.reasoning_summary_text.delta","delta":"thinking…"}),
            json!({"type":"response.output_text.delta","delta":"Hello "}),
            json!({"type":"response.output_text.delta","delta":"world"}),
            json!({"type":"response.completed","response":{"usage":{"input_tokens":12,"output_tokens":3}}}),
        ]);
        assert_eq!(out.content, "Hello world");
        assert_eq!(out.reasoning, "thinking…");
        assert_eq!(out.finish_reason, "stop");
        let u = out.usage.unwrap();
        assert_eq!((u.prompt_tokens, u.completion_tokens), (12, 3));
        assert!(deltas.iter().any(|d| matches!(d, LlmDelta::Content(_))));
        assert!(deltas.iter().any(|d| matches!(d, LlmDelta::Reasoning(_))));
    }

    #[test]
    fn parses_function_call_via_added_deltas_and_done() {
        let (out, deltas) = collect(&[
            json!({"type":"response.output_item.added","item":{"type":"function_call","id":"fc_1","call_id":"call_abc","name":"shell"}}),
            json!({"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"{\"cmd\":"}),
            json!({"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"\"ls\"}"}),
            json!({"type":"response.output_item.done","item":{"type":"function_call","id":"fc_1","call_id":"call_abc","name":"shell","arguments":"{\"cmd\":\"ls\"}"}}),
            json!({"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}),
        ]);
        assert_eq!(out.tool_calls.len(), 1);
        let tc = &out.tool_calls[0];
        assert_eq!(tc.id, "call_abc");
        assert_eq!(tc.name, "shell");
        assert_eq!(tc.arguments, "{\"cmd\":\"ls\"}");
        assert_eq!(out.finish_reason, "tool_calls");
        assert!(deltas.iter().any(|d| matches!(d, LlmDelta::ToolCallStarted)));
    }

    #[test]
    fn response_failed_is_an_error() {
        let mut out = TurnOutput::default();
        let mut think = ThinkSplit::default();
        let mut calls = Vec::new();
        let r = handle_responses_event(
            &json!({"type":"response.failed","response":{"error":{"message":"boom"}}}),
            &mut out,
            &mut think,
            &mut calls,
            &mut |_| {},
        );
        assert!(r.is_err());
    }

    #[test]
    fn build_body_maps_system_messages_and_tools() {
        let msgs = vec![
            ChatMessage::system("be brief"),
            ChatMessage::user("hi"),
            ChatMessage::assistant("", vec![ToolCall { id: "call_1".into(), name: "shell".into(), arguments: "{}".into() }]),
            ChatMessage::tool("call_1", "output here"),
        ];
        let tools = json!([{"type":"function","function":{"name":"shell","description":"run","parameters":{"type":"object"}}}]);
        let body = build_responses_body("gpt-5.5", &msgs, &tools, None, "medium");

        assert_eq!(body["model"], "gpt-5.5");
        assert_eq!(body["instructions"], "be brief");
        assert_eq!(body["stream"], true);
        // System message must NOT appear as an input item.
        let input = body["input"].as_array().unwrap();
        assert_eq!(input[0]["role"], "user");
        assert_eq!(input[0]["content"][0]["type"], "input_text");
        // assistant tool call -> function_call item; tool result -> output item.
        assert!(input.iter().any(|i| i["type"] == "function_call" && i["call_id"] == "call_1"));
        assert!(input.iter().any(|i| i["type"] == "function_call_output" && i["output"] == "output here"));
        // Tool schema flattened (no nested `function`).
        assert_eq!(body["tools"][0]["name"], "shell");
        assert_eq!(body["tools"][0]["type"], "function");
        assert_eq!(body["tool_choice"], "auto");
        assert_eq!(body["reasoning"]["effort"], "medium");
    }

    #[test]
    fn effort_is_normalized_and_never_minimal() {
        assert_eq!(normalize_effort("low"), "low");
        assert_eq!(normalize_effort("HIGH"), "high");
        assert_eq!(normalize_effort("xhigh"), "xhigh");
        assert_eq!(normalize_effort("medium"), "medium");
        // GPT-5.x rejects minimal — and unknown/empty all fall back to medium.
        assert_eq!(normalize_effort("minimal"), "medium");
        assert_eq!(normalize_effort(""), "medium");
        assert_eq!(normalize_effort("bogus"), "medium");
        // The body must carry the clamped value, not the raw input.
        let body = build_responses_body("gpt-5.5", &[], &json!([]), None, "minimal");
        assert_eq!(body["reasoning"]["effort"], "medium");
    }
}
