// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>

use super::*;
use crate::widget::{ LaidOutWidget, WidgetHandlers };
use crate::widget::scroll::ScrollAxis;

#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg
{
	Pressed,
	LongPressed,
	SliderReleased,
}

fn rect( x: f32, y: f32, w: f32, h: f32 ) -> Rect
{
	Rect { x, y, width: w, height: h }
}

fn pt( x: f32, y: f32 ) -> Point
{
	Point { x, y }
}

fn button( idx: usize, r: Rect, on_press: Option<Msg>, on_long_press: Option<Msg> )
	-> LaidOutWidget<Msg>
{
	button_full( idx, r, on_press, on_long_press, None )
}

fn button_full(
	idx: usize, r: Rect,
	on_press: Option<Msg>, on_long_press: Option<Msg>, on_drag_start: Option<Msg>,
) -> LaidOutWidget<Msg>
{
	LaidOutWidget
	{
		rect:               r,
		flat_idx:           idx,
		id:                 None,
		paint_rect:         r,
		handlers:           WidgetHandlers::Button { on_press, on_long_press, on_drag_start, on_escape: None, repeating: false },
		keyboard_focusable: true,
		cursor:             crate::types::CursorShape::Default,
		tooltip:            None,
		accessible_label:   None,
		is_live_region:     false,
	}
}

fn slider_widget( idx: usize, r: Rect, on_release: Option<Msg> ) -> LaidOutWidget<Msg>
{
	LaidOutWidget
	{
		rect:               r,
		flat_idx:           idx,
		id:                 None,
		paint_rect:         r,
		handlers:           WidgetHandlers::Slider
		{
			on_change:  None,
			on_release: on_release.map( |m| -> std::sync::Arc<dyn Fn( f32 ) -> Msg>
			{
				std::sync::Arc::new( move |_| m.clone() )
			} ),
			axis:       crate::widget::slider::SliderAxis::Horizontal,
			value:      0.0,
			thumb_px:   0.0,
		},
		keyboard_focusable: true,
		cursor:             crate::types::CursorShape::Default,
		tooltip:            None,
		accessible_label:   None,
		is_live_region:     false,
	}
}

fn cfg_full( w: u32, h: u32 ) -> SwipeConfig
{
	SwipeConfig
	{
		up_thresh:         0.30,
		down_thresh:       0.30,
		down_edge:         0.10,
		horizontal_thresh: 0.30,
		overscroll_thresh: 0.25,
		surface_width:     w,
		surface_height:    h,
	}
}

// ── on_press ──────────────────────────────────────────────────────────────

#[ test ]
fn press_records_origin_and_hit_idx()
{
	let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), Some( Msg::Pressed ), None ) ];
	let mut g = GestureState::<Msg>::new();
	let out = g.on_press( pt( 50.0, 50.0 ), &widgets, &[] );
	assert_eq!( out.hit_idx, Some( 1 ) );
	assert!( out.initial_slider_msg.is_none() );
	assert_eq!( g.start, Some( pt( 50.0, 50.0 ) ) );
	assert_eq!( g.pressed_idx, Some( 1 ) );
}

#[ test ]
fn press_outside_any_widget_records_origin_with_no_hit()
{
	let widgets = vec![ button( 1, rect( 0.0, 0.0, 50.0, 50.0 ), Some( Msg::Pressed ), None ) ];
	let mut g = GestureState::<Msg>::new();
	let out = g.on_press( pt( 200.0, 200.0 ), &widgets, &[] );
	assert_eq!( out.hit_idx, None );
	assert_eq!( g.start, Some( pt( 200.0, 200.0 ) ) );
	assert!( g.long_press_start.is_none() );
}

#[ test ]
fn press_arms_long_press_when_handler_is_present()
{
	let widgets = vec![ button( 7, rect( 0.0, 0.0, 100.0, 100.0 ), None, Some( Msg::LongPressed ) ) ];
	let mut g = GestureState::<Msg>::new();
	let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
	assert!( g.long_press_start.is_some() );
	assert_eq!( g.long_press_msg, Some( Msg::LongPressed ) );
	assert_eq!( g.long_press_origin, Some( pt( 10.0, 10.0 ) ) );
	assert!( !g.long_press_fired );
}

#[ test ]
fn press_does_not_arm_long_press_without_handler()
{
	let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), Some( Msg::Pressed ), None ) ];
	let mut g = GestureState::<Msg>::new();
	let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
	assert!( g.long_press_start.is_none() );
	assert!( g.long_press_msg.is_none() );
	assert!( g.drag_start_msg.is_none() );
}

#[ test ]
fn press_arms_long_press_when_only_drag_start_is_set()
{
	// A draggable-but-menu-less widget (e.g. launcher icons) still
	// needs the timer so a touch hold can promote into a drag.
	let widgets = vec![ button_full( 3, rect( 0.0, 0.0, 100.0, 100.0 ), None, None, Some( Msg::Pressed ) ) ];
	let mut g = GestureState::<Msg>::new();
	let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
	assert!( g.long_press_start.is_some() );
	assert!( g.long_press_msg.is_none() );
	assert_eq!( g.drag_start_msg, Some( Msg::Pressed ) );
}

#[ test ]
fn move_past_six_pixels_cancels_drag_start_msg_too()
{
	let widgets = vec![ button_full( 4, rect( 0.0, 0.0, 200.0, 200.0 ),
		None, Some( Msg::LongPressed ), Some( Msg::Pressed ) ) ];
	let mut g = GestureState::<Msg>::new();
	let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
	let mut offsets = HashMap::new();
	let _ = g.on_move( pt( 30.0, 30.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
	assert!( g.long_press_msg.is_none() );
	assert!( g.drag_start_msg.is_none() );
}

#[ test ]
fn mouse_press_skips_six_pixel_cancel_so_promotion_can_fire()
{
	// With `mouse_press = true` the gesture state must keep the
	// drag-start / long-press slots alive past the 6 px touch
	// tolerance — otherwise the pointer-side promotion at 24 px
	// would never see them and a mouse drag past the cancel
	// distance would do nothing until the hold-timer deadline.
	let widgets = vec![ button_full( 5, rect( 0.0, 0.0, 200.0, 200.0 ),
		None, Some( Msg::LongPressed ), Some( Msg::Pressed ) ) ];
	let mut g = GestureState::<Msg>::new();
	let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
	g.mouse_press = true;
	let mut offsets = HashMap::new();
	let _ = g.on_move( pt( 30.0, 30.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
	assert_eq!( g.long_press_msg, Some( Msg::LongPressed ) );
	assert_eq!( g.drag_start_msg, Some( Msg::Pressed ) );
	assert!( g.long_press_origin.is_some() );
}

#[ test ]
fn press_identifies_scroll_target()
{
	let widgets: Vec<LaidOutWidget<Msg>> = vec![];
	let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 200.0, 200.0 ), 42, ScrollAxis::Vertical ) ];
	let mut g = GestureState::<Msg>::new();
	let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &scrolls );
	assert_eq!( g.scrolling_widget, Some( ( 42, ScrollAxis::Vertical ) ) );
}

#[ test ]
fn press_resets_stale_long_press_fired_flag()
{
	let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), Some( Msg::Pressed ), None ) ];
	let mut g = GestureState::<Msg>::new();
	g.long_press_fired = true;  // stale from a prior gesture
	let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
	assert!( !g.long_press_fired );
}

// ── on_move: long-press cancellation ──────────────────────────────────────

#[ test ]
fn move_within_six_pixels_keeps_long_press_armed()
{
	let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), None, Some( Msg::LongPressed ) ) ];
	let mut g = GestureState::<Msg>::new();
	let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
	let mut offsets = HashMap::new();
	let _ = g.on_move( pt( 14.0, 12.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
	assert!( g.long_press_start.is_some() );
	assert!( g.long_press_msg.is_some() );
}

#[ test ]
fn move_past_six_pixels_cancels_long_press()
{
	let widgets = vec![ button( 1, rect( 0.0, 0.0, 200.0, 200.0 ), None, Some( Msg::LongPressed ) ) ];
	let mut g = GestureState::<Msg>::new();
	let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
	let mut offsets = HashMap::new();
	let _ = g.on_move( pt( 30.0, 30.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
	assert!( g.long_press_start.is_none() );
	assert!( g.long_press_msg.is_none() );
	assert!( g.long_press_origin.is_none() );
}

// ── on_move: drag fork ────────────────────────────────────────────────────

#[ test ]
fn move_after_long_press_fired_returns_drag()
{
	let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), None, Some( Msg::LongPressed ) ) ];
	let mut g = GestureState::<Msg>::new();
	let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
	g.long_press_fired = true;
	let mut offsets = HashMap::new();
	let out = g.on_move( pt( 50.0, 50.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
	assert!( matches!( out, MoveOutcome::Drag { .. } ) );
}

#[ test ]
fn move_with_global_drag_returns_drag()
{
	let widgets: Vec<LaidOutWidget<Msg>> = vec![];
	let mut g = GestureState::<Msg>::new();
	g.start = Some( pt( 0.0, 0.0 ) );
	let mut offsets = HashMap::new();
	let out = g.on_move( pt( 30.0, 30.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), true );
	assert!( matches!( out, MoveOutcome::Drag { .. } ) );
}

// ── on_move: scroll ───────────────────────────────────────────────────────

#[ test ]
fn move_inside_scroll_widget_mutates_offset_in_place()
{
	let widgets: Vec<LaidOutWidget<Msg>> = vec![];
	let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 7, ScrollAxis::Vertical ) ];
	let mut g = GestureState::<Msg>::new();
	let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
	let mut offsets = HashMap::new();
	// Drag finger upward → content scrolls down → offset increases.
	let out = g.on_move( pt( 100.0, 60.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
	assert!( matches!( out, MoveOutcome::Scroll ) );
	assert_eq!( offsets.get( &7 ).copied(), Some( ( 0.0, 40.0 ) ) );
	assert!( g.scroll_drag_started, "drag past 8 px must arm scroll_drag_started" );
}

#[ test ]
fn move_inside_scroll_widget_clamps_offset_at_zero()
{
	let widgets: Vec<LaidOutWidget<Msg>> = vec![];
	let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 9, ScrollAxis::Vertical ) ];
	let mut g = GestureState::<Msg>::new();
	let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
	let mut offsets = HashMap::new();
	// Drag finger downward → content tries to scroll up past origin → clamp at 0.
	let _ = g.on_move( pt( 100.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
	assert_eq!( offsets.get( &9 ).copied(), Some( ( 0.0, 0.0 ) ) );
}

#[ test ]
fn move_below_eight_pixels_does_not_arm_scroll_drag()
{
	let widgets: Vec<LaidOutWidget<Msg>> = vec![];
	let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 1, ScrollAxis::Vertical ) ];
	let mut g = GestureState::<Msg>::new();
	let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
	let mut offsets = HashMap::new();
	let _ = g.on_move( pt( 100.0, 95.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
	assert!( !g.scroll_drag_started );
}

// ── on_move / on_release: top overscroll dismiss ──────────────────────────

#[ test ]
fn pull_down_at_top_emits_overscroll_progress()
{
	let widgets: Vec<LaidOutWidget<Msg>> = vec![];
	let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 3, ScrollAxis::Vertical ) ];
	let mut g = GestureState::<Msg>::new();
	let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
	let mut offsets = HashMap::new();
	// Already at the top; drag down 120 px → all of it is overscroll and the
	// content offset stays pinned at 0.
	let out = g.on_move( pt( 100.0, 220.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
	assert_eq!( offsets.get( &3 ).copied(), Some( ( 0.0, 0.0 ) ) );
	match out
	{
		MoveOutcome::OverscrollDown { progress } =>
			assert!( ( progress - 120.0 / 1200.0 ).abs() < 1e-4 ),
		_ => panic!( "expected OverscrollDown" ),
	}
}

#[ test ]
fn upward_drag_unwinds_overscroll_before_scrolling_content()
{
	let widgets: Vec<LaidOutWidget<Msg>> = vec![];
	let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 4, ScrollAxis::Vertical ) ];
	let mut g = GestureState::<Msg>::new();
	let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
	let mut offsets = HashMap::new();
	let _ = g.on_move( pt( 100.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
	assert!( ( g.overscroll_y - 100.0 ).abs() < 1e-4 );
	// Drag back up 60 px → unwinds 60 px of overscroll, content stays at top.
	let _ = g.on_move( pt( 100.0, 140.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
	assert!( ( g.overscroll_y - 40.0 ).abs() < 1e-4 );
	assert_eq!( offsets.get( &4 ).map( |o| o.1 ), Some( 0.0 ) );
	// A further 90 px up unwinds the remaining 40 and scrolls the 50 px excess.
	let _ = g.on_move( pt( 100.0, 50.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
	assert_eq!( g.overscroll_y, 0.0 );
	assert_eq!( offsets.get( &4 ).map( |o| o.1 ), Some( 50.0 ) );
}

#[ test ]
fn release_after_overscroll_past_threshold_commits_dismiss()
{
	let widgets: Vec<LaidOutWidget<Msg>> = vec![];
	let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 5, ScrollAxis::Vertical ) ];
	let mut g = GestureState::<Msg>::new();
	let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
	let mut offsets = HashMap::new();
	// cfg_full overscroll_thresh is 0.25 → 300 px of 1200 commits; drag 400 px.
	let _ = g.on_move( pt( 100.0, 500.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
	let events = g.on_release( pt( 100.0, 500.0 ), &widgets, &cfg_full( 800, 1200 ), false );
	assert!( events.iter().any( |e| matches!( e, ReleaseEvent::OverscrollDown { committed: true } ) ) );
}

#[ test ]
fn release_after_short_overscroll_does_not_commit()
{
	let widgets: Vec<LaidOutWidget<Msg>> = vec![];
	let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 6, ScrollAxis::Vertical ) ];
	let mut g = GestureState::<Msg>::new();
	let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
	let mut offsets = HashMap::new();
	// 100 px is below the 300 px commit threshold.
	let _ = g.on_move( pt( 100.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
	let events = g.on_release( pt( 100.0, 200.0 ), &widgets, &cfg_full( 800, 1200 ), false );
	assert!( events.iter().any( |e| matches!( e, ReleaseEvent::OverscrollDown { committed: false } ) ) );
}

// ── on_move: swipe progress ───────────────────────────────────────────────

#[ test ]
fn move_below_eight_pixels_emits_swipe_progress_without_arming_drag()
{
	// The 8 px threshold only gates `*_drag_started`; the Swipe outcome
	// itself fires on every motion that has a non-empty axis reading.
	let widgets: Vec<LaidOutWidget<Msg>> = vec![];
	let mut g = GestureState::<Msg>::new();
	g.start = Some( pt( 100.0, 100.0 ) );
	let mut offsets = HashMap::new();
	let out = g.on_move( pt( 102.0, 99.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
	assert!( matches!( out, MoveOutcome::Swipe { .. } ) );
	assert!( !g.horizontal_drag_started );
	assert!( !g.vertical_drag_started );
}

#[ test ]
fn move_up_eleven_pixels_arms_vertical_drag()
{
	let widgets: Vec<LaidOutWidget<Msg>> = vec![];
	let mut g = GestureState::<Msg>::new();
	g.start = Some( pt( 100.0, 600.0 ) );
	let mut offsets = HashMap::new();
	let _ = g.on_move( pt( 100.0, 589.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
	assert!( g.vertical_drag_started );
}

#[ test ]
fn move_horizontal_eleven_pixels_arms_horizontal_drag()
{
	let widgets: Vec<LaidOutWidget<Msg>> = vec![];
	let mut g = GestureState::<Msg>::new();
	g.start = Some( pt( 100.0, 600.0 ) );
	let mut offsets = HashMap::new();
	let _ = g.on_move( pt( 111.5, 600.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
	assert!( g.horizontal_drag_started );
}

#[ test ]
fn vertical_lock_suppresses_later_horizontal_drift()
{
	// A swipe that locks vertical must not arm the horizontal pager even
	// if the finger later drifts sideways past the 8 px threshold.
	let widgets: Vec<LaidOutWidget<Msg>> = vec![];
	let mut g = GestureState::<Msg>::new();
	g.start = Some( pt( 100.0, 600.0 ) );
	let mut offsets = HashMap::new();
	let _ = g.on_move( pt( 100.0, 560.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
	assert_eq!( g.swipe_axis, Some( SwipeAxis::Vertical ) );
	let out = g.on_move( pt( 140.0, 540.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
	assert!( !g.horizontal_drag_started );
	assert!( matches!( out, MoveOutcome::Swipe { horizontal: None, .. } ) );
}

#[ test ]
fn horizontal_lock_suppresses_later_vertical_drift()
{
	let widgets: Vec<LaidOutWidget<Msg>> = vec![];
	let mut g = GestureState::<Msg>::new();
	g.start = Some( pt( 100.0, 600.0 ) );
	let mut offsets = HashMap::new();
	let _ = g.on_move( pt( 140.0, 600.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
	assert_eq!( g.swipe_axis, Some( SwipeAxis::Horizontal ) );
	let out = g.on_move( pt( 160.0, 560.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
	assert!( !g.vertical_drag_started );
	assert!( matches!( out, MoveOutcome::Swipe { up: None, down: None, .. } ) );
}

#[ test ]
fn pre_lock_lateral_drift_does_not_drive_horizontal_pager()
{
	// Regression: a vertical swipe that opens with a few px of lateral drift
	// (axis not yet locked) must not emit horizontal progress. It used to, which
	// armed the pager without ever tripping `horizontal_drag_started` — so the
	// release sent no horizontal event and the pager stayed stuck active,
	// freezing the homescreen.
	let widgets: Vec<LaidOutWidget<Msg>> = vec![];
	let mut g = GestureState::<Msg>::new();
	g.start = Some( pt( 100.0, 600.0 ) );
	let mut offsets = HashMap::new();
	let out = g.on_move( pt( 103.0, 595.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
	assert_eq!( g.swipe_axis, None );
	assert!( matches!( out, MoveOutcome::Swipe { horizontal: None, .. } ) );
}

#[ test ]
fn move_up_progress_unclamped_past_unit_interval()
{
	// Surface 1000 px tall, threshold 30 % = 300 px. A 600 px upward drag
	// overshoots to 2.0 — up progress is unclamped so follow-the-finger
	// panels can keep tracking past the commit threshold.
	let widgets: Vec<LaidOutWidget<Msg>> = vec![];
	let mut g = GestureState::<Msg>::new();
	g.start = Some( pt( 100.0, 800.0 ) );
	let mut offsets = HashMap::new();
	let out = g.on_move( pt( 100.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1000 ), false );
	match out
	{
		MoveOutcome::Swipe { up: Some( v ), .. } =>
			assert!( ( v - 2.0 ).abs() < 1e-6, "expected unclamped 2.0, got {v}" ),
		_ => panic!( "expected Swipe with up=Some" ),
	}
}

// ── on_release ────────────────────────────────────────────────────────────

#[ test ]
fn release_on_button_emits_press_msg()
{
	let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), Some( Msg::Pressed ), None ) ];
	let mut g = GestureState::<Msg>::new();
	let _ = g.on_press( pt( 50.0, 50.0 ), &widgets, &[] );
	let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false );
	assert_eq!( events.len(), 1 );
	assert!( matches!( events[ 0 ], ReleaseEvent::PushMsg( Msg::Pressed ) ) );
}

#[ test ]
fn release_on_different_widget_does_not_emit_press_msg()
{
	let widgets = vec![
		button( 1, rect( 0.0, 0.0, 50.0, 50.0 ), Some( Msg::Pressed ), None ),
		button( 2, rect( 100.0, 0.0, 50.0, 50.0 ), Some( Msg::Pressed ), None ),
	];
	let mut g = GestureState::<Msg>::new();
	let _ = g.on_press( pt( 25.0, 25.0 ), &widgets, &[] );
	let events = g.on_release( pt( 125.0, 25.0 ), &widgets, &cfg_full( 800, 1200 ), false );
	assert!(
		events.iter().all( |e| !matches!( e, ReleaseEvent::PushMsg( _ ) ) ),
		"press_msg must not fire when release lands on a different widget"
	);
}

#[ test ]
fn release_on_empty_area_emits_empty_release()
{
	let widgets: Vec<LaidOutWidget<Msg>> = vec![];
	let mut g = GestureState::<Msg>::new();
	let _ = g.on_press( pt( 50.0, 50.0 ), &widgets, &[] );
	let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false );
	assert!( events.iter().any( |e| matches!( e, ReleaseEvent::EmptyRelease ) ) );
}

#[ test ]
fn release_after_long_press_fired_emits_drop()
{
	let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), None, Some( Msg::LongPressed ) ) ];
	let mut g = GestureState::<Msg>::new();
	let _ = g.on_press( pt( 50.0, 50.0 ), &widgets, &[] );
	g.long_press_fired = true;
	let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false );
	assert!( matches!( events[ 0 ], ReleaseEvent::Drop { .. } ) );
}

#[ test ]
fn release_with_global_drag_emits_drop()
{
	let widgets: Vec<LaidOutWidget<Msg>> = vec![];
	let mut g = GestureState::<Msg>::new();
	let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
	let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), true );
	assert!( matches!( events[ 0 ], ReleaseEvent::Drop { .. } ) );
}

#[ test ]
fn release_committing_swipe_up_emits_swipe_up()
{
	let widgets: Vec<LaidOutWidget<Msg>> = vec![];
	let mut g = GestureState::<Msg>::new();
	// No press_idx (release on empty area) so the vertical-swipe branch runs.
	let _ = g.on_press( pt( 400.0, 900.0 ), &widgets, &[] );
	let mut offsets = HashMap::new();
	// Drag well past 30 % of 1000 px to trip vertical_drag_started.
	let _ = g.on_move( pt( 400.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1000 ), false );
	let events = g.on_release( pt( 400.0, 200.0 ), &widgets, &cfg_full( 800, 1000 ), false );
	assert!( events.iter().any( |e| matches!( e, ReleaseEvent::SwipeUp ) ) );
}

#[ test ]
fn release_horizontal_below_threshold_falls_through()
{
	let widgets: Vec<LaidOutWidget<Msg>> = vec![];
	let mut g = GestureState::<Msg>::new();
	let _ = g.on_press( pt( 100.0, 600.0 ), &widgets, &[] );
	let mut offsets = HashMap::new();
	// 50 px horizontal — below 30 % of 800 = 240 — but past 8 px, so
	// horizontal_drag_started flips to true.
	let _ = g.on_move( pt( 150.0, 600.0 ), &widgets, &mut offsets, &cfg_full( 800, 1000 ), false );
	let events = g.on_release( pt( 150.0, 600.0 ), &widgets, &cfg_full( 800, 1000 ), false );
	assert!( events.iter().any( |e| matches!( e, ReleaseEvent::HorizontalFellThrough ) ) );
	assert!( events.iter().all( |e| !matches!( e, ReleaseEvent::SwipeLeft | ReleaseEvent::SwipeRight ) ) );
}

#[ test ]
fn release_after_slider_drag_emits_no_events()
{
	let widgets: Vec<LaidOutWidget<Msg>> = vec![];
	let mut g = GestureState::<Msg>::new();
	g.start            = Some( pt( 50.0, 50.0 ) );
	g.dragging_slider  = Some( 1 );
	let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false );
	assert!( events.is_empty() );
	assert!( g.dragging_slider.is_none() );
}

#[ test ]
fn release_after_slider_drag_emits_release_msg()
{
	let widgets = vec![ slider_widget( 1, rect( 0.0, 0.0, 100.0, 20.0 ), Some( Msg::SliderReleased ) ) ];
	let mut g = GestureState::<Msg>::new();
	g.start           = Some( pt( 50.0, 10.0 ) );
	g.dragging_slider = Some( 1 );
	let events = g.on_release( pt( 50.0, 10.0 ), &widgets, &cfg_full( 800, 1200 ), false );
	assert!( matches!( events.as_slice(), [ ReleaseEvent::PushMsg( Msg::SliderReleased ) ] ) );
	assert!( g.dragging_slider.is_none() );
}

#[ test ]
fn release_after_consumed_scroll_emits_no_events()
{
	let widgets: Vec<LaidOutWidget<Msg>> = vec![];
	let mut g = GestureState::<Msg>::new();
	g.start                = Some( pt( 50.0, 50.0 ) );
	g.scrolling_widget     = Some( ( 1, ScrollAxis::Vertical ) );
	g.scroll_drag_started  = true;
	let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false );
	assert!( events.is_empty() );
	assert!( g.scrolling_widget.is_none() );
	assert!( !g.scroll_drag_started );
}

// ── on_cancel ─────────────────────────────────────────────────────────────

#[ test ]
fn cancel_resets_every_field()
{
	let mut g = GestureState::<Msg>::new();
	g.start                   = Some( pt( 1.0, 2.0 ) );
	g.pressed_idx             = Some( 99 );
	g.scrolling_widget        = Some( ( 99, ScrollAxis::Vertical ) );
	g.scroll_drag_started     = true;
	g.horizontal_drag_started = true;
	g.vertical_drag_started   = true;
	g.dragging_slider         = Some( 99 );
	g.long_press_fired        = true;
	g.long_press_msg          = Some( Msg::LongPressed );
	g.drag_start_msg          = Some( Msg::Pressed );

	g.on_cancel();

	assert!( g.start.is_none() );
	assert!( g.pressed_idx.is_none() );
	assert!( g.scrolling_widget.is_none() );
	assert!( !g.scroll_drag_started );
	assert!( !g.horizontal_drag_started );
	assert!( !g.vertical_drag_started );
	assert!( g.dragging_slider.is_none() );
	assert!( !g.long_press_fired );
	assert!( g.long_press_msg.is_none() );
	assert!( g.drag_start_msg.is_none() );
}
