BareGit
//! Bounded normalization and prompt rendering for room-local Matrix context.

use std::fmt::Write as _;

use serde_json::Value;

use crate::{config::ContextConfig, models::MatrixMessage};

/// Builds chronological, bounded context from events preceding an invocation.
#[must_use]
pub fn buildContext(
    room_id: &str,
    invocation: &Value,
    events_before_newest_first: Vec<Value>,
    limits: &ContextConfig,
) -> Vec<MatrixMessage> {
    let Some(invocation) = normalizeEvent(room_id, invocation, limits.max_event_characters) else {
        return Vec::new();
    };
    let mut selected = vec![invocation];
    let mut character_count = selected[0].body.chars().count();
    for event in events_before_newest_first {
        if selected.len() >= limits.max_events as usize
            || character_count >= limits.max_characters as usize
        {
            break;
        }
        let remaining = limits.max_characters as usize - character_count;
        let remaining = u32::try_from(remaining).unwrap_or(u32::MAX);
        let Some(message) =
            normalizeEvent(room_id, &event, limits.max_event_characters.min(remaining))
        else {
            continue;
        };
        character_count += message.body.chars().count();
        selected.push(message);
    }
    selected.reverse();
    selected
}

/// Renders normalized room context as explicitly delimited untrusted data.
#[must_use]
pub fn renderContext(messages: &[MatrixMessage]) -> String {
    let mut output = String::from("<room_context>\n");
    for (index, message) in messages.iter().enumerate() {
        let sequence = index + 1;
        let _ = write!(
            output,
            "<message sequence=\"{sequence}\" event_id=\"{}\" author_id=\"{}\" timestamp_ms=\"{}\">\n{}\n",
            escape_attribute(&message.event_id),
            escape_attribute(&message.sender_id),
            message.timestamp_ms,
            message.body,
        );
        if let Some(reply_to) = &message.reply_to_event_id {
            let _ = writeln!(
                output,
                "<reply_to event_id=\"{}\" />",
                escape_attribute(reply_to)
            );
        }
        output.push_str("</message>\n");
    }
    output.push_str("</room_context>");
    output
}

/// Normalizes a Matrix text event without retaining executable HTML.
#[must_use]
pub fn normalizeEvent(
    room_id: &str,
    event: &Value,
    maximum_characters: u32,
) -> Option<MatrixMessage> {
    if event.get("type")?.as_str()? != "m.room.message"
        || event.pointer("/content/msgtype")?.as_str()? != "m.text"
    {
        return None;
    }
    let body = event.pointer("/content/body")?.as_str()?;
    let body = strip_reply_fallback(body);
    let body = truncate(&body, maximum_characters as usize);
    Some(MatrixMessage {
        event_id: event.get("event_id")?.as_str()?.to_owned(),
        room_id: room_id.to_owned(),
        sender_id: event.get("sender")?.as_str()?.to_owned(),
        timestamp_ms: event
            .get("origin_server_ts")
            .and_then(Value::as_u64)
            .unwrap_or_default(),
        body,
        reply_to_event_id: event
            .pointer("/content/m.relates_to/m.in_reply_to/event_id")
            .and_then(Value::as_str)
            .map(str::to_owned),
    })
}

fn strip_reply_fallback(body: &str) -> String {
    if body.starts_with('>') {
        body.split_once("\n\n")
            .map_or_else(|| body.to_owned(), |(_, reply)| reply.to_owned())
    } else {
        body.to_owned()
    }
}

fn truncate(value: &str, maximum_characters: usize) -> String {
    let mut output: String = value.chars().take(maximum_characters).collect();
    if value.chars().count() > maximum_characters {
        output.push_str("… [truncated]");
    }
    output
}

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

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::{buildContext, renderContext};
    use crate::config::ContextConfig;

    fn event(event_id: &str, body: &str) -> serde_json::Value {
        json!({
            "event_id": event_id,
            "sender": "@human:example.test",
            "type": "m.room.message",
            "origin_server_ts": 1,
            "content": {"msgtype": "m.text", "body": body}
        })
    }

    #[test]
    fn keeps_chronological_bounded_context_and_strips_reply_fallback() {
        let limits = ContextConfig {
            max_events: 2,
            max_characters: 100,
            max_event_characters: 100,
            max_reply_depth: 0,
        };
        let messages = buildContext(
            "!room:example.test",
            &event("$invoke", "> quote\n\nanswer"),
            vec![event("$newer", "newer"), event("$older", "older")],
            &limits,
        );
        assert_eq!(
            messages
                .iter()
                .map(|message| message.event_id.as_str())
                .collect::<Vec<_>>(),
            vec!["$newer", "$invoke"]
        );
        assert_eq!(messages[1].body, "answer");
        assert!(renderContext(&messages).contains("author_id=\"@human:example.test\""));
    }
}