use std::time::Duration;

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

/// Smartwatch-aware keyword-extraction prompt: leading noun must come from
/// the transcript itself (no hallucination) so the doctor can recognize the
/// case from the watch face. English instructions for small-model
/// robustness, German output. Priority-ordered so a medical keyword always
/// wins, with a fallback for non-medical narrative transcripts so the label
/// stays useful for case recognition.
const SYSTEM_PROMPT: &str = "You are a keyword-extraction assistant for a German medical dictation app. Given a transcript, output a short German one-line label so the doctor can recognize the case on the watch face.\n\nPriority order for the leading noun (apply strictly top-down):\n1. Explicit case label: if the transcript contains \"Bezeichnung: X\" or \"Fall-ID: X\", use X verbatim as the label.\n2. Medical entity: diagnosis, important body region, organ, imaging modality, or key finding that appears in the transcript.\n3. Fallback: the most distinctive noun(s) from the transcript — event, object, role, or context — BUT ONLY if no medical entity from rule 2 is present.\n\nSilence rule (narrow):\nOnly when the transcript is completely empty or has no usable words at all, return no text (emit zero characters). Do NOT echo 'empty', 'nothing', 'none', 'N/A', 'null', '-', or any placeholder. Never invoke the silence rule as an escape when the transcript contains words — use the Fallback rule instead.\n\nRules:\n- 2-4 words, at most 50 characters, single line.\n- Use ONLY terms that appear in the transcript. Do not invent, translate, or paraphrase. Do not add diagnoses the doctor did not mention.\n- No filler prefixes: \"Patient\", \"Fall\", \"Verdacht auf\", \"Untersuchung\", \"Diktat\", articles.\n- No intensifiers (\"massiv\", \"ausgeprägt\", \"deutlich\", \"hochgradig\").\n- No quotes, no trailing period, no introductory phrase.\n- Respond in German. One line only.\n\nExamples:\n\nTranscript: \"Bezeichnung: Schulter links. Patient klagt über...\"\nOutput: Schulter links\n\nTranscript: \"...V.a. Bandscheibenvorfall in der LWS, Schmerzen strahlen...\"\nOutput: LWS, V.a. Bandscheibenvorfall\n\nTranscript: \"Nach einer gescheiterten Osterhasenrettungsaktion ist einer der Einsatzhelfer bei einem Ostereier-Wettessen schwer verunglückt.\"\nOutput: Osterhasenrettung, Wettessen\n\nREMINDER: The silence rule fires only on truly empty transcripts. If any words exist, always produce output — medical first (rule 2), otherwise Fallback (rule 3). Never emit placeholders.";

/// Deterministic leading-filler prefixes stripped after the LLM response.
/// The trailing space is part of the match — we only strip whole tokens.
const LEADING_FILLERS: &[&str] = &[
    "Der Patient ",
    "Die Patientin ",
    "Patient ",
    "Patientin ",
    "Fall: ",
    "Fall ",
    "Untersuchung ",
    "Diktat zu ",
    "Diktat ",
    "Zusammenfassung: ",
    "Zusammenfassung ",
];

const MAX_CHARS: usize = 60;

#[derive(Debug)]
pub enum OllamaError {
    Http(reqwest::Error),
    Status {
        status: u16,
        body: String,
    },
    Parse(String),
    /// Model obeyed the silence rule: transcript had no extractable keyword,
    /// so the response was empty. Not a failure — callers should treat this
    /// as "no oneliner this round" and log below error level.
    EmptyResponse,
}

impl std::fmt::Display for OllamaError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Http(e) => write!(f, "ollama http error: {e}"),
            Self::Status { status, body } => write!(f, "ollama returned {status}: {body}"),
            Self::Parse(msg) => write!(f, "ollama response parse error: {msg}"),
            Self::EmptyResponse => write!(
                f,
                "ollama response was empty (no medical keyword in transcript)"
            ),
        }
    }
}

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

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

#[derive(Serialize)]
struct Options {
    temperature: f32,
}

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

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

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

/// Generate a one-line case title via Ollama chat API. The returned string is
/// already normalized (single line, no quotes, no trailing period, no leading
/// filler words, capped at 120 chars).
pub async fn generate_oneliner(
    client: &reqwest::Client,
    ollama_url: &str,
    model: &str,
    keep_alive_seconds: u32,
    transcript: &str,
    timeout: Duration,
) -> Result<String, OllamaError> {
    let url = join_url(ollama_url, "/api/chat");
    debug!(%url, model, "generating oneliner");

    let body = ChatRequest {
        model,
        stream: false,
        keep_alive: keep_alive_seconds,
        options: Options { temperature: 0.2 },
        messages: vec![
            Message {
                role: "system",
                content: SYSTEM_PROMPT,
            },
            Message {
                role: "user",
                content: transcript,
            },
        ],
    };

    let response = client
        .post(&url)
        .timeout(timeout)
        .json(&body)
        .send()
        .await
        .map_err(OllamaError::Http)?;

    let status = response.status();
    if !status.is_success() {
        let body = response.text().await.unwrap_or_default();
        return Err(OllamaError::Status {
            status: status.as_u16(),
            body,
        });
    }

    let parsed: ChatResponse = response.json().await.map_err(OllamaError::Http)?;
    let content = parsed
        .message
        .and_then(|m| m.content)
        .ok_or_else(|| OllamaError::Parse("missing message.content".into()))?;

    let normalized = normalize(&content);
    if normalized.is_empty() {
        return Err(OllamaError::EmptyResponse);
    }
    Ok(normalized)
}

/// Pure, unit-testable normalization. Order matters:
/// trim → first non-empty line → strip quote pair → strip trailing `.` →
/// strip leading filler → cap at MAX_CHARS chars (byte-safe) → trim again.
pub(crate) fn normalize(raw: &str) -> String {
    let mut s: &str = raw.trim();

    // First non-empty line.
    s = s
        .lines()
        .find(|l| !l.trim().is_empty())
        .unwrap_or("")
        .trim();

    // A single pair of surrounding quotes (' or ").
    let bytes = s.as_bytes();
    if bytes.len() >= 2
        && ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
            || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
    {
        s = &s[1..s.len() - 1];
    }

    // Trailing period.
    let s = s.trim();
    let s = s.strip_suffix('.').unwrap_or(s);

    // Leading filler — take the first matching prefix, then uppercase the new head.
    let mut out: String = s.to_string();
    for prefix in LEADING_FILLERS {
        if out
            .get(..prefix.len())
            .is_some_and(|slice| slice.eq_ignore_ascii_case(prefix))
        {
            out = out[prefix.len()..].to_string();
            out = uppercase_first(&out);
            break;
        }
    }

    // Byte-safe cap to MAX_CHARS chars.
    if let Some((byte_idx, _)) = out.char_indices().nth(MAX_CHARS) {
        out.truncate(byte_idx);
    }

    out.trim().to_string()
}

fn uppercase_first(s: &str) -> String {
    let mut chars = s.chars();
    match chars.next() {
        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
        None => String::new(),
    }
}

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

    #[test]
    fn normalize_passes_clean_input() {
        assert_eq!(
            normalize("HOCM mit Septum-Hypertrophie"),
            "HOCM mit Septum-Hypertrophie"
        );
    }

    #[test]
    fn normalize_strips_quotes_and_trailing_dot_and_extra_lines() {
        assert_eq!(
            normalize("\"Kniegelenk re., V.a. Meniskus.\"\nSonstiges"),
            "Kniegelenk re., V.a. Meniskus"
        );
    }

    #[test]
    fn normalize_strips_leading_filler_table() {
        let cases: &[(&str, &str)] = &[
            ("Fall: Thorax-CT", "Thorax-CT"),
            ("Diktat zu Schulter rechts", "Schulter rechts"),
            ("Zusammenfassung: HOCM", "HOCM"),
            ("Der Patient mit Hüftfraktur links", "Mit Hüftfraktur links"),
            ("patient mit HOCM", "Mit HOCM"), // case-insensitive
        ];
        for (input, expected) in cases {
            assert_eq!(normalize(input), *expected, "input={input}");
        }
    }

    #[test]
    fn normalize_leaves_va_prefix_alone() {
        assert_eq!(normalize("V.a. Meniskusriss"), "V.a. Meniskusriss");
    }

    #[test]
    fn normalize_caps_at_60_chars_unicode_safe() {
        // 80 × 'ä' → 160 bytes but 80 chars; cap must not split a codepoint.
        let input: String = "ä".repeat(80);
        let out = normalize(&input);
        assert_eq!(out.chars().count(), 60);
        // Round-trip: must still be valid UTF-8 (would have panicked otherwise).
        assert!(out.starts_with('ä'));
    }

    #[test]
    fn normalize_returns_empty_for_blank_input() {
        assert_eq!(normalize("   \n  "), "");
    }
}
