use std::time::Duration;

use doctate_common::join_url;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{debug, warn};

use super::backend::LlmBackend;

/// Error variants returned by the LLM client (OpenAI-compatible Chat
/// Completions API — Ionos, Azure OpenAI, OpenAI proper, Together.ai, etc.).
///
/// **Security:** `Display` and `Debug` MUST NOT contain the HTTP response body.
/// Provider error bodies can echo back details that include the API key or
/// other sensitive context (e.g. `{"error": "invalid api_key sk-…"}`). Only
/// the status and structured numeric/enum fields (token counts, finish_reason)
/// are safe to log.
#[derive(Debug)]
pub enum LlmError {
    Http(reqwest::Error),
    Status {
        status: u16,
    },
    Parse(&'static str),
    /// Response was 200 OK but `choices[0].message.content` was null/missing.
    /// The structured fields below come from the same response and let the
    /// caller distinguish "model truncated at token cap" (`finish_reason=length`)
    /// from "content filter rejected" or other null-content modes.
    EmptyContent {
        finish_reason: Option<String>,
        prompt_tokens: Option<u32>,
        completion_tokens: Option<u32>,
        reasoning_tokens: Option<u32>,
        max_completion_tokens: u32,
    },
}

/// Diagnostic detail extracted from an `EmptyContent` error. The worker
/// copies these fields into the persisted `FailureDetails` so the UI's
/// "Technische Details" block can show finish_reason and token counts.
#[derive(Debug, Clone)]
pub struct EmptyContentDetails<'a> {
    pub finish_reason: Option<&'a str>,
    pub prompt_tokens: Option<u32>,
    pub completion_tokens: Option<u32>,
    pub reasoning_tokens: Option<u32>,
    pub max_completion_tokens: u32,
}

impl LlmError {
    /// `Some` iff this error is `EmptyContent`, exposing the diagnostic
    /// fields the worker needs to populate the failure-marker UI.
    pub fn empty_content_details(&self) -> Option<EmptyContentDetails<'_>> {
        match self {
            Self::EmptyContent {
                finish_reason,
                prompt_tokens,
                completion_tokens,
                reasoning_tokens,
                max_completion_tokens,
            } => Some(EmptyContentDetails {
                finish_reason: finish_reason.as_deref(),
                prompt_tokens: *prompt_tokens,
                completion_tokens: *completion_tokens,
                reasoning_tokens: *reasoning_tokens,
                max_completion_tokens: *max_completion_tokens,
            }),
            _ => None,
        }
    }
}

impl std::fmt::Display for LlmError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Http(e) => write!(f, "llm http error: {e}"),
            Self::Status { status } => write!(f, "llm returned status {status}"),
            Self::Parse(msg) => write!(f, "llm response parse error: {msg}"),
            Self::EmptyContent {
                finish_reason,
                prompt_tokens,
                completion_tokens,
                reasoning_tokens,
                max_completion_tokens,
            } => {
                let fr = finish_reason.as_deref().unwrap_or("unknown");
                write!(
                    f,
                    "llm returned no content (finish_reason={fr}, completion_tokens={ct}/{cap}",
                    ct = completion_tokens
                        .map(|n| n.to_string())
                        .unwrap_or_else(|| "?".into()),
                    cap = max_completion_tokens,
                )?;
                if let Some(rt) = reasoning_tokens {
                    write!(f, ", reasoning_tokens={rt}")?;
                }
                if let Some(pt) = prompt_tokens {
                    write!(f, ", prompt_tokens={pt}")?;
                }
                write!(f, ")")
            }
        }
    }
}

impl std::error::Error for LlmError {}

#[derive(Serialize)]
struct ChatRequest<'a> {
    model: &'a str,
    temperature: f32,
    #[serde(skip_serializing_if = "Option::is_none")]
    top_p: Option<f32>,
    max_completion_tokens: u32,
    #[serde(skip_serializing_if = "Option::is_none")]
    reasoning_effort: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    response_format: Option<serde_json::Value>,
    stream: bool,
    messages: Vec<Message<'a>>,
}

#[derive(Serialize)]
struct Message<'a> {
    role: &'a str,
    content: &'a str,
}

#[derive(Deserialize)]
struct ChatResponse {
    choices: Vec<Choice>,
    #[serde(default)]
    usage: Option<Usage>,
}

#[derive(Deserialize)]
struct Choice {
    message: ResponseMessage,
    #[serde(default)]
    finish_reason: Option<String>,
}

#[derive(Deserialize)]
struct ResponseMessage {
    content: Option<String>,
}

#[derive(Deserialize, Default)]
struct Usage {
    #[serde(default)]
    prompt_tokens: Option<u32>,
    #[serde(default)]
    completion_tokens: Option<u32>,
    #[serde(default)]
    completion_tokens_details: Option<CompletionTokensDetails>,
}

#[derive(Deserialize, Default)]
struct CompletionTokensDetails {
    #[serde(default)]
    reasoning_tokens: Option<u32>,
}

#[derive(Deserialize)]
struct DocumentEnvelope {
    document: String,
}

/// JSON schema used by `use_json_schema` backends. Forces the model to emit
/// `{"document": "..."}` so decoding stops at the closing brace — the same
/// code path works for gpt-oss, Llama 3.1 (where this also avoids the
/// `<|eot_id|>` leak), and any future schema-aware OpenAI-compatible model.
fn document_schema() -> serde_json::Value {
    json!({
        "type": "json_schema",
        "json_schema": {
            "name": "document",
            "strict": true,
            "schema": {
                "type": "object",
                "properties": {
                    "document": { "type": "string" }
                },
                "required": ["document"],
                "additionalProperties": false
            }
        }
    })
}

/// Single-shot chat completion against the OpenAI-compatible endpoint
/// described by `backend`. Empty `api_key` omits the `Authorization`
/// header (correct for unauthenticated local backends like Ollama).
pub async fn chat_once(
    client: &reqwest::Client,
    backend: &LlmBackend,
    user_content: &str,
) -> Result<String, LlmError> {
    let url = join_url(&backend.url, "/v1/chat/completions");
    debug!(%url, model = %backend.model_id, backend = %backend.id, "calling llm chat completions");

    let response_format = backend.use_json_schema.then(document_schema);
    // Build messages dynamically: an optional second `system` message lets a
    // backend (e.g. Llama 3.1) inject a format hint without touching the
    // shared medical prompt. Backends without `format_instruction` produce
    // exactly the two-message shape used pre-fix — gpt-oss path stays
    // byte-identical.
    let mut messages = Vec::with_capacity(3);
    messages.push(Message {
        role: "system",
        content: &backend.system_prompt,
    });
    if let Some(format) = backend.format_instruction.as_deref() {
        messages.push(Message {
            role: "system",
            content: format,
        });
    }
    messages.push(Message {
        role: "user",
        content: user_content,
    });

    let body = ChatRequest {
        model: &backend.model_id,
        temperature: backend.temperature,
        top_p: backend.top_p,
        max_completion_tokens: backend.max_completion_tokens,
        reasoning_effort: backend.reasoning_effort.as_deref(),
        response_format,
        stream: false,
        messages,
    };

    let timeout = Duration::from_secs(backend.timeout_seconds);
    let mut req = client.post(&url).timeout(timeout).json(&body);
    if !backend.api_key.is_empty() {
        req = req.bearer_auth(&backend.api_key);
    }
    let response = req.send().await.map_err(LlmError::Http)?;

    let status = response.status();
    if !status.is_success() {
        // Body deliberately dropped — see LlmError docstring.
        drop(response);
        return Err(LlmError::Status {
            status: status.as_u16(),
        });
    }

    let parsed: ChatResponse = response.json().await.map_err(LlmError::Http)?;
    let usage = parsed.usage;
    let first = parsed.choices.into_iter().next();
    let (finish_reason, content) = match first {
        Some(c) => (c.finish_reason, c.message.content),
        None => (None, None),
    };

    let content = match content {
        Some(s) => s,
        None => {
            // Pull diagnostic fields out of the response so the caller can
            // surface them in logs and the failure-marker UI. With
            // reasoning models (gpt-oss, o1) the most common cause is
            // `finish_reason=length`: the CoT consumed the budget before
            // the answer started. Without these fields the failure used
            // to read "missing choices[0].message.content" — true but
            // useless.
            let prompt_tokens = usage.as_ref().and_then(|u| u.prompt_tokens);
            let completion_tokens = usage.as_ref().and_then(|u| u.completion_tokens);
            let reasoning_tokens = usage
                .as_ref()
                .and_then(|u| u.completion_tokens_details.as_ref())
                .and_then(|d| d.reasoning_tokens);
            warn!(
                model = %backend.model_id,
                backend = %backend.id,
                finish_reason = ?finish_reason,
                prompt_tokens = ?prompt_tokens,
                completion_tokens = ?completion_tokens,
                reasoning_tokens = ?reasoning_tokens,
                max_completion_tokens = backend.max_completion_tokens,
                "llm returned no content"
            );
            return Err(LlmError::EmptyContent {
                finish_reason,
                prompt_tokens,
                completion_tokens,
                reasoning_tokens,
                max_completion_tokens: backend.max_completion_tokens,
            });
        }
    };

    let text = if backend.use_json_schema {
        let env: DocumentEnvelope = serde_json::from_str(&content)
            .map_err(|_| LlmError::Parse("schema response was not {document: string}"))?;
        env.document
    } else {
        content
    };

    let trimmed = text.trim().to_string();
    if trimmed.is_empty() {
        return Err(LlmError::Parse("empty content after trim"));
    }
    Ok(trimmed)
}
