//! OpenAI-compatible backend — `POST /v1/chat/completions`.
//!
//! Compatible with:
//! - OpenAI API
//! - llama.cpp server / OpenRouter / any OpenAI-compatible host
//! - Any server exposing the chat/completions v1 API
//!
//! ## Authentication
//!
//! bearer token via `Authorization: Bearer <key>` header. Omitted when the
//! key is an empty string (no-auth mode for internal LAN endpoints).

use std::time::Duration;

use async_trait::async_trait;
use reqwest::Client;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};

use crate::backend::{CuratorDecision, LlmBackend, LlmError};

/// Default timeout for LLM requests (short curator classification).
const DEFAULT_TIMEOUT_SECS: u64 = 30;

/// Maximum number of tokens generated by the LLM when no value is configured.
///
/// 1024 matches the legacy gatekeeper default (`max_tokens: Some(1024)`)
/// and is well above the ceiling for a complete curator JSON response
/// (section + 5 tags + 5 wikilinks + duplicate_hint ≈ 150–300 tokens).
const DEFAULT_MAX_TOKENS: u32 = 1024;

/// HTTP OpenAI-compatible backend.
///
/// # Example
///
/// ```rust,no_run
/// use gradatum_chat::openai_compat::OpenAiCompatBackend;
/// use secrecy::SecretString;
///
/// let backend = OpenAiCompatBackend::new(
///     "http://your-llm-host:8435".to_string(),
///     "qwen3-4b".to_string(),
///     SecretString::new("my-bearer-token".to_string().into()),
/// );
/// ```
pub struct OpenAiCompatBackend {
    client: Client,
    base_url: String,
    model: String,
    api_key: SecretString,
    /// Maximum number of tokens in the LLM response.
    /// Default: [`DEFAULT_MAX_TOKENS`] (1024). Configurable via [`with_max_tokens`].
    ///
    /// [`with_max_tokens`]: OpenAiCompatBackend::with_max_tokens
    max_tokens: u32,
}

impl OpenAiCompatBackend {
    /// Creates a backend with a default timeout of 30 seconds.
    ///
    /// `max_tokens` is initialised to `DEFAULT_MAX_TOKENS` (1024).
    /// Use [`with_max_tokens`] to override from TOML configuration.
    ///
    /// [`with_max_tokens`]: OpenAiCompatBackend::with_max_tokens
    pub fn new(base_url: String, model: String, api_key: SecretString) -> Self {
        let client = Client::builder()
            .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
            .build()
            .expect("construction reqwest Client avec timeout valide");
        Self {
            client,
            base_url,
            model,
            api_key,
            max_tokens: DEFAULT_MAX_TOKENS,
        }
    }

    /// Replaces the timeout (rebuilds the internal client).
    #[must_use]
    pub fn with_timeout(self, timeout: Duration) -> Self {
        let client = Client::builder()
            .timeout(timeout)
            .build()
            .expect("construction reqwest Client avec timeout valide");
        Self { client, ..self }
    }

    /// Replaces the maximum number of tokens generated by the LLM.
    ///
    /// Wired from `CuratorPipelineConfig.llm_review_max_tokens` via `from_config()`.
    /// The TOML value `[curator] llm_review_max_tokens = N` propagates here.
    #[must_use]
    pub fn with_max_tokens(self, n: u32) -> Self {
        Self {
            max_tokens: n,
            ..self
        }
    }
}

// --- DTOs reqwest ---

#[derive(Serialize)]
struct ChatRequest<'a> {
    model: &'a str,
    messages: Vec<ChatMessage<'a>>,
    temperature: f32,
    top_p: f32,
    max_tokens: u32,
}

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

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

#[derive(Deserialize)]
struct ChatChoice {
    message: ChatRespMsg,
}

#[derive(Deserialize)]
struct ChatRespMsg {
    content: String,
}

#[async_trait]
impl LlmBackend for OpenAiCompatBackend {
    fn name(&self) -> &'static str {
        "openai_compat"
    }

    fn is_local(&self) -> bool {
        // Considers loopback and RFC 1918 private ranges as local.
        crate::is_local_url(&self.base_url)
    }

    async fn classify(&self, system: &str, user: &str) -> Result<CuratorDecision, LlmError> {
        let url = format!(
            "{}/v1/chat/completions",
            self.base_url.trim_end_matches('/')
        );

        let req = ChatRequest {
            model: &self.model,
            messages: vec![
                ChatMessage {
                    role: "system",
                    content: system,
                },
                ChatMessage {
                    role: "user",
                    content: user,
                },
            ],
            temperature: 0.0,
            top_p: 0.9,
            max_tokens: self.max_tokens,
        };

        let mut builder = self.client.post(&url).json(&req);
        let key = self.api_key.expose_secret();
        if !key.is_empty() {
            builder = builder.bearer_auth(key);
        }

        let resp = builder.send().await.map_err(map_reqwest_err)?;

        map_status_code(&resp)?;

        let parsed: ChatResponse = resp
            .json()
            .await
            .map_err(|e| LlmError::Parse(e.to_string()))?;

        let content = parsed
            .choices
            .into_iter()
            .next()
            .ok_or_else(|| LlmError::Parse("no choices in response".into()))?
            .message
            .content;

        parse_curator_decision(&content)
    }
}

// --- Helpers partagés ---

/// Converts a reqwest error into an `LlmError`.
pub(crate) fn map_reqwest_err(e: reqwest::Error) -> LlmError {
    if e.is_timeout() {
        LlmError::Timeout
    } else if e.is_connect() {
        LlmError::ConnectionRefused
    } else if let Some(status) = e.status() {
        LlmError::ServerError {
            status: status.as_u16(),
            body: e.to_string(),
        }
    } else {
        LlmError::Transport(e.to_string())
    }
}

/// Checks the HTTP status code and returns the appropriate error.
pub(crate) fn map_status_code(resp: &reqwest::Response) -> Result<(), LlmError> {
    let status = resp.status();
    if status == 429 {
        return Err(LlmError::RateLimit);
    }
    if status == 401 || status == 403 {
        return Err(LlmError::AuthError);
    }
    if status == 400 {
        return Err(LlmError::BadRequest(status.to_string()));
    }
    if status.is_server_error() {
        return Err(LlmError::ServerError {
            status: status.as_u16(),
            body: String::new(),
        });
    }
    Ok(())
}

/// Parses the LLM response text into a `CuratorDecision`.
///
/// Attempts a direct JSON parse, then extracts the first `{...}` block on failure
/// (handles LLM preambles such as `"Here is the JSON:"`).
pub(crate) fn parse_curator_decision(content: &str) -> Result<CuratorDecision, LlmError> {
    // Tentative directe
    if let Ok(d) = serde_json::from_str::<CuratorDecision>(content.trim()) {
        return Ok(d);
    }

    // Extraction du premier bloc JSON {...}
    // Cherche la première accolade ouvrante et la dernière fermante correspondante
    if let Some(start) = content.find('{')
        && let Some(end) = content.rfind('}')
        && end > start
    {
        let json_slice = &content[start..=end];
        return serde_json::from_str::<CuratorDecision>(json_slice)
            .map_err(|e| LlmError::Parse(format!("json extract failed: {e}")));
    }

    Err(LlmError::Parse(format!(
        "no valid JSON in response: {content:?}"
    )))
}
