#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]

//! Minimal markdown → `Vec<Line<'static>>` renderer for assistant
//! messages. Walks pulldown-cmark events and emits styled ratatui Lines.
//!
//! Modelled on codex's `markdown_render.rs` but stripped to the subset
//! capo actually needs for M2: paragraphs, headings, bold / italic /
//! strikethrough, inline code, links, fenced code blocks (no syntax
//! highlighting yet — that's M3+), unordered + ordered lists,
//! blockquotes, horizontal rules, soft/hard line breaks. Tables,
//! footnotes, images, raw HTML are rendered as plain text fallbacks.
//!
//! Width-naive: long lines are NOT wrapped here. The caller wraps via
//! `Paragraph::wrap(Wrap { trim: false })`, which handles the wrapping
//! at render time when the area width is known. `_width` parameter
//! kept so the signature mirrors codex's API for future use.

use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};

/// Render a markdown source string into styled Lines.
pub fn render_markdown(src: &str, _width: u16) -> Vec<Line<'static>> {
    let mut opts = Options::empty();
    opts.insert(Options::ENABLE_STRIKETHROUGH);
    opts.insert(Options::ENABLE_TABLES);
    let parser = Parser::new_ext(src, opts);

    let mut w = Writer::default();
    for event in parser {
        w.handle(event);
    }
    w.finish();
    w.lines
}

#[derive(Default)]
struct Writer {
    lines: Vec<Line<'static>>,
    current: Vec<Span<'static>>,
    inline_styles: Vec<Style>,
    /// Stack of list contexts: `None` = unordered (use `•`), `Some(n)` =
    /// ordered (next index = `n`, incremented per item).
    lists: Vec<Option<u64>>,
    /// True between `Start(BlockQuote)` and `End(BlockQuote)`; emits a
    /// `▌ ` prefix on each line of the quote in dim green.
    in_blockquote: u8,
    /// True between `Start(CodeBlock)` and `End(CodeBlock)`; raw text
    /// events inside are split on `\n` into separate code lines.
    in_code_block: bool,
    /// Set after a block that needs a trailing blank before the next
    /// block (paragraph, heading, list, code, blockquote). Consumed +
    /// cleared when the next block starts.
    needs_blank: bool,
}

impl Writer {
    fn handle(&mut self, event: Event<'_>) {
        match event {
            Event::Start(tag) => self.start_tag(tag),
            Event::End(tag) => self.end_tag(tag),
            Event::Text(t) => {
                if self.in_code_block {
                    // Code blocks: split on newlines, each line its own
                    // styled row. Trailing empty fragment after final \n
                    // is skipped so we don't emit a phantom blank row
                    // before the closing fence.
                    let s = t.into_string();
                    let parts: Vec<&str> = s.split('\n').collect();
                    for (i, part) in parts.iter().enumerate() {
                        if i > 0 {
                            self.finish_line();
                        }
                        if !part.is_empty() {
                            self.current.push(Span::styled(
                                part.to_string(),
                                Style::default().fg(Color::Cyan),
                            ));
                        }
                    }
                } else {
                    let style = self.current_inline_style();
                    self.current.push(Span::styled(t.into_string(), style));
                }
            }
            Event::Code(t) => {
                // Inline `code` — always cyan, ignores surrounding bold/italic.
                self.current.push(Span::styled(
                    t.into_string(),
                    Style::default().fg(Color::Cyan),
                ));
            }
            Event::SoftBreak => {
                // Soft break in source (single `\n`) renders as a space
                // within the current visual paragraph.
                self.current.push(Span::raw(" "));
            }
            Event::HardBreak => {
                // Hard break (two trailing spaces, or `\\`) forces a new
                // line within the same block.
                self.finish_line();
            }
            Event::Rule => {
                self.finish_line();
                self.flush_blank_if_needed();
                self.lines.push(Line::from(Span::styled(
                    "─".repeat(40),
                    Style::default().add_modifier(Modifier::DIM),
                )));
                self.needs_blank = true;
            }
            Event::Html(s) | Event::InlineHtml(s) => {
                // Don't interpret HTML; emit verbatim dim.
                self.current.push(Span::styled(
                    s.into_string(),
                    Style::default().add_modifier(Modifier::DIM),
                ));
            }
            Event::TaskListMarker(checked) => {
                let marker = if checked { "[x] " } else { "[ ] " };
                self.current.push(Span::styled(
                    marker.to_string(),
                    Style::default().fg(Color::Cyan),
                ));
            }
            Event::FootnoteReference(_) => {}
        }
    }

    fn start_tag(&mut self, tag: Tag<'_>) {
        match tag {
            Tag::Paragraph => {
                self.flush_blank_if_needed();
            }
            Tag::Heading { level, .. } => {
                self.flush_blank_if_needed();
                let mut style = Style::default().add_modifier(Modifier::BOLD);
                if matches!(level, HeadingLevel::H1) {
                    style = style.add_modifier(Modifier::UNDERLINED);
                }
                if matches!(
                    level,
                    HeadingLevel::H3 | HeadingLevel::H4 | HeadingLevel::H5 | HeadingLevel::H6
                ) {
                    style = style.add_modifier(Modifier::ITALIC);
                }
                self.inline_styles.push(style);
            }
            Tag::BlockQuote => {
                self.flush_blank_if_needed();
                self.in_blockquote = self.in_blockquote.saturating_add(1);
            }
            Tag::CodeBlock(_) => {
                self.flush_blank_if_needed();
                self.in_code_block = true;
            }
            Tag::List(start) => {
                self.flush_blank_if_needed();
                self.lists.push(start);
            }
            Tag::Item => {
                // Bullet / number for this row. Emitted as a leading span
                // before any inline content.
                let marker = match self.lists.last_mut() {
                    Some(Some(n)) => {
                        let m = format!("{n}. ");
                        *n += 1;
                        m
                    }
                    Some(None) => "• ".to_string(),
                    None => "• ".to_string(),
                };
                self.current
                    .push(Span::styled(marker, Style::default().fg(Color::LightBlue)));
            }
            Tag::Emphasis => {
                let base = self.current_inline_style();
                self.inline_styles.push(base.add_modifier(Modifier::ITALIC));
            }
            Tag::Strong => {
                let base = self.current_inline_style();
                self.inline_styles.push(base.add_modifier(Modifier::BOLD));
            }
            Tag::Strikethrough => {
                let base = self.current_inline_style();
                self.inline_styles
                    .push(base.add_modifier(Modifier::CROSSED_OUT));
            }
            Tag::Link { .. } => {
                let base = self.current_inline_style();
                self.inline_styles
                    .push(base.fg(Color::Cyan).add_modifier(Modifier::UNDERLINED));
            }
            Tag::Image { .. } => {
                self.current.push(Span::raw("[image]"));
            }
            Tag::Table(_) | Tag::TableHead | Tag::TableRow | Tag::TableCell => {
                // Tables: degrade to plain text (cell content concatenated
                // with " | " separator). Not pretty but doesn't lose info.
            }
            Tag::FootnoteDefinition(_) | Tag::HtmlBlock | Tag::MetadataBlock(_) => {}
        }
    }

    fn end_tag(&mut self, tag: TagEnd) {
        match tag {
            TagEnd::Paragraph => {
                self.finish_line();
                self.needs_blank = true;
            }
            TagEnd::Heading(_) => {
                self.inline_styles.pop();
                self.finish_line();
                self.needs_blank = true;
            }
            TagEnd::BlockQuote => {
                self.in_blockquote = self.in_blockquote.saturating_sub(1);
                self.needs_blank = true;
            }
            TagEnd::CodeBlock => {
                self.finish_line();
                self.in_code_block = false;
                self.needs_blank = true;
            }
            TagEnd::List(_) => {
                self.lists.pop();
                self.needs_blank = true;
            }
            TagEnd::Item => {
                self.finish_line();
            }
            TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough | TagEnd::Link => {
                self.inline_styles.pop();
            }
            TagEnd::Image
            | TagEnd::Table
            | TagEnd::TableHead
            | TagEnd::TableRow
            | TagEnd::TableCell
            | TagEnd::FootnoteDefinition
            | TagEnd::HtmlBlock
            | TagEnd::MetadataBlock(_) => {}
        }
    }

    fn current_inline_style(&self) -> Style {
        self.inline_styles
            .last()
            .copied()
            .unwrap_or_else(Style::default)
    }

    fn finish_line(&mut self) {
        let spans = std::mem::take(&mut self.current);
        if spans.is_empty() && self.in_blockquote == 0 {
            // Empty inline content + no blockquote prefix → suppress to
            // avoid duplicate blanks (caller handles `needs_blank`).
            return;
        }
        let mut final_spans = Vec::with_capacity(spans.len() + 1);
        if self.in_blockquote > 0 {
            final_spans.push(Span::styled(
                "▌ ".repeat(self.in_blockquote as usize),
                Style::default().fg(Color::Green),
            ));
        }
        final_spans.extend(spans);
        self.lines.push(Line::from(final_spans));
    }

    fn flush_blank_if_needed(&mut self) {
        if self.needs_blank && !self.lines.is_empty() {
            self.lines.push(Line::from(""));
        }
        self.needs_blank = false;
    }

    fn finish(&mut self) {
        self.finish_line();
        // Strip trailing blank lines that would inflate transcript height.
        while matches!(self.lines.last(), Some(line) if is_blank(line)) {
            self.lines.pop();
        }
    }
}

fn is_blank(line: &Line<'_>) -> bool {
    line.spans.iter().all(|span| span.content.trim().is_empty())
}

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

    fn text_of(lines: &[Line]) -> String {
        lines
            .iter()
            .map(|l| {
                l.spans
                    .iter()
                    .map(|s| s.content.as_ref())
                    .collect::<String>()
            })
            .collect::<Vec<_>>()
            .join("\n")
    }

    #[test]
    fn plain_paragraph_renders_as_one_line() {
        let lines = render_markdown("Hello world.", 80);
        assert_eq!(text_of(&lines), "Hello world.");
    }

    #[test]
    fn soft_break_becomes_space() {
        let lines = render_markdown("Hello\nworld.", 80);
        assert_eq!(text_of(&lines), "Hello world.");
    }

    #[test]
    fn hard_break_inserts_newline() {
        let lines = render_markdown("Hello  \nworld.", 80);
        assert_eq!(text_of(&lines), "Hello\nworld.");
    }

    #[test]
    fn headings_become_bold() {
        let lines = render_markdown("# Title\n\nbody", 80);
        let h1 = lines.first().expect("h1 line");
        let span = h1.spans.first().expect("heading span");
        assert!(span.style.add_modifier.contains(Modifier::BOLD));
        assert_eq!(span.content, "Title");
    }

    #[test]
    fn inline_code_renders_cyan() {
        let lines = render_markdown("press `Enter` to send", 80);
        let line = lines.first().expect("line");
        let code_span = line
            .spans
            .iter()
            .find(|s| s.content.as_ref() == "Enter")
            .expect("code span");
        assert_eq!(code_span.style.fg, Some(Color::Cyan));
    }

    #[test]
    fn bold_and_italic_combine_modifiers() {
        let lines = render_markdown("**_both_**", 80);
        let line = lines.first().expect("line");
        let span = line
            .spans
            .iter()
            .find(|s| s.content.as_ref() == "both")
            .expect("inner span");
        assert!(span.style.add_modifier.contains(Modifier::BOLD));
        assert!(span.style.add_modifier.contains(Modifier::ITALIC));
    }

    #[test]
    fn unordered_list_uses_bullet() {
        let lines = render_markdown("- one\n- two", 80);
        let rendered = text_of(&lines);
        assert!(rendered.contains("• one"));
        assert!(rendered.contains("• two"));
    }

    #[test]
    fn ordered_list_uses_numbers() {
        let lines = render_markdown("1. first\n2. second", 80);
        let rendered = text_of(&lines);
        assert!(rendered.contains("1. first"));
        assert!(rendered.contains("2. second"));
    }

    #[test]
    fn fenced_code_block_renders_lines_cyan() {
        let src = "```rust\nfn main() {}\nprintln!();\n```";
        let lines = render_markdown(src, 80);
        let rendered = text_of(&lines);
        assert!(rendered.contains("fn main() {}"));
        assert!(rendered.contains("println!();"));
        // First code line should be cyan
        let fn_line = lines
            .iter()
            .find(|l| l.spans.iter().any(|s| s.content.as_ref() == "fn main() {}"))
            .expect("fn line");
        let fn_span = fn_line.spans.first().expect("span");
        assert_eq!(fn_span.style.fg, Some(Color::Cyan));
    }

    #[test]
    fn blockquote_gets_green_prefix() {
        let lines = render_markdown("> quoted", 80);
        let line = lines.first().expect("line");
        let prefix = line.spans.first().expect("prefix span");
        assert_eq!(prefix.style.fg, Some(Color::Green));
        assert!(prefix.content.starts_with('▌'));
    }

    #[test]
    fn link_text_is_cyan_underlined() {
        let lines = render_markdown("see [docs](https://example.com)", 80);
        let line = lines.first().expect("line");
        let link_span = line
            .spans
            .iter()
            .find(|s| s.content.as_ref() == "docs")
            .expect("link text");
        assert_eq!(link_span.style.fg, Some(Color::Cyan));
        assert!(link_span.style.add_modifier.contains(Modifier::UNDERLINED));
    }

    #[test]
    fn trailing_blank_lines_stripped() {
        let lines = render_markdown("hello\n\n\n", 80);
        assert_eq!(lines.len(), 1);
        assert_eq!(text_of(&lines), "hello");
    }
}
