fn encode_html(s: &str) -> String {
    if !s.as_bytes().iter().any(|&b| matches!(b, b'<' | b'>' | b'&' | b'"' | b'\'')) {
        return s.to_string();
    }
    let mut out = String::with_capacity(s.len() + 8);
    for ch in s.chars() {
        match ch {
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '&' => out.push_str("&amp;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&#39;"),
            _ => out.push(ch),
        }
    }
    out
}

fn normalize_multiline(val: &str) -> String {
    // Normalize CRLF to LF to keep paragraph/BR logic consistent across platforms
    let val = val.replace("\r\n", "\n");

    if !val.contains('\n') {
        return encode_html(&val);
    }

    // Split into paragraphs by runs of >= 2 newlines
    let mut parts: Vec<&str> = Vec::new();
    let bytes = val.as_bytes();
    let mut start = 0usize;
    let mut i = 0usize;
    let len = bytes.len();

    while i < len {
        if bytes[i] == b'\n' {
            let mut j = i;
            while j < len && bytes[j] == b'\n' {
                j += 1;
            }
            let count = j - i;
            if count >= 2 {
                if i > start {
                    parts.push(&val[start..i]);
                }
                start = j;
                i = j;
                continue;
            }
            i = j;
            continue;
        }
        i += 1;
    }
    if start < len {
        parts.push(&val[start..]);
    }

    if parts.len() == 1 {
        // only one paragraph: escape + replace single \n with <br>, no <p> wrapper
        let escaped = encode_html(parts[0]);
        return escaped.replace('\n', "<br>");
    }

    // multiple paragraphs: wrap each with <p>…</p>
    // capacity hint: approximate
    let mut out = String::with_capacity(val.len() + 16 * parts.len());
    for p in parts.into_iter().filter(|p| !p.is_empty()) {
        let escaped = encode_html(p);
        let with_breaks = escaped.replace('\n', "<br>");
        out.push_str("<p>");
        out.push_str(&with_breaks);
        out.push_str("</p>");
    }
    out
}

pub fn render_with_resolver(
    tpl: &str,
    mut resolve: impl FnMut(&str) -> Option<String>,
) -> String {
    substitute_vars(tpl, |k| resolve(k).map(|v| normalize_multiline(&v)))
}

fn substitute_vars(s: &str, mut get: impl FnMut(&str) -> Option<String>) -> String {
    let b = s.as_bytes();
    let mut out = String::with_capacity(s.len() + 16);
    let mut i = 0usize;
    let mut last = 0usize;

    while i + 2 <= b.len() {
        if &b[i..i + 2] == b"{{" {
            out.push_str(&s[last..i]);

            let mut j = i + 2;
            while j + 2 <= b.len() && &b[j..j + 2] != b"}}" {
                j += 1;
            }
            if j + 2 > b.len() {
                out.push_str(&s[i..]);
                return out;
            }

            let key = s[i + 2..j].trim();
            if let Some(val) = get(key) {
                out.push_str(&val);
            } else {
                out.push_str(&s[i..j + 2]);
            }

            i = j + 2;
            last = i;
            continue;
        }
        i += 1;
    }
    out.push_str(&s[last..]);
    out
}

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

    #[test]
    fn crlf_normalization_single_paragraph() {
        let tpl = "Hello {{name}}!";
        let mut called = false;
        let out = render_with_resolver(tpl, |k| {
            called = true;
            assert_eq!(k, "name");
            Some("Line1\r\nLine2".to_string())
        });
        assert!(called);
        assert_eq!(out, "Hello Line1<br>Line2!");
    }

    #[test]
    fn crlf_normalization_multi_paragraphs() {
        let tpl = "{{text}}";
        let out = render_with_resolver(tpl, |_k| {
            Some("P1 line\r\nnext\r\n\r\nP2 only".to_string())
        });
        assert_eq!(out, "<p>P1 line<br>next</p><p>P2 only</p>");
    }
}
