use crate::a11y::{AccessNode, AccessRange, Role};
use crate::frame::{FrameCtx, InteractDeclaration};
use crate::hit_test::{Interaction, Sense};
use crate::input::{KeyCode, NamedKey};
use crate::layout::{Axis, LayoutPos, LayoutPx, LayoutRect, LayoutSize};
use crate::strings::StringKey;
use crate::theme::{Border, Step12, StrokeWidth};
use crate::widget_id::{WidgetId, WidgetKey};

use super::keys::{TakeKey, take_key};
use super::paint::WidgetPaint;
use super::visuals::push_focus_ring;

const MIN_THUMB_PX: f32 = 18.0;
const LINE_STEP_PX: f32 = 24.0;
const THUMB_INSET_PX: f32 = 2.0;
const SCROLLBAR_WIDTH_PX: f32 = 14.0;

pub struct RowWindow {
    pub content_rect: LayoutRect,
    pub bar: Option<LayoutRect>,
    pub content_len: LayoutPx,
    pub offset: LayoutPx,
    pub first_row: usize,
    pub last_row: usize,
}

#[must_use]
pub fn row_window(
    rect: LayoutRect,
    row_count: usize,
    row_height: LayoutPx,
    requested: LayoutPx,
) -> RowWindow {
    let row_h = row_height.value();
    let viewport_h = rect.size.height.value();
    #[allow(
        clippy::cast_precision_loss,
        reason = "row count fits the f32 mantissa"
    )]
    let content_h = row_count as f32 * row_h;
    let scrollable = row_h > 0.0 && content_h > viewport_h + 0.5;
    let bar_w = if scrollable { SCROLLBAR_WIDTH_PX } else { 0.0 };
    let content_rect = LayoutRect::new(
        rect.origin,
        LayoutSize::new(
            LayoutPx::saturating_nonneg(rect.size.width.value() - bar_w),
            rect.size.height,
        ),
    );
    if !scrollable {
        return RowWindow {
            content_rect,
            bar: None,
            content_len: LayoutPx::new(content_h),
            offset: LayoutPx::ZERO,
            first_row: 0,
            last_row: row_count,
        };
    }
    let max_offset = (content_h - viewport_h).max(0.0);
    let clamped = requested.value().clamp(0.0, max_offset);
    let (first_row, fit_rows) = row_span(clamped, viewport_h, row_h);
    let last_row = (first_row + fit_rows).min(row_count);
    #[allow(
        clippy::cast_precision_loss,
        reason = "row index fits the f32 mantissa"
    )]
    let snapped = first_row as f32 * row_h;
    let bar = LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(rect.origin.x.value() + rect.size.width.value() - bar_w),
            rect.origin.y,
        ),
        LayoutSize::new(LayoutPx::new(bar_w), rect.size.height),
    );
    RowWindow {
        content_rect,
        bar: Some(bar),
        content_len: LayoutPx::new(content_h),
        offset: LayoutPx::new(snapped),
        first_row,
        last_row,
    }
}

#[allow(
    clippy::cast_possible_truncation,
    clippy::cast_sign_loss,
    reason = "non-negative row counts stay well within usize"
)]
fn row_span(offset_px: f32, viewport_h: f32, row_h: f32) -> (usize, usize) {
    let first = (offset_px / row_h).floor() as usize;
    let fit = (viewport_h / row_h).floor() as usize;
    (first, fit)
}

#[must_use]
pub fn wheel_scroll(ctx: &FrameCtx<'_>, rect: LayoutRect, offset: LayoutPx) -> LayoutPx {
    if ctx.input.pointer.is_some_and(|p| rect.contains(p.position)) {
        LayoutPx::new(offset.value() + ctx.input.scroll_y)
    } else {
        offset
    }
}

#[must_use]
pub fn window_scrollbar(
    ctx: &mut FrameCtx<'_>,
    container_id: WidgetId,
    label: StringKey,
    window: &RowWindow,
    offset: LayoutPx,
) -> (Vec<WidgetPaint>, LayoutPx) {
    let Some(bar_rect) = window.bar else {
        return (Vec::new(), offset);
    };
    let response = show_scrollbar(
        ctx,
        Scrollbar::new(
            container_id.child(WidgetKey::new("scrollbar")),
            bar_rect,
            Axis::Vertical,
            label,
            window.content_len,
            offset,
        ),
    );
    let next = if response.changed {
        response.offset
    } else {
        offset
    };
    (response.paint, next)
}

pub struct Scrollbar {
    pub id: WidgetId,
    pub rect: LayoutRect,
    pub axis: Axis,
    pub label: StringKey,
    pub content: LayoutPx,
    pub offset: LayoutPx,
    pub disabled: bool,
}

impl Scrollbar {
    #[must_use]
    pub fn new(
        id: WidgetId,
        rect: LayoutRect,
        axis: Axis,
        label: StringKey,
        content: LayoutPx,
        offset: LayoutPx,
    ) -> Self {
        Self {
            id,
            rect,
            axis,
            label,
            content,
            offset,
            disabled: false,
        }
    }

    #[must_use]
    pub fn disabled(self, disabled: bool) -> Self {
        Self { disabled, ..self }
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct ScrollbarResponse {
    pub interaction: Interaction,
    pub offset: LayoutPx,
    pub changed: bool,
    pub paint: Vec<WidgetPaint>,
}

#[derive(Copy, Clone, Debug, PartialEq)]
struct Geometry {
    track_len: f32,
    thumb_len: f32,
    max_offset: f32,
}

impl Geometry {
    fn of(rect: LayoutRect, axis: Axis, content: LayoutPx) -> Self {
        let track_len = axis_len(rect, axis);
        let content = content.value().max(track_len);
        let ratio = if content > 0.0 {
            (track_len / content).clamp(0.0, 1.0)
        } else {
            1.0
        };
        let thumb_len = (track_len * ratio).clamp(MIN_THUMB_PX.min(track_len), track_len);
        Self {
            track_len,
            thumb_len,
            max_offset: (content - track_len).max(0.0),
        }
    }

    fn scrollable(self) -> bool {
        self.max_offset > 0.0
    }

    fn travel(self) -> f32 {
        (self.track_len - self.thumb_len).max(0.0)
    }

    fn thumb_start(self, offset: f32) -> f32 {
        if self.max_offset <= 0.0 {
            return 0.0;
        }
        (offset / self.max_offset).clamp(0.0, 1.0) * self.travel()
    }

    fn offset_at(self, local: f32) -> f32 {
        let travel = self.travel();
        if travel <= 0.0 {
            return 0.0;
        }
        let unit = ((local - self.thumb_len / 2.0) / travel).clamp(0.0, 1.0);
        unit * self.max_offset
    }
}

#[must_use]
#[allow(
    clippy::needless_pass_by_value,
    reason = "destructure consumes the scrollbar"
)]
pub fn show_scrollbar(ctx: &mut FrameCtx<'_>, scrollbar: Scrollbar) -> ScrollbarResponse {
    let Scrollbar {
        id,
        rect,
        axis,
        label,
        content,
        offset: initial,
        disabled,
    } = scrollbar;
    let geom = Geometry::of(rect, axis, content);
    let interactive = !disabled && geom.scrollable();
    let interaction = ctx.interact(
        InteractDeclaration::new(id, rect, Sense::DRAGGABLE)
            .focusable(interactive)
            .disabled(!interactive),
    );

    let mut offset = initial.value().clamp(0.0, geom.max_offset);
    let mut changed = false;

    if interactive
        && (interaction.click() || interaction.drag_start() || interaction.pressed())
        && let Some(next) = pointer_offset(rect, axis, geom, ctx.input)
        && (next - offset).abs() > f32::EPSILON
    {
        offset = next;
        changed = true;
    }

    let live_focused = ctx.is_focused(id);
    if interactive
        && live_focused
        && let Some(event) = take_key(ctx.input, keyboard_targets(axis))
    {
        let next = apply_key(offset, geom, axis, event.code);
        if (next - offset).abs() > f32::EPSILON {
            offset = next;
            changed = true;
        }
    }

    ctx.a11y.push(
        id,
        rect,
        AccessNode::new(Role::ScrollBar)
            .with_label(label)
            .with_disabled(!interactive)
            .with_range(AccessRange {
                value: f64::from(offset),
                min: 0.0,
                max: f64::from(geom.max_offset),
                step: f64::from(LINE_STEP_PX),
            }),
    );

    let thumb = thumb_rect(rect, axis, geom, offset);
    let paint = build_paint(ctx, rect, thumb, !interactive, interaction, live_focused);
    ScrollbarResponse {
        interaction,
        offset: LayoutPx::new(offset),
        changed,
        paint,
    }
}

fn keyboard_targets(axis: Axis) -> &'static [TakeKey] {
    const VERTICAL: [TakeKey; 6] = [
        TakeKey::named(NamedKey::ArrowUp),
        TakeKey::named(NamedKey::ArrowDown),
        TakeKey::named(NamedKey::PageUp),
        TakeKey::named(NamedKey::PageDown),
        TakeKey::named(NamedKey::Home),
        TakeKey::named(NamedKey::End),
    ];
    const HORIZONTAL: [TakeKey; 6] = [
        TakeKey::named(NamedKey::ArrowLeft),
        TakeKey::named(NamedKey::ArrowRight),
        TakeKey::named(NamedKey::PageUp),
        TakeKey::named(NamedKey::PageDown),
        TakeKey::named(NamedKey::Home),
        TakeKey::named(NamedKey::End),
    ];
    match axis {
        Axis::Vertical => &VERTICAL,
        Axis::Horizontal => &HORIZONTAL,
    }
}

fn apply_key(offset: f32, geom: Geometry, axis: Axis, code: KeyCode) -> f32 {
    let (back, forward) = match axis {
        Axis::Vertical => (NamedKey::ArrowUp, NamedKey::ArrowDown),
        Axis::Horizontal => (NamedKey::ArrowLeft, NamedKey::ArrowRight),
    };
    let next = match code {
        KeyCode::Named(key) if key == back => offset - LINE_STEP_PX,
        KeyCode::Named(key) if key == forward => offset + LINE_STEP_PX,
        KeyCode::Named(NamedKey::PageUp) => offset - geom.track_len,
        KeyCode::Named(NamedKey::PageDown) => offset + geom.track_len,
        KeyCode::Named(NamedKey::Home) => 0.0,
        KeyCode::Named(NamedKey::End) => geom.max_offset,
        _ => offset,
    };
    next.clamp(0.0, geom.max_offset)
}

fn pointer_offset(
    rect: LayoutRect,
    axis: Axis,
    geom: Geometry,
    input: &crate::input::InputSnapshot,
) -> Option<f32> {
    let pointer = input.pointer?.position;
    let local = match axis {
        Axis::Vertical => pointer.y.value() - rect.origin.y.value(),
        Axis::Horizontal => pointer.x.value() - rect.origin.x.value(),
    };
    Some(geom.offset_at(local))
}

fn axis_len(rect: LayoutRect, axis: Axis) -> f32 {
    match axis {
        Axis::Vertical => rect.size.height.value(),
        Axis::Horizontal => rect.size.width.value(),
    }
}

fn build_paint(
    ctx: &FrameCtx<'_>,
    track: LayoutRect,
    thumb: LayoutRect,
    inactive: bool,
    interaction: Interaction,
    live_focused: bool,
) -> Vec<WidgetPaint> {
    let neutral = ctx.theme().colors.neutral;
    let radius = ctx.theme().radius.sm;
    let thumb_fill = if inactive {
        neutral.step(Step12::SELECTED_BG)
    } else if interaction.pressed() {
        neutral.step(Step12::SOLID)
    } else if interaction.hover() {
        neutral.step(Step12::HOVER_BORDER)
    } else {
        neutral.step(Step12::BORDER)
    };
    let mut paint = vec![
        WidgetPaint::Surface {
            rect: track,
            fill: neutral.step(Step12::SUBTLE_BG),
            border: Some(Border {
                width: StrokeWidth::HAIRLINE,
                color: neutral.step(Step12::SUBTLE_BORDER),
            }),
            radius,
            elevation: None,
        },
        WidgetPaint::Surface {
            rect: thumb,
            fill: thumb_fill,
            border: Some(Border {
                width: StrokeWidth::HAIRLINE,
                color: neutral.step(Step12::BORDER),
            }),
            radius,
            elevation: None,
        },
    ];
    push_focus_ring(ctx, &mut paint, thumb, radius, live_focused);
    paint
}

fn thumb_rect(rect: LayoutRect, axis: Axis, geom: Geometry, offset: f32) -> LayoutRect {
    let start = geom.thumb_start(offset);
    let inset = THUMB_INSET_PX.min(cross_len(rect, axis) / 2.0);
    match axis {
        Axis::Vertical => LayoutRect::new(
            LayoutPos::new(
                LayoutPx::new(rect.origin.x.value() + inset),
                LayoutPx::new(rect.origin.y.value() + start),
            ),
            LayoutSize::new(
                LayoutPx::new((rect.size.width.value() - 2.0 * inset).max(0.0)),
                LayoutPx::new(geom.thumb_len),
            ),
        ),
        Axis::Horizontal => LayoutRect::new(
            LayoutPos::new(
                LayoutPx::new(rect.origin.x.value() + start),
                LayoutPx::new(rect.origin.y.value() + inset),
            ),
            LayoutSize::new(
                LayoutPx::new(geom.thumb_len),
                LayoutPx::new((rect.size.height.value() - 2.0 * inset).max(0.0)),
            ),
        ),
    }
}

fn cross_len(rect: LayoutRect, axis: Axis) -> f32 {
    match axis {
        Axis::Vertical => rect.size.width.value(),
        Axis::Horizontal => rect.size.height.value(),
    }
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use super::{Geometry, MIN_THUMB_PX, Scrollbar, ScrollbarResponse, show_scrollbar};
    use crate::a11y::AccessTreeBuilder;
    use crate::focus::FocusManager;
    use crate::frame::FrameCtx;
    use crate::hit_test::{HitFrame, HitState, resolve};
    use crate::hotkey::HotkeyTable;
    use crate::input::{
        FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton,
        PointerButtonMask, PointerSample,
    };
    use crate::layout::{Axis, LayoutPos, LayoutPx, LayoutRect, LayoutSize};
    use crate::strings::{StringKey, StringTable};
    use crate::theme::Theme;
    use crate::widget_id::{WidgetId, WidgetKey};

    const LABEL: StringKey = StringKey::new("scrollbar.label");
    const TRACK_LEN: f32 = 140.0;

    fn bar_id() -> WidgetId {
        WidgetId::ROOT.child(WidgetKey::new("scrollbar"))
    }

    fn rect() -> LayoutRect {
        LayoutRect::new(
            LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)),
            LayoutSize::new(LayoutPx::new(14.0), LayoutPx::new(TRACK_LEN)),
        )
    }

    #[test]
    fn thumb_shrinks_with_content() {
        let small = Geometry::of(rect(), Axis::Vertical, LayoutPx::new(280.0));
        let large = Geometry::of(rect(), Axis::Vertical, LayoutPx::new(700.0));
        assert!(small.thumb_len > large.thumb_len);
        assert!((small.thumb_len - TRACK_LEN / 2.0).abs() < 1e-3);
    }

    #[test]
    fn thumb_fills_track_when_content_fits() {
        let geom = Geometry::of(rect(), Axis::Vertical, LayoutPx::new(100.0));
        assert!((geom.thumb_len - TRACK_LEN).abs() < 1e-3);
        assert!(!geom.scrollable());
    }

    #[test]
    fn thumb_never_shorter_than_minimum() {
        let geom = Geometry::of(rect(), Axis::Vertical, LayoutPx::new(100_000.0));
        assert!(geom.thumb_len >= MIN_THUMB_PX - 1e-3);
    }

    fn run_keys(offset: f32, content: f32, events: Vec<KeyEvent>) -> ScrollbarResponse {
        let theme = Arc::new(Theme::light());
        let mut focus = FocusManager::new();
        focus.register_focusable(bar_id());
        focus.request_focus(bar_id());
        focus.end_frame();
        let table = HotkeyTable::new();
        let mut hits = HitFrame::new();
        let prev = HitState::new();
        let mut input = InputSnapshot::idle(FrameInstant::ZERO);
        input.keys_pressed = events;
        let widget = Scrollbar::new(
            bar_id(),
            rect(),
            Axis::Vertical,
            LABEL,
            LayoutPx::new(content),
            LayoutPx::new(offset),
        );
        let mut shaper = bone_text::Shaper::new();
        let mut a11y = AccessTreeBuilder::new();
        let mut ctx = FrameCtx::new(
            theme,
            &mut input,
            &mut focus,
            &table,
            StringTable::empty(),
            &mut hits,
            &prev,
            &mut a11y,
            &mut shaper,
        );
        show_scrollbar(&mut ctx, widget)
    }

    fn key(named: NamedKey) -> KeyEvent {
        KeyEvent::new(KeyCode::Named(named), ModifierMask::NONE)
    }

    #[test]
    fn arrow_down_steps_forward() {
        let response = run_keys(0.0, 560.0, vec![key(NamedKey::ArrowDown)]);
        assert!((response.offset.value() - 24.0).abs() < 1e-3);
        assert!(response.changed);
    }

    #[test]
    fn arrow_up_clamps_at_top() {
        let response = run_keys(0.0, 560.0, vec![key(NamedKey::ArrowUp)]);
        assert!((response.offset.value() - 0.0).abs() < 1e-3);
        assert!(!response.changed);
    }

    #[test]
    fn end_jumps_to_max_offset() {
        let response = run_keys(0.0, 560.0, vec![key(NamedKey::End)]);
        assert!((response.offset.value() - (560.0 - TRACK_LEN)).abs() < 1e-3);
    }

    #[test]
    fn page_down_steps_by_track_length() {
        let response = run_keys(0.0, 560.0, vec![key(NamedKey::PageDown)]);
        assert!((response.offset.value() - TRACK_LEN).abs() < 1e-3);
    }

    #[test]
    fn horizontal_ignores_vertical_arrows() {
        let theme = Arc::new(Theme::light());
        let mut focus = FocusManager::new();
        focus.register_focusable(bar_id());
        focus.request_focus(bar_id());
        focus.end_frame();
        let table = HotkeyTable::new();
        let mut hits = HitFrame::new();
        let prev = HitState::new();
        let mut input = InputSnapshot::idle(FrameInstant::ZERO);
        input.keys_pressed = vec![key(NamedKey::ArrowDown)];
        let rect = LayoutRect::new(
            LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
            LayoutSize::new(LayoutPx::new(TRACK_LEN), LayoutPx::new(14.0)),
        );
        let widget = Scrollbar::new(
            bar_id(),
            rect,
            Axis::Horizontal,
            LABEL,
            LayoutPx::new(560.0),
            LayoutPx::new(0.0),
        );
        let mut shaper = bone_text::Shaper::new();
        let mut a11y = AccessTreeBuilder::new();
        let mut ctx = FrameCtx::new(
            theme,
            &mut input,
            &mut focus,
            &table,
            StringTable::empty(),
            &mut hits,
            &prev,
            &mut a11y,
            &mut shaper,
        );
        let response = show_scrollbar(&mut ctx, widget);
        assert!(!response.changed);
    }

    #[test]
    fn not_scrollable_ignores_keys() {
        let response = run_keys(0.0, 100.0, vec![key(NamedKey::End)]);
        assert!((response.offset.value() - 0.0).abs() < 1e-3);
        assert!(!response.changed);
    }

    fn run_pointer_press_at(content: f32, y: f32) -> ScrollbarResponse {
        let theme = Arc::new(Theme::light());
        let mut focus = FocusManager::new();
        let table = HotkeyTable::new();
        let mut prev_state = HitState::new();
        let mut offset = 0.0_f32;
        let pos = LayoutPos::new(LayoutPx::new(7.0), LayoutPx::new(y));
        let mut frame = |pressed: PointerButtonMask| -> ScrollbarResponse {
            let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
            snap.pointer = Some(PointerSample::new(pos));
            snap.buttons_pressed = pressed;
            let mut hits = HitFrame::new();
            let response = {
                let widget = Scrollbar::new(
                    bar_id(),
                    rect(),
                    Axis::Vertical,
                    LABEL,
                    LayoutPx::new(content),
                    LayoutPx::new(offset),
                );
                let mut shaper = bone_text::Shaper::new();
                let mut a11y = AccessTreeBuilder::new();
                let mut ctx = FrameCtx::new(
                    theme.clone(),
                    &mut snap,
                    &mut focus,
                    &table,
                    StringTable::empty(),
                    &mut hits,
                    &prev_state,
                    &mut a11y,
                    &mut shaper,
                );
                show_scrollbar(&mut ctx, widget)
            };
            offset = response.offset.value();
            prev_state = resolve(&prev_state, &hits, &snap, focus.focused());
            response
        };
        let _ = frame(PointerButtonMask::just(PointerButton::Primary));
        frame(PointerButtonMask::EMPTY)
    }

    #[test]
    fn pointer_press_near_bottom_scrolls_toward_max() {
        let response = run_pointer_press_at(560.0, TRACK_LEN - 1.0);
        assert!(
            response.offset.value() > (560.0 - TRACK_LEN) * 0.8,
            "expected near max, got {}",
            response.offset.value(),
        );
        assert!(response.changed);
    }

    #[test]
    fn pointer_press_near_top_scrolls_toward_zero() {
        let response = run_pointer_press_at(560.0, 1.0);
        assert!(
            response.offset.value() < (560.0 - TRACK_LEN) * 0.2,
            "expected near zero, got {}",
            response.offset.value(),
        );
    }
}
