//! Render the LLM's Markdown document to safe HTML.
//!
//! The LLM produces Markdown with a non-standard `==text==` highlight
//! convention for stretches the doctor should double-check (suspected
//! transcription errors, incomplete info, numbers without context). We
//! convert those to `<mark>` tags — the only raw HTML we inject — after
//! HTML-escaping the entire LLM output. That way even if the LLM writes
//! `<script>`, it becomes visible text, not executable markup.
//!
//! Pipeline:
//!   raw markdown → html-escape → ==X== → <mark>X</mark> → pulldown-cmark → html

use pulldown_cmark::{Options, Parser, html};

/// Render analysis Markdown to HTML. Safe to inject into an Askama template
/// with `{{ var|safe }}` because every `<`/`>` from the LLM is pre-escaped
/// and only our whitelisted `<mark>` tags remain as live HTML.
pub fn md_to_html(md: &str) -> String {
    let prepared = escape_and_mark(md);
    let parser = Parser::new_ext(&prepared, Options::empty());
    let mut out = String::with_capacity(prepared.len());
    html::push_html(&mut out, parser);
    out
}

/// HTML-escape every `<`, `>`, `&`, `"`, `'` in the input, then convert
/// `==text==` highlight markers to `<mark>text</mark>`. Because the escape
/// step runs first, the substitution output is the only live HTML in the
/// resulting string — LLM-emitted angle brackets can never execute.
///
/// Pure, unit-testable. Order matters: escape before substitution, because
/// otherwise `<mark>` itself would be escaped.
pub(crate) fn escape_and_mark(md: &str) -> String {
    let escaped = html_escape(md);
    substitute_marks(&escaped)
}

fn html_escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&#39;"),
            other => out.push(other),
        }
    }
    out
}

/// Replace `==text==` with `<mark>text</mark>`. Unmatched openers are left
/// as literal text. No nesting (once we enter a mark, the next `==` closes).
/// `text` may not contain newlines — a paragraph break cancels the mark
/// (prevents runaway highlighting if the LLM forgets the closer).
fn substitute_marks(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let bytes = s.as_bytes();
    let mut i = 0;
    while i < bytes.len() {
        if i + 1 < bytes.len()
            && bytes[i] == b'='
            && bytes[i + 1] == b'='
            && let Some(end) = find_closer(bytes, i + 2)
        {
            out.push_str("<mark>");
            out.push_str(&s[i + 2..end]);
            out.push_str("</mark>");
            i = end + 2;
            continue;
        }
        // Not a mark opener — push one byte. Safe because we only peek at
        // ASCII `=`; any multi-byte char is pushed whole by this single-byte
        // copy sequence as long as the loop visits each of its bytes. But
        // string slicing requires char boundaries, so we push by char.
        let ch_end = next_char_boundary(s, i);
        out.push_str(&s[i..ch_end]);
        i = ch_end;
    }
    out
}

/// Find the byte index of the opening `=` of the next `==` closer in
/// `bytes[start..]`, or `None` if none before a newline or end-of-input.
fn find_closer(bytes: &[u8], start: usize) -> Option<usize> {
    let mut j = start;
    while j + 1 < bytes.len() {
        match bytes[j] {
            b'\n' => return None,
            b'=' if bytes[j + 1] == b'=' => return Some(j),
            _ => j += 1,
        }
    }
    None
}

fn next_char_boundary(s: &str, i: usize) -> usize {
    let mut j = i + 1;
    while j < s.len() && !s.is_char_boundary(j) {
        j += 1;
    }
    j
}

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

    #[test]
    fn escape_and_mark_converts_highlight() {
        let out = escape_and_mark("Patient klagt über ==Tastanalyse== am Po.");
        assert_eq!(out, "Patient klagt über <mark>Tastanalyse</mark> am Po.");
    }

    #[test]
    fn escape_and_mark_escapes_llm_html_injection() {
        let out = escape_and_mark("<script>alert('x')</script>");
        assert_eq!(out, "&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;");
    }

    #[test]
    fn escape_and_mark_allows_only_our_mark_tags() {
        // LLM emits both raw HTML and a ==mark==; only the mark becomes live HTML.
        let out = escape_and_mark("<b>fett</b> und ==wichtig==");
        assert_eq!(out, "&lt;b&gt;fett&lt;/b&gt; und <mark>wichtig</mark>");
    }

    #[test]
    fn escape_and_mark_preserves_unmatched_opener_as_text() {
        let out = escape_and_mark("==offen ohne Ende");
        assert_eq!(out, "==offen ohne Ende");
    }

    #[test]
    fn escape_and_mark_does_not_cross_newlines() {
        let out = escape_and_mark("==kein\nmark==");
        // A newline between the openers cancels the highlight.
        assert_eq!(out, "==kein\nmark==");
    }

    #[test]
    fn escape_and_mark_handles_multiple_marks_in_one_line() {
        let out = escape_and_mark("==A== und ==B==");
        assert_eq!(out, "<mark>A</mark> und <mark>B</mark>");
    }

    #[test]
    fn escape_and_mark_handles_unicode_content_in_mark() {
        let out = escape_and_mark("==Blutdruck 140/90 über Norm==");
        assert_eq!(out, "<mark>Blutdruck 140/90 über Norm</mark>");
    }

    #[test]
    fn md_to_html_wraps_plain_text_in_paragraph() {
        let out = md_to_html("Fließtext ohne Auszeichnung.");
        assert_eq!(out.trim(), "<p>Fließtext ohne Auszeichnung.</p>");
    }

    #[test]
    fn md_to_html_renders_mark_inline() {
        let out = md_to_html("Patient klagt über ==Tastanalyse==.");
        assert!(out.contains("<mark>Tastanalyse</mark>"));
        assert!(out.contains("<p>"));
    }

    #[test]
    fn md_to_html_blocks_raw_html_from_llm() {
        // Even if the LLM embeds a <script>, it's rendered as escaped text.
        let out = md_to_html("Vorsicht: <script>alert(1)</script>");
        assert!(!out.contains("<script>"));
        assert!(out.contains("&lt;script&gt;"));
    }

    #[test]
    fn md_to_html_renders_paragraphs_separated_by_blank_line() {
        let out = md_to_html("Erster Absatz.\n\nZweiter Absatz.");
        assert!(out.contains("<p>Erster Absatz.</p>"));
        assert!(out.contains("<p>Zweiter Absatz.</p>"));
    }
}
