use std::time::Duration;

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

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 a short category string are safe to log.
#[derive(Debug)]
pub enum LlmError {
    Http(reqwest::Error),
    Status { status: u16 },
    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 {}

#[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>,
}

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

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

#[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 content = parsed
        .choices
        .into_iter()
        .next()
        .and_then(|c| c.message.content)
        .ok_or(LlmError::Parse("missing choices[0].message.content"))?;

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