#![forbid(unsafe_code)]

//! Scrollbar widget.
//!
//! A widget to display a scrollbar.

use crate::mouse::MouseResult;
use crate::{StatefulWidget, Widget, clear_text_area, draw_text_span};
use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
use ftui_core::geometry::Rect;
use ftui_render::frame::{Frame, HitId, HitRegion};
use ftui_style::Style;
use ftui_text::display_width;

/// Scrollbar orientation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ScrollbarOrientation {
    /// Vertical scrollbar on the right side.
    #[default]
    VerticalRight,
    /// Vertical scrollbar on the left side.
    VerticalLeft,
    /// Horizontal scrollbar on the bottom.
    HorizontalBottom,
    /// Horizontal scrollbar on the top.
    HorizontalTop,
}

/// Hit data part for track (background).
pub const SCROLLBAR_PART_TRACK: u64 = 0;
/// Hit data part for thumb (draggable).
pub const SCROLLBAR_PART_THUMB: u64 = 1;
/// Hit data part for begin button (up/left).
pub const SCROLLBAR_PART_BEGIN: u64 = 2;
/// Hit data part for end button (down/right).
pub const SCROLLBAR_PART_END: u64 = 3;

/// A widget to display a scrollbar.
#[derive(Debug, Clone, Default)]
pub struct Scrollbar<'a> {
    orientation: ScrollbarOrientation,
    thumb_style: Style,
    track_style: Style,
    begin_symbol: Option<&'a str>,
    end_symbol: Option<&'a str>,
    track_symbol: Option<&'a str>,
    thumb_symbol: Option<&'a str>,
    hit_id: Option<HitId>,
}

impl<'a> Scrollbar<'a> {
    /// Create a new scrollbar with the given orientation.
    #[must_use]
    pub fn new(orientation: ScrollbarOrientation) -> Self {
        Self {
            orientation,
            thumb_style: Style::default(),
            track_style: Style::default(),
            begin_symbol: None,
            end_symbol: None,
            track_symbol: None,
            thumb_symbol: None,
            hit_id: None,
        }
    }

    /// Set the style for the thumb (draggable indicator).
    #[must_use]
    pub fn thumb_style(mut self, style: Style) -> Self {
        self.thumb_style = style;
        self
    }

    /// Set the style for the track background.
    #[must_use]
    pub fn track_style(mut self, style: Style) -> Self {
        self.track_style = style;
        self
    }

    /// Set custom symbols for track, thumb, begin, and end markers.
    #[must_use]
    pub fn symbols(
        mut self,
        track: &'a str,
        thumb: &'a str,
        begin: Option<&'a str>,
        end: Option<&'a str>,
    ) -> Self {
        self.track_symbol = Some(track);
        self.thumb_symbol = Some(thumb);
        self.begin_symbol = begin;
        self.end_symbol = end;
        self
    }

    /// Set a hit ID for mouse interaction.
    #[must_use]
    pub fn hit_id(mut self, id: HitId) -> Self {
        self.hit_id = Some(id);
        self
    }
}

/// Mutable state for a [`Scrollbar`] widget.
#[derive(Debug, Clone, Default)]
pub struct ScrollbarState {
    /// Total number of scrollable content units.
    pub content_length: usize,
    /// Current scroll position within the content.
    pub position: usize,
    /// Number of content units visible in the viewport.
    pub viewport_length: usize,
    /// Drag anchor point (offset from thumb top) to prevent jumping.
    pub drag_anchor: Option<usize>,
    /// Cache of the track geometry for hit-testing fallback during drag.
    track_layout: Option<TrackLayout>,
}

#[derive(Debug, Clone, Copy)]
struct TrackLayout {
    rect: Rect,
    is_vertical: bool,
}

impl ScrollbarState {
    /// Create a new scrollbar state with given content, position, and viewport sizes.
    #[must_use]
    pub fn new(content_length: usize, position: usize, viewport_length: usize) -> Self {
        Self {
            content_length,
            position,
            viewport_length,
            drag_anchor: None,
            track_layout: None,
        }
    }

    /// Calculate thumb offset and size for a given track length.
    fn calc_thumb_geometry(&self, track_len: usize) -> (usize, usize) {
        if track_len == 0 {
            return (0, 0);
        }
        if self.content_length == 0 {
            return (0, track_len);
        }

        let viewport_ratio = self.viewport_length as f64 / self.content_length as f64;
        let thumb_size = (track_len as f64 * viewport_ratio).max(1.0).round() as usize;
        let thumb_size = thumb_size.min(track_len);

        let max_pos = self.content_length.saturating_sub(self.viewport_length);
        let pos_ratio = if max_pos == 0 {
            0.0
        } else {
            self.position.min(max_pos) as f64 / max_pos as f64
        };

        let available_track = track_len.saturating_sub(thumb_size);
        let thumb_offset = (available_track as f64 * pos_ratio).round() as usize;

        (thumb_offset, thumb_size)
    }

    /// Handle a mouse event for this scrollbar.
    ///
    /// # Hit data convention
    ///
    /// The hit data (`u64`) is encoded as:
    /// `[8: part] [28: track_len] [28: track_pos]`
    ///
    /// - `part`: One of `SCROLLBAR_PART_*`.
    /// - `track_len`: The effective length of the track (excluding buttons).
    /// - `track_pos`: The position within the effective track (0-based).
    ///
    /// # Arguments
    ///
    /// * `event` — the mouse event from the terminal
    /// * `hit` — result of `frame.hit_test(event.x, event.y)`, if available
    /// * `expected_id` — the `HitId` this scrollbar was rendered with
    pub fn handle_mouse(
        &mut self,
        event: &MouseEvent,
        hit: Option<(HitId, HitRegion, u64)>,
        expected_id: HitId,
    ) -> MouseResult {
        match event.kind {
            MouseEventKind::Down(MouseButton::Left) => {
                if let Some((id, HitRegion::Scrollbar, data)) = hit
                    && id == expected_id
                {
                    let part = data >> 56;
                    match part {
                        SCROLLBAR_PART_BEGIN => {
                            self.scroll_up(1);
                            return MouseResult::Scrolled;
                        }
                        SCROLLBAR_PART_END => {
                            self.scroll_down(1);
                            return MouseResult::Scrolled;
                        }
                        SCROLLBAR_PART_THUMB => {
                            let track_len = ((data >> 28) & 0x0FFF_FFFF) as usize;
                            let track_pos = (data & 0x0FFF_FFFF) as usize;
                            let (thumb_offset, _) = self.calc_thumb_geometry(track_len);
                            // Store where we grabbed the thumb (relative to thumb top)
                            self.drag_anchor = Some(track_pos.saturating_sub(thumb_offset));
                            // Prevent event bubbling by returning Scrolled
                            return MouseResult::Scrolled;
                        }
                        SCROLLBAR_PART_TRACK => {
                            let track_len = ((data >> 28) & 0x0FFF_FFFF) as usize;
                            let track_pos = (data & 0x0FFF_FFFF) as usize;
                            if track_len == 0 {
                                return MouseResult::Ignored;
                            }

                            // Unify track click with drag logic: center the thumb on the click.
                            let (_, thumb_size) = self.calc_thumb_geometry(track_len);
                            let available = track_len.saturating_sub(thumb_size);
                            let denom = available.max(1);

                            let target_thumb_top = track_pos.saturating_sub(thumb_size / 2);
                            let clamped_top = target_thumb_top.min(denom);

                            let max_pos = self.content_length.saturating_sub(self.viewport_length);
                            self.position = if max_pos == 0 {
                                0
                            } else {
                                let num = (clamped_top as u128) * (max_pos as u128);
                                let pos = (num + (denom as u128 / 2)) / denom as u128;
                                pos as usize
                            };

                            // Update drag anchor to match the new thumb position immediately,
                            // preventing a jump if the user drags after clicking the track.
                            let (thumb_offset, _) = self.calc_thumb_geometry(track_len);
                            self.drag_anchor = Some(track_pos.saturating_sub(thumb_offset));

                            return MouseResult::Scrolled;
                        }
                        _ => {}
                    }
                }
                MouseResult::Ignored
            }
            MouseEventKind::Drag(MouseButton::Left) => {
                // Determine track length and mouse position relative to track.
                // 1. Try Hit data (accurate for wide chars, complex layouts).
                // 2. Fallback to cached layout (if dragging outside component).
                let hit_data = if let Some((id, HitRegion::Scrollbar, data)) = hit
                    && id == expected_id
                {
                    let part = data >> 56;
                    if matches!(part, SCROLLBAR_PART_TRACK | SCROLLBAR_PART_THUMB) {
                        let len = ((data >> 28) & 0x0FFF_FFFF) as usize;
                        let pos = (data & 0x0FFF_FFFF) as usize;
                        Some((len, pos))
                    } else {
                        None
                    }
                } else {
                    None
                };

                let (track_len, track_pos) = if let Some((len, pos)) = hit_data {
                    (len, pos)
                } else if self.drag_anchor.is_some()
                    && let Some(layout) = self.track_layout
                {
                    // Fallback: calculate from mouse event relative to track rect
                    let rel = if layout.is_vertical {
                        event.y.saturating_sub(layout.rect.y)
                    } else {
                        event.x.saturating_sub(layout.rect.x)
                    };
                    let len = if layout.is_vertical {
                        layout.rect.height as usize
                    } else {
                        layout.rect.width as usize
                    };
                    let pos = rel.min(len.saturating_sub(1) as u16) as usize;
                    (len, pos)
                } else {
                    return MouseResult::Ignored;
                };

                if track_len == 0 {
                    return MouseResult::Ignored;
                }

                let (_, thumb_size) = self.calc_thumb_geometry(track_len);
                let available = track_len.saturating_sub(thumb_size);
                let denom = available.max(1);

                // If we have an anchor, align that point on thumb to mouse.
                // Otherwise (drag started outside thumb?), center it or use raw pos.
                let anchor = self.drag_anchor.unwrap_or(thumb_size / 2);

                // Desired top of thumb
                let target_thumb_top = track_pos.saturating_sub(anchor);
                let clamped_top = target_thumb_top.min(denom);

                let max_pos = self.content_length.saturating_sub(self.viewport_length);
                self.position = if max_pos == 0 {
                    0
                } else {
                    // Map thumb top range [0, available] to content range [0, max_pos]
                    let num = (clamped_top as u128) * (max_pos as u128);
                    // Round to nearest
                    let pos = (num + (denom as u128 / 2)) / denom as u128;
                    pos as usize
                };
                MouseResult::Scrolled
            }
            MouseEventKind::Up(MouseButton::Left) => {
                let was_dragging = self.drag_anchor.take().is_some();
                if was_dragging {
                    MouseResult::Scrolled
                } else {
                    MouseResult::Ignored
                }
            }
            MouseEventKind::ScrollUp => {
                self.scroll_up(3);
                MouseResult::Scrolled
            }
            MouseEventKind::ScrollDown => {
                self.scroll_down(3);
                MouseResult::Scrolled
            }
            _ => MouseResult::Ignored,
        }
    }

    /// Scroll the content up by the given number of lines.
    pub fn scroll_up(&mut self, lines: usize) {
        self.position = self.position.saturating_sub(lines);
    }

    /// Scroll the content down by the given number of lines.
    ///
    /// Clamps so that the viewport stays within content bounds.
    pub fn scroll_down(&mut self, lines: usize) {
        let max_pos = self.content_length.saturating_sub(self.viewport_length);
        self.position = self.position.saturating_add(lines).min(max_pos);
    }
}

impl<'a> StatefulWidget for Scrollbar<'a> {
    type State = ScrollbarState;

    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
        #[cfg(feature = "tracing")]
        let _span = tracing::debug_span!(
            "widget_render",
            widget = "Scrollbar",
            x = area.x,
            y = area.y,
            w = area.width,
            h = area.height
        )
        .entered();

        // Scrollbar is decorative — skip at EssentialOnly+
        if !frame.buffer.degradation.render_decorative() {
            state.track_layout = None;
            clear_text_area(frame, area, Style::default());
            return;
        }

        if area.is_empty() || state.content_length == 0 {
            state.track_layout = None;
            clear_text_area(frame, area, Style::default());
            return;
        }

        let is_vertical = match self.orientation {
            ScrollbarOrientation::VerticalRight | ScrollbarOrientation::VerticalLeft => true,
            ScrollbarOrientation::HorizontalBottom | ScrollbarOrientation::HorizontalTop => false,
        };

        let length = if is_vertical { area.height } else { area.width } as usize;
        if length == 0 {
            state.track_layout = None;
            return;
        }

        // Calculate layout
        let start_offset = if let Some(s) = self.begin_symbol {
            if is_vertical { 1 } else { display_width(s) }
        } else {
            0
        };
        let end_offset = if let Some(s) = self.end_symbol {
            if is_vertical { 1 } else { display_width(s) }
        } else {
            0
        };

        // Effective track excludes buttons
        let track_len = length.saturating_sub(start_offset + end_offset);

        // Calculate thumb size and position within the effective track
        let (thumb_offset, thumb_size) = state.calc_thumb_geometry(track_len);

        // Symbols
        let track_char = self
            .track_symbol
            .unwrap_or(if is_vertical { "│" } else { "─" });
        let thumb_char = self.thumb_symbol.unwrap_or("█");
        let begin_char = self
            .begin_symbol
            .unwrap_or(if is_vertical { "▲" } else { "◄" });
        let end_char = self
            .end_symbol
            .unwrap_or(if is_vertical { "▼" } else { "►" });

        // Update track layout for mouse interaction fallback
        let max_w = display_width(track_char)
            .max(display_width(thumb_char))
            .max(1);
        let track_rect = if is_vertical {
            let x = match self.orientation {
                ScrollbarOrientation::VerticalRight => {
                    area.right().saturating_sub(max_w as u16).max(area.left())
                }
                ScrollbarOrientation::VerticalLeft => area.left(),
                _ => unreachable!(),
            };
            Rect::new(
                x,
                area.top().saturating_add(start_offset as u16),
                max_w as u16,
                track_len as u16,
            )
        } else {
            let y = match self.orientation {
                ScrollbarOrientation::HorizontalBottom => area.bottom().saturating_sub(1),
                ScrollbarOrientation::HorizontalTop => area.top(),
                _ => unreachable!(),
            };
            Rect::new(
                area.left().saturating_add(start_offset as u16),
                y,
                track_len as u16,
                1,
            )
        };
        state.track_layout = Some(TrackLayout {
            rect: track_rect,
            is_vertical,
        });

        // Draw
        let mut next_draw_index = 0;
        for i in 0..length {
            if i < next_draw_index {
                continue;
            }

            // Determine part type and relative position
            let (symbol, part, rel_pos) = if i < start_offset {
                (begin_char, SCROLLBAR_PART_BEGIN, 0)
            } else if i >= length.saturating_sub(end_offset) {
                (end_char, SCROLLBAR_PART_END, 0)
            } else {
                let track_idx = i - start_offset;
                let is_thumb = track_idx >= thumb_offset && track_idx < thumb_offset + thumb_size;
                if is_thumb {
                    (thumb_char, SCROLLBAR_PART_THUMB, track_idx)
                } else {
                    (track_char, SCROLLBAR_PART_TRACK, track_idx)
                }
            };

            let symbol_width = display_width(symbol);
            if is_vertical {
                next_draw_index = i + 1;
            } else {
                next_draw_index = i + symbol_width;
            }

            let style = if !frame.buffer.degradation.apply_styling() {
                Style::default()
            } else if part == SCROLLBAR_PART_THUMB {
                self.thumb_style
            } else {
                self.track_style
            };

            let (x, y) = if is_vertical {
                let x = match self.orientation {
                    ScrollbarOrientation::VerticalRight => area
                        .right()
                        .saturating_sub(symbol_width.max(1) as u16)
                        .max(area.left()),
                    ScrollbarOrientation::VerticalLeft => area.left(),
                    _ => unreachable!(),
                };
                (x, area.top().saturating_add(i as u16))
            } else {
                let y = match self.orientation {
                    ScrollbarOrientation::HorizontalBottom => area.bottom().saturating_sub(1),
                    ScrollbarOrientation::HorizontalTop => area.top(),
                    _ => unreachable!(),
                };
                (area.left().saturating_add(i as u16), y)
            };

            // Only draw if within bounds
            if x < area.right() && y < area.bottom() {
                draw_text_span(frame, x, y, symbol, style, area.right());

                if let Some(id) = self.hit_id {
                    // Encode data: [8: part] [28: track_len] [28: track_pos]
                    let data = (part << 56)
                        | ((track_len as u64 & 0x0FFF_FFFF) << 28)
                        | (rel_pos as u64 & 0x0FFF_FFFF);

                    let hit_w = (symbol_width.max(1) as u16).min(area.right().saturating_sub(x));
                    frame.register_hit(Rect::new(x, y, hit_w, 1), id, HitRegion::Scrollbar, data);
                }
            }
        }
    }
}

impl<'a> Widget for Scrollbar<'a> {
    fn render(&self, area: Rect, frame: &mut Frame) {
        let mut state = ScrollbarState::default();
        StatefulWidget::render(self, area, frame, &mut state);
    }
}

// ============================================================================
// Accessibility
// ============================================================================

impl ftui_a11y::Accessible for Scrollbar<'_> {
    fn accessibility_nodes(&self, area: Rect) -> Vec<ftui_a11y::node::A11yNodeInfo> {
        use ftui_a11y::node::{A11yNodeInfo, A11yRole, A11yState};

        let id = crate::a11y_node_id(area);
        let orientation = match self.orientation {
            ScrollbarOrientation::VerticalRight | ScrollbarOrientation::VerticalLeft => "vertical",
            ScrollbarOrientation::HorizontalBottom | ScrollbarOrientation::HorizontalTop => {
                "horizontal"
            }
        };
        let name = format!("{orientation} scrollbar");
        let node = A11yNodeInfo::new(id, A11yRole::ScrollBar, area)
            .with_name(name)
            .with_state(A11yState {
                // Scrollbars are always interactive but don't themselves hold focus;
                // the viewport they scroll is the focusable element.
                ..A11yState::default()
            });
        vec![node]
    }
}

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

    #[test]
    fn scrollbar_empty_area() {
        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
        let area = Rect::new(0, 0, 0, 0);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(1, 1, &mut pool);
        let mut state = ScrollbarState::new(100, 0, 10);
        StatefulWidget::render(&sb, area, &mut frame, &mut state);
    }

    #[test]
    fn scrollbar_zero_content() {
        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
        let area = Rect::new(0, 0, 1, 10);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(1, 10, &mut pool);
        let mut state = ScrollbarState::new(100, 0, 10);
        StatefulWidget::render(&sb, area, &mut frame, &mut state);

        state.content_length = 0;
        StatefulWidget::render(&sb, area, &mut frame, &mut state);

        for y in 0..10u16 {
            assert_eq!(frame.buffer.get(0, y).unwrap().content.as_char(), Some(' '));
        }
        assert!(state.track_layout.is_none());
    }

    #[test]
    fn scrollbar_vertical_right_renders() {
        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
        let area = Rect::new(0, 0, 1, 10);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(1, 10, &mut pool);
        let mut state = ScrollbarState::new(100, 0, 10);
        StatefulWidget::render(&sb, area, &mut frame, &mut state);

        // Thumb should be at the top (position=0), track should have chars
        let top_cell = frame.buffer.get(0, 0).unwrap();
        assert!(top_cell.content.as_char().is_some());
    }

    #[test]
    fn scrollbar_vertical_left_renders() {
        let sb = Scrollbar::new(ScrollbarOrientation::VerticalLeft);
        let area = Rect::new(0, 0, 1, 10);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(1, 10, &mut pool);
        let mut state = ScrollbarState::new(100, 0, 10);
        StatefulWidget::render(&sb, area, &mut frame, &mut state);

        let top_cell = frame.buffer.get(0, 0).unwrap();
        assert!(top_cell.content.as_char().is_some());
    }

    #[test]
    fn scrollbar_horizontal_renders() {
        let sb = Scrollbar::new(ScrollbarOrientation::HorizontalBottom);
        let area = Rect::new(0, 0, 10, 1);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(10, 1, &mut pool);
        let mut state = ScrollbarState::new(100, 0, 10);
        StatefulWidget::render(&sb, area, &mut frame, &mut state);

        let left_cell = frame.buffer.get(0, 0).unwrap();
        assert!(left_cell.content.as_char().is_some());
    }

    #[test]
    fn scrollbar_thumb_moves_with_position() {
        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
        let area = Rect::new(0, 0, 1, 10);

        // Position at start
        let mut pool1 = GraphemePool::new();
        let mut frame1 = Frame::new(1, 10, &mut pool1);
        let mut state1 = ScrollbarState::new(100, 0, 10);
        StatefulWidget::render(&sb, area, &mut frame1, &mut state1);

        // Position at end
        let mut pool2 = GraphemePool::new();
        let mut frame2 = Frame::new(1, 10, &mut pool2);
        let mut state2 = ScrollbarState::new(100, 90, 10);
        StatefulWidget::render(&sb, area, &mut frame2, &mut state2);

        // The thumb char (█) should be at different positions
        let thumb_char = '█';
        let thumb_pos_1 = (0..10u16)
            .find(|&y| frame1.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
        let thumb_pos_2 = (0..10u16)
            .find(|&y| frame2.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));

        // At start, thumb should be near top; at end, near bottom
        assert!(thumb_pos_1.unwrap_or(0) < thumb_pos_2.unwrap_or(0));
    }

    #[test]
    fn scrollbar_state_constructor() {
        let state = ScrollbarState::new(200, 50, 20);
        assert_eq!(state.content_length, 200);
        assert_eq!(state.position, 50);
        assert_eq!(state.viewport_length, 20);
    }

    #[test]
    fn scrollbar_content_fits_viewport() {
        // When viewport >= content, thumb should fill the whole track
        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
        let area = Rect::new(0, 0, 1, 10);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(1, 10, &mut pool);
        let mut state = ScrollbarState::new(5, 0, 10);
        StatefulWidget::render(&sb, area, &mut frame, &mut state);

        // All cells should be thumb (█)
        let thumb_char = '█';
        for y in 0..10u16 {
            assert_eq!(
                frame.buffer.get(0, y).unwrap().content.as_char(),
                Some(thumb_char)
            );
        }
    }

    #[test]
    fn scrollbar_horizontal_top_renders() {
        let sb = Scrollbar::new(ScrollbarOrientation::HorizontalTop);
        let area = Rect::new(0, 0, 10, 1);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(10, 1, &mut pool);
        let mut state = ScrollbarState::new(100, 0, 10);
        StatefulWidget::render(&sb, area, &mut frame, &mut state);

        let left_cell = frame.buffer.get(0, 0).unwrap();
        assert!(left_cell.content.as_char().is_some());
    }

    #[test]
    fn scrollbar_custom_symbols() {
        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight).symbols(
            ".",
            "#",
            Some("^"),
            Some("v"),
        );
        let area = Rect::new(0, 0, 1, 5);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(1, 5, &mut pool);
        let mut state = ScrollbarState::new(50, 0, 10);
        StatefulWidget::render(&sb, area, &mut frame, &mut state);

        // Should use our custom symbols
        let mut chars: Vec<Option<char>> = Vec::new();
        for y in 0..5u16 {
            chars.push(frame.buffer.get(0, y).unwrap().content.as_char());
        }
        // At least some cells should have our custom chars
        assert!(chars.contains(&Some('#')) || chars.contains(&Some('.')));
    }

    #[test]
    fn scrollbar_position_clamped_beyond_max() {
        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
        let area = Rect::new(0, 0, 1, 10);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(1, 10, &mut pool);
        // Position way beyond content_length
        let mut state = ScrollbarState::new(100, 500, 10);
        StatefulWidget::render(&sb, area, &mut frame, &mut state);

        // Should still render without panic, thumb at bottom
        let thumb_char = '█';
        let thumb_pos = (0..10u16)
            .find(|&y| frame.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
        assert!(thumb_pos.is_some());
    }

    #[test]
    fn scrollbar_state_default() {
        let state = ScrollbarState::default();
        assert_eq!(state.content_length, 0);
        assert_eq!(state.position, 0);
        assert_eq!(state.viewport_length, 0);
    }

    #[test]
    fn scrollbar_widget_trait_renders() {
        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
        let area = Rect::new(0, 0, 1, 5);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(1, 5, &mut pool);
        // Widget trait uses default state (content_length=0, so no rendering)
        Widget::render(&sb, area, &mut frame);
        // Should not panic with default state
    }

    #[test]
    fn scrollbar_orientation_default_is_vertical_right() {
        assert_eq!(
            ScrollbarOrientation::default(),
            ScrollbarOrientation::VerticalRight
        );
    }

    // --- Degradation tests ---

    #[test]
    fn degradation_essential_only_skips_entirely() {
        use ftui_render::budget::DegradationLevel;

        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
        let area = Rect::new(0, 0, 1, 10);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(1, 10, &mut pool);
        frame.buffer.degradation = DegradationLevel::EssentialOnly;
        let mut state = ScrollbarState::new(100, 0, 10);
        frame.buffer.degradation = DegradationLevel::Full;
        StatefulWidget::render(&sb, area, &mut frame, &mut state);
        frame.buffer.degradation = DegradationLevel::EssentialOnly;
        StatefulWidget::render(&sb, area, &mut frame, &mut state);

        for y in 0..10u16 {
            assert_eq!(frame.buffer.get(0, y).unwrap().content.as_char(), Some(' '));
        }
        assert!(state.track_layout.is_none());
    }

    #[test]
    fn degradation_skeleton_skips_entirely() {
        use ftui_render::budget::DegradationLevel;

        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
        let area = Rect::new(0, 0, 1, 10);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(1, 10, &mut pool);
        frame.buffer.degradation = DegradationLevel::Skeleton;
        let mut state = ScrollbarState::new(100, 0, 10);
        frame.buffer.degradation = DegradationLevel::Full;
        StatefulWidget::render(&sb, area, &mut frame, &mut state);
        frame.buffer.degradation = DegradationLevel::Skeleton;
        StatefulWidget::render(&sb, area, &mut frame, &mut state);

        for y in 0..10u16 {
            assert_eq!(frame.buffer.get(0, y).unwrap().content.as_char(), Some(' '));
        }
        assert!(state.track_layout.is_none());
    }

    #[test]
    fn degradation_full_renders_scrollbar() {
        use ftui_render::budget::DegradationLevel;

        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
        let area = Rect::new(0, 0, 1, 10);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(1, 10, &mut pool);
        frame.buffer.degradation = DegradationLevel::Full;
        let mut state = ScrollbarState::new(100, 0, 10);
        StatefulWidget::render(&sb, area, &mut frame, &mut state);

        // Should render something (thumb or track)
        let top_cell = frame.buffer.get(0, 0).unwrap();
        assert!(top_cell.content.as_char().is_some());
    }

    #[test]
    fn degradation_simple_borders_still_renders() {
        use ftui_render::budget::DegradationLevel;

        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
        let area = Rect::new(0, 0, 1, 10);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(1, 10, &mut pool);
        frame.buffer.degradation = DegradationLevel::SimpleBorders;
        let mut state = ScrollbarState::new(100, 0, 10);
        StatefulWidget::render(&sb, area, &mut frame, &mut state);

        // SimpleBorders still renders decorative content
        let top_cell = frame.buffer.get(0, 0).unwrap();
        assert!(top_cell.content.as_char().is_some());
    }

    #[test]
    fn scrollbar_wide_symbols_horizontal() {
        let sb =
            Scrollbar::new(ScrollbarOrientation::HorizontalBottom).symbols("🔴", "👍", None, None);
        // Area width 4. Expect "🔴🔴" (2 chars * 2 width = 4 cells)
        let area = Rect::new(0, 0, 4, 1);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(4, 1, &mut pool);
        // Track only (thumb size 0 or pos 0?)
        // Let's make thumb small/invisible or check track part.
        // If content_length=10, viewport=10, thumb fills all.
        // Let's fill with thumb "👍"
        let mut state = ScrollbarState::new(10, 0, 10);

        StatefulWidget::render(&sb, area, &mut frame, &mut state);

        // x=0: Head "👍" (wide emoji stored as grapheme, not direct char)
        let c0 = frame.buffer.get(0, 0).unwrap();
        assert!(!c0.is_empty() && !c0.is_continuation()); // Head
        // x=1: Continuation
        let c1 = frame.buffer.get(1, 0).unwrap();
        assert!(c1.is_continuation());

        // x=2: Head "👍"
        let c2 = frame.buffer.get(2, 0).unwrap();
        assert!(!c2.is_empty() && !c2.is_continuation()); // Head
        // x=3: Continuation
        let c3 = frame.buffer.get(3, 0).unwrap();
        assert!(c3.is_continuation());
    }

    #[test]
    fn scrollbar_wide_symbols_vertical() {
        let sb =
            Scrollbar::new(ScrollbarOrientation::VerticalRight).symbols("🔴", "👍", None, None);
        // Area height 2. Width 2 (to fit the wide char).
        let area = Rect::new(0, 0, 2, 2);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(2, 2, &mut pool);
        let mut state = ScrollbarState::new(10, 0, 10); // Fill with thumb

        StatefulWidget::render(&sb, area, &mut frame, &mut state);

        // Row 0: "👍" at x=0 (wide emoji stored as grapheme, not direct char)
        let r0_c0 = frame.buffer.get(0, 0).unwrap();
        assert!(!r0_c0.is_empty() && !r0_c0.is_continuation()); // Head
        let r0_c1 = frame.buffer.get(1, 0).unwrap();
        assert!(r0_c1.is_continuation()); // Tail

        // Row 1: "👍" at x=0 (should NOT be skipped)
        let r1_c0 = frame.buffer.get(0, 1).unwrap();
        assert!(!r1_c0.is_empty() && !r1_c0.is_continuation()); // Head
        let r1_c1 = frame.buffer.get(1, 1).unwrap();
        assert!(r1_c1.is_continuation()); // Tail
    }

    #[test]
    fn scrollbar_wide_symbol_clips_drawing_and_hits_to_area() {
        // Regression: wide symbols must not draw/register hit cells outside the widget area.
        let sb = Scrollbar::new(ScrollbarOrientation::HorizontalBottom)
            .symbols("🔴", "👍", None, None)
            .hit_id(HitId::new(1));
        let area = Rect::new(0, 0, 3, 1);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::with_hit_grid(5, 1, &mut pool);
        let mut state = ScrollbarState::new(3, 0, 3); // Thumb fills the track.

        StatefulWidget::render(&sb, area, &mut frame, &mut state);

        // x=3 is outside the widget area (area.right() == 3). It must remain untouched.
        let outside = frame.buffer.get(3, 0).unwrap();
        assert!(outside.is_empty(), "cell outside area should remain empty");
        assert!(frame.hit_test(3, 0).is_none(), "no hit outside area");
    }

    #[test]
    fn scrollbar_wide_symbol_vertical_clips_drawing_and_hits_to_area() {
        let sb = Scrollbar::new(ScrollbarOrientation::VerticalLeft)
            .symbols("🔴", "👍", None, None)
            .hit_id(HitId::new(1));
        let area = Rect::new(0, 0, 1, 2);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::with_hit_grid(2, 2, &mut pool);
        let mut state = ScrollbarState::new(10, 0, 10); // Thumb fills the track.

        StatefulWidget::render(&sb, area, &mut frame, &mut state);

        // x=1 is outside the widget area (area.right() == 1). It must remain untouched.
        let outside = frame.buffer.get(1, 0).unwrap();
        assert!(outside.is_empty(), "cell outside area should remain empty");
        assert!(frame.hit_test(1, 0).is_none(), "no hit outside area");
    }

    #[test]
    fn scrollbar_vertical_right_never_draws_left_of_area_for_wide_symbols() {
        // Regression: when the area is narrower than the symbol, VerticalRight must not
        // shift the draw position left of the widget area.
        let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight)
            .symbols("🔴", "👍", None, None)
            .hit_id(HitId::new(1));
        let area = Rect::new(2, 0, 1, 2);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::with_hit_grid(4, 2, &mut pool);
        let mut state = ScrollbarState::new(10, 0, 10);

        StatefulWidget::render(&sb, area, &mut frame, &mut state);

        // x=1 is left of the widget area (area.left() == 2). It must remain untouched.
        let outside = frame.buffer.get(1, 0).unwrap();
        assert!(outside.is_empty(), "cell left of area should remain empty");
        assert!(frame.hit_test(1, 0).is_none(), "no hit left of area");
    }

    // --- Mouse handling tests ---

    use crate::mouse::MouseResult;
    use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};

    #[test]
    fn scrollbar_state_begin_button() {
        let mut state = ScrollbarState::new(100, 10, 20);
        let data = SCROLLBAR_PART_BEGIN << 56;
        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
        let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
        let result = state.handle_mouse(&event, hit, HitId::new(1));
        assert_eq!(result, MouseResult::Scrolled);
        assert_eq!(state.position, 9);
    }

    #[test]
    fn scrollbar_state_end_button() {
        let mut state = ScrollbarState::new(100, 10, 20);
        let data = SCROLLBAR_PART_END << 56;
        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
        let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
        let result = state.handle_mouse(&event, hit, HitId::new(1));
        assert_eq!(result, MouseResult::Scrolled);
        assert_eq!(state.position, 11);
    }

    #[test]
    fn scrollbar_state_track_click() {
        let mut state = ScrollbarState::new(100, 0, 20);
        let track_len = 20u64;
        let track_pos = 10u64;
        let data = (SCROLLBAR_PART_TRACK << 56)
            | ((track_len & 0x0FFF_FFFF) << 28)
            | (track_pos & 0x0FFF_FFFF);
        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
        let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
        let result = state.handle_mouse(&event, hit, HitId::new(1));
        assert_eq!(result, MouseResult::Scrolled);
        // Track click maps proportionally to content position, centered on thumb.
        assert_eq!(state.position, 40);
    }

    #[test]
    fn scrollbar_state_track_click_clamps() {
        let mut state = ScrollbarState::new(100, 0, 20);
        let track_len = 20u64;
        let track_pos = 95u64;
        let data = (SCROLLBAR_PART_TRACK << 56)
            | ((track_len & 0x0FFF_FFFF) << 28)
            | (track_pos & 0x0FFF_FFFF);
        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
        let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
        let result = state.handle_mouse(&event, hit, HitId::new(1));
        assert_eq!(result, MouseResult::Scrolled);
        assert_eq!(state.position, 80); // content_length - viewport_length
    }

    #[test]
    fn scrollbar_state_thumb_drag_updates_position() {
        let mut state = ScrollbarState::new(100, 0, 20);
        let track_len = 20u64;
        let track_pos = 19u64;
        let data = (SCROLLBAR_PART_THUMB << 56)
            | ((track_len & 0x0FFF_FFFF) << 28)
            | (track_pos & 0x0FFF_FFFF);
        let event = MouseEvent::new(MouseEventKind::Drag(MouseButton::Left), 0, 0);
        let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
        let result = state.handle_mouse(&event, hit, HitId::new(1));
        assert_eq!(result, MouseResult::Scrolled);
        assert_eq!(state.position, 80);
    }

    #[test]
    fn scrollbar_state_scroll_wheel_up() {
        let mut state = ScrollbarState::new(100, 10, 20);
        let event = MouseEvent::new(MouseEventKind::ScrollUp, 0, 0);
        let result = state.handle_mouse(&event, None, HitId::new(1));
        assert_eq!(result, MouseResult::Scrolled);
        assert_eq!(state.position, 7);
    }

    #[test]
    fn scrollbar_state_scroll_wheel_down() {
        let mut state = ScrollbarState::new(100, 10, 20);
        let event = MouseEvent::new(MouseEventKind::ScrollDown, 0, 0);
        let result = state.handle_mouse(&event, None, HitId::new(1));
        assert_eq!(result, MouseResult::Scrolled);
        assert_eq!(state.position, 13);
    }

    #[test]
    fn scrollbar_state_scroll_down_clamps() {
        let mut state = ScrollbarState::new(100, 78, 20);
        state.scroll_down(5);
        assert_eq!(state.position, 80); // max = 100 - 20
    }

    #[test]
    fn scrollbar_state_scroll_up_clamps() {
        let mut state = ScrollbarState::new(100, 2, 20);
        state.scroll_up(5);
        assert_eq!(state.position, 0);
    }

    #[test]
    fn scrollbar_state_wrong_id_ignored() {
        let mut state = ScrollbarState::new(100, 10, 20);
        let data = SCROLLBAR_PART_BEGIN << 56;
        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
        let hit = Some((HitId::new(99), HitRegion::Scrollbar, data));
        let result = state.handle_mouse(&event, hit, HitId::new(1));
        assert_eq!(result, MouseResult::Ignored);
        assert_eq!(state.position, 10);
    }

    #[test]
    fn scrollbar_state_right_click_ignored() {
        let mut state = ScrollbarState::new(100, 10, 20);
        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Right), 0, 0);
        let result = state.handle_mouse(&event, None, HitId::new(1));
        assert_eq!(result, MouseResult::Ignored);
    }
}
