#![allow(dead_code)] // Phase 2: consumed by Issue Detail + MR Detail screens

//! Discussion tree widget for entity detail screens.
//!
//! Renders threaded conversations from GitLab issues/MRs. Discussions are
//! top-level expandable nodes, with notes as children. Supports expand/collapse
//! persistence, system note styling, and diff note file path rendering.

use std::collections::HashSet;

use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;

use crate::clock::Clock;
use crate::safety::{UrlPolicy, sanitize_for_terminal};

// ---------------------------------------------------------------------------
// Data types
// ---------------------------------------------------------------------------

/// A single discussion thread (top-level node).
#[derive(Debug, Clone)]
pub struct DiscussionNode {
    /// GitLab discussion ID (used as expand/collapse key).
    pub discussion_id: String,
    /// Notes within this discussion, ordered by position.
    pub notes: Vec<NoteNode>,
    /// Whether this discussion is resolvable (MR discussions only).
    pub resolvable: bool,
    /// Whether this discussion has been resolved.
    pub resolved: bool,
}

impl DiscussionNode {
    /// Summary line for collapsed display.
    fn summary(&self) -> String {
        let first = self.notes.first();
        let author = first.map_or("unknown", |n| n.author.as_str());
        let note_count = self.notes.len();
        let resolved_tag = if self.resolved { " [resolved]" } else { "" };

        if note_count == 1 {
            format!("{author}{resolved_tag}")
        } else {
            format!("{author} ({note_count} notes){resolved_tag}")
        }
    }

    /// First line of the first note body, sanitized and truncated.
    fn preview(&self, max_chars: usize) -> String {
        self.notes
            .first()
            .and_then(|n| n.body.lines().next())
            .map(|line| {
                let sanitized = sanitize_for_terminal(line, UrlPolicy::Strip);
                if sanitized.len() > max_chars {
                    let trunc = max_chars.saturating_sub(3);
                    // Find the last valid char boundary at or before `trunc`
                    // to avoid panicking on multi-byte UTF-8 (emoji, CJK).
                    let safe_end = sanitized
                        .char_indices()
                        .take_while(|&(i, _)| i <= trunc)
                        .last()
                        .map_or(0, |(i, c)| i + c.len_utf8());
                    format!("{}...", &sanitized[..safe_end])
                } else {
                    sanitized
                }
            })
            .unwrap_or_default()
    }
}

/// A single note within a discussion.
#[derive(Debug, Clone)]
pub struct NoteNode {
    /// Author username.
    pub author: String,
    /// Note body (markdown text from GitLab).
    pub body: String,
    /// Creation timestamp in milliseconds since epoch.
    pub created_at: i64,
    /// Whether this is a system-generated note.
    pub is_system: bool,
    /// Whether this is a diff/code review note.
    pub is_diff_note: bool,
    /// File path for diff notes.
    pub diff_file_path: Option<String>,
    /// New line number for diff notes.
    pub diff_new_line: Option<i64>,
}

// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------

/// Rendering state for the discussion tree.
#[derive(Debug, Clone, Default)]
pub struct DiscussionTreeState {
    /// Index of the selected discussion (0-based).
    pub selected: usize,
    /// First visible row index for scrolling.
    pub scroll_offset: usize,
    /// Set of expanded discussion IDs.
    pub expanded: HashSet<String>,
}

impl DiscussionTreeState {
    /// Move selection down.
    pub fn select_next(&mut self, total: usize) {
        if total > 0 && self.selected < total - 1 {
            self.selected += 1;
        }
    }

    /// Move selection up.
    pub fn select_prev(&mut self) {
        self.selected = self.selected.saturating_sub(1);
    }

    /// Toggle expand/collapse for the selected discussion.
    pub fn toggle_selected(&mut self, discussions: &[DiscussionNode]) {
        if let Some(d) = discussions.get(self.selected) {
            let id = &d.discussion_id;
            if self.expanded.contains(id) {
                self.expanded.remove(id);
            } else {
                self.expanded.insert(id.clone());
            }
        }
    }

    /// Whether a discussion is expanded.
    #[must_use]
    pub fn is_expanded(&self, discussion_id: &str) -> bool {
        self.expanded.contains(discussion_id)
    }
}

// ---------------------------------------------------------------------------
// Colors
// ---------------------------------------------------------------------------

/// Color scheme for discussion tree rendering.
pub struct DiscussionTreeColors {
    /// Author name foreground.
    pub author_fg: PackedRgba,
    /// Timestamp foreground.
    pub timestamp_fg: PackedRgba,
    /// Note body foreground.
    pub body_fg: PackedRgba,
    /// System note foreground (muted).
    pub system_fg: PackedRgba,
    /// Diff file path foreground.
    pub diff_path_fg: PackedRgba,
    /// Resolved indicator foreground.
    pub resolved_fg: PackedRgba,
    /// Tree guide characters.
    pub guide_fg: PackedRgba,
    /// Selected discussion background.
    pub selected_fg: PackedRgba,
    /// Selected discussion background.
    pub selected_bg: PackedRgba,
    /// Expand/collapse indicator.
    pub expand_fg: PackedRgba,
}

// ---------------------------------------------------------------------------
// Relative time formatting
// ---------------------------------------------------------------------------

/// Format a timestamp as a human-readable relative time string.
///
/// Uses the provided `Clock` for deterministic rendering in tests.
#[must_use]
pub fn format_relative_time(epoch_ms: i64, clock: &dyn Clock) -> String {
    let now_ms = clock.now_ms();
    let diff_ms = now_ms.saturating_sub(epoch_ms);

    if diff_ms < 0 {
        return "just now".to_string();
    }

    let seconds = diff_ms / 1_000;
    let minutes = seconds / 60;
    let hours = minutes / 60;
    let days = hours / 24;
    let weeks = days / 7;
    let months = days / 30;

    if seconds < 60 {
        "just now".to_string()
    } else if minutes < 60 {
        format!("{minutes}m ago")
    } else if hours < 24 {
        format!("{hours}h ago")
    } else if days < 7 {
        format!("{days}d ago")
    } else if weeks < 4 {
        format!("{weeks}w ago")
    } else {
        format!("{months}mo ago")
    }
}

// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------

/// Maximum indent depth for nested content (notes within discussions).
const INDENT: u16 = 4;

/// Render a discussion tree within the given area.
///
/// Returns the number of rows consumed.
///
/// Layout:
/// ```text
/// > alice (3 notes) [resolved]          <- collapsed discussion
///   First line of note body preview...
///
/// v bob (2 notes)                       <- expanded discussion
///   |  bob · 3h ago
///   |  This is the first note body...
///   |
///   |  alice · 1h ago                   <- diff note
///   |  diff src/auth.rs:42
///   |  Code review comment about...
/// ```
pub fn render_discussion_tree(
    frame: &mut Frame<'_>,
    discussions: &[DiscussionNode],
    state: &DiscussionTreeState,
    area: Rect,
    colors: &DiscussionTreeColors,
    clock: &dyn Clock,
) -> u16 {
    if discussions.is_empty() || area.height == 0 || area.width < 15 {
        return 0;
    }

    let max_x = area.x.saturating_add(area.width);
    let mut y = area.y;
    let y_max = area.y.saturating_add(area.height);

    // Pre-compute all visual rows to support scroll offset.
    let rows = compute_visual_rows_with_clock(
        discussions,
        state,
        max_x.saturating_sub(area.x) as usize,
        clock,
    );

    // Apply scroll offset.
    let visible_rows = rows
        .iter()
        .skip(state.scroll_offset)
        .take(area.height as usize);

    for row in visible_rows {
        if y >= y_max {
            break;
        }

        match row {
            VisualRow::DiscussionHeader {
                disc_idx,
                expanded,
                summary,
                preview,
            } => {
                let is_selected = *disc_idx == state.selected;

                // Background fill for selected.
                if is_selected {
                    frame.draw_rect_filled(
                        Rect::new(area.x, y, area.width, 1),
                        Cell {
                            fg: colors.selected_fg,
                            bg: colors.selected_bg,
                            ..Cell::default()
                        },
                    );
                }

                let style = if is_selected {
                    Cell {
                        fg: colors.selected_fg,
                        bg: colors.selected_bg,
                        ..Cell::default()
                    }
                } else {
                    Cell {
                        fg: colors.author_fg,
                        ..Cell::default()
                    }
                };

                let indicator = if *expanded { "v " } else { "> " };
                let mut x = frame.print_text_clipped(area.x, y, indicator, style, max_x);
                x = frame.print_text_clipped(x, y, summary, style, max_x);

                // Show preview on same line for collapsed.
                if !expanded && !preview.is_empty() {
                    let preview_style = if is_selected {
                        style
                    } else {
                        Cell {
                            fg: colors.timestamp_fg,
                            ..Cell::default()
                        }
                    };
                    x = frame.print_text_clipped(x, y, " - ", preview_style, max_x);
                    let _ = frame.print_text_clipped(x, y, preview, preview_style, max_x);
                }

                y += 1;
            }

            VisualRow::NoteHeader {
                author,
                relative_time,
                is_system,
                ..
            } => {
                let style = if *is_system {
                    Cell {
                        fg: colors.system_fg,
                        ..Cell::default()
                    }
                } else {
                    Cell {
                        fg: colors.author_fg,
                        ..Cell::default()
                    }
                };

                let guide_style = Cell {
                    fg: colors.guide_fg,
                    ..Cell::default()
                };

                let indent_x = area.x.saturating_add(INDENT);
                let mut x = frame.print_text_clipped(area.x, y, "  | ", guide_style, max_x);
                x = frame.print_text_clipped(x.max(indent_x), y, author, style, max_x);

                let time_style = Cell {
                    fg: colors.timestamp_fg,
                    ..Cell::default()
                };
                x = frame.print_text_clipped(x, y, " · ", time_style, max_x);
                let _ = frame.print_text_clipped(x, y, relative_time, time_style, max_x);

                y += 1;
            }

            VisualRow::DiffPath { file_path, line } => {
                let guide_style = Cell {
                    fg: colors.guide_fg,
                    ..Cell::default()
                };
                let path_style = Cell {
                    fg: colors.diff_path_fg,
                    ..Cell::default()
                };

                let mut x = frame.print_text_clipped(area.x, y, "  | ", guide_style, max_x);
                let indent_x = area.x.saturating_add(INDENT);
                x = x.max(indent_x);

                let location = match line {
                    Some(l) => format!("diff {file_path}:{l}"),
                    None => format!("diff {file_path}"),
                };
                let _ = frame.print_text_clipped(x, y, &location, path_style, max_x);

                y += 1;
            }

            VisualRow::BodyLine { text, is_system } => {
                let guide_style = Cell {
                    fg: colors.guide_fg,
                    ..Cell::default()
                };
                let body_style = if *is_system {
                    Cell {
                        fg: colors.system_fg,
                        ..Cell::default()
                    }
                } else {
                    Cell {
                        fg: colors.body_fg,
                        ..Cell::default()
                    }
                };

                let mut x = frame.print_text_clipped(area.x, y, "  | ", guide_style, max_x);
                let indent_x = area.x.saturating_add(INDENT);
                x = x.max(indent_x);
                let _ = frame.print_text_clipped(x, y, text, body_style, max_x);

                y += 1;
            }

            VisualRow::Separator => {
                let guide_style = Cell {
                    fg: colors.guide_fg,
                    ..Cell::default()
                };
                let _ = frame.print_text_clipped(area.x, y, "  |", guide_style, max_x);
                y += 1;
            }
        }
    }

    y.saturating_sub(area.y)
}

// ---------------------------------------------------------------------------
// Visual row computation
// ---------------------------------------------------------------------------

/// Pre-computed visual row for the discussion tree.
///
/// We flatten the tree into rows to support scroll offset correctly.
#[derive(Debug)]
enum VisualRow {
    /// Discussion header (collapsed or expanded).
    DiscussionHeader {
        disc_idx: usize,
        expanded: bool,
        summary: String,
        preview: String,
    },
    /// Note author + timestamp line.
    NoteHeader {
        author: String,
        relative_time: String,
        is_system: bool,
    },
    /// Diff note file path line.
    DiffPath {
        file_path: String,
        line: Option<i64>,
    },
    /// Note body text line.
    BodyLine { text: String, is_system: bool },
    /// Blank separator between notes.
    Separator,
}

/// Maximum body lines shown per note to prevent one huge note from
/// consuming the entire viewport.
const MAX_BODY_LINES: usize = 10;

/// Compute visual rows with relative timestamps from the clock.
fn compute_visual_rows_with_clock(
    discussions: &[DiscussionNode],
    state: &DiscussionTreeState,
    available_width: usize,
    clock: &dyn Clock,
) -> Vec<VisualRow> {
    let mut rows = Vec::new();
    let preview_max = available_width.saturating_sub(40).max(20);

    for (idx, disc) in discussions.iter().enumerate() {
        let expanded = state.is_expanded(&disc.discussion_id);

        rows.push(VisualRow::DiscussionHeader {
            disc_idx: idx,
            expanded,
            summary: disc.summary(),
            preview: if expanded {
                String::new()
            } else {
                disc.preview(preview_max)
            },
        });

        if expanded {
            for (note_idx, note) in disc.notes.iter().enumerate() {
                if note_idx > 0 {
                    rows.push(VisualRow::Separator);
                }

                rows.push(VisualRow::NoteHeader {
                    author: note.author.clone(),
                    relative_time: format_relative_time(note.created_at, clock),
                    is_system: note.is_system,
                });

                if note.is_diff_note
                    && let Some(ref path) = note.diff_file_path
                {
                    rows.push(VisualRow::DiffPath {
                        file_path: path.clone(),
                        line: note.diff_new_line,
                    });
                }

                let sanitized = sanitize_for_terminal(&note.body, UrlPolicy::Strip);
                for (line_idx, line) in sanitized.lines().enumerate() {
                    if line_idx >= MAX_BODY_LINES {
                        rows.push(VisualRow::BodyLine {
                            text: "...".to_string(),
                            is_system: note.is_system,
                        });
                        break;
                    }
                    rows.push(VisualRow::BodyLine {
                        text: line.to_string(),
                        is_system: note.is_system,
                    });
                }
            }
        }
    }

    rows
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::clock::FakeClock;
    use ftui::render::grapheme_pool::GraphemePool;

    macro_rules! with_frame {
        ($width:expr, $height:expr, |$frame:ident| $body:block) => {{
            let mut pool = GraphemePool::new();
            let mut $frame = Frame::new($width, $height, &mut pool);
            $body
        }};
    }

    fn sample_note(author: &str, body: &str, created_at: i64) -> NoteNode {
        NoteNode {
            author: author.into(),
            body: body.into(),
            created_at,
            is_system: false,
            is_diff_note: false,
            diff_file_path: None,
            diff_new_line: None,
        }
    }

    fn system_note(body: &str, created_at: i64) -> NoteNode {
        NoteNode {
            author: "system".into(),
            body: body.into(),
            created_at,
            is_system: true,
            is_diff_note: false,
            diff_file_path: None,
            diff_new_line: None,
        }
    }

    fn diff_note(author: &str, body: &str, path: &str, line: i64, created_at: i64) -> NoteNode {
        NoteNode {
            author: author.into(),
            body: body.into(),
            created_at,
            is_system: false,
            is_diff_note: true,
            diff_file_path: Some(path.into()),
            diff_new_line: Some(line),
        }
    }

    fn sample_discussions() -> Vec<DiscussionNode> {
        vec![
            DiscussionNode {
                discussion_id: "disc-1".into(),
                notes: vec![
                    sample_note("alice", "This looks good overall", 1_700_000_000_000),
                    sample_note("bob", "Agreed, but one concern", 1_700_000_060_000),
                ],
                resolvable: false,
                resolved: false,
            },
            DiscussionNode {
                discussion_id: "disc-2".into(),
                notes: vec![diff_note(
                    "charlie",
                    "This function needs error handling",
                    "src/auth.rs",
                    42,
                    1_700_000_120_000,
                )],
                resolvable: true,
                resolved: true,
            },
            DiscussionNode {
                discussion_id: "disc-3".into(),
                notes: vec![system_note("changed the description", 1_700_000_180_000)],
                resolvable: false,
                resolved: false,
            },
        ]
    }

    fn test_colors() -> DiscussionTreeColors {
        DiscussionTreeColors {
            author_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
            timestamp_fg: PackedRgba::rgb(0x87, 0x87, 0x80),
            body_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
            system_fg: PackedRgba::rgb(0x6F, 0x6E, 0x69),
            diff_path_fg: PackedRgba::rgb(0x87, 0x96, 0x6B),
            resolved_fg: PackedRgba::rgb(0x87, 0x96, 0x6B),
            guide_fg: PackedRgba::rgb(0x40, 0x40, 0x3C),
            selected_fg: PackedRgba::rgb(0x10, 0x0F, 0x0F),
            selected_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
            expand_fg: PackedRgba::rgb(0xDA, 0x70, 0x2C),
        }
    }

    // Clock set to 1h after the last sample note.
    fn test_clock() -> FakeClock {
        FakeClock::from_ms(1_700_000_180_000 + 3_600_000)
    }

    #[test]
    fn test_format_relative_time_just_now() {
        let clock = FakeClock::from_ms(1_000_000);
        assert_eq!(format_relative_time(1_000_000, &clock), "just now");
        assert_eq!(format_relative_time(999_990, &clock), "just now");
    }

    #[test]
    fn test_format_relative_time_minutes() {
        let clock = FakeClock::from_ms(1_000_000 + 5 * 60 * 1_000);
        assert_eq!(format_relative_time(1_000_000, &clock), "5m ago");
    }

    #[test]
    fn test_format_relative_time_hours() {
        let clock = FakeClock::from_ms(1_000_000 + 3 * 3_600 * 1_000);
        assert_eq!(format_relative_time(1_000_000, &clock), "3h ago");
    }

    #[test]
    fn test_format_relative_time_days() {
        let clock = FakeClock::from_ms(1_000_000 + 2 * 86_400 * 1_000);
        assert_eq!(format_relative_time(1_000_000, &clock), "2d ago");
    }

    #[test]
    fn test_format_relative_time_weeks() {
        let clock = FakeClock::from_ms(1_000_000 + 14 * 86_400 * 1_000);
        assert_eq!(format_relative_time(1_000_000, &clock), "2w ago");
    }

    #[test]
    fn test_format_relative_time_months() {
        let clock = FakeClock::from_ms(1_000_000 + 60 * 86_400 * 1_000);
        assert_eq!(format_relative_time(1_000_000, &clock), "2mo ago");
    }

    #[test]
    fn test_discussion_node_summary() {
        let disc = DiscussionNode {
            discussion_id: "d1".into(),
            notes: vec![
                sample_note("alice", "body", 0),
                sample_note("bob", "reply", 1000),
            ],
            resolvable: false,
            resolved: false,
        };
        assert_eq!(disc.summary(), "alice (2 notes)");
    }

    #[test]
    fn test_discussion_node_summary_single() {
        let disc = DiscussionNode {
            discussion_id: "d1".into(),
            notes: vec![sample_note("alice", "body", 0)],
            resolvable: false,
            resolved: false,
        };
        assert_eq!(disc.summary(), "alice");
    }

    #[test]
    fn test_discussion_node_summary_resolved() {
        let disc = DiscussionNode {
            discussion_id: "d1".into(),
            notes: vec![sample_note("alice", "body", 0)],
            resolvable: true,
            resolved: true,
        };
        assert_eq!(disc.summary(), "alice [resolved]");
    }

    #[test]
    fn test_discussion_node_preview() {
        let disc = DiscussionNode {
            discussion_id: "d1".into(),
            notes: vec![sample_note("alice", "First line\nSecond line", 0)],
            resolvable: false,
            resolved: false,
        };
        assert_eq!(disc.preview(50), "First line");
    }

    #[test]
    fn test_discussion_tree_state_navigation() {
        let mut state = DiscussionTreeState::default();
        assert_eq!(state.selected, 0);

        state.select_next(3);
        assert_eq!(state.selected, 1);

        state.select_next(3);
        assert_eq!(state.selected, 2);

        state.select_next(3);
        assert_eq!(state.selected, 2);

        state.select_prev();
        assert_eq!(state.selected, 1);

        state.select_prev();
        assert_eq!(state.selected, 0);

        state.select_prev();
        assert_eq!(state.selected, 0);
    }

    #[test]
    fn test_discussion_tree_state_toggle() {
        let discussions = sample_discussions();
        let mut state = DiscussionTreeState::default();

        assert!(!state.is_expanded("disc-1"));

        state.toggle_selected(&discussions);
        assert!(state.is_expanded("disc-1"));

        state.toggle_selected(&discussions);
        assert!(!state.is_expanded("disc-1"));
    }

    #[test]
    fn test_render_discussion_tree_collapsed_no_panic() {
        with_frame!(80, 20, |frame| {
            let discussions = sample_discussions();
            let state = DiscussionTreeState::default();
            let clock = test_clock();
            let rows = render_discussion_tree(
                &mut frame,
                &discussions,
                &state,
                Rect::new(0, 0, 80, 20),
                &test_colors(),
                &clock,
            );
            // 3 discussions, all collapsed = 3 rows.
            assert_eq!(rows, 3);
        });
    }

    #[test]
    fn test_render_discussion_tree_expanded_no_panic() {
        with_frame!(80, 30, |frame| {
            let discussions = sample_discussions();
            let mut state = DiscussionTreeState::default();
            state.expanded.insert("disc-1".into());
            let clock = test_clock();
            let rows = render_discussion_tree(
                &mut frame,
                &discussions,
                &state,
                Rect::new(0, 0, 80, 30),
                &test_colors(),
                &clock,
            );
            // disc-1 expanded: header + 2 notes (each: header + body line) + separator between
            // = 1 + (1+1) + 1 + (1+1) = 6 rows from disc-1
            // disc-2 collapsed: 1 row
            // disc-3 collapsed: 1 row
            // Total: 8
            assert!(rows >= 6); // At least disc-1 content + 2 collapsed.
        });
    }

    #[test]
    fn test_render_discussion_tree_empty() {
        with_frame!(80, 20, |frame| {
            let state = DiscussionTreeState::default();
            let clock = test_clock();
            let rows = render_discussion_tree(
                &mut frame,
                &[],
                &state,
                Rect::new(0, 0, 80, 20),
                &test_colors(),
                &clock,
            );
            assert_eq!(rows, 0);
        });
    }

    #[test]
    fn test_render_discussion_tree_tiny_area() {
        with_frame!(10, 2, |frame| {
            let discussions = sample_discussions();
            let state = DiscussionTreeState::default();
            let clock = test_clock();
            let rows = render_discussion_tree(
                &mut frame,
                &discussions,
                &state,
                Rect::new(0, 0, 10, 2),
                &test_colors(),
                &clock,
            );
            // Too narrow (< 15), should bail.
            assert_eq!(rows, 0);
        });
    }

    #[test]
    fn test_render_discussion_tree_with_diff_note() {
        with_frame!(80, 30, |frame| {
            let discussions = vec![DiscussionNode {
                discussion_id: "diff-disc".into(),
                notes: vec![diff_note(
                    "reviewer",
                    "Add error handling here",
                    "src/main.rs",
                    42,
                    1_700_000_000_000,
                )],
                resolvable: true,
                resolved: false,
            }];
            let mut state = DiscussionTreeState::default();
            state.expanded.insert("diff-disc".into());
            let clock = test_clock();
            let rows = render_discussion_tree(
                &mut frame,
                &discussions,
                &state,
                Rect::new(0, 0, 80, 30),
                &test_colors(),
                &clock,
            );
            // header + note header + diff path + body line = 4
            assert!(rows >= 3);
        });
    }

    #[test]
    fn test_render_discussion_tree_system_note() {
        with_frame!(80, 20, |frame| {
            let discussions = vec![DiscussionNode {
                discussion_id: "sys-disc".into(),
                notes: vec![system_note("changed the description", 1_700_000_000_000)],
                resolvable: false,
                resolved: false,
            }];
            let mut state = DiscussionTreeState::default();
            state.expanded.insert("sys-disc".into());
            let clock = test_clock();
            let rows = render_discussion_tree(
                &mut frame,
                &discussions,
                &state,
                Rect::new(0, 0, 80, 20),
                &test_colors(),
                &clock,
            );
            assert!(rows >= 2);
        });
    }

    #[test]
    fn test_compute_visual_rows_collapsed() {
        let discussions = sample_discussions();
        let state = DiscussionTreeState::default();
        let clock = test_clock();
        let rows = compute_visual_rows_with_clock(&discussions, &state, 80, &clock);

        // 3 collapsed headers.
        assert_eq!(rows.len(), 3);
        assert!(matches!(
            rows[0],
            VisualRow::DiscussionHeader {
                expanded: false,
                ..
            }
        ));
    }

    #[test]
    fn test_compute_visual_rows_expanded() {
        let discussions = sample_discussions();
        let mut state = DiscussionTreeState::default();
        state.expanded.insert("disc-1".into());
        let clock = test_clock();
        let rows = compute_visual_rows_with_clock(&discussions, &state, 80, &clock);

        // disc-1: header + note1 (header + body) + separator + note2 (header + body) = 6
        // disc-2: 1 header
        // disc-3: 1 header
        // Total: 8
        assert!(rows.len() >= 6);
        assert!(matches!(
            rows[0],
            VisualRow::DiscussionHeader { expanded: true, .. }
        ));
    }

    #[test]
    fn test_long_body_truncation() {
        let long_body = (0..20)
            .map(|i| format!("Line {i} of a very long discussion note"))
            .collect::<Vec<_>>()
            .join("\n");

        let discussions = vec![DiscussionNode {
            discussion_id: "long".into(),
            notes: vec![sample_note("alice", &long_body, 1_700_000_000_000)],
            resolvable: false,
            resolved: false,
        }];
        let mut state = DiscussionTreeState::default();
        state.expanded.insert("long".into());
        let clock = test_clock();
        let rows = compute_visual_rows_with_clock(&discussions, &state, 80, &clock);

        // Header + note header + MAX_BODY_LINES + 1 ("...") = 1 + 1 + 10 + 1 = 13
        let body_lines: Vec<_> = rows
            .iter()
            .filter(|r| matches!(r, VisualRow::BodyLine { .. }))
            .collect();
        // Should cap at MAX_BODY_LINES + 1 (for the "..." truncation line).
        assert!(body_lines.len() <= MAX_BODY_LINES + 1);
    }

    #[test]
    fn test_preview_multibyte_utf8_no_panic() {
        // Emoji are 4 bytes each. Truncating at a byte boundary that falls
        // inside a multi-byte char must not panic.
        let disc = DiscussionNode {
            discussion_id: "d-utf8".into(),
            notes: vec![sample_note(
                "alice",
                "Hello 🌍🌎🌏 world of emoji 🎉🎊🎈",
                0,
            )],
            resolvable: false,
            resolved: false,
        };
        // max_chars=10 would land inside the first emoji's bytes.
        let preview = disc.preview(10);
        assert!(preview.ends_with("..."));
        assert!(preview.len() <= 20); // char-bounded + "..."

        // Edge: max_chars smaller than a single multi-byte char.
        let disc2 = DiscussionNode {
            discussion_id: "d-utf8-2".into(),
            notes: vec![sample_note("bob", "🌍🌎🌏", 0)],
            resolvable: false,
            resolved: false,
        };
        let preview2 = disc2.preview(3);
        assert!(preview2.ends_with("..."));
    }
}
