use std::time::Duration;

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

/// 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 a short category string are safe to log.
#[derive(Debug)]
pub enum LlmError {
    /// Transport-level failure (DNS, TLS, connect, timeout). `reqwest::Error`
    /// is intentionally included — its `Display` only reveals URL/method,
    /// not request headers, so the API key stays out of logs.
    Http(reqwest::Error),
    /// Provider returned a non-2xx status. Body is deliberately discarded.
    Status { status: u16 },
    /// Response was 2xx but the expected `choices[0].message.content`
    /// shape was absent or empty.
    Parse(&'static str),
}

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}"),
        }
    }
}

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

/// Grouped provider configuration. Passed by reference to `chat_once`.
pub struct LlmSettings<'a> {
    pub base_url: &'a str,
    pub api_key: &'a str,
    pub model: &'a str,
    pub temperature: f32,
}

#[derive(Serialize)]
struct ChatRequest<'a> {
    model: &'a str,
    temperature: f32,
    stream: bool,
    messages: Vec<Message<'a>>,
}

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

#[derive(Deserialize)]
struct ChatResponse {
    choices: Vec<Choice>,
}

#[derive(Deserialize)]
struct Choice {
    message: ResponseMessage,
}

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

/// Single-shot chat completion against an OpenAI-compatible endpoint.
/// The `api_key` in `settings` is used as a Bearer token; do not log it.
/// An empty `api_key` is treated as "no auth" — the `Authorization` header
/// is omitted entirely, which is the correct mode for Ollama's
/// OpenAI-compatible endpoint (some gateways object to a blank Bearer).
pub async fn chat_once(
    client: &reqwest::Client,
    settings: &LlmSettings<'_>,
    system_prompt: &str,
    user_content: &str,
    timeout: Duration,
) -> Result<String, LlmError> {
    let url = join_url(settings.base_url, "/v1/chat/completions");
    debug!(%url, model = settings.model, "calling llm chat completions");

    let body = ChatRequest {
        model: settings.model,
        temperature: settings.temperature,
        stream: false,
        messages: vec![
            Message {
                role: "system",
                content: system_prompt,
            },
            Message {
                role: "user",
                content: user_content,
            },
        ],
    };

    let mut req = client.post(&url).timeout(timeout).json(&body);
    if !settings.api_key.is_empty() {
        req = req.bearer_auth(settings.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 content = parsed
        .choices
        .into_iter()
        .next()
        .and_then(|c| c.message.content)
        .ok_or(LlmError::Parse("missing choices[0].message.content"))?;

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