//! Framework-owned viewport state for built-in panes.

use ratatui::Frame;
use ratatui::layout::Position;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::Paragraph;
use unicode_width::UnicodeWidthStr;

/// Scroll overflow facts for a rendered viewport.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ViewportOverflow {
    len:           usize,
    scroll_offset: usize,
    visible_rows:  usize,
    cursor:        usize,
}

impl ViewportOverflow {
    /// Construct viewport overflow facts from rendered row counts and
    /// the cursor row. The page index is anchored to the cursor so it
    /// advances and retreats by one as the user moves up or down.
    #[must_use]
    pub const fn new(len: usize, scroll_offset: usize, visible_rows: usize, cursor: usize) -> Self {
        Self {
            len,
            scroll_offset,
            visible_rows,
            cursor,
        }
    }

    /// Return the overflow label for the current position. The label
    /// pairs scroll arrows with an `N of M` indicator. `M` is
    /// `len.div_ceil(visible_rows)`; `N` is the page containing the
    /// cursor row, so each step the cursor takes that crosses a page
    /// boundary updates the indicator in both directions.
    #[must_use]
    pub fn label(self) -> Option<String> {
        if self.visible_rows == 0 || self.len <= self.visible_rows {
            return None;
        }

        let has_above = self.scroll_offset > 0;
        let has_below = self.scroll_offset.saturating_add(self.visible_rows) < self.len;
        let page_count = self.len.div_ceil(self.visible_rows);
        let page_number = (self.cursor / self.visible_rows + 1).min(page_count);
        let body = format!("{page_number} of {page_count}");
        match (has_above, has_below) {
            (true, true) => Some(format!("▲ {body} ▼")),
            (true, false) => Some(format!("▲ {body}")),
            (false, true) => Some(format!("{body} ▼")),
            (false, false) => None,
        }
    }
}

/// Cursor, hover, and rendered-area state for framework-owned panes.
#[derive(Clone, Debug, Default)]
pub struct Viewport {
    pos:            usize,
    hovered:        Option<usize>,
    /// Addressable row count: the rows the cursor moves through. For a
    /// single-column pane this also equals the content's rendered height; a
    /// multi-column pane (rows that render side by side) has more addressable
    /// rows than rendered height, so the two diverge.
    len:            usize,
    /// Rendered vertical height of the content, in rows. The scroll overflow
    /// pager paginates this, not [`Self::len`]. [`Self::set_len`] resets it to
    /// the row count (the single-column default); a multi-column pane lowers
    /// it via [`Self::set_content_height`] so the pager counts rows that
    /// actually stack rather than the flat addressable-row total.
    content_height: usize,
    content_area:   Rect,
    scroll_offset:  usize,
    visible_rows:   usize,
}

impl Viewport {
    /// Construct an empty viewport.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            pos:            0,
            hovered:        None,
            len:            0,
            content_height: 0,
            content_area:   Rect::ZERO,
            scroll_offset:  0,
            visible_rows:   0,
        }
    }

    /// Move the cursor up one row.
    pub const fn up(&mut self) {
        if self.pos > 0 {
            self.pos -= 1;
        }
    }

    /// Move the cursor down one row.
    pub const fn down(&mut self) {
        if self.len > 0 && self.pos < self.len - 1 {
            self.pos += 1;
        }
    }

    /// Move the cursor up by `step` rows.
    pub const fn up_by(&mut self, step: usize) { self.pos = self.pos.saturating_sub(step); }

    /// Move the cursor down by `step` rows.
    pub fn down_by(&mut self, step: usize) {
        if self.len == 0 {
            return;
        }
        self.pos = self.pos.saturating_add(step).min(self.len - 1);
    }

    /// Move up by a full page step when visible rows are known.
    pub fn page_up(&mut self) {
        if let Some(step) = self.page_step() {
            self.up_by(step);
        }
    }

    /// Move down by a full page step when visible rows are known.
    pub fn page_down(&mut self) {
        if let Some(step) = self.page_step() {
            self.down_by(step);
        }
    }

    /// Move up by a half-page step when visible rows are known.
    pub fn half_page_up(&mut self) {
        if let Some(step) = self.half_page_step() {
            self.up_by(step);
        }
    }

    /// Move down by a half-page step when visible rows are known.
    pub fn half_page_down(&mut self) {
        if let Some(step) = self.half_page_step() {
            self.down_by(step);
        }
    }

    fn page_step(&self) -> Option<usize> {
        if self.visible_rows == 0 {
            None
        } else {
            Some(self.visible_rows.saturating_sub(1).max(1))
        }
    }

    fn half_page_step(&self) -> Option<usize> {
        if self.visible_rows == 0 {
            None
        } else {
            Some((self.visible_rows / 2).max(1))
        }
    }

    /// Move the cursor to the first row.
    pub const fn home(&mut self) { self.pos = 0; }

    /// Move the cursor to the last row.
    pub const fn end(&mut self) { self.pos = self.len.saturating_sub(1); }

    /// Current cursor row.
    #[must_use]
    pub const fn pos(&self) -> usize { self.pos }

    /// Set the current cursor row.
    pub const fn set_pos(&mut self, pos: usize) { self.pos = pos; }

    /// Set the addressable row count. Also resets the content height to match
    /// (the single-column default, where rendered height equals the row
    /// count); a multi-column pane overrides it afterward with
    /// [`Self::set_content_height`].
    pub const fn set_len(&mut self, len: usize) {
        self.len = len;
        self.content_height = len;
        if len == 0 {
            self.pos = 0;
        } else if self.pos >= len {
            self.pos = len - 1;
        }
        if let Some(row) = self.hovered
            && row >= len
        {
            self.hovered = None;
        }
    }

    /// Clear rendered viewport surface state.
    pub const fn clear_surface(&mut self) {
        self.len = 0;
        self.content_height = 0;
        self.hovered = None;
        self.content_area = Rect::ZERO;
        self.scroll_offset = 0;
        self.visible_rows = 0;
        self.pos = 0;
    }

    /// Current backing row count.
    #[must_use]
    pub const fn len(&self) -> usize { self.len }

    /// Whether the backing row set is empty.
    #[must_use]
    pub const fn is_empty(&self) -> bool { self.len == 0 }

    /// Override the rendered content height the scroll overflow pager
    /// paginates. Call after [`Self::set_len`] for a multi-column pane whose
    /// rows render side by side, passing the height the rows actually occupy
    /// when stacked, so the pager does not count the wider addressable-row
    /// total. [`Self::set_len`] resets this to the row count, so it must be
    /// called after `set_len`.
    pub const fn set_content_height(&mut self, content_height: usize) {
        self.content_height = content_height;
    }

    /// Rendered content height in rows (see [`Self::set_content_height`]).
    #[must_use]
    pub const fn content_height(&self) -> usize { self.content_height }

    /// Set the screen-space content area.
    pub const fn set_content_area(&mut self, area: Rect) { self.content_area = area; }

    /// Screen-space content area.
    #[must_use]
    pub const fn content_area(&self) -> Rect { self.content_area }

    /// Set the current scroll offset.
    pub const fn set_scroll_offset(&mut self, offset: usize) { self.scroll_offset = offset; }

    /// Current scroll offset.
    #[must_use]
    pub const fn scroll_offset(&self) -> usize { self.scroll_offset }

    /// Set the visible row count.
    pub const fn set_viewport_rows(&mut self, rows: usize) { self.visible_rows = rows; }

    /// Visible row count.
    #[must_use]
    pub const fn visible_rows(&self) -> usize { self.visible_rows }

    /// Set the currently hovered row.
    pub const fn set_hovered(&mut self, hovered: Option<usize>) { self.hovered = hovered; }

    /// Currently hovered row.
    #[must_use]
    pub const fn hovered(&self) -> Option<usize> { self.hovered }

    /// Scroll overflow facts for this viewport. Paginates the rendered
    /// content height (not the addressable row count), with `pos` as the
    /// cursor row anchoring the page indicator.
    #[must_use]
    pub const fn overflow(&self) -> ViewportOverflow {
        ViewportOverflow::new(
            self.content_height,
            self.scroll_offset,
            self.visible_rows,
            self.pos,
        )
    }

    /// Current overflow label for this viewport.
    #[must_use]
    pub fn overflow_affordance(&self) -> Option<String> { self.overflow().label() }

    /// Convert a screen-space position to a row in this viewport.
    #[must_use]
    pub const fn pos_to_local_row(&self, pos: Position) -> Option<usize> {
        if self.content_area.width == 0 || self.content_area.height == 0 {
            return None;
        }
        if !self.content_area.contains(pos) {
            return None;
        }
        let visual_row = pos.y.saturating_sub(self.content_area.y);
        let row = self.scroll_offset + visual_row as usize;
        if row >= self.len {
            return None;
        }
        Some(row)
    }
}

/// Scroll offset that keeps `cursor` on screen.
///
/// Given the cursor row, the number of visible rows, and the total row
/// count, return the smallest scroll offset that keeps `cursor` within the
/// visible window and never scrolls past the last full page. Returns `0`
/// when every row fits (`len <= visible_rows`) or nothing is visible
/// (`visible_rows == 0`).
#[must_use]
pub const fn keep_visible_scroll_offset(cursor: usize, visible_rows: usize, len: usize) -> usize {
    if visible_rows == 0 || len <= visible_rows {
        return 0;
    }
    let max_offset = len - visible_rows;
    let offset = cursor.saturating_add(1).saturating_sub(visible_rows);
    if offset < max_offset {
        offset
    } else {
        max_offset
    }
}

/// Render a centered overflow affordance on `area`'s bottom row.
pub fn render_overflow_affordance(
    frame: &mut Frame,
    area: Rect,
    overflow: ViewportOverflow,
    style: Style,
) {
    let Some(label) = overflow.label() else {
        return;
    };
    if area.width <= 2 || area.height == 0 {
        return;
    }

    let inner_width = area.width.saturating_sub(2);
    let label_width = u16::try_from(label.width()).unwrap_or(u16::MAX);
    if label_width == 0 || label_width > inner_width {
        return;
    }

    let x = area
        .x
        .saturating_add(1)
        .saturating_add(inner_width.saturating_sub(label_width) / 2);
    let affordance_area = Rect::new(x, area.bottom().saturating_sub(1), label_width, 1);
    frame.render_widget(Paragraph::new(label).style(style), affordance_area);
}

#[cfg(test)]
#[allow(
    clippy::expect_used,
    reason = "tests should panic on unexpected values"
)]
mod tests {
    use super::Viewport;
    use super::ViewportOverflow;
    use super::keep_visible_scroll_offset;

    #[test]
    fn keep_visible_offset_is_zero_when_all_rows_fit() {
        assert_eq!(keep_visible_scroll_offset(7, 10, 10), 0);
        assert_eq!(keep_visible_scroll_offset(0, 5, 3), 0);
    }

    #[test]
    fn keep_visible_offset_is_zero_when_nothing_is_visible() {
        assert_eq!(keep_visible_scroll_offset(7, 0, 20), 0);
    }

    #[test]
    fn keep_visible_offset_is_zero_while_cursor_is_on_the_first_page() {
        assert_eq!(keep_visible_scroll_offset(0, 5, 20), 0);
        assert_eq!(keep_visible_scroll_offset(4, 5, 20), 0);
    }

    #[test]
    fn keep_visible_offset_tracks_the_cursor_past_the_first_page() {
        assert_eq!(keep_visible_scroll_offset(7, 5, 20), 3);
    }

    #[test]
    fn keep_visible_offset_stops_at_the_last_full_page() {
        assert_eq!(keep_visible_scroll_offset(19, 5, 20), 15);
        // A cursor reported past the end still clamps to the last page.
        assert_eq!(keep_visible_scroll_offset(99, 5, 20), 15);
    }

    #[test]
    fn overflow_affordance_is_hidden_when_all_rows_fit() {
        let overflow = ViewportOverflow::new(3, 0, 3, 0);

        assert_eq!(overflow.label(), None);
    }

    #[test]
    fn overflow_affordance_shows_bottom_only_at_top() {
        let overflow = ViewportOverflow::new(5, 0, 3, 0);

        assert_eq!(overflow.label().as_deref(), Some("1 of 2 ▼"));
    }

    #[test]
    fn overflow_affordance_shows_both_in_middle() {
        let overflow = ViewportOverflow::new(7, 2, 3, 3);

        assert_eq!(overflow.label().as_deref(), Some("▲ 2 of 3 ▼"));
    }

    #[test]
    fn overflow_affordance_advances_when_cursor_crosses_page_boundary_down() {
        // len=40, vr=10. Cursor=10 means we just stepped down from row 9
        // into page 2; the page indicator updates immediately.
        let overflow = ViewportOverflow::new(40, 1, 10, 10);

        assert_eq!(overflow.label().as_deref(), Some("▲ 2 of 4 ▼"));
    }

    #[test]
    fn overflow_affordance_retreats_when_cursor_crosses_page_boundary_up() {
        // Symmetric reverse of the down case: cursor=29 (last row of
        // page 3) after stepping up from row 30 in page 4. The page
        // count retreats by one in the same step.
        let overflow = ViewportOverflow::new(40, 21, 10, 29);

        assert_eq!(overflow.label().as_deref(), Some("▲ 3 of 4 ▼"));
    }

    #[test]
    fn overflow_affordance_shows_top_only_at_bottom() {
        let overflow = ViewportOverflow::new(5, 2, 3, 4);

        assert_eq!(overflow.label().as_deref(), Some("▲ 2 of 2"));
    }

    #[test]
    fn viewport_overflow_delegates_to_overflow_state() {
        let mut viewport = Viewport::new();
        viewport.set_len(5);
        viewport.set_viewport_rows(3);

        assert_eq!(viewport.overflow_affordance().as_deref(), Some("1 of 2 ▼"));
    }

    #[test]
    fn overflow_paginates_content_height_not_addressable_rows() {
        // A multi-column pane: more addressable rows (cursor space) than the
        // rendered height, because rows render side by side. With the content
        // height matching the visible rows, every row is on screen, so no
        // pager — even though the flat row count exceeds the visible rows.
        let mut viewport = Viewport::new();
        viewport.set_len(20);
        viewport.set_content_height(16);
        viewport.set_viewport_rows(16);

        assert_eq!(viewport.overflow_affordance(), None);
    }

    #[test]
    fn set_len_defaults_content_height_to_the_row_count() {
        // Single-column default: without an explicit content height, the pager
        // paginates the row count, and `set_len` clears any prior override.
        let mut viewport = Viewport::new();
        viewport.set_len(20);
        viewport.set_content_height(16);
        viewport.set_len(5);
        viewport.set_viewport_rows(3);

        assert_eq!(viewport.content_height(), 5);
        assert_eq!(viewport.overflow_affordance().as_deref(), Some("1 of 2 ▼"));
    }

    #[test]
    fn clear_surface_resets_rendered_state() {
        let mut viewport = Viewport::new();
        viewport.set_len(5);
        viewport.set_pos(3);
        viewport.set_content_area(ratatui::layout::Rect::new(1, 2, 3, 4));
        viewport.set_scroll_offset(2);
        viewport.set_viewport_rows(3);
        viewport.set_hovered(Some(4));

        viewport.clear_surface();

        assert_eq!(viewport.pos(), 0);
        assert_eq!(viewport.len(), 0);
        assert_eq!(viewport.content_area(), ratatui::layout::Rect::ZERO);
        assert_eq!(viewport.scroll_offset(), 0);
        assert_eq!(viewport.visible_rows(), 0);
        assert_eq!(viewport.hovered(), None);
    }
}
