use core::fmt::Debug;
use core::num::NonZeroU8;

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

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

pub trait SliderScalar: Copy + Debug + Default + PartialOrd {
    #[must_use]
    fn to_unit(self, range: SliderRange<Self>) -> f64;
    #[must_use]
    fn from_unit(unit: f64, range: SliderRange<Self>) -> Self;
    #[must_use]
    fn to_f64(self) -> f64;
    #[must_use]
    fn step_by(self, step: SliderStep<Self>, sign: i32) -> Self;
    #[must_use]
    fn clamp_to(self, range: SliderRange<Self>) -> Self;
    #[must_use]
    fn snap_to_step(self, step: SliderStep<Self>, range: SliderRange<Self>) -> Self;
}

#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum SliderRangeError {
    #[error("slider range min must be strictly less than max")]
    NotOrdered,
    #[error("slider range bounds must be comparable")]
    NotComparable,
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub struct SliderRange<T> {
    min: T,
    max: T,
}

impl<T: SliderScalar> SliderRange<T> {
    pub fn try_new(min: T, max: T) -> Result<Self, SliderRangeError> {
        match min.partial_cmp(&max) {
            Some(core::cmp::Ordering::Less) => Ok(Self { min, max }),
            Some(_) => Err(SliderRangeError::NotOrdered),
            None => Err(SliderRangeError::NotComparable),
        }
    }

    #[must_use]
    pub fn min(self) -> T {
        self.min
    }

    #[must_use]
    pub fn max(self) -> T {
        self.max
    }
}

#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum SliderStepError {
    #[error("slider step must be strictly positive")]
    NotPositive,
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub struct SliderStep<T>(T);

impl<T: SliderScalar> SliderStep<T> {
    pub fn try_new(value: T) -> Result<Self, SliderStepError> {
        match value.partial_cmp(&T::default()) {
            Some(core::cmp::Ordering::Greater) => Ok(Self(value)),
            _ => Err(SliderStepError::NotPositive),
        }
    }

    #[must_use]
    pub fn value(self) -> T {
        self.0
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct SliderCoarseStep(NonZeroU8);

impl SliderCoarseStep {
    pub const DEFAULT: Self = match NonZeroU8::new(10) {
        Some(n) => Self(n),
        None => panic!("default coarse step must be non-zero"),
    };

    #[must_use]
    pub const fn new(value: NonZeroU8) -> Self {
        Self(value)
    }

    #[must_use]
    pub const fn get(self) -> NonZeroU8 {
        self.0
    }
}

pub struct Slider<T: SliderScalar> {
    pub id: WidgetId,
    pub rect: LayoutRect,
    pub label: StringKey,
    pub value: T,
    pub range: SliderRange<T>,
    pub step: SliderStep<T>,
    pub coarse_multiplier: SliderCoarseStep,
    pub disabled: bool,
}

impl<T: SliderScalar> Slider<T> {
    #[must_use]
    pub fn new(
        id: WidgetId,
        rect: LayoutRect,
        label: StringKey,
        value: T,
        range: SliderRange<T>,
        step: SliderStep<T>,
    ) -> Self {
        Self {
            id,
            rect,
            label,
            value,
            range,
            step,
            coarse_multiplier: SliderCoarseStep::DEFAULT,
            disabled: false,
        }
    }

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

#[derive(Clone, Debug, PartialEq)]
pub struct SliderResponse<T: SliderScalar> {
    pub interaction: Interaction,
    pub value: T,
    pub changed: bool,
    pub paint: Vec<WidgetPaint>,
}

#[must_use]
#[allow(
    clippy::needless_pass_by_value,
    reason = "destructure consumes the slider"
)]
pub fn show_slider<T: SliderScalar>(
    ctx: &mut FrameCtx<'_>,
    slider: Slider<T>,
) -> SliderResponse<T> {
    let Slider {
        id,
        rect,
        label,
        value: initial_value,
        range,
        step,
        coarse_multiplier,
        disabled,
    } = slider;
    let interactive = !disabled;
    let interaction = ctx.interact(
        InteractDeclaration::new(id, rect, Sense::DRAGGABLE)
            .focusable(interactive)
            .disabled(!interactive),
    );
    let mut value = initial_value;
    let mut changed = false;

    if interactive
        && (interaction.click() || interaction.drag_start() || interaction.pressed())
        && let Some(unit) = pointer_unit(rect, ctx.input)
    {
        let next = T::from_unit(unit, range)
            .snap_to_step(step, range)
            .clamp_to(range);
        if next != value {
            value = next;
            changed = true;
        }
    }

    let live_focused = ctx.is_focused(id);
    if interactive
        && live_focused
        && let Some(event) = take_key(ctx.input, &KEYBOARD_TARGETS)
    {
        let next = apply_key(value, range, step, coarse_multiplier, event);
        if next != value {
            value = next;
            changed = true;
        }
    }

    ctx.a11y.push(
        id,
        rect,
        AccessNode::new(Role::Slider)
            .with_label(label)
            .with_disabled(!interactive)
            .with_range(AccessRange {
                value: value.to_f64(),
                min: range.min().to_f64(),
                max: range.max().to_f64(),
                step: step.value().to_f64(),
            }),
    );

    let paint = build_paint(ctx, rect, range, disabled, value, interaction, live_focused);
    SliderResponse {
        interaction,
        value,
        changed,
        paint,
    }
}

const KEYBOARD_TARGETS: [TakeKey; 10] = [
    TakeKey::named(NamedKey::ArrowLeft),
    TakeKey::named(NamedKey::ArrowRight),
    TakeKey::named(NamedKey::ArrowUp),
    TakeKey::named(NamedKey::ArrowDown),
    TakeKey::named(NamedKey::Home),
    TakeKey::named(NamedKey::End),
    TakeKey::new(KeyCode::Named(NamedKey::ArrowLeft), ModifierMask::SHIFT),
    TakeKey::new(KeyCode::Named(NamedKey::ArrowRight), ModifierMask::SHIFT),
    TakeKey::new(KeyCode::Named(NamedKey::ArrowUp), ModifierMask::SHIFT),
    TakeKey::new(KeyCode::Named(NamedKey::ArrowDown), ModifierMask::SHIFT),
];

fn pointer_unit(rect: LayoutRect, input: &crate::input::InputSnapshot) -> Option<f64> {
    let width = rect.size.width.value();
    if width <= 0.0 {
        return None;
    }
    let pointer = input.pointer?.position;
    let local_x = pointer.x.value() - rect.origin.x.value();
    Some(f64::from((local_x / width).clamp(0.0, 1.0)))
}

fn apply_key<T: SliderScalar>(
    value: T,
    range: SliderRange<T>,
    step: SliderStep<T>,
    coarse: SliderCoarseStep,
    event: KeyEvent,
) -> T {
    let magnitude = if event.modifiers.contains(ModifierMask::SHIFT) {
        i32::from(coarse.get().get())
    } else {
        1
    };
    let stepped = |signed: i32| {
        value
            .step_by(step, signed)
            .snap_to_step(step, range)
            .clamp_to(range)
    };
    match event.code {
        KeyCode::Named(NamedKey::ArrowLeft | NamedKey::ArrowDown) => stepped(-magnitude),
        KeyCode::Named(NamedKey::ArrowRight | NamedKey::ArrowUp) => stepped(magnitude),
        KeyCode::Named(NamedKey::Home) => range.min(),
        KeyCode::Named(NamedKey::End) => range.max(),
        _ => value,
    }
}

fn build_paint<T: SliderScalar>(
    ctx: &FrameCtx<'_>,
    rect: LayoutRect,
    range: SliderRange<T>,
    disabled: bool,
    value: T,
    interaction: Interaction,
    live_focused: bool,
) -> Vec<WidgetPaint> {
    let neutral = ctx.theme().colors.neutral;
    let accent = ctx.theme().colors.accent;
    let radius = ctx.theme().radius.pill;
    let track_height = LayoutPx::new(4.0);
    let track_y = LayoutPx::new(
        rect.origin.y.value() + rect.size.height.value() / 2.0 - track_height.value() / 2.0,
    );
    let track_rect = LayoutRect::new(
        LayoutPos::new(rect.origin.x, track_y),
        LayoutSize::new(rect.size.width, track_height),
    );
    let unit = value.to_unit(range).clamp(0.0, 1.0);
    #[allow(
        clippy::cast_possible_truncation,
        reason = "unit is clamped to [0, 1] before f32 narrowing"
    )]
    let unit_f32 = unit as f32;
    let filled_width = LayoutPx::new(rect.size.width.value() * unit_f32);
    let filled_rect = LayoutRect::new(
        LayoutPos::new(rect.origin.x, track_y),
        LayoutSize::new(filled_width, track_height),
    );
    let thumb_diameter = LayoutPx::new(rect.size.height.value().min(20.0));
    let thumb_x = LayoutPx::new(
        rect.origin.x.value() + rect.size.width.value() * unit_f32 - thumb_diameter.value() / 2.0,
    );
    let thumb_y = LayoutPx::new(
        rect.origin.y.value() + rect.size.height.value() / 2.0 - thumb_diameter.value() / 2.0,
    );
    let thumb_rect = LayoutRect::new(
        LayoutPos::new(thumb_x, thumb_y),
        LayoutSize::new(thumb_diameter, thumb_diameter),
    );
    let track_fill = neutral.step(Step12::ELEMENT_BG);
    let filled_fill = if disabled {
        neutral.step(Step12::SUBTLE_BORDER)
    } else {
        accent.step(Step12::SOLID)
    };
    let mut paint = vec![
        WidgetPaint::Surface {
            rect: track_rect,
            fill: track_fill,
            border: Some(Border {
                width: StrokeWidth::HAIRLINE,
                color: neutral.step(Step12::BORDER),
            }),
            radius,
            elevation: None,
        },
        WidgetPaint::Surface {
            rect: filled_rect,
            fill: filled_fill,
            border: None,
            radius,
            elevation: None,
        },
        WidgetPaint::Surface {
            rect: thumb_rect,
            fill: if interaction.pressed() || interaction.hover() {
                accent.step(Step12::HOVER_SOLID)
            } else {
                accent.step(Step12::SOLID)
            },
            border: Some(Border {
                width: StrokeWidth::HAIRLINE,
                color: neutral.step(Step12::BORDER),
            }),
            radius,
            elevation: None,
        },
        WidgetPaint::Mark {
            rect: thumb_rect,
            kind: GlyphMark::SliderThumb,
            color: ctx.theme().colors.contrast_text(filled_fill),
        },
    ];
    push_focus_ring(ctx, &mut paint, thumb_rect, radius, live_focused);
    paint
}

impl SliderScalar for f64 {
    fn to_unit(self, range: SliderRange<Self>) -> f64 {
        ((self - range.min) / (range.max - range.min)).clamp(0.0, 1.0)
    }

    fn from_unit(unit: f64, range: SliderRange<Self>) -> Self {
        range.min + (range.max - range.min) * unit.clamp(0.0, 1.0)
    }

    fn to_f64(self) -> f64 {
        self
    }

    fn step_by(self, step: SliderStep<Self>, sign: i32) -> Self {
        self + step.value() * f64::from(sign)
    }

    fn clamp_to(self, range: SliderRange<Self>) -> Self {
        self.clamp(range.min, range.max)
    }

    fn snap_to_step(self, step: SliderStep<Self>, range: SliderRange<Self>) -> Self {
        let s = step.value();
        range.min + ((self - range.min) / s).round() * s
    }
}

impl SliderScalar for f32 {
    fn to_unit(self, range: SliderRange<Self>) -> f64 {
        f64::from((self - range.min) / (range.max - range.min)).clamp(0.0, 1.0)
    }

    #[allow(
        clippy::cast_possible_truncation,
        reason = "unit is clamped to [0, 1] before f32 narrowing"
    )]
    fn from_unit(unit: f64, range: SliderRange<Self>) -> Self {
        range.min + (range.max - range.min) * unit.clamp(0.0, 1.0) as f32
    }

    fn to_f64(self) -> f64 {
        f64::from(self)
    }

    fn step_by(self, step: SliderStep<Self>, sign: i32) -> Self {
        #[allow(
            clippy::cast_precision_loss,
            reason = "sign values fit f32 mantissa exactly"
        )]
        let mul = sign as f32;
        self + step.value() * mul
    }

    fn clamp_to(self, range: SliderRange<Self>) -> Self {
        self.clamp(range.min, range.max)
    }

    fn snap_to_step(self, step: SliderStep<Self>, range: SliderRange<Self>) -> Self {
        let s = step.value();
        range.min + ((self - range.min) / s).round() * s
    }
}

impl SliderScalar for i32 {
    fn to_unit(self, range: SliderRange<Self>) -> f64 {
        let span = i64::from(range.max) - i64::from(range.min);
        let offset = i64::from(self) - i64::from(range.min);
        #[allow(
            clippy::cast_precision_loss,
            reason = "i64 span fits in f64 for i32 inputs"
        )]
        let result = offset as f64 / span as f64;
        result
    }

    fn to_f64(self) -> f64 {
        f64::from(self)
    }

    fn from_unit(unit: f64, range: SliderRange<Self>) -> Self {
        let span = i64::from(range.max) - i64::from(range.min);
        #[allow(
            clippy::cast_precision_loss,
            reason = "i64 span fits in f64 for i32 inputs"
        )]
        let span_f = span as f64;
        #[allow(
            clippy::cast_possible_truncation,
            reason = "round + clamp keeps result in i64 range"
        )]
        let offset = (span_f * unit.clamp(0.0, 1.0)).round() as i64;
        let raw = i64::from(range.min).saturating_add(offset);
        #[allow(
            clippy::cast_possible_truncation,
            reason = "clamp before cast keeps result in i32"
        )]
        let clamped = raw.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32;
        clamped
    }

    fn step_by(self, step: SliderStep<Self>, sign: i32) -> Self {
        self.saturating_add(step.value().saturating_mul(sign))
    }

    fn clamp_to(self, range: SliderRange<Self>) -> Self {
        self.clamp(range.min, range.max)
    }

    fn snap_to_step(self, step: SliderStep<Self>, range: SliderRange<Self>) -> Self {
        let offset = i64::from(self) - i64::from(range.min);
        let step_i64 = i64::from(step.value());
        #[allow(clippy::cast_precision_loss, reason = "i32-derived spans fit in f64")]
        let n = (offset as f64 / step_i64 as f64).round();
        #[allow(
            clippy::cast_possible_truncation,
            reason = "clamp before cast keeps result in i32"
        )]
        let snapped = i64::from(range.min)
            .saturating_add((n as i64).saturating_mul(step_i64))
            .clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32;
        snapped
    }
}

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

    use super::{
        Slider, SliderRange, SliderRangeError, SliderScalar, SliderStep, SliderStepError,
        show_slider,
    };
    use crate::focus::FocusManager;
    use crate::frame::FrameCtx;
    use crate::hit_test::{HitFrame, HitState};
    use crate::hotkey::HotkeyTable;
    use crate::input::{FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey};
    use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
    use crate::strings::{StringKey, StringTable};
    use crate::theme::Theme;
    use crate::widget_id::{WidgetId, WidgetKey};

    const LABEL: StringKey = StringKey::new("slider.label");

    fn rect() -> LayoutRect {
        LayoutRect::new(
            LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
            LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(20.0)),
        )
    }

    fn slider_id() -> WidgetId {
        WidgetId::ROOT.child(WidgetKey::new("slider"))
    }

    fn unit_range() -> SliderRange<f64> {
        let Ok(range) = SliderRange::try_new(0.0_f64, 100.0) else {
            panic!("test range must be valid");
        };
        range
    }

    fn unit_step() -> SliderStep<f64> {
        let Ok(step) = SliderStep::try_new(1.0_f64) else {
            panic!("test step must be valid");
        };
        step
    }

    fn run(value: f64, events: Vec<KeyEvent>) -> super::SliderResponse<f64> {
        let theme = Arc::new(Theme::light());
        let mut focus = FocusManager::new();
        focus.register_focusable(slider_id());
        focus.request_focus(slider_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 = Slider::new(slider_id(), rect(), LABEL, value, unit_range(), unit_step());
        let mut shaper = bone_text::Shaper::new();
        let mut a11y = crate::a11y::AccessTreeBuilder::new();
        let mut ctx = FrameCtx::new(
            theme,
            &mut input,
            &mut focus,
            &table,
            StringTable::empty(),
            &mut hits,
            &prev,
            &mut a11y,
            &mut shaper,
        );
        show_slider(&mut ctx, widget)
    }

    #[test]
    fn arrow_right_steps_up() {
        let response = run(
            10.0,
            vec![KeyEvent::new(
                KeyCode::Named(NamedKey::ArrowRight),
                ModifierMask::NONE,
            )],
        );
        assert!((response.value - 11.0).abs() < 1e-9);
        assert!(response.changed);
    }

    #[test]
    fn arrow_left_steps_down() {
        let response = run(
            10.0,
            vec![KeyEvent::new(
                KeyCode::Named(NamedKey::ArrowLeft),
                ModifierMask::NONE,
            )],
        );
        assert!((response.value - 9.0).abs() < 1e-9);
    }

    #[test]
    fn shift_arrow_uses_coarse_multiplier() {
        let response = run(
            10.0,
            vec![KeyEvent::new(
                KeyCode::Named(NamedKey::ArrowRight),
                ModifierMask::SHIFT,
            )],
        );
        assert!((response.value - 20.0).abs() < 1e-9);
    }

    #[test]
    fn home_jumps_to_min() {
        let response = run(
            42.0,
            vec![KeyEvent::new(
                KeyCode::Named(NamedKey::Home),
                ModifierMask::NONE,
            )],
        );
        assert!((response.value - 0.0).abs() < 1e-9);
    }

    #[test]
    fn end_jumps_to_max() {
        let response = run(
            42.0,
            vec![KeyEvent::new(
                KeyCode::Named(NamedKey::End),
                ModifierMask::NONE,
            )],
        );
        assert!((response.value - 100.0).abs() < 1e-9);
    }

    #[test]
    fn arrow_keys_clamp_at_range_edges() {
        let response = run(
            0.0,
            vec![KeyEvent::new(
                KeyCode::Named(NamedKey::ArrowLeft),
                ModifierMask::NONE,
            )],
        );
        assert!((response.value - 0.0).abs() < 1e-9);
    }

    #[test]
    fn idle_keeps_value_unchanged() {
        let response = run(50.0, vec![]);
        assert!((response.value - 50.0).abs() < 1e-9);
        assert!(!response.changed);
    }

    #[test]
    fn unit_conversions_round_trip() {
        let range = unit_range();
        [0.0_f64, 25.0, 50.0, 75.0, 100.0]
            .into_iter()
            .for_each(|v| {
                let unit = v.to_unit(range);
                let back = f64::from_unit(unit, range);
                assert!((back - v).abs() < 1e-9);
            });
    }

    #[test]
    fn integer_slider_steps_by_one() {
        let Ok(range) = SliderRange::try_new(0_i32, 10) else {
            panic!("range valid");
        };
        let Ok(step) = SliderStep::try_new(2_i32) else {
            panic!("step valid");
        };
        let unit_at_5 = 5_i32.to_unit(range);
        assert!((unit_at_5 - 0.5).abs() < 1e-9);
        assert_eq!(i32::from_unit(0.7, range), 7);
        assert_eq!(5_i32.step_by(step, -1).clamp_to(range), 3);
    }

    #[test]
    fn range_try_new_rejects_reversed() {
        assert_eq!(
            SliderRange::try_new(10.0_f64, 5.0),
            Err(SliderRangeError::NotOrdered),
        );
    }

    #[test]
    fn range_try_new_rejects_equal() {
        assert_eq!(
            SliderRange::try_new(5.0_f64, 5.0),
            Err(SliderRangeError::NotOrdered),
        );
    }

    #[test]
    fn range_try_new_rejects_nan() {
        assert_eq!(
            SliderRange::try_new(f64::NAN, 1.0),
            Err(SliderRangeError::NotComparable),
        );
        assert_eq!(
            SliderRange::try_new(0.0, f64::NAN),
            Err(SliderRangeError::NotComparable),
        );
    }

    #[test]
    fn step_try_new_rejects_zero() {
        assert_eq!(
            SliderStep::try_new(0.0_f64),
            Err(SliderStepError::NotPositive),
        );
    }

    #[test]
    fn step_try_new_rejects_negative() {
        assert_eq!(
            SliderStep::try_new(-1.0_f64),
            Err(SliderStepError::NotPositive),
        );
        assert_eq!(
            SliderStep::try_new(-1_i32),
            Err(SliderStepError::NotPositive)
        );
    }

    #[test]
    fn step_try_new_rejects_nan() {
        assert_eq!(
            SliderStep::try_new(f64::NAN),
            Err(SliderStepError::NotPositive),
        );
    }

    fn run_pointer_press_at_with(
        initial: f64,
        x: f32,
        step: SliderStep<f64>,
    ) -> super::SliderResponse<f64> {
        use crate::hit_test::resolve;
        use crate::input::{PointerButton, PointerButtonMask, PointerSample};
        let theme = Arc::new(Theme::light());
        let mut focus = FocusManager::new();
        let table = HotkeyTable::new();
        let mut prev_state = HitState::new();
        let mut value = initial;
        let pos = LayoutPos::new(LayoutPx::new(x), LayoutPx::new(10.0));
        let mut frame = |pressed: PointerButtonMask| -> super::SliderResponse<f64> {
            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 = Slider::new(slider_id(), rect(), LABEL, value, unit_range(), step);
                let mut shaper = bone_text::Shaper::new();
                let mut a11y = crate::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_slider(&mut ctx, widget)
            };
            value = response.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_left_edge_snaps_value_to_min() {
        let response = run_pointer_press_at_with(50.0, 1.0, unit_step());
        assert!(
            response.value < 5.0,
            "expected near-min, got {}",
            response.value
        );
        assert!(response.changed);
    }

    #[test]
    fn pointer_press_near_right_edge_snaps_value_to_max() {
        let response = run_pointer_press_at_with(50.0, 199.0, unit_step());
        assert!(
            response.value > 95.0,
            "expected near-max, got {}",
            response.value
        );
        assert!(response.changed);
    }

    #[test]
    fn pointer_press_at_known_offset_yields_expected_value() {
        let response = run_pointer_press_at_with(0.0, 50.0, unit_step());
        assert!(
            (response.value - 25.0).abs() < 1.0,
            "got {}",
            response.value
        );
    }

    #[test]
    fn pointer_drag_snaps_float_to_step_grid() {
        let Ok(step5) = SliderStep::try_new(5.0_f64) else {
            panic!("step valid");
        };
        let response = run_pointer_press_at_with(0.0, 53.0, step5);
        assert!(
            (response.value - 25.0).abs() < 1e-9,
            "expected snap to nearest 5, got {}",
            response.value,
        );
    }

    #[test]
    fn fine_step_approximates_continuous_drag() {
        let Ok(fine) = SliderStep::try_new(0.001_f64) else {
            panic!("step valid");
        };
        let response = run_pointer_press_at_with(0.0, 53.0, fine);
        assert!(
            (response.value - 26.5).abs() < 0.5,
            "expected near 26.5, got {}",
            response.value,
        );
    }

    #[test]
    fn integer_pointer_drag_snaps_to_step_grid() {
        let Ok(range) = SliderRange::try_new(0_i32, 20) else {
            panic!("range valid");
        };
        let Ok(step) = SliderStep::try_new(5_i32) else {
            panic!("step valid");
        };
        let raw = i32::from_unit(0.55, range);
        let snapped = raw.snap_to_step(step, range);
        assert_eq!(snapped, 10, "0.55 rounds to 11 then snaps to 10");
    }

    #[test]
    fn keyboard_arrow_snaps_off_grid_value_to_grid() {
        let Ok(step) = SliderStep::try_new(3.0_f64) else {
            panic!("step valid");
        };
        let response = run_with_step(
            5.0,
            step,
            vec![KeyEvent::new(
                KeyCode::Named(NamedKey::ArrowRight),
                ModifierMask::NONE,
            )],
        );
        assert!(
            (response.value - 9.0).abs() < 1e-9,
            "5 + 3 = 8, snap-to-3-grid = 9, got {}",
            response.value,
        );
    }

    fn run_with_step(
        value: f64,
        step: SliderStep<f64>,
        events: Vec<KeyEvent>,
    ) -> super::SliderResponse<f64> {
        let theme = Arc::new(Theme::light());
        let mut focus = FocusManager::new();
        focus.register_focusable(slider_id());
        focus.request_focus(slider_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 = Slider::new(slider_id(), rect(), LABEL, value, unit_range(), step);
        let mut shaper = bone_text::Shaper::new();
        let mut a11y = crate::a11y::AccessTreeBuilder::new();
        let mut ctx = FrameCtx::new(
            theme,
            &mut input,
            &mut focus,
            &table,
            StringTable::empty(),
            &mut hits,
            &prev,
            &mut a11y,
            &mut shaper,
        );
        show_slider(&mut ctx, widget)
    }
}
