//! Subset markdown → [`ratatui::text::Span`] for colored terminal chat.
//!
//! The server runs `TuiFormatter` (strip internal protocol metadata, keep markdown); this module
//! maps common LLM markdown to bold, italics, headings, inline code, blockquotes, and fenced code.

use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;

fn body_style() -> Style {
    Style::default().fg(Color::White)
}

/// Styled spans for one logical line of assistant content (no role label).
pub(crate) fn assistant_line_to_spans(line: &str, in_code_fence: bool) -> Vec<Span<'static>> {
    if in_code_fence {
        return vec![Span::styled(
            line.to_string(),
            Style::default()
                .fg(Color::LightGreen)
                .bg(Color::Rgb(24, 24, 27)),
        )];
    }

    let trimmed = line.trim_start();
    if let Some(title) = markdown_heading_title(trimmed) {
        return vec![Span::styled(
            title.to_string(),
            Style::default()
                .fg(Color::Yellow)
                .add_modifier(Modifier::BOLD),
        )];
    }

    if let Some(rest) = trimmed.strip_prefix('>') {
        let quote = rest.strip_prefix(' ').unwrap_or(rest);
        let mut spans = vec![Span::styled(
            "│ ".to_string(),
            Style::default().fg(Color::DarkGray),
        )];
        spans.extend(parse_inline_spans(quote));
        return spans;
    }

    parse_inline_spans(line)
}

/// `#` … `######` ATX headings — returns title text without leading `#` run.
fn markdown_heading_title(line: &str) -> Option<&str> {
    let mut count = 0usize;
    for c in line.chars() {
        if c == '#' {
            count += 1;
            if count > 6 {
                return None;
            }
        } else {
            break;
        }
    }
    if count == 0 {
        return None;
    }
    let rest = line.get(count..)?;
    let rest = rest.strip_prefix(' ')?.trim_start();
    if rest.is_empty() {
        return None;
    }
    Some(rest)
}

fn parse_inline_spans(line: &str) -> Vec<Span<'static>> {
    let mut out: Vec<Span<'static>> = Vec::new();
    let mut rest = line;

    while !rest.is_empty() {
        if let Some(after) = rest.strip_prefix("**") {
            if let Some(end) = after.find("**") {
                let inner = &after[..end];
                out.push(Span::styled(
                    inner.to_string(),
                    body_style().add_modifier(Modifier::BOLD),
                ));
                rest = &after[end + 2..];
                continue;
            }
        }

        if let Some(after) = rest.strip_prefix('`') {
            if let Some(end) = after.find('`') {
                let inner = &after[..end];
                out.push(Span::styled(
                    inner.to_string(),
                    Style::default()
                        .fg(Color::LightCyan)
                        .add_modifier(Modifier::ITALIC),
                ));
                rest = &after[end + 1..];
                continue;
            }
        }

        if let Some((text, url, consumed)) = parse_markdown_link_prefix(rest) {
            out.push(Span::styled(
                text.to_string(),
                body_style().add_modifier(Modifier::UNDERLINED),
            ));
            out.push(Span::styled(
                format!(" ({url})"),
                Style::default().fg(Color::DarkGray),
            ));
            rest = &rest[consumed..];
            continue;
        }

        // *italic* — only if not followed by another *
        if let Some(after) = rest.strip_prefix('*') {
            if !after.starts_with('*') {
                if let Some(end) = after.find('*') {
                    let inner = &after[..end];
                    out.push(Span::styled(
                        inner.to_string(),
                        body_style().add_modifier(Modifier::ITALIC),
                    ));
                    rest = &after[end + 1..];
                    continue;
                }
            }
        }

        // Plain: up to next special starter (byte-scan for ASCII triggers only)
        let take = rest
            .char_indices()
            .find(|(_, c)| matches!(c, '*' | '`' | '['))
            .map(|(idx, _)| idx)
            .unwrap_or(rest.len());
        if take == 0 {
            // Avoid infinite loop — emit one codepoint raw
            let ch = rest.chars().next().unwrap();
            let len = ch.len_utf8();
            out.push(Span::styled(rest[..len].to_string(), body_style()));
            rest = &rest[len..];
            continue;
        }
        out.push(Span::styled(rest[..take].to_string(), body_style()));
        rest = &rest[take..];
    }

    if out.is_empty() {
        out.push(Span::raw(String::new()));
    }
    out
}

/// Parse `[text](url)` at start of `s`. Returns `(text, url, total_byte_len)`.
fn parse_markdown_link_prefix(s: &str) -> Option<(&str, &str, usize)> {
    let rest = s.strip_prefix('[')?;
    let close_text = rest.find(']')?;
    let text = rest.get(..close_text)?;
    let after = rest.get(close_text + 1..)?.strip_prefix('(')?;
    let close_url = after.find(')')?;
    let url = after.get(..close_url)?;
    let consumed = 1 + close_text + 1 + 1 + close_url + 1;
    if text.is_empty() {
        return None;
    }
    Some((text, url, consumed))
}

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

    #[test]
    fn heading_contains_title() {
        let spans = assistant_line_to_spans("### Section", false);
        assert_eq!(spans.len(), 1);
        assert_eq!(spans[0].content, "Section");
    }

    #[test]
    fn bold_and_code_strip_delimiters() {
        let spans = assistant_line_to_spans("Say **hi** and `code` ok", false);
        let flat: String = spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(flat.contains("hi"));
        assert!(flat.contains("code"));
        assert!(!flat.contains("**"));
        assert!(!flat.contains('`'));
    }

    #[test]
    fn code_fence_line_green() {
        let spans = assistant_line_to_spans("  let x = 1;", true);
        assert_eq!(spans.len(), 1);
        assert!(spans[0].content.contains("let x"));
    }

    #[test]
    fn blockquote_renders_leading_gutter_and_content() {
        let spans = assistant_line_to_spans("> quoted text", false);
        let flat: String = spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(flat.starts_with("│ "));
        assert!(flat.contains("quoted text"));
    }

    #[test]
    fn inline_italic_and_link_render_without_markers() {
        let spans = assistant_line_to_spans("*note* [docs](https://example.com)", false);
        let flat: String = spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(flat.contains("note"));
        assert!(flat.contains("docs"));
        assert!(flat.contains("(https://example.com)"));
        assert!(!flat.contains("[docs]"));
    }

    #[test]
    fn unmatched_markers_fall_back_to_plain_text() {
        let spans = assistant_line_to_spans("**broken and `odd", false);
        let flat: String = spans.iter().map(|s| s.content.as_ref()).collect();
        assert_eq!(flat, "**broken and `odd");
    }

    #[test]
    fn heading_parser_rejects_too_many_hashes_or_empty_titles() {
        assert_eq!(markdown_heading_title("####### nope"), None);
        assert_eq!(markdown_heading_title("###"), None);
        assert_eq!(markdown_heading_title("###    "), None);
        assert_eq!(markdown_heading_title("## Valid"), Some("Valid"));
    }

    #[test]
    fn empty_line_produces_empty_span() {
        let spans = assistant_line_to_spans("", false);
        assert_eq!(spans.len(), 1);
        assert!(spans[0].content.is_empty());
    }
}
