use super::components::{ScrollAxis, ScrollConfig, ScrollDelta, ScrollOffset};
use crate::anim::{BOUNCY, SMOOTH, Spring};
use crate::ecs::{Entity, World};
use crate::event::hit_test::hit_test;
use crate::event::input::InputEvent;
use crate::types::Fixed;

fn accumulate_scroll_delta(world: &mut World, target: Entity, dx: Fixed, dy: Fixed) {
    if dx == Fixed::ZERO && dy == Fixed::ZERO {
        return;
    }
    if let Some(d) = world.get_mut::<ScrollDelta>(target) {
        d.dx += dx;
        d.dy += dy;
    } else {
        world.insert(target, ScrollDelta { dx, dy });
    }
}

/// Scroll drag state resource
pub struct ScrollDragState {
    pub active: bool,
    pub resolved: bool,
    pub target: Entity,
    pub hit_entity: Entity,
    pub start_x: Fixed,
    pub start_y: Fixed,
    pub last_x: Fixed,
    pub last_y: Fixed,
    pub vel_x: Fixed,
    pub vel_y: Fixed,
    pub last_resolved_target: Option<Entity>,
}

#[derive(Default)]
pub struct ScrollSpring {
    pub x: Option<Spring>,
    pub y: Option<Spring>,
    pub target_entity: Option<Entity>,
}

impl Default for ScrollDragState {
    fn default() -> Self {
        let null = Entity {
            id: 0,
            generation: 0,
        };
        Self {
            active: false,
            resolved: false,
            target: null,
            hit_entity: null,
            start_x: Fixed::ZERO,
            start_y: Fixed::ZERO,
            last_x: Fixed::ZERO,
            last_y: Fixed::ZERO,
            vel_x: Fixed::ZERO,
            vel_y: Fixed::ZERO,
            last_resolved_target: None,
        }
    }
}

const DIRECTION_THRESHOLD: Fixed = Fixed::from_int(5);

pub fn scroll_system(
    world: &mut World,
    root: Entity,
    event: &InputEvent,
    screen_w: u16,
    screen_h: u16,
) {
    match event {
        InputEvent::PointerDown { x, y, .. } => {
            let hit = hit_test(world, root, *x, *y, screen_w, screen_h);
            if let Some(state) = world.resource_mut::<ScrollDragState>() {
                state.active = true;
                state.resolved = false;
                state.hit_entity = hit.unwrap_or(Entity {
                    id: 0,
                    generation: 0,
                });
                state.start_x = *x;
                state.start_y = *y;
                state.last_x = *x;
                state.last_y = *y;
                state.vel_x = Fixed::ZERO;
                state.vel_y = Fixed::ZERO;
            }
            if let Some(ss) = world.resource_mut::<ScrollSpring>() {
                ss.x = None;
                ss.y = None;
                ss.target_entity = None;
            }
        }
        InputEvent::PointerMove { x, y, .. } => {
            let (active, resolved, _target, hit_entity, last_x, last_y, start_x, start_y) = {
                let Some(state) = world.resource::<ScrollDragState>() else {
                    return;
                };
                (
                    state.active,
                    state.resolved,
                    state.target,
                    state.hit_entity,
                    state.last_x,
                    state.last_y,
                    state.start_x,
                    state.start_y,
                )
            };

            if !active {
                return;
            }

            // If not resolved yet, check if we've moved enough to determine direction
            if !resolved {
                let total_dx = (*x - start_x).abs();
                let total_dy = (*y - start_y).abs();

                if total_dx < DIRECTION_THRESHOLD && total_dy < DIRECTION_THRESHOLD {
                    // Not enough movement to determine direction
                    if let Some(state) = world.resource_mut::<ScrollDragState>() {
                        state.last_x = *x;
                        state.last_y = *y;
                    }
                    return;
                }

                // Determine gesture direction
                let gesture_dir = if total_dy > total_dx {
                    ScrollAxis::Vertical
                } else {
                    ScrollAxis::Horizontal
                };

                // Find scroll target matching this direction
                // delta sign: positive = user dragging content up (wants to scroll down)
                let scroll_dx = -(*x - start_x);
                let scroll_dy = -(*y - start_y);
                let found = find_scroll_target_for_direction(
                    world,
                    hit_entity,
                    gesture_dir,
                    scroll_dx,
                    scroll_dy,
                );

                if let Some(state) = world.resource_mut::<ScrollDragState>() {
                    state.resolved = true;
                    if let Some(t) = found {
                        state.target = t;
                        state.last_resolved_target = Some(t);
                    } else {
                        // No matching scroll target — deactivate
                        state.active = false;
                        return;
                    }
                }
            }

            // Apply scroll delta — no mid-drag chaining (iOS behavior)
            // Chain decision was made at resolve time. During drag, always apply to target.
            let (target,) = {
                let Some(state) = world.resource::<ScrollDragState>() else {
                    return;
                };
                (state.target,)
            };

            let dx = *x - last_x;
            let dy = *y - last_y;

            let config = world.get::<ScrollConfig>(target);
            let dir = config.map(|c| c.direction).unwrap_or(ScrollAxis::Vertical);
            let elastic = config.map(|c| c.elastic).unwrap_or(true);
            let computed = world.get::<crate::widget::ComputedRect>(target);
            let container_h = computed.map(|c| c.0.h).unwrap_or(Fixed::ZERO);
            let container_w = computed.map(|c| c.0.w).unwrap_or(Fixed::ZERO);
            let content_h: Fixed = config.map(|c| c.content_height).unwrap_or(container_h);
            let content_w: Fixed = config.map(|c| c.content_width).unwrap_or(container_w);
            let max_y = (content_h - container_h).max(Fixed::ZERO);
            let max_x = (content_w - container_w).max(Fixed::ZERO);

            // elastic=true rubber-bands past the edges; elastic=false hard-clamps.
            let bound = |offset: Fixed, delta: Fixed, max: Fixed| -> Fixed {
                if elastic {
                    elastic_resist(offset, delta, max)
                } else {
                    let target = (offset + delta).clamp(Fixed::ZERO, max);
                    target - offset
                }
            };

            let mut applied_dx = Fixed::ZERO;
            let mut applied_dy = Fixed::ZERO;
            if let Some(scroll) = world.get_mut::<ScrollOffset>(target) {
                match dir {
                    ScrollAxis::Vertical => {
                        let d = bound(scroll.y, -dy, max_y);
                        scroll.y += d;
                        applied_dy = d;
                    }
                    ScrollAxis::Horizontal => {
                        let d = bound(scroll.x, -dx, max_x);
                        scroll.x += d;
                        applied_dx = d;
                    }
                    ScrollAxis::Both => {
                        let dxa = bound(scroll.x, -dx, max_x);
                        let dya = bound(scroll.y, -dy, max_y);
                        scroll.x += dxa;
                        scroll.y += dya;
                        applied_dx = dxa;
                        applied_dy = dya;
                    }
                }
            }
            accumulate_scroll_delta(world, target, applied_dx, applied_dy);
            // Stuck-at-edge drags translate to zero applied delta; no
            // need to redraw a frame whose pixels didn't move.
            if applied_dx != Fixed::ZERO || applied_dy != Fixed::ZERO {
                world.insert(target, crate::widget::dirty::Dirty);
            }

            let dir = world
                .get::<ScrollConfig>(target)
                .map(|c| c.direction)
                .unwrap_or(ScrollAxis::Vertical);
            if let Some(state) = world.resource_mut::<ScrollDragState>() {
                match dir {
                    ScrollAxis::Vertical => {
                        state.vel_x = Fixed::ZERO;
                        state.vel_y = -dy;
                    }
                    ScrollAxis::Horizontal => {
                        state.vel_x = -dx;
                        state.vel_y = Fixed::ZERO;
                    }
                    ScrollAxis::Both => {
                        state.vel_x = -dx;
                        state.vel_y = -dy;
                    }
                }
                state.last_x = *x;
                state.last_y = *y;
            }
        }
        InputEvent::PointerUp { .. } => {
            let (vel_x, vel_y, target_entity, dir, elastic) = {
                let Some(state) = world.resource::<ScrollDragState>() else {
                    return;
                };
                if !state.resolved {
                    if let Some(state) = world.resource_mut::<ScrollDragState>() {
                        state.active = false;
                    }
                    return;
                }
                let t = state.target;
                let cfg = world.get::<ScrollConfig>(t);
                let dir = cfg.map(|c| c.direction).unwrap_or(ScrollAxis::Vertical);
                let elastic = cfg.map(|c| c.elastic).unwrap_or(true);
                (state.vel_x, state.vel_y, t, dir, elastic)
            };

            let (offset_x, offset_y) = world
                .get::<ScrollOffset>(target_entity)
                .map(|s| (s.x, s.y))
                .unwrap_or((Fixed::ZERO, Fixed::ZERO));

            let (max_x, max_y) = {
                let cfg = world.get::<ScrollConfig>(target_entity);
                let computed = world.get::<crate::widget::ComputedRect>(target_entity);
                let container_h = computed.map(|c| c.0.h).unwrap_or(Fixed::ZERO);
                let container_w = computed.map(|c| c.0.w).unwrap_or(Fixed::ZERO);
                let content_h = cfg.map(|c| c.content_height).unwrap_or(container_h);
                let content_w = cfg.map(|c| c.content_width).unwrap_or(container_w);
                (
                    (content_w - container_w).max(Fixed::ZERO),
                    (content_h - container_h).max(Fixed::ZERO),
                )
            };

            // elastic=false: clamp the inertia target so the spring can't
            // push offset past the content edge.
            let clamp_to_bounds = |projected: Fixed, max: Fixed| {
                if elastic {
                    projected
                } else {
                    projected.clamp(Fixed::ZERO, max)
                }
            };

            let spring_x = match dir {
                ScrollAxis::Horizontal | ScrollAxis::Both => {
                    let projected = clamp_to_bounds(offset_x + vel_x * Fixed::from_int(20), max_x);
                    Some(Spring::preset(offset_x, projected, SMOOTH).with_velocity(vel_x))
                }
                _ => None,
            };
            let spring_y = match dir {
                ScrollAxis::Vertical | ScrollAxis::Both => {
                    let projected = clamp_to_bounds(offset_y + vel_y * Fixed::from_int(20), max_y);
                    Some(Spring::preset(offset_y, projected, SMOOTH).with_velocity(vel_y))
                }
                _ => None,
            };

            if let Some(ss) = world.resource_mut::<ScrollSpring>() {
                ss.x = spring_x;
                ss.y = spring_y;
                ss.target_entity = Some(target_entity);
            }

            if let Some(state) = world.resource_mut::<ScrollDragState>() {
                state.active = false;
            }
        }
        InputEvent::Rotary { delta, .. } => {
            let target = world
                .resource::<ScrollDragState>()
                .and_then(|s| s.last_resolved_target);
            if let Some(target) = target {
                let step = Fixed::from_int(20);
                let offset = Fixed::from(*delta as i32) * step;
                let axis = world
                    .get::<ScrollConfig>(target)
                    .map(|c| c.direction)
                    .unwrap_or(ScrollAxis::Vertical);
                let mut applied_dx = Fixed::ZERO;
                let mut applied_dy = Fixed::ZERO;
                if let Some(scroll) = world.get_mut::<ScrollOffset>(target) {
                    match axis {
                        ScrollAxis::Vertical => {
                            scroll.y -= offset;
                            applied_dy = -offset;
                        }
                        ScrollAxis::Horizontal => {
                            scroll.x -= offset;
                            applied_dx = -offset;
                        }
                        ScrollAxis::Both => {
                            scroll.y -= offset;
                            applied_dy = -offset;
                        }
                    }
                }
                accumulate_scroll_delta(world, target, applied_dx, applied_dy);
                world.insert(target, crate::widget::dirty::Dirty);
            }
        }
        InputEvent::Wheel { dx, dy, x, y } => {
            let Some(hit) = hit_test(world, root, *x, *y, screen_w, screen_h) else {
                return;
            };
            // Two-finger / wheel scroll feels right at "one tick = ~20 px"
            // — same step constant as Rotary above.
            // SDL flips dx vs dy sign on macOS natural-scroll: dy is
            // negated (drag down = scroll up), dx is not. Match that.
            let step = Fixed::from_int(20);
            let delta_x = *dx * step;
            let delta_y = -*dy * step;
            let gesture_dir = if dy.to_int().abs() >= dx.to_int().abs() {
                ScrollAxis::Vertical
            } else {
                ScrollAxis::Horizontal
            };
            let Some(target) =
                find_scroll_target_for_direction(world, hit, gesture_dir, delta_x, delta_y)
            else {
                return;
            };

            let (axis, max_x, max_y, elastic) = {
                let cfg = world.get::<ScrollConfig>(target);
                let computed = world.get::<crate::widget::ComputedRect>(target);
                let container_h = computed.map(|c| c.0.h).unwrap_or(Fixed::ZERO);
                let container_w = computed.map(|c| c.0.w).unwrap_or(Fixed::ZERO);
                let content_h = cfg.map(|c| c.content_height).unwrap_or(container_h);
                let content_w = cfg.map(|c| c.content_width).unwrap_or(container_w);
                let dir = cfg.map(|c| c.direction).unwrap_or(ScrollAxis::Vertical);
                let elastic = cfg.map(|c| c.elastic).unwrap_or(true);
                (
                    dir,
                    (content_w - container_w).max(Fixed::ZERO),
                    (content_h - container_h).max(Fixed::ZERO),
                    elastic,
                )
            };

            // Push the spring's target, don't touch ScrollOffset directly —
            // scroll_inertia_system writes the spring's value to the offset
            // each frame, so the wheel inherits the same animated settle as
            // drag-release inertia.
            let want_x_target = |cur: Fixed| {
                let projected = cur + delta_x;
                if elastic {
                    projected
                } else {
                    projected.clamp(Fixed::ZERO, max_x)
                }
            };
            let want_y_target = |cur: Fixed| {
                let projected = cur + delta_y;
                if elastic {
                    projected
                } else {
                    projected.clamp(Fixed::ZERO, max_y)
                }
            };

            let (offset_x, offset_y) = world
                .get::<ScrollOffset>(target)
                .map(|s| (s.x, s.y))
                .unwrap_or((Fixed::ZERO, Fixed::ZERO));

            if let Some(ss) = world.resource_mut::<ScrollSpring>() {
                if ss.target_entity != Some(target) {
                    ss.x = None;
                    ss.y = None;
                    ss.target_entity = Some(target);
                }
                let needs_x = matches!(axis, ScrollAxis::Horizontal | ScrollAxis::Both)
                    && delta_x != Fixed::ZERO;
                let needs_y = matches!(axis, ScrollAxis::Vertical | ScrollAxis::Both)
                    && delta_y != Fixed::ZERO;
                if needs_x {
                    let new_target = match ss.x.as_ref() {
                        Some(s) => want_x_target(s.target),
                        None => want_x_target(offset_x),
                    };
                    match ss.x.as_mut() {
                        Some(s) => s.retarget(new_target, Some(SMOOTH)),
                        None => ss.x = Some(Spring::preset(offset_x, new_target, SMOOTH)),
                    }
                }
                if needs_y {
                    let new_target = match ss.y.as_ref() {
                        Some(s) => want_y_target(s.target),
                        None => want_y_target(offset_y),
                    };
                    match ss.y.as_mut() {
                        Some(s) => s.retarget(new_target, Some(SMOOTH)),
                        None => ss.y = Some(Spring::preset(offset_y, new_target, SMOOTH)),
                    }
                }
            }
        }
        _ => {}
    }
}

/// Inertia system — call every frame to decelerate scroll after release
#[crate::system(order = SCROLL_INERTIA)]
pub fn scroll_inertia_system(world: &mut World) {
    let active = {
        let Some(state) = world.resource::<ScrollDragState>() else {
            return;
        };
        state.active
    };

    if active {
        return;
    }

    let (has_spring, target_entity) = {
        let Some(ss) = world.resource::<ScrollSpring>() else {
            return;
        };
        (ss.x.is_some() || ss.y.is_some(), ss.target_entity)
    };

    if !has_spring {
        return;
    }

    let Some(target) = target_entity else {
        return;
    };

    let dt = world
        .resource::<crate::ecs::DeltaTimeMs>()
        .map_or(16, |r| r.0);

    let (max_x, max_y, elastic) = {
        let config = world.get::<ScrollConfig>(target);
        let computed = world.get::<crate::widget::ComputedRect>(target);
        let container_h = computed.map(|c| c.0.h).unwrap_or(Fixed::ZERO);
        let container_w = computed.map(|c| c.0.w).unwrap_or(Fixed::ZERO);
        let content_h = config.map(|c| c.content_height).unwrap_or(container_h);
        let content_w = config.map(|c| c.content_width).unwrap_or(container_w);
        let max_y = (content_h - container_h).max(Fixed::ZERO);
        let max_x = (content_w - container_w).max(Fixed::ZERO);
        let elastic = config.map(|c| c.elastic).unwrap_or(true);
        (max_x, max_y, elastic)
    };

    let (new_x, new_y, done) = {
        let Some(ss) = world.resource_mut::<ScrollSpring>() else {
            return;
        };

        let mut done = true;

        // elastic=true bounces past the edge with a softer curve;
        // elastic=false snaps to the edge with the same smooth preset
        // the spring already uses, never letting the offset leave bounds.
        let edge_curve = if elastic { Some(BOUNCY) } else { Some(SMOOTH) };

        if let Some(ref mut sx) = ss.x {
            sx.tick(dt);
            let pos = sx.value();
            if pos < Fixed::ZERO {
                sx.retarget(Fixed::ZERO, edge_curve);
            } else if pos > max_x {
                sx.retarget(max_x, edge_curve);
            }
            if !sx.is_settled() {
                done = false;
            }
        }

        if let Some(ref mut sy) = ss.y {
            sy.tick(dt);
            let pos = sy.value();
            if pos < Fixed::ZERO {
                sy.retarget(Fixed::ZERO, edge_curve);
            } else if pos > max_y {
                sy.retarget(max_y, edge_curve);
            }
            if !sy.is_settled() {
                done = false;
            }
        }

        let nx = ss.x.as_ref().map(|s| s.value());
        let ny = ss.y.as_ref().map(|s| s.value());
        (nx, ny, done)
    };

    let mut changed = false;
    let mut applied_dx = Fixed::ZERO;
    let mut applied_dy = Fixed::ZERO;
    if let Some(scroll) = world.get_mut::<ScrollOffset>(target) {
        if let Some(nx) = new_x {
            // >=1px gate would skip spring-tail sub-pixel drift, leaving
            // ScrollOffset and screen out of sync at settle.
            if nx != scroll.x {
                changed = true;
                applied_dx = nx - scroll.x;
            }
            scroll.x = nx;
        }
        if let Some(ny) = new_y {
            if ny != scroll.y {
                changed = true;
                applied_dy = ny - scroll.y;
            }
            scroll.y = ny;
        }
    }
    if changed {
        accumulate_scroll_delta(world, target, applied_dx, applied_dy);
        world.insert(target, crate::widget::dirty::Dirty);
    }

    if done {
        if let Some(ss) = world.resource_mut::<ScrollSpring>() {
            ss.x = None;
            ss.y = None;
            ss.target_entity = None;
        }
    }
}

fn find_scroll_target_for_direction(
    world: &World,
    start: Entity,
    gesture_dir: ScrollAxis,
    delta_x: Fixed,
    delta_y: Fixed,
) -> Option<Entity> {
    let mut current = start;
    let mut last_matching: Option<Entity> = None;
    loop {
        if world.get::<ScrollOffset>(current).is_some() {
            let config = world.get::<ScrollConfig>(current);
            let scroll_dir = config.map(|c| c.direction).unwrap_or(ScrollAxis::Vertical);

            let dir_matches = matches!(
                (scroll_dir, gesture_dir),
                (ScrollAxis::Both, _)
                    | (ScrollAxis::Vertical, ScrollAxis::Vertical)
                    | (ScrollAxis::Horizontal, ScrollAxis::Horizontal)
            );

            if dir_matches {
                last_matching = Some(current);
                let at_boundary = is_at_boundary(world, current, delta_x, delta_y);
                if !at_boundary {
                    return Some(current);
                }
            }
        }
        if let Some(parent) = world.get::<crate::widget::Parent>(current) {
            current = parent.0;
        } else {
            break;
        }
    }
    last_matching
}

/// Apply elastic resistance when overscrolling.
/// `offset`: current scroll offset, `delta`: requested change, `max`: max allowed offset.
/// Returns the effective delta to apply (reduced when out of bounds).
#[cfg(test)]
mod tests {
    use super::*;

    fn run_drag(elastic: bool, init_y: i32, dy_per_frame: i32, frames: usize) -> Fixed {
        use crate::ecs::World;
        use crate::event::input::InputEvent;
        use crate::event::scroll::components::{ScrollAxis, ScrollConfig, ScrollOffset};
        use crate::widget::{ComputedRect, Widget};
        let mut world = World::new();
        world.insert_resource(ScrollDragState::default());
        let target = world.spawn_empty();
        world.insert(target, Widget);
        world.insert(
            target,
            ComputedRect(crate::types::Rect::new(0, 0, 128, 114)),
        );
        world.insert(
            target,
            ScrollOffset {
                x: Fixed::ZERO,
                y: Fixed::from_int(init_y),
            },
        );
        world.insert(
            target,
            ScrollConfig {
                direction: ScrollAxis::Vertical,
                elastic,
                content_height: Fixed::from_int(600),
                content_width: Fixed::ZERO,
            },
        );
        // Simulate PointerDown at (64, 50) hitting target.
        if let Some(state) = world.resource_mut::<ScrollDragState>() {
            state.active = true;
            state.resolved = true;
            state.target = target;
            state.last_resolved_target = Some(target);
            state.last_x = Fixed::from_int(64);
            state.last_y = Fixed::from_int(50);
            state.start_x = Fixed::from_int(64);
            state.start_y = Fixed::from_int(50);
        }
        let mut y = 50;
        for _ in 0..frames {
            y += dy_per_frame;
            let event = InputEvent::PointerMove {
                id: 0,
                x: Fixed::from_int(64),
                y: Fixed::from_int(y),
            };
            scroll_system(&mut world, target, &event, 128, 128);
        }
        world
            .get::<ScrollOffset>(target)
            .map(|s| s.y)
            .unwrap_or(Fixed::ZERO)
    }

    #[test]
    fn elastic_false_at_top_drag_down_does_not_go_negative() {
        // init at top (y=0), drag finger down +5 px each of 10 frames.
        // Expected: scroll.y stays at 0, never goes negative.
        let final_y = run_drag(false, 0, 5, 10);
        assert!(
            final_y >= Fixed::ZERO,
            "scroll.y went negative: {:?}",
            final_y
        );
    }

    #[test]
    fn elastic_false_pointer_up_clamps_inertia_projection() {
        use crate::ecs::World;
        use crate::event::input::InputEvent;
        use crate::event::scroll::components::{ScrollAxis, ScrollConfig, ScrollOffset};
        use crate::widget::{ComputedRect, Widget};
        let mut world = World::new();
        world.insert_resource(ScrollDragState::default());
        let target = world.spawn_empty();
        world.insert(target, Widget);
        world.insert(
            target,
            ComputedRect(crate::types::Rect::new(0, 0, 128, 114)),
        );
        world.insert(
            target,
            ScrollOffset {
                x: Fixed::ZERO,
                y: Fixed::ZERO,
            },
        );
        world.insert(
            target,
            ScrollConfig {
                direction: ScrollAxis::Vertical,
                elastic: false,
                content_height: Fixed::from_int(600),
                content_width: Fixed::ZERO,
            },
        );
        // PointerUp at top with downward velocity (vel_y < 0 = pulling
        // content down past the top edge).
        if let Some(state) = world.resource_mut::<ScrollDragState>() {
            state.active = true;
            state.resolved = true;
            state.target = target;
            state.last_resolved_target = Some(target);
            state.vel_x = Fixed::ZERO;
            state.vel_y = -Fixed::from_int(2);
        }
        let event = InputEvent::PointerUp {
            id: 0,
            x: Fixed::from_int(64),
            y: Fixed::from_int(50),
        };
        scroll_system(&mut world, target, &event, 128, 128);
        // Tick the spring forward and assert offset never goes negative.
        world.insert_resource(crate::ecs::DeltaTimeMs(16));
        for _ in 0..30 {
            scroll_inertia_system(&mut world);
            let off = world
                .get::<ScrollOffset>(target)
                .map(|s| s.y)
                .unwrap_or(Fixed::ZERO);
            assert!(
                off >= Fixed::ZERO,
                "spring drove offset negative: {:?}",
                off
            );
        }
    }

    #[test]
    fn find_scroll_target_walks_to_matching_axis() {
        use crate::ecs::World;
        use crate::event::scroll::components::{ScrollAxis, ScrollConfig, ScrollOffset};
        use crate::widget::{ComputedRect, Widget};
        let mut world = World::new();
        let target = world.spawn_empty();
        world.insert(target, Widget);
        world.insert(
            target,
            ComputedRect(crate::types::Rect::new(0, 0, 128, 114)),
        );
        world.insert(
            target,
            ScrollOffset {
                x: Fixed::ZERO,
                y: Fixed::from_int(100),
            },
        );
        world.insert(
            target,
            ScrollConfig {
                direction: ScrollAxis::Vertical,
                elastic: false,
                content_height: Fixed::from_int(600),
                content_width: Fixed::ZERO,
            },
        );

        let found = find_scroll_target_for_direction(
            &world,
            target,
            ScrollAxis::Vertical,
            Fixed::ZERO,
            Fixed::from_int(40),
        );
        assert_eq!(
            found,
            Some(target),
            "wheel scroll target lookup must resolve a vertical scroller from the hit entity",
        );
    }

    #[test]
    fn drag_accumulates_scroll_delta_across_frames() {
        use crate::ecs::World;
        use crate::event::input::InputEvent;
        use crate::event::scroll::components::{
            ScrollAxis, ScrollConfig, ScrollDelta, ScrollOffset,
        };
        use crate::widget::{ComputedRect, Widget};
        let mut world = World::new();
        world.insert_resource(ScrollDragState::default());
        let target = world.spawn_empty();
        world.insert(target, Widget);
        world.insert(
            target,
            ComputedRect(crate::types::Rect::new(0, 0, 128, 100)),
        );
        world.insert(
            target,
            ScrollOffset {
                x: Fixed::ZERO,
                y: Fixed::from_int(50),
            },
        );
        world.insert(
            target,
            ScrollConfig {
                direction: ScrollAxis::Vertical,
                elastic: false,
                content_height: Fixed::from_int(500),
                content_width: Fixed::ZERO,
            },
        );
        scroll_system(
            &mut world,
            target,
            &InputEvent::PointerDown {
                id: 0,
                x: Fixed::from_int(64),
                y: Fixed::from_int(50),
            },
            128,
            100,
        );
        let mut y = 50;
        for _ in 0..4 {
            y += 5;
            scroll_system(
                &mut world,
                target,
                &InputEvent::PointerMove {
                    id: 0,
                    x: Fixed::from_int(64),
                    y: Fixed::from_int(y),
                },
                128,
                100,
            );
        }
        let delta = world
            .get::<ScrollDelta>(target)
            .copied()
            .unwrap_or_default();
        assert_eq!(delta.dx, Fixed::ZERO);
        assert!(
            delta.dy.to_int().abs() >= 19,
            "expected |dy| ≈ 20, got {:?}",
            delta.dy
        );
    }
}

fn elastic_resist(offset: Fixed, delta: Fixed, max: Fixed) -> Fixed {
    const DAMPING: i32 = 200;

    let new_offset = offset + delta;

    // If staying in bounds, no resistance
    if new_offset >= Fixed::ZERO && new_offset <= max {
        return delta;
    }

    // Calculate how far out of bounds we are (or will be)
    let overscroll = if new_offset < Fixed::ZERO {
        -new_offset
    } else {
        new_offset - max
    };

    // Resistance: damping / (damping + overscroll)
    // Use integer math on raw values
    let over_int = overscroll.to_int();
    let resistance_denom = DAMPING + over_int;
    if resistance_denom == 0 {
        return Fixed::ZERO;
    }
    delta * DAMPING / resistance_denom
}
fn is_at_boundary(world: &World, entity: Entity, delta_x: Fixed, delta_y: Fixed) -> bool {
    let Some(scroll) = world.get::<ScrollOffset>(entity) else {
        return false;
    };
    let config = world.get::<ScrollConfig>(entity);
    let computed = world.get::<crate::widget::ComputedRect>(entity);
    let container_h = computed.map(|c| c.0.h).unwrap_or(Fixed::ZERO);
    let container_w = computed.map(|c| c.0.w).unwrap_or(Fixed::ZERO);
    let content_h = config.map(|c| c.content_height).unwrap_or(container_h);
    let content_w = config.map(|c| c.content_width).unwrap_or(container_w);
    let max_y = (content_h - container_h).max(Fixed::ZERO);
    let max_x = (content_w - container_w).max(Fixed::ZERO);

    let at_y = (scroll.y <= Fixed::ZERO && delta_y < Fixed::ZERO)
        || (scroll.y >= max_y && delta_y > Fixed::ZERO);
    let at_x = (scroll.x <= Fixed::ZERO && delta_x < Fixed::ZERO)
        || (scroll.x >= max_x && delta_x > Fixed::ZERO);

    let dir = config.map(|c| c.direction).unwrap_or(ScrollAxis::Vertical);
    match dir {
        ScrollAxis::Vertical => at_y,
        ScrollAxis::Horizontal => at_x,
        ScrollAxis::Both => at_y || at_x,
    }
}
