//! Render cache for fullscreen TUI history panel.
//!
//! Stores styled `Line`s in a `VecDeque` and provides virtual scrolling
//! with "sticky bottom" behavior. The buffer is a **render cache** — the
//! DB is the source of truth. Lines evicted from the buffer can be
//! re-rendered from DB on demand.
//!
//! See #472 for the fullscreen migration RFC.

use ratatui::text::Line;
use std::collections::VecDeque;

/// Maximum lines held in the render cache.
/// Enough for ~50 pages of scroll-up at 50 lines/page.
const MAX_CACHE_LINES: usize = 2500;

/// Scrollable buffer of rendered `Line`s with sticky-bottom auto-scroll.
pub struct ScrollBuffer {
    /// The rendered lines (ring buffer).
    lines: VecDeque<Line<'static>>,

    /// Per-line gutter width (columns to skip during NoSelect copy).
    /// 0 = no gutter (normal text). Parallel with `lines`.
    gutter_widths: VecDeque<u16>,

    /// Scroll offset: number of lines scrolled UP from the bottom.
    /// 0 = viewing the bottom (latest content).
    scroll_offset: usize,

    /// When true, new lines auto-scroll to keep the bottom visible.
    /// Disengages when the user scrolls up; re-engages when they
    /// scroll back to the bottom.
    sticky_bottom: bool,

    /// Oldest DB message ID currently rendered in the buffer.
    /// Used by virtual scroll to know which page to fetch next.
    /// `None` means no DB messages have been loaded yet.
    oldest_message_id: Option<i64>,

    /// Cached terminal width for eviction offset adjustment.
    /// Updated on scroll operations and used by `enforce_capacity()`
    /// to compute visual height of evicted lines.
    cached_term_width: usize,
}

impl ScrollBuffer {
    pub fn new(capacity: usize) -> Self {
        let cap = capacity.min(4096);
        Self {
            lines: VecDeque::with_capacity(cap),
            gutter_widths: VecDeque::with_capacity(cap),
            scroll_offset: 0,
            sticky_bottom: true,
            oldest_message_id: None,
            cached_term_width: 80,
        }
    }

    /// Append a single line to the buffer.
    ///
    /// If sticky bottom is active, the view stays pinned to the latest
    /// content. If the buffer exceeds the max cache size, the oldest
    /// lines are evicted from the front.
    pub fn push(&mut self, line: Line<'static>) {
        self.lines.push_back(line);
        self.gutter_widths.push_back(0);
        self.enforce_capacity();

        // If sticky, keep scroll at bottom
        if self.sticky_bottom {
            self.scroll_offset = 0;
        }
    }

    /// Append a line with a gutter width (for NoSelect diff lines).
    pub fn push_with_gutter(&mut self, line: Line<'static>, gutter_width: u16) {
        self.lines.push_back(line);
        self.gutter_widths.push_back(gutter_width);
        self.enforce_capacity();

        if self.sticky_bottom {
            self.scroll_offset = 0;
        }
    }

    /// Append multiple lines at once.
    pub fn push_lines(&mut self, lines: impl IntoIterator<Item = Line<'static>>) {
        for line in lines {
            self.lines.push_back(line);
            self.gutter_widths.push_back(0);
        }
        self.enforce_capacity();

        if self.sticky_bottom {
            self.scroll_offset = 0;
        }
    }

    /// Scroll up by `n` visual lines. Disengages sticky bottom.
    pub fn scroll_up(&mut self, n: usize, term_width: usize, viewport_height: usize) {
        self.cached_term_width = term_width;
        let total = self.total_visual_lines(term_width);
        let max_offset = total.saturating_sub(viewport_height);
        self.scroll_offset = (self.scroll_offset + n).min(max_offset);
        self.sticky_bottom = false;
    }

    /// Scroll down by `n` lines. Re-engages sticky bottom if we reach
    /// the bottom.
    pub fn scroll_down(&mut self, n: usize) {
        self.scroll_offset = self.scroll_offset.saturating_sub(n);
        if self.scroll_offset == 0 {
            self.sticky_bottom = true;
        }
    }

    /// Re-clamp `scroll_offset` to valid bounds for the current
    /// terminal dimensions. Must be called after any resize so the
    /// offset doesn't exceed `total_visual - viewport_height`.
    pub fn clamp_offset(&mut self, term_width: usize, viewport_height: usize) {
        self.cached_term_width = term_width;
        let total = self.total_visual_lines(term_width);
        let max_offset = total.saturating_sub(viewport_height);
        if self.scroll_offset > max_offset {
            self.scroll_offset = max_offset;
        }
        if self.scroll_offset == 0 {
            self.sticky_bottom = true;
        }
    }

    /// Jump to the bottom and re-engage sticky mode.
    pub fn scroll_to_bottom(&mut self) {
        self.scroll_offset = 0;
        self.sticky_bottom = true;
    }

    /// Jump to the top of the buffer.
    pub fn scroll_to_top(&mut self, term_width: usize, viewport_height: usize) {
        self.cached_term_width = term_width;
        if !self.lines.is_empty() {
            let total = self.total_visual_lines(term_width);
            self.scroll_offset = total.saturating_sub(viewport_height);
            self.sticky_bottom = false;
        }
    }

    /// Returns `true` when the user has scrolled to the very top of the
    /// buffer. Used to trigger loading older messages from the DB.
    #[allow(dead_code)] // wired when virtual scroll pagination lands
    pub fn at_top(&self, term_width: usize, viewport_height: usize) -> bool {
        if self.lines.is_empty() {
            return false;
        }
        let total = self.total_visual_lines(term_width);
        let max_offset = total.saturating_sub(viewport_height);
        self.scroll_offset >= max_offset && max_offset > 0
    }

    /// Return all lines in the buffer.
    ///
    /// Used by `render_history()` which passes everything to
    /// `Paragraph::wrap().scroll()` — ratatui handles the visual
    /// line math for word-wrapped content.
    pub fn all_lines(&self) -> impl Iterator<Item = &Line<'static>> {
        self.lines.iter()
    }

    /// Return the per-line gutter widths (parallel with `all_lines()`).
    ///
    /// Used by mouse selection to skip gutter columns during copy.
    pub fn gutter_widths(&self) -> &VecDeque<u16> {
        &self.gutter_widths
    }

    /// Compute the total number of visual (wrapped) lines at a given
    /// terminal width. Used for scrollbar state and offset clamping.
    pub fn total_visual_lines(&self, term_width: usize) -> usize {
        let w = term_width.max(1);
        self.lines.iter().map(|l| visual_height(l, w)).sum()
    }

    /// Compute the Paragraph scroll-from-top offset for the current
    /// scroll position. Returns `(row_offset, 0)` for `Paragraph::scroll()`.
    ///
    /// `scroll_offset` is visual lines from the bottom.
    /// Paragraph wants visual lines from the top.
    pub fn paragraph_scroll(&self, viewport_height: usize, term_width: usize) -> (u16, u16) {
        let total = self.total_visual_lines(term_width);
        let from_top = total
            .saturating_sub(viewport_height)
            .saturating_sub(self.scroll_offset);
        // Clamp to u16::MAX to prevent silent truncation (#528).
        (from_top.min(u16::MAX as usize) as u16, 0)
    }

    /// Total number of lines in the buffer.
    pub fn len(&self) -> usize {
        self.lines.len()
    }

    /// Whether the buffer is empty.
    #[allow(dead_code)]
    pub fn is_empty(&self) -> bool {
        self.lines.is_empty()
    }

    /// Current scroll offset (lines from bottom).
    pub fn offset(&self) -> usize {
        self.scroll_offset
    }

    /// Whether sticky bottom is active.
    pub fn is_sticky(&self) -> bool {
        self.sticky_bottom
    }

    /// Get the oldest DB message ID rendered in this buffer.
    #[allow(dead_code)] // wired when virtual scroll pagination lands
    pub fn oldest_message_id(&self) -> Option<i64> {
        self.oldest_message_id
    }

    /// Set the oldest DB message ID (called after rendering history).
    pub fn set_oldest_message_id(&mut self, id: i64) {
        self.oldest_message_id = Some(id);
    }

    /// Clear all lines and reset scroll state.
    #[allow(dead_code)]
    pub fn clear(&mut self) {
        self.lines.clear();
        self.gutter_widths.clear();
        self.scroll_offset = 0;
        self.sticky_bottom = true;
    }

    /// Evict oldest lines if we exceed capacity.
    ///
    /// Adjusts `scroll_offset` by the evicted line's visual height
    /// (not a flat 1) to prevent scroll position drift when wrapped
    /// lines are evicted (#528).
    fn enforce_capacity(&mut self) {
        let w = self.cached_term_width.max(1);
        while self.lines.len() > MAX_CACHE_LINES {
            if let Some(evicted) = self.lines.pop_front()
                && self.scroll_offset > 0
            {
                let vis = visual_height(&evicted, w);
                self.scroll_offset = self.scroll_offset.saturating_sub(vis);
            }
            self.gutter_widths.pop_front();
        }
    }

    /// Prepend lines at the top of the buffer (for DB-backed virtual scroll).
    ///
    /// Used when the user scrolls past the top of the cache and older
    /// messages are fetched from the DB and re-rendered.
    #[allow(dead_code)] // wired in a follow-up PR
    pub fn prepend_lines(&mut self, lines: impl IntoIterator<Item = Line<'static>>) {
        let lines: Vec<_> = lines.into_iter().collect();
        let count = lines.len();
        for line in lines.into_iter().rev() {
            self.lines.push_front(line);
            self.gutter_widths.push_front(0);
        }
        // Adjust scroll offset to keep the viewport stable
        // (content shifted down by `count` logical lines)
        self.scroll_offset += count;
        self.enforce_capacity();
    }
}

/// Extract plain text from a `Line` by concatenating all span contents.
fn line_text(line: &Line<'_>) -> String {
    line.spans.iter().map(|s| s.content.as_ref()).collect()
}

/// Compute how many visual rows a `Line` occupies at the given terminal width.
///
/// Delegates to `wrap_util::visual_line_count` — the single source of truth
/// for word-boundary wrapping consistent with `Wrap { trim: false }`.
fn visual_height(line: &Line<'_>, term_width: usize) -> usize {
    crate::wrap_util::visual_line_count(&line_text(line), term_width)
}

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

    const W: usize = 80; // test terminal width
    const H: usize = 50; // test viewport height

    fn make_line(text: &str) -> Line<'static> {
        Line::from(Span::raw(text.to_string()))
    }

    /// Collect the visible lines via paragraph_scroll logic.
    /// For tests with short lines that don't wrap, this matches the old visible_lines().
    fn visible_text(buf: &ScrollBuffer, height: usize) -> Vec<String> {
        let lines: Vec<Line<'_>> = buf.all_lines().cloned().collect();
        let total_visual = buf.total_visual_lines(W);
        let from_top = total_visual
            .saturating_sub(height)
            .saturating_sub(buf.offset());
        // Simulate what Paragraph would show
        lines
            .iter()
            .skip(from_top)
            .take(height)
            .map(line_text)
            .collect()
    }

    #[test]
    fn test_push_and_visible() {
        let mut buf = ScrollBuffer::new(2500);
        for i in 0..10 {
            buf.push(make_line(&format!("line {i}")));
        }
        assert_eq!(buf.len(), 10);

        // Viewport of 3 lines at bottom
        let visible = visible_text(&buf, 3);
        assert_eq!(visible.len(), 3);
        assert_eq!(visible[0], "line 7");
        assert_eq!(visible[1], "line 8");
        assert_eq!(visible[2], "line 9");
    }

    #[test]
    fn test_sticky_bottom() {
        let mut buf = ScrollBuffer::new(2500);
        for i in 0..5 {
            buf.push(make_line(&format!("line {i}")));
        }
        assert!(buf.is_sticky());
        assert_eq!(buf.offset(), 0);

        // New lines keep us at bottom
        buf.push(make_line("line 5"));
        assert_eq!(buf.offset(), 0);
        let visible = visible_text(&buf, 2);
        assert_eq!(visible[1], "line 5");
    }

    #[test]
    fn test_scroll_up_breaks_sticky() {
        let mut buf = ScrollBuffer::new(2500);
        for i in 0..10 {
            buf.push(make_line(&format!("line {i}")));
        }

        // Use viewport smaller than content so scroll has room
        buf.scroll_up(3, W, 5);
        assert!(!buf.is_sticky());
        assert_eq!(buf.offset(), 3);
    }

    #[test]
    fn test_scroll_down_restores_sticky() {
        let mut buf = ScrollBuffer::new(2500);
        for i in 0..10 {
            buf.push(make_line(&format!("line {i}")));
        }

        buf.scroll_up(5, W, 5);
        assert!(!buf.is_sticky());

        buf.scroll_down(5);
        assert!(buf.is_sticky());
        assert_eq!(buf.offset(), 0);
    }

    #[test]
    fn test_scroll_up_clamped() {
        let mut buf = ScrollBuffer::new(2500);
        for i in 0..5 {
            buf.push(make_line(&format!("line {i}")));
        }

        // Viewport height 3, 5 lines total → max offset = 5-3 = 2
        buf.scroll_up(100, W, 3);
        assert_eq!(buf.offset(), 2);
    }

    #[test]
    fn test_eviction() {
        let mut buf = ScrollBuffer::new(2500);
        for i in 0..MAX_CACHE_LINES + 100 {
            buf.push(make_line(&format!("line {i}")));
        }
        assert_eq!(buf.len(), MAX_CACHE_LINES);
        // Latest line should be at the bottom
        let visible = visible_text(&buf, 1);
        assert_eq!(visible[0], format!("line {}", MAX_CACHE_LINES + 99));
    }

    #[test]
    fn test_empty_buffer() {
        let buf = ScrollBuffer::new(2500);
        assert_eq!(buf.all_lines().count(), 0);
        assert_eq!(buf.total_visual_lines(80), 0);
    }

    #[test]
    fn test_scroll_to_top_and_bottom() {
        let mut buf = ScrollBuffer::new(2500);
        for i in 0..20 {
            buf.push(make_line(&format!("line {i}")));
        }

        // 20 lines, viewport 10 → max offset = 10
        buf.scroll_to_top(W, 10);
        assert!(!buf.is_sticky());
        assert_eq!(buf.offset(), 10);

        buf.scroll_to_bottom();
        assert!(buf.is_sticky());
        assert_eq!(buf.offset(), 0);
    }

    #[test]
    fn test_push_lines_batch() {
        let mut buf = ScrollBuffer::new(2500);
        let batch: Vec<Line<'static>> = (0..5).map(|i| make_line(&format!("line {i}"))).collect();
        buf.push_lines(batch);
        assert_eq!(buf.len(), 5);
    }

    #[test]
    fn test_eviction_adjusts_scroll_offset() {
        let mut buf = ScrollBuffer::new(2500);
        // Fill to capacity
        for i in 0..MAX_CACHE_LINES {
            buf.push(make_line(&format!("line {i}")));
        }
        // Scroll up
        buf.scroll_up(100, W, H);
        let offset_before = buf.offset();

        // Push more lines, triggering eviction
        for i in 0..50 {
            buf.push(make_line(&format!("new {i}")));
        }

        // Offset should have been adjusted down
        assert!(buf.offset() < offset_before);
        assert_eq!(buf.len(), MAX_CACHE_LINES);
    }

    // ── Visual line math ──

    #[test]
    fn test_visual_height_short_line() {
        let line = make_line("hello"); // 5 chars
        assert_eq!(visual_height(&line, 80), 1);
    }

    #[test]
    fn test_visual_height_wrapping_line() {
        // 160 chars in an 80-column terminal = 2 visual lines
        let line = make_line(&"x".repeat(160));
        assert_eq!(visual_height(&line, 80), 2);
    }

    #[test]
    fn test_visual_height_empty_line() {
        let line = make_line("");
        assert_eq!(visual_height(&line, 80), 1);
    }

    // ── Word-wrap regression tests (#520) ──

    #[test]
    fn test_visual_height_word_wrap_breaks_before_word() {
        // 76 chars of 'a' + space + 6-char word = 83 chars
        // Character wrap: row 1 = 80 chars, row 2 = 3 chars = 2 rows
        // Word wrap: row 1 = 76 chars + space (77), row 2 = 6-char word = 2 rows
        let text = format!("{}  foobar", "a".repeat(76));
        let line = make_line(&text);
        assert_eq!(visual_height(&line, 80), 2);
    }

    #[test]
    fn test_visual_height_word_wrap_longer_word() {
        // 78 chars + space + 5-char word = 84 chars
        // Character wrap: row 1 = 80 chars, row 2 = 4 chars = 2 rows
        // Word wrap: row 1 = 78+space (79), word "hello" doesn't fit (79+5=84>80)
        //   -> wrap before "hello": row 1 = 79, row 2 = 5 = 2 rows
        let text = format!("{} hello", "x".repeat(78));
        let line = make_line(&text);
        assert_eq!(visual_height(&line, 80), 2);
    }

    #[test]
    fn test_visual_height_word_wrap_three_rows() {
        // Two long words that each nearly fill a row + a short word
        // "<75 a's> <75 b's> end"
        let text = format!("{} {} end", "a".repeat(75), "b".repeat(75));
        let line = make_line(&text);
        // Row 1: 75 a's + space (76), "b"*75 doesn't fit (76+75=151>80)
        // Row 2: 75 b's + space (76), "end" fits (76+3=79<=80) -> actually...
        // Wait: row 2 starts with 75 b's = 75, + space = 76, + "end" = 79 <= 80
        // So: 2 rows
        // Actually: row 1 = "aaa... " (76), b's don't fit, wrap
        // row 2 = "bbb... end" (75+1+3=79)
        // = 2 rows
        assert_eq!(visual_height(&line, 80), 2);
    }

    #[test]
    fn test_visual_height_single_word_longer_than_width() {
        // Single word with no spaces, 200 chars at width 80
        // Must force-break: ceil(200/80) = 3 rows
        let line = make_line(&"x".repeat(200));
        assert_eq!(visual_height(&line, 80), 3);
    }

    #[test]
    fn test_visual_height_exact_width() {
        // Exactly 80 chars = 1 row, not 2
        let line = make_line(&"x".repeat(80));
        assert_eq!(visual_height(&line, 80), 1);
    }

    #[test]
    fn test_visual_height_exact_width_plus_one() {
        let line = make_line(&"x".repeat(81));
        assert_eq!(visual_height(&line, 80), 2);
    }

    #[test]
    fn test_total_visual_lines() {
        let mut buf = ScrollBuffer::new(2500);
        buf.push(make_line("short")); // 1 visual line
        buf.push(make_line(&"x".repeat(160))); // 2 visual lines
        buf.push(make_line("")); // 1 visual line
        assert_eq!(buf.total_visual_lines(80), 4);
    }

    #[test]
    fn test_paragraph_scroll_at_bottom() {
        let mut buf = ScrollBuffer::new(2500);
        for i in 0..20 {
            buf.push(make_line(&format!("line {i}")));
        }
        // At bottom: offset=0, viewport=10, total=20
        // → scroll from top = 20 - 10 - 0 = 10
        let (row, _) = buf.paragraph_scroll(10, 80);
        assert_eq!(row, 10);
    }

    #[test]
    fn test_paragraph_scroll_at_top() {
        let mut buf = ScrollBuffer::new(2500);
        for i in 0..20 {
            buf.push(make_line(&format!("line {i}")));
        }
        buf.scroll_to_top(80, 10);
        // At top: offset=10, viewport=10, total=20
        // → scroll from top = 20 - 10 - 10 = 0
        let (row, _) = buf.paragraph_scroll(10, 80);
        assert_eq!(row, 0);
    }

    // ── Prepend ──

    // ── Clamp offset ──

    #[test]
    fn test_clamp_offset_after_resize() {
        let mut buf = ScrollBuffer::new(2500);
        for i in 0..20 {
            buf.push(make_line(&format!("line {i}")));
        }
        // Scroll to top: 20 lines, viewport 10 → offset = 10
        buf.scroll_to_top(W, 10);
        assert_eq!(buf.offset(), 10);
        assert!(!buf.is_sticky());

        // Simulate resize to viewport height 18 → max_offset = 2
        buf.clamp_offset(W, 18);
        assert_eq!(buf.offset(), 2);
    }

    #[test]
    fn test_clamp_offset_restores_sticky() {
        let mut buf = ScrollBuffer::new(2500);
        for i in 0..5 {
            buf.push(make_line(&format!("line {i}")));
        }
        // Scroll up then grow viewport to fit everything
        buf.scroll_up(3, W, 3);
        assert!(!buf.is_sticky());

        buf.clamp_offset(W, 10); // viewport bigger than content
        assert_eq!(buf.offset(), 0);
        assert!(buf.is_sticky());
    }

    /// Regression: clamp_offset must use the history panel viewport height,
    /// NOT the full terminal height. Using a too-large viewport computes
    /// max_offset too small, clamping the user's scroll position toward
    /// the bottom and re-engaging sticky during inference streaming.
    #[test]
    fn test_clamp_offset_with_correct_viewport_preserves_scroll() {
        let mut buf = ScrollBuffer::new(2500);
        // 50 lines of content
        for i in 0..50 {
            buf.push(make_line(&format!("line {i}")));
        }

        // User scrolls up 15 lines from the bottom
        buf.scroll_up(15, W, 30);
        assert_eq!(buf.offset(), 15);
        assert!(!buf.is_sticky());

        // With correct viewport (30 lines): max_offset = 50 - 30 = 20.
        // 15 < 20 → no clamping.
        buf.clamp_offset(W, 30);
        assert_eq!(buf.offset(), 15);
        assert!(
            !buf.is_sticky(),
            "correct viewport must NOT re-engage sticky"
        );

        // BUG CASE: if caller mistakenly passes full terminal height (40),
        // max_offset = 50 - 40 = 10.  15 > 10 → clamped to 10.
        // This is the wrong behavior the fix prevents.
        buf.clamp_offset(W, 40);
        assert_eq!(buf.offset(), 10, "over-large viewport incorrectly clamps");
    }

    /// Streaming new content while scrolled up must not snap to bottom.
    #[test]
    fn test_push_during_scrolled_up_preserves_position() {
        let mut buf = ScrollBuffer::new(2500);
        for i in 0..30 {
            buf.push(make_line(&format!("line {i}")));
        }

        // User scrolls up 10 lines
        buf.scroll_up(10, W, 20);
        assert!(!buf.is_sticky());
        assert_eq!(buf.offset(), 10);

        // New content arrives (simulating inference streaming)
        buf.push(make_line("streamed token"));
        buf.push(make_line("another token"));

        // Offset and sticky must be unchanged
        assert_eq!(buf.offset(), 10);
        assert!(
            !buf.is_sticky(),
            "push must not re-engage sticky when user scrolled up"
        );
    }

    #[test]
    fn test_prepend_lines() {
        let mut buf = ScrollBuffer::new(2500);
        buf.push(make_line("current"));
        buf.scroll_up(0, W, H); // stay at bottom

        let old_lines = vec![make_line("old1"), make_line("old2")];
        buf.prepend_lines(old_lines);

        assert_eq!(buf.len(), 3);
        // Offset adjusted by prepend count
        assert_eq!(buf.offset(), 2);
        // First line is now "old1"
        let first = line_text(buf.all_lines().next().unwrap());
        assert_eq!(first, "old1");
    }
}
