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

//! Per-surface gesture state machine. Source-agnostic — both
//! `PointerHandler` (Wayland `wl_pointer`) and `TouchHandler`
//! (`wl_touch`) feed into the same machine and apply the decisions it
//! emits.
//!
//! ## Lifecycle
//!
//! 1. [`GestureState::on_press`] — records the gesture origin, snapshots
//!    the hit widget's long-press AND drag-start handlers, identifies
//!    slider / scroll targets. Returns a [`PressOutcome`] with the hit
//!    index and any initial slider message.
//! 2. [`GestureState::on_move`] — cancels the long-press candidate if
//!    the finger strays past 6 px, updates slider values, mutates
//!    scroll offsets, computes swipe progress. Returns a
//!    [`MoveOutcome`] telling the handler which app callbacks to fire.
//! 3. [`GestureState::on_release`] — decides between drop (long-press
//!    was already fired), slider / scroll done, horizontal / vertical
//!    swipe commit or fall-through, tap or button press. Returns an
//!    ordered [`Vec<ReleaseEvent>`] — a single release can emit more
//!    than one event when a horizontal swipe falls through and the
//!    handler still has to check the vertical branch.
//! 4. [`GestureState::on_cancel`] — resets everything (touch-cancel
//!    only; the compositor is stealing the gesture).
//!
//! The long-press DEADLINE itself is not polled here. `AppData`
//! walks every surface's `gesture.long_press_start` against the app's
//! `long_press_duration()` and flips `gesture.long_press_fired` when
//! the deadline elapses — this module only records the start instant
//! and reacts to the flip.

use std::collections::HashMap;
use std::time::Instant;

use crate::tree::{ find_handlers, find_widget, find_widget_at };
use crate::types::{ Point, Rect };
use crate::widget::LaidOutWidget;

mod outcomes;

pub( crate ) use outcomes::*;

#[ cfg( test ) ]
mod tests;

// ─── State ───────────────────────────────────────────────────────────────────

/// Axis a swipe locked onto during its first 8 px of travel. Once set,
/// the perpendicular axis is ignored for the rest of the gesture so a
/// vertical swipe that drifts sideways never also drives the pager (and
/// vice-versa).
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum SwipeAxis
{
	Vertical,
	Horizontal,
}

/// Per-surface gesture tracking. Lives on `SurfaceState::gesture`.
///
/// Every field is `pub` because `AppData`'s long-press deadline
/// checker in `event_loop/app_data.rs` needs to read the start instant
/// and set `long_press_fired` from outside the state machine. The
/// lifecycle methods ([`Self::on_press`], [`Self::on_move`],
/// [`Self::on_release`], [`Self::on_cancel`]) are the supported way
/// for input handlers to drive the machine; direct mutation is only
/// for the deadline path.
#[ derive( Debug, Clone ) ]
pub struct GestureState<Msg: Clone>
{
	/// Position where the current press / touch-down landed. `None`
	/// between gestures.
	pub start:                   Option<Point>,
	/// Widget index under the press, if any. Set on press; taken on
	/// release.
	pub pressed_idx:             Option<usize>,
	/// Index of the Scroll viewport that owns the current gesture, if
	/// the press landed inside one.
	pub scrolling_widget:        Option<( usize, crate::widget::scroll::ScrollAxis )>,
	/// Scroll viewports containing the press, innermost first.
	pub scroll_candidates:       Vec<( usize, crate::widget::scroll::ScrollAxis )>,
	/// `true` once the first 8 px of motion has pinned `scrolling_widget` to a definite axis.
	pub scroll_locked:           bool,
	/// Scroll-viewport drag exceeded the 8 px start tolerance — the
	/// release will be consumed as a scroll instead of a tap.
	pub scroll_drag_started:     bool,
	/// Horizontal drag exceeded the 8 px threshold. Release runs the
	/// horizontal-swipe commit check instead of the button path.
	pub horizontal_drag_started: bool,
	/// Vertical drag exceeded the 8 px threshold. Release runs the
	/// swipe-up / swipe-down commit check instead of the button path.
	pub vertical_drag_started:   bool,
	/// Axis this swipe locked onto on its first 8 px of travel. Pins the
	/// gesture to one axis so vertical and horizontal swipes stay
	/// mutually exclusive. `None` until the lock fires.
	pub swipe_axis:              Option<SwipeAxis>,
	/// Slider widget being dragged for a live-value update.
	pub dragging_slider:         Option<usize>,
	/// Instant when the long-press window opened. `None` if the hit
	/// widget has no long-press handler (or the candidate was
	/// cancelled by movement).
	pub long_press_start:        Option<Instant>,
	/// Original press position for the long-press candidate — used by
	/// [`Self::on_move`] to cancel if the finger strays more than 6 px.
	pub long_press_origin:       Option<Point>,
	/// Message to fire when the deadline elapses (touch hold) or when
	/// the user right-clicks. Drained by the deadline checker on touch
	/// and by the right-click handler on mouse. Firing this on its own
	/// does NOT put the gesture into drag mode — that is governed by
	/// [`Self::drag_start_msg`].
	pub long_press_msg:          Option<Msg>,
	/// Drag-arm message captured from the hit widget on press. Fired
	/// by the touch deadline alongside `long_press_msg` and by the
	/// mouse-motion drag-promotion path on its own. When fired, the
	/// gesture transitions into drag mode (`long_press_fired = true`)
	/// and subsequent motion / release run through `on_drag_move` /
	/// `on_drop`.
	pub drag_start_msg:          Option<Msg>,
	/// `widget_rects` index of a TextEdit the press landed on, when
	/// no user `on_long_press` is configured. Lets the runtime show
	/// the built-in Copy / Cut / Paste menu after the same delay
	/// without forcing every text field to opt in.
	pub long_press_text_idx:     Option<usize>,
	/// `true` once the gesture has transitioned into drag mode —
	/// subsequent motion fires `app.on_drag_move`, release fires
	/// `app.on_drop`. Set by the touch deadline when `drag_start_msg`
	/// is present, or by the mouse-motion promotion path.
	pub long_press_fired:        bool,
	/// Accumulated downward overscroll (physical px) while a vertical
	/// scroll is pinned at its top — built up by [`Self::on_move`] when a
	/// pull-down can no longer scroll content, unwound by upward drag, and
	/// drained on release into an `OverscrollDown` event. Drives
	/// pull-from-top-to-dismiss.
	pub overscroll_y:            f32,
	/// `true` when the press came from a mouse (`wl_pointer`) rather
	/// than touch. The 6 px stray-cancel in [`Self::on_move`] is gated
	/// on this: touch needs the cancel so a hold that turns into a
	/// swipe / scroll doesn't keep the long-press candidate armed,
	/// but mouse motion is always intentional and never competes with
	/// a swipe gesture, so the candidate stays alive until the
	/// pointer-side promotion threshold or the deadline fires. Set by
	/// the pointer Press handler right after [`Self::on_press`]; left
	/// `false` for touch presses.
	pub mouse_press:             bool,
}

impl<Msg: Clone> Default for GestureState<Msg>
{
	fn default() -> Self { Self::new() }
}

impl<Msg: Clone> GestureState<Msg>
{
	/// Fresh state — no press in flight.
	pub fn new() -> Self
	{
		Self
		{
			start:                   None,
			pressed_idx:             None,
			scrolling_widget:        None,
			scroll_candidates:       Vec::new(),
			scroll_locked:           false,
			scroll_drag_started:     false,
			horizontal_drag_started: false,
			vertical_drag_started:   false,
			swipe_axis:              None,
			dragging_slider:         None,
			long_press_start:        None,
			long_press_origin:       None,
			long_press_msg:          None,
			drag_start_msg:          None,
			long_press_text_idx:     None,
			long_press_fired:        false,
			overscroll_y:            0.0,
			mouse_press:             false,
		}
	}

	/// Press / touch-down: record the gesture origin, capture long-press
	/// handler if the hit widget has one, identify slider / scroll
	/// targets. Returns the hit index (for keyboard focus update) and
	/// any initial slider message (the slider applies its value
	/// immediately on press so the thumb tracks the finger from pixel
	/// one).
	pub fn on_press
	(
		&mut self,
		pos:          Point,
		widget_rects: &[LaidOutWidget<Msg>],
		scroll_rects: &[( Rect, usize, crate::widget::scroll::ScrollAxis )],
	) -> PressOutcome<Msg>
	{
		let hit = find_widget_at( widget_rects, pos );
		let ( lp_msg, ds_msg ) = hit.map( |idx|
		{
			let h = find_handlers( widget_rects, idx );
			(
				h.as_ref().and_then( |h| h.long_press_msg() ),
				h.as_ref().and_then( |h| h.drag_start_msg() ),
			)
		}).unwrap_or( ( None, None ) );

		self.start                   = Some( pos );
		self.scroll_candidates       = scroll_rects.iter()
			.filter( |( r, _, _ )| r.contains( pos ) )
			.map( |( _, idx, ax )| ( *idx, *ax ) )
			.collect();
		self.scrolling_widget        = self.scroll_candidates.first().copied();
		self.scroll_locked           = false;
		self.scroll_drag_started     = false;
		self.overscroll_y            = 0.0;
		self.horizontal_drag_started = false;
		self.vertical_drag_started   = false;
		self.swipe_axis              = None;
		self.pressed_idx             = hit;
		// A fresh press resets any stale long-press / source state
		// from a prior gesture that never released cleanly. The
		// pointer Press handler flips `mouse_press` back to true
		// right after this call when the press came from a mouse;
		// touch leaves it `false`.
		self.long_press_fired  = false;
		self.mouse_press       = false;
		// Built-in long-press for text inputs: arms the same timer
		// even when the widget has no user-set `on_long_press`, so
		// the runtime can show the Copy / Cut / Paste menu after the
		// usual delay.
		let text_idx = hit.and_then( |idx|
		{
			find_handlers( widget_rects, idx )
				.and_then( |h| if h.is_text_input() { Some( idx ) } else { None } )
		} );
		// Arm the long-press timer if anything is pending: the menu
		// message, the drag-start message, or the built-in text menu.
		// A widget that only carries `on_drag_start` (no menu) still
		// needs the timer so a touch hold can promote into a drag.
		let arm_long_press = lp_msg.is_some() || ds_msg.is_some() || text_idx.is_some();
		self.long_press_start    = arm_long_press.then( Instant::now );
		self.long_press_origin   = arm_long_press.then_some( pos );
		self.long_press_msg      = lp_msg;
		self.drag_start_msg      = ds_msg;
		self.long_press_text_idx = text_idx;

		// Slider drag: if the hit widget is a slider, arm the drag and
		// apply the press-point value immediately.
		let mut initial_slider_msg = None;
		if let Some( idx ) = hit
		{
			if let Some( w ) = find_widget( widget_rects, idx )
			{
				if w.handlers.is_slider()
				{
					self.dragging_slider = Some( idx );
					let value = w.handlers.slider_value_from_pos( w.rect, pos );
					initial_slider_msg = w.handlers.slider_change_msg( value );
				}
			}
		}

		PressOutcome { hit_idx: hit, initial_slider_msg }
	}

	/// Move / touch-motion: classify the motion and mutate scroll
	/// offsets in place. `global_drag` is `true` when another surface
	/// has an active long-press drag (cross-surface drags route every
	/// motion through `on_drag_move` even on surfaces that didn't
	/// start the gesture).
	pub fn on_move
	(
		&mut self,
		pos:            Point,
		widget_rects:   &[LaidOutWidget<Msg>],
		scroll_offsets: &mut HashMap<usize, ( f32, f32 )>,
		swipe:          &SwipeConfig,
		global_drag:    bool,
	) -> MoveOutcome<Msg>
	{
		// Drag phase: once the long-press has fired, every motion
		// goes to the app's drag handler and bypasses slider / scroll /
		// swipe logic.
		if self.long_press_fired || global_drag
		{
			return MoveOutcome::Drag { pos };
		}

		// Cancel the long-press candidate if the finger strayed > 6 px
		// — but ONLY for touch. Touch needs this so a hold that turns
		// into a swipe / scroll doesn't keep the menu candidate
		// armed and fire mid-swipe; mouse motion is always
		// intentional and never competes with a swipe, so the
		// candidate stays alive until the pointer-side promotion at
		// 24 px (or the hold-timer deadline) consumes it. Without
		// this gate, mouse motion in the 6–24 px band would clear
		// `drag_start_msg` before promotion could fire and the user
		// would have to wait out the full 500 ms hold timer to drag.
		if !self.mouse_press
		{
			if let Some( origin ) = self.long_press_origin
			{
				let d = ( pos.x - origin.x ).hypot( pos.y - origin.y );
				if d > 6.0
				{
					self.long_press_start    = None;
					self.long_press_origin   = None;
					self.long_press_msg      = None;
					self.drag_start_msg      = None;
					self.long_press_text_idx = None;
				}
			}
		}

		// Slider drag: continuously update value.
		if let Some( slider_idx ) = self.dragging_slider
		{
			if let Some( w ) = find_widget( widget_rects, slider_idx )
			{
				let value = w.handlers.slider_value_from_pos( w.rect, pos );
				let msg   = w.handlers.slider_change_msg( value );
				return MoveOutcome::Slider { msg };
			}
			return MoveOutcome::Idle;
		}

		if self.scrolling_widget.is_some()
		{
			if let Some( start ) = self.start
			{
				let dx = pos.x - start.x;
				let dy = pos.y - start.y;

				if !self.scroll_locked
				{
					if dx.abs() <= 8.0 && dy.abs() <= 8.0
					{
						return MoveOutcome::Idle;
					}
					let prefer_x = dx.abs() > dy.abs();
					if let Some( c ) = self.scroll_candidates.iter()
						.find( |( _, ax )| if prefer_x { ax.allows_x() } else { ax.allows_y() } )
						.copied()
					{
						self.scrolling_widget = Some( c );
					}
					self.scroll_locked = true;
				}

				let ( scroll_idx, axis ) = self.scrolling_widget.unwrap();
				let entry = scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) );
				if axis.allows_x() { entry.0 = ( entry.0 - dx ).max( 0.0 ); }
				if axis.allows_y()
				{
					// Split the vertical delta between content scroll and top
					// overscroll: a downward pull first scrolls toward the top,
					// then any excess (the offset can't go below 0) feeds
					// `overscroll_y`; an upward pull unwinds that overscroll
					// before it resumes scrolling content.
					if dy >= 0.0
					{
						let consume = dy.min( entry.1 );
						entry.1 -= consume;
						self.overscroll_y += dy - consume;
					}
					else
					{
						let up      = -dy;
						let consume = up.min( self.overscroll_y );
						self.overscroll_y -= consume;
						entry.1 += up - consume;
					}
				}
				let moved = if axis.allows_x() && axis.allows_y() { dx.hypot( dy ) }
				            else if axis.allows_x()               { dx.abs() }
				            else                                   { dy.abs() };
				if moved > 8.0 { self.scroll_drag_started = true; }
				self.start = Some( pos );
				// Gate the dismiss on the same 8 px arm as a real scroll drag so
				// tap jitter at the top can't engage a follow-the-finger close.
				if self.overscroll_y > 0.0 && axis.allows_y() && self.scroll_drag_started
				{
					let progress = if swipe.surface_height > 0
					{
						self.overscroll_y / swipe.surface_height as f32
					}
					else { 0.0 };
					return MoveOutcome::OverscrollDown { progress };
				}
				return MoveOutcome::Scroll;
			}
			return MoveOutcome::Idle;
		}

		// Swipe progress — locked to a single axis (see below) so a
		// gesture only ever drives one of the vertical panels or the
		// horizontal pager, never both.
		if let Some( start ) = self.start
		{
			let dy = start.y - pos.y;
			let dx = pos.x - start.x;

			// Lock onto the dominant axis on the first 8 px of travel so
			// vertical and horizontal swipes stay mutually exclusive.
			if self.swipe_axis.is_none() && dx.abs().max( dy.abs() ) > 8.0
			{
				self.swipe_axis = Some( if dy.abs() >= dx.abs() { SwipeAxis::Vertical } else { SwipeAxis::Horizontal } );
			}
			let vertical_allowed = self.swipe_axis != Some( SwipeAxis::Horizontal );

			let ( up, down ) = if vertical_allowed && swipe.surface_height > 0
			{
				let h = swipe.surface_height as f32;
				if dy > 0.0
				{
					let threshold = h * swipe.up_thresh;
					if dy.abs() > 8.0 { self.vertical_drag_started = true; }
					// Unclamped on purpose — lets follow-the-finger
					// panels track past the commit threshold.
					( Some( ( dy / threshold ).max( 0.0 ) ), None )
				}
				else if start.y <= h * swipe.down_edge
				{
					let threshold = h * swipe.down_thresh;
					if dy.abs() > 8.0 { self.vertical_drag_started = true; }
					// Unclamped on purpose — lets follow-the-finger
					// panels track past the commit threshold.
					( None, Some( ( -dy / threshold ).max( 0.0 ) ) )
				}
				else { ( None, None ) }
			}
			else { ( None, None ) };

			// Only drive the horizontal pager once the gesture has LOCKED onto the
			// horizontal axis. Emitting progress while `swipe_axis` is still None
			// would activate the pager from the sub-8 px lateral jitter at the
			// start of a vertical swipe; `horizontal_drag_started` would then never
			// trip, so the release emits no horizontal event and the pager stays
			// stuck active (frozen homescreen). Locking to Horizontal implies
			// `dx.abs() > 8.0`, so the drag is unambiguously started here.
			let horizontal = if self.swipe_axis == Some( SwipeAxis::Horizontal ) && swipe.surface_width > 0
			{
				let h_thr      = swipe.surface_width as f32 * swipe.horizontal_thresh;
				let h_progress = if h_thr > 0.0 { dx / h_thr } else { 0.0 };
				self.horizontal_drag_started = true;
				Some( h_progress )
			}
			else { None };

			if up.is_some() || down.is_some() || horizontal.is_some()
			{
				return MoveOutcome::Swipe { up, down, horizontal };
			}
		}

		MoveOutcome::Idle
	}

	/// Release / touch-up: decide between drop / slider done / scroll
	/// done / swipe commit / swipe fall-through / button press / tap.
	///
	/// A release can emit multiple events: a horizontal swipe that
	/// didn't commit still fires `on_swipe_horizontal_progress(0.0)`
	/// before falling through to the vertical branch, and a below-
	/// threshold vertical swipe pulses the vertical progress
	/// callbacks. The handler dispatches the events in order.
	///
	/// `global_drag` mirrors the parameter of [`Self::on_move`].
	pub fn on_release
	(
		&mut self,
		pos:          Point,
		widget_rects: &[LaidOutWidget<Msg>],
		swipe:        &SwipeConfig,
		global_drag:  bool,
	) -> Vec<ReleaseEvent<Msg>>
	{
		let pressed                 = self.pressed_idx.take();
		let released_slider         = self.dragging_slider;
		let long_press_fired        = self.long_press_fired;
		let horizontal_drag_started = self.horizontal_drag_started;
		let vertical_drag_started   = self.vertical_drag_started;

		// Drain all the drag / long-press slots. A release ALWAYS
		// closes the long-press window: if the deadline fired we've
		// already consumed the msg; if not, the candidate is cancelled.
		self.dragging_slider         = None;
		self.long_press_start        = None;
		self.long_press_origin       = None;
		self.long_press_msg          = None;
		self.drag_start_msg          = None;
		self.long_press_text_idx     = None;
		self.long_press_fired        = false;
		self.mouse_press             = false;
		self.horizontal_drag_started = false;
		self.vertical_drag_started   = false;

		let mut events = Vec::new();

		// Drop — long-press had fired earlier in the gesture.
		if long_press_fired || global_drag
		{
			self.start = None;
			events.push( ReleaseEvent::Drop { pos } );
			return events;
		}

		// Slider drag complete — not a swipe, not a tap. The final value
		// goes out once here so an app can keep an expensive commit off
		// the per-motion `on_change` path.
		if let Some( slider_idx ) = released_slider
		{
			self.scroll_drag_started = false;
			self.start               = None;
			if let Some( w ) = find_widget( widget_rects, slider_idx )
			{
				let value = w.handlers.slider_value_from_pos( w.rect, pos );
				if let Some( msg ) = w.handlers.slider_release_msg( value )
				{
					events.push( ReleaseEvent::PushMsg( msg ) );
				}
			}
			return events;
		}

		// Scroll gesture consumed.
		let scroll_consumed = self.scrolling_widget.take().is_some()
			&& self.scroll_drag_started;
		self.scroll_drag_started = false;
		if scroll_consumed
		{
			self.start = None;
			// A pull past the top releases into an overscroll-dismiss event;
			// the app decides whether the accumulated pull committed.
			let over = self.overscroll_y;
			self.overscroll_y = 0.0;
			if over > 0.0
			{
				let threshold = swipe.surface_height as f32 * swipe.overscroll_thresh;
				events.push( ReleaseEvent::OverscrollDown { committed: over >= threshold } );
			}
			return events;
		}

		// Horizontal swipe commit / fall-through. On commit we return
		// immediately; on fall-through we send the 0.0 pulse, keep
		// `self.start` for the vertical branch, and continue.
		if horizontal_drag_started
		{
			if let Some( start ) = self.start
			{
				let dx    = pos.x - start.x;
				let width = swipe.surface_width;
				let committed = width > 0
					&& dx.abs() >= width as f32 * swipe.horizontal_thresh;
				if committed
				{
					self.start = None;
					events.push( if dx < 0.0 { ReleaseEvent::SwipeLeft } else { ReleaseEvent::SwipeRight } );
					return events;
				}
				events.push( ReleaseEvent::HorizontalFellThrough );
			}
		}

		// Vertical swipe commit / fall-through. Consumes `self.start`.
		if vertical_drag_started || pressed.is_none()
		{
			if let Some( start ) = self.start.take()
			{
				let dy        = start.y - pos.y;
				let height    = swipe.surface_height as f32;
				let up_thr    = height * swipe.up_thresh;
				let down_thr  = height * swipe.down_thresh;
				let down_edge = height * swipe.down_edge;
				if dy >= up_thr
				{
					events.push( ReleaseEvent::SwipeUp );
					return events;
				}
				else if -dy >= down_thr && start.y <= down_edge
				{
					events.push( ReleaseEvent::SwipeDown );
					return events;
				}
				else
				{
					events.push( ReleaseEvent::VerticalFellThrough );
				}
			}
		}
		else
		{
			self.start = None;
		}

		// Any drag started — skip the button path so a gesture that
		// fell through without committing does not also trigger a tap.
		if horizontal_drag_started || vertical_drag_started
		{
			return events;
		}

		// Button / slider press on release: the release must land on
		// the same widget the press did.
		let release_hit = find_widget_at( widget_rects, pos );
		if let Some( idx ) = pressed
		{
			if release_hit == Some( idx )
			{
				if let Some( w ) = find_widget( widget_rects, idx )
				{
					let value = w.handlers.slider_value_from_pos( w.rect, pos );
					if let Some( msg ) = w.handlers.slider_change_msg( value )
					{
						events.push( ReleaseEvent::PushMsg( msg ) );
						return events;
					}
					// Repeating buttons fire `press_msg` on *press* (and
					// keep firing through the calloop repeat timer
					// while held) — suppressing the release-time fire
					// avoids a double tap on a quick click.
					if !w.handlers.is_repeating()
					{
						if let Some( msg ) = w.handlers.press_msg()
						{
							events.push( ReleaseEvent::PushMsg( msg ) );
						}
					}
				}
			}
		}
		else if release_hit.is_none()
		{
			// Release on empty area — handler decides Tap vs
			// overlay-dismiss based on focus.
			events.push( ReleaseEvent::EmptyRelease );
		}

		events
	}

	/// Touch-cancel: drop all in-flight gesture state.
	pub fn on_cancel( &mut self ) { *self = Self::new(); }
}
