BareGit
//! Safe, bounded Matrix reply rendering for model-produced text.

use std::fmt::Write as _;

use serde_json::json;

use crate::{errors::MatrixError, tools::Source};

/// Renders a bounded answer as a Matrix rich reply payload.
pub fn renderReply(
    invocation_event_id: &str,
    invocation_sender_id: &str,
    invocation_excerpt: &str,
    markdown: &str,
    maximum_characters: u32,
) -> Result<String, MatrixError> {
    renderReplyWithSources(
        invocation_event_id,
        invocation_sender_id,
        invocation_excerpt,
        markdown,
        &[],
        maximum_characters,
    )
}

/// Renders a bounded answer and host-validated research sources as a Matrix reply.
pub fn renderReplyWithSources(
    invocation_event_id: &str,
    invocation_sender_id: &str,
    invocation_excerpt: &str,
    markdown: &str,
    sources: &[Source],
    maximum_characters: u32,
) -> Result<String, MatrixError> {
    let plain_markdown = append_sources(markdown, sources);
    if plain_markdown.trim().is_empty()
        || plain_markdown.chars().count() > maximum_characters as usize
    {
        return Err(MatrixError::InvalidResponse(
            "answer exceeded the configured response limit".to_owned(),
        ));
    }
    let excerpt = truncate(invocation_excerpt, 500);
    let fallback = format!("> <{invocation_sender_id}> {excerpt}\n>\n{plain_markdown}");
    let rendered_answer = format!("{}{}", render_markdown(markdown), render_sources_html(sources));
    let formatted_body = format!(
        "<mx-reply><blockquote><a href=\"https://matrix.to/#/{sender}\">{sender}</a><br>{excerpt}</blockquote></mx-reply>{answer}",
        sender = escape_html(invocation_sender_id),
        excerpt = escape_html(&excerpt),
        answer = rendered_answer,
    );
    serde_json::to_string(&json!({
        "msgtype": "m.text",
        "body": fallback,
        "format": "org.matrix.custom.html",
        "formatted_body": formatted_body,
        "m.relates_to": {"m.in_reply_to": {"event_id": invocation_event_id}},
    }))
    .map_err(|error| MatrixError::InvalidResponse(error.to_string()))
}

fn append_sources(markdown: &str, sources: &[Source]) -> String {
    if sources.is_empty() {
        return markdown.to_owned();
    }
    let mut answer = String::from(markdown);
    answer.push_str("\n\nSources:");
    for source in sources.iter().take(8) {
        let _ = write!(answer, "\n- [{}]({})", source.title, source.url);
    }
    answer
}

fn render_sources_html(sources: &[Source]) -> String {
    if sources.is_empty() {
        return String::new();
    }
    let mut output = String::from("<p>Sources:</p><ul>");
    for source in sources.iter().take(8) {
        output.push_str("<li><a href=\"");
        output.push_str(&escape_html(source.url.as_str()));
        output.push_str("\">");
        output.push_str(&escape_html(&source.title));
        output.push_str("</a></li>");
    }
    output.push_str("</ul>");
    output
}

fn render_markdown(markdown: &str) -> String {
    let mut output = String::new();
    let mut code_block = false;
    let mut list_open = false;
    for line in markdown.lines() {
        if line.starts_with("```") {
            if list_open {
                output.push_str("</ul>");
                list_open = false;
            }
            output.push_str(if code_block {
                "</code></pre>"
            } else {
                "<pre><code>"
            });
            code_block = !code_block;
            continue;
        }
        let escaped = escape_html(line);
        if code_block {
            output.push_str(&escaped);
            output.push('\n');
            continue;
        }
        if let Some(item) = escaped.strip_prefix("- ") {
            if !list_open {
                output.push_str("<ul>");
                list_open = true;
            }
            output.push_str("<li>");
            output.push_str(item);
            output.push_str("</li>");
        } else {
            if list_open {
                output.push_str("</ul>");
                list_open = false;
            }
            if !escaped.is_empty() {
                output.push_str("<p>");
                output.push_str(&escaped);
                output.push_str("</p>");
            }
        }
    }
    if list_open {
        output.push_str("</ul>");
    }
    if code_block {
        output.push_str("</code></pre>");
    }
    output
}

fn escape_html(value: &str) -> String {
    value
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

fn truncate(value: &str, maximum: usize) -> String {
    let mut output: String = value.chars().take(maximum).collect();
    if value.chars().count() > maximum {
        output.push('…');
    }
    output
}

#[cfg(test)]
mod tests {
    use url::Url;

    use super::{renderReply, renderReplyWithSources};
    use crate::tools::Source;

    #[test]
    fn escapes_untrusted_markdown_and_sets_the_reply_relation() {
        let payload = renderReply(
            "$event:example.test",
            "@human:example.test",
            "<script>",
            "- <unsafe>",
            100,
        )
        .unwrap();
        assert!(payload.contains("m.in_reply_to"));
        assert!(payload.contains("&lt;unsafe&gt;"));
        assert!(payload.contains("&lt;script&gt;"));
    }

    #[test]
    fn renders_host_validated_sources_as_html_links() {
        let source = Source {
            url: Url::parse("https://example.test/article").unwrap(),
            title: "Article".to_owned(),
        };
        let payload = renderReplyWithSources(
            "$event:example.test",
            "@human:example.test",
            "question",
            "answer",
            &[source],
            500,
        ).unwrap();
        assert!(payload.contains("href=\\\"https://example.test/article\\\""));
    }
}