//! Drag-and-drop and edit-mode mechanics.
//!
//! Covers everything that happens between a press on a homescreen icon
//! and the release that commits (or discards) the drop. The flow is:
//!
//! 1. `StartDrag` arms a [`DraggingItem`] carrying the origin (launcher
//!    / dock / grid / grid-page) and the initial page count at the
//!    instant of the press promotion. Touch fires it on the hold-timer
//!    deadline (alongside `ShowEditMenu` for dock / grid icons); mouse
//!    fires it on left-button motion past the drag-promotion threshold
//!    (without firing `ShowEditMenu`, so no menu flashes).
//! 2. Motion updates the cursor. Once the finger has travelled past
//!    [`DRAG_THRESHOLD_SQ`], the drag is "committed": the edit menu
//!    closes and the cursor starts being watched for lateral edge-zone
//!    dwell ([`Crustace::update_edge_dwell`]).
//! 3. Edge dwell ≥ [`EDGE_DWELL_MS`] inside a lateral zone triggers a
//!    page navigation ([`Crustace::fire_drag_page_nav`]), with the
//!    strict anti-cascade guard preventing a chain of empty pages from
//!    appearing.
//! 4. Release calls [`Crustace::handle_drop`], which routes through
//!    [`Crustace::apply_drop`] based on the drop target and the origin
//!    (add / move / reorder) and then trims trailing empty pages that
//!    edge navigation created but the user never populated.

use crate::config::{ DockItem, GridItem, Page };
use crate::editmenu::EditMenu;
use crate::homescreen::{ self, DragOrigin, DropTarget };
use crate::message::Message;
use crate::state::Crustace;

use std::time::{ Duration, Instant };

/// Minimum cursor travel (logical px, squared) past the long-press origin
/// before a hold-and-move is interpreted as a drag. Below this, the
/// release is a pure long-press: the context menu stays open and the
/// drop is discarded.
pub( crate ) const DRAG_THRESHOLD_SQ: f32 = 16.0 * 16.0;

/// Width (logical pixels) of the lateral hot zones that trigger page
/// navigation while a drag is in flight. Must be wide enough for a
/// finger to sit inside comfortably but narrow enough that the zone
/// doesn't eat into the content the user is trying to drop on.
pub( crate ) const EDGE_ZONE_LP: f32 = 36.0;

/// How long (milliseconds) the drag cursor has to dwell inside a lateral
/// edge zone before the shell commits to paging. Short enough to feel
/// responsive, long enough that a user briefly clipping the edge on the
/// way to a cell does not flip pages accidentally.
pub( crate ) const EDGE_DWELL_MS: u64 = 500;

/// Which lateral edge the drag cursor is currently dwelling inside. Used
/// to drive edge-triggered page navigation during a long-press drag.
#[ derive( Clone, Copy, PartialEq, Eq ) ]
pub( crate ) enum DragEdgeSide
{
	Left,
	Right,
}

/// In-flight edge dwell. Set the instant the cursor enters a lateral
/// edge zone, reset when it leaves or crosses to the opposite side.
/// Once `entered_at.elapsed() >= EDGE_DWELL_MS`,
/// [`Crustace::fire_drag_page_nav`] commits a page change and sets
/// `fired = true` so the same dwell does not re-trigger until the
/// finger leaves and re-enters the zone.
pub( crate ) struct DragEdge
{
	pub side:       DragEdgeSide,
	pub entered_at: Instant,
	pub fired:      bool,
}

/// Live drag state. Set when the user long-presses any icon (launcher,
/// dock or grid), updated on every pointer motion, and consumed on
/// release by [`ltk::App::on_drop`]. `origin` tells the drop logic
/// whether to add (launcher) or move (dock / grid) the item. `edge`
/// carries the lateral-edge dwell state used by the drag-to-navigate
/// flow. `initial_page_count` snapshots `config.mobile.pages.len()` at
/// drag start so [`Crustace::handle_drop`] can trim any trailing empty
/// pages that edge navigation created but the user never populated with
/// a drop.
pub( crate ) struct DraggingItem
{
	pub app_id: String,
	pub cursor: ( f32, f32 ),
	pub origin: DragOrigin,
	pub edge:   Option<DragEdge>,
	pub initial_page_count: usize,
}

impl Crustace
{
	/// Track the finger during a drag. Updates the cursor every sample,
	/// promotes a hold to a committed drag once the threshold is
	/// crossed, and starts watching for lateral edge-zone dwell.
	pub( crate ) fn handle_drag_move( &mut self, x: f32, y: f32 )
	{
		let Some( ref mut drag ) = self.dragging_item else { return };
		drag.cursor = ( x, y );

		// Record the first post-long-press cursor as the drag origin.
		match self.drag_origin_cursor
		{
			None =>
			{
				self.drag_origin_cursor = Some( ( x, y ) );
			}
			Some( ( ox, oy ) ) if !self.drag_moved =>
			{
				let dx = x - ox;
				let dy = y - oy;
				if dx * dx + dy * dy > DRAG_THRESHOLD_SQ
				{
					// Committed to a real drag — dismiss the context
					// menu so it doesn't eat the drop.
					self.drag_moved = true;
					self.edit_menu  = None;
				}
			}
			_ => {}
		}

		// Once the drag is actually moving (past the hold-vs-drag
		// threshold), watch for the cursor entering a lateral edge
		// zone. The dwell drives page navigation — see
		// `fire_drag_page_nav`.
		if self.drag_moved
		{
			self.update_edge_dwell( x, y );
		}
	}

	/// Release handler: consume the drag and route the drop. If the
	/// user never crossed the drag threshold it's a pure long-press and
	/// the menu stays up; otherwise dispatch through `apply_drop` and
	/// trim any empty trailing pages that edge nav created.
	pub( crate ) fn handle_drop( &mut self, x: f32, y: f32 ) -> Option<Message>
	{
		let Some( drag ) = self.dragging_item.take() else { return None; };
		let moved = self.drag_moved;
		self.drag_origin_cursor = None;
		self.drag_moved         = false;

		// Release without ever crossing the drag threshold → pure
		// long-press. Menu stays up (if any), no placement happens.
		if !moved
		{
			return None;
		}

		let target   = homescreen::drop_target( self.screen_width, self.screen_height, self.config.dock.items.len(), x, y );
		let accepted = self.apply_drop( &drag.origin, &drag.app_id, target, x, y );
		// Rollback pages that edge-drag navigation created but the
		// user never populated. Strict anti-cascade prevents
		// intermediate empty pages during the drag, so any trailing
		// empty page above the drag-start snapshot is pure leftover
		// from a cancelled edge flip.
		let trimmed = self.trim_trailing_empty_pages( drag.initial_page_count );
		if accepted || trimmed
		{
			// Persist so the edit survives a restart. Best-effort: a
			// failed save logs but should not crash the shell.
			if let Err( e ) = self.config.save()
			{
				eprintln!( "[crustace] failed to save config after drop: {e}" );
			}
		}
		None
	}

	/// Track whether the drag cursor is inside a lateral edge zone and,
	/// if so, since when. Called from [`handle_drag_move`] after the
	/// drag has crossed the hold-vs-drag threshold. The dock band is
	/// excluded — nobody wants a page flip while trying to drop on the
	/// favorites row. When the cursor crosses from one side to the
	/// other (a rare but legal motion) the dwell restarts from the new
	/// side so a fast sweep through the middle does not immediately
	/// fire.
	pub( crate ) fn update_edge_dwell( &mut self, x: f32, y: f32 )
	{
		if !self.use_mobile_layout() { return; }
		let sw = self.screen_width as f32;
		if sw <= 0.0 { return; }

		let is_landscape = self.screen_width > self.screen_height;
		let ( dock_top_y, _ ) = homescreen::dock_band( self.screen_width, self.screen_height, is_landscape );

		let side = if y >= dock_top_y { None }
			else if x < EDGE_ZONE_LP { Some( DragEdgeSide::Left ) }
			else if x > sw - EDGE_ZONE_LP { Some( DragEdgeSide::Right ) }
			else { None };

		let Some( ref mut drag ) = self.dragging_item else { return };
		// Copy out the current side before the match so the arms can
		// freely reassign `drag.edge` without tripping the borrow
		// checker on the scrutinee. DragEdgeSide is Copy.
		let cur_side = drag.edge.as_ref().map( |e| e.side );
		match ( side, cur_side )
		{
			( None, _ ) =>
			{
				drag.edge = None;
			}
			( Some( new_side ), Some( cur ) ) if cur == new_side =>
			{
				// Same side — leave the dwell clock running.
			}
			( Some( new_side ), _ ) =>
			{
				drag.edge = Some( DragEdge {
					side:       new_side,
					entered_at: Instant::now(),
					fired:      false,
				} );
			}
		}
	}

	/// Per-tick check that fires one page navigation when the current
	/// edge dwell has exceeded [`EDGE_DWELL_MS`]. Arms `fired` on the
	/// dwell regardless of whether the nav committed — if the guard
	/// rejected (current page empty going right, already at page 0
	/// going left) the conditions cannot change mid-drag from the
	/// finger alone, so looping would just spin. The user can leave
	/// the zone and re-enter to get a fresh dwell.
	pub( crate ) fn tick_edge_fire( &mut self )
	{
		let fire = if let Some( ref drag ) = self.dragging_item
		{
			drag.edge.as_ref().and_then( |e|
			{
				if !e.fired && Instant::now().saturating_duration_since( e.entered_at )
					>= Duration::from_millis( EDGE_DWELL_MS )
				{
					Some( e.side )
				} else {
					None
				}
			} )
		} else {
			None
		};
		if let Some( side ) = fire
		{
			let _ = self.fire_drag_page_nav( side );
			if let Some( ref mut drag ) = self.dragging_item
			{
				if let Some( ref mut edge ) = drag.edge
				{
					edge.fired = true;
				}
			}
		}
	}

	/// Whether a lateral edge dwell is armed and pending fire. Used by
	/// [`ltk::App::is_animating`] to keep the runtime polling while the
	/// finger is sitting inside an edge zone so the dwell timer fires
	/// even without further pointer motion.
	pub( crate ) fn has_armed_edge_dwell( &self ) -> bool
	{
		self.dragging_item.as_ref()
			.and_then( |d| d.edge.as_ref() )
			.map( |e| !e.fired )
			.unwrap_or( false )
	}

	/// Commit a drag-edge page navigation to `side`. Strict anti-cascade:
	/// advancing to the right requires the current page to be non-empty,
	/// which blocks both *creating* a new trailing page and *jumping* to
	/// an existing one; retreating to the left is always allowed (the
	/// guard exists to prevent empty intermediate pages, and leftward
	/// navigation never creates anything). Returns `true` if a page was
	/// actually navigated so the caller can arm `fired` on the dwell.
	pub( crate ) fn fire_drag_page_nav( &mut self, side: DragEdgeSide ) -> bool
	{
		if !self.use_mobile_layout() { return false; }
		let sw = self.screen_width as f32;
		if sw <= 0.0 { return false; }
		let pages = self.config.mobile.pages.len();
		if pages == 0 { return false; }

		match side
		{
			DragEdgeSide::Right =>
			{
				let cur_empty = self.config.mobile.pages
					.get( self.current_page )
					.map( |p| p.items.is_empty() )
					.unwrap_or( true );
				if cur_empty { return false; }
				if self.current_page + 1 >= pages
				{
					// No trailing page — create one so the drag can keep
					// moving right. Rolled back on drop if left empty.
					self.config.mobile.pages.push( Page::default() );
				}
				self.current_page += 1;
				// Pre-offset so the new page appears to slide in from the
				// right: at the moment of commit it sits visually where
				// the old one was, then the settle animates it to centre.
				self.drag_dx += sw;
				self.start_page_settle();
				true
			}
			DragEdgeSide::Left =>
			{
				if self.current_page == 0 { return false; }
				self.current_page -= 1;
				self.drag_dx -= sw;
				self.start_page_settle();
				true
			}
		}
	}

	/// Pop trailing empty pages while the page count exceeds `floor`,
	/// which callers set to `initial_page_count` captured at drag
	/// start. Any trailing empty page above that floor is a rollback
	/// target — edge-drag navigation created it, the user never
	/// dropped anything on it, and the strict anti-cascade guard
	/// guarantees empty pages only appear at the tail (never
	/// intermediate), so popping the end is sufficient. Returns
	/// `true` if any page was removed so the caller knows a save is
	/// warranted.
	pub( crate ) fn trim_trailing_empty_pages( &mut self, floor: usize ) -> bool
	{
		let original = self.config.mobile.pages.len();
		while self.config.mobile.pages.len() > floor
		{
			let last_empty = self.config.mobile.pages.last()
				.map( |p| p.items.is_empty() )
				.unwrap_or( false );
			if !last_empty { break; }
			self.config.mobile.pages.pop();
		}
		let new_len = self.config.mobile.pages.len();
		if new_len == original { return false; }
		// If the current page was trimmed, clamp to the new tail and
		// reset any in-flight slide so the next frame renders a
		// consistent layout. The layer-shell surface would otherwise
		// keep sliding a page that no longer exists.
		if new_len == 0
		{
			self.current_page = 0;
			self.drag_dx      = 0.0;
			self.page_anim    = None;
		} else if self.current_page >= new_len {
			self.current_page = new_len - 1;
			self.drag_dx      = 0.0;
			self.page_anim    = None;
		}
		true
	}

	/// Apply a drop of `app_id` onto `target`, taking the drag's origin
	/// into account. Returns `true` if the drop mutated the config (and
	/// therefore needs saving), `false` if the drop was rejected silently.
	///
	/// Rules by origin:
	///
	/// - **Launcher**: drops on occupied grid cells, a full dock, or a
	///   duplicate dock id are rejected. Desktop dock drops respect the
	///   live drop-preview slot (see [`Self::dock_drop_index`]) so the
	///   icon lands in the gap the user saw open; a `None` index means
	///   the cursor is outside the band or accepting the drop would
	///   push the dock pill past the icon grid's width — both reject.
	/// - **Dock → Dock**: reorder using the preview slot on desktop;
	///   round to nearest item slot via `drop_x` on mobile (the
	///   grid-aligned dock has no preview).
	/// - **Dock → Grid vacía**: remove from dock, pin at `(col, row)`.
	/// - **Grid → Grid vacía** (same page / section): move to the new
	///   cell. Any collision is rejected — no swap in this phase
	///   (folders land later, when a grid→grid-occupied drop will make
	///   one).
	/// - **Grid → Dock**: same drop-preview logic as Launcher → Dock,
	///   plus removal from the source page on success.
	/// - **Target = None**: no-op.
	pub( crate ) fn apply_drop( &mut self, origin: &DragOrigin, app_id: &str, target: DropTarget, drop_x: f32, drop_y: f32 ) -> bool
	{
		match ( origin, target )
		{
			// Every origin silently rejects a drop that lands in neither
			// the grid nor the dock band. The caller's early-return path
			// for the launcher case matches what we used to do there too.
			( _, DropTarget::None ) => false,

			// ================================================================
			// Launcher → * (append-only)
			// ================================================================
			( DragOrigin::Launcher, DropTarget::Dock ) =>
			{
				let at = self.dock_drop_index( drop_x, drop_y, origin );
				self.dock_insert_checked( app_id, at )
			}
			( DragOrigin::Launcher, DropTarget::Grid { col, row } ) =>
			{
				self.grid_place( app_id, col, row )
			}

			// ================================================================
			// Dock → *
			// ================================================================
			( DragOrigin::Dock { index }, DropTarget::Dock ) =>
			{
				let at = self.dock_drop_index( drop_x, drop_y, origin );
				self.dock_reorder( *index, drop_x, at )
			}
			( DragOrigin::Dock { index }, DropTarget::Grid { col, row } ) =>
			{
				// Move from dock to grid. Reject on collision (we've
				// already decided no swap for this phase).
				if self.grid_cell_occupied( col, row )
				{
					return false;
				}
				if *index >= self.config.dock.items.len() { return false; }
				self.config.dock.items.remove( *index );
				self.grid_place( app_id, col, row )
			}

			// ================================================================
			// Grid → *
			// ================================================================
			( DragOrigin::GridMobile { page, index }, DropTarget::Grid { col, row } ) =>
			{
				// Destination page is whichever page the user is viewing
				// at release — after an edge-drag navigation that may be
				// a different page from the one the drag started on.
				self.grid_mobile_move( *page, *index, self.current_page, col, row )
			}
			( DragOrigin::GridDesktop { index }, DropTarget::Grid { col, row } ) =>
			{
				self.grid_desktop_move( *index, col, row )
			}
			( DragOrigin::GridMobile { page, index }, DropTarget::Dock ) =>
			{
				let at = self.dock_drop_index( drop_x, drop_y, origin );
				if !self.dock_insert_checked( app_id, at ) { return false; }
				// Dock push succeeded — remove from the origin page.
				if let Some( p ) = self.config.mobile.pages.get_mut( *page )
				{
					if *index < p.items.len()
					{
						p.items.remove( *index );
					}
				}
				true
			}
			( DragOrigin::GridDesktop { index }, DropTarget::Dock ) =>
			{
				let at = self.dock_drop_index( drop_x, drop_y, origin );
				if !self.dock_insert_checked( app_id, at ) { return false; }
				if *index < self.config.desktop.items.len()
				{
					self.config.desktop.items.remove( *index );
				}
				true
			}
			( DragOrigin::Running { .. }, _ ) => false,
		}
	}

	/// Resolve a desktop-dock drop's slot index from the cursor. Wraps
	/// [`homescreen::dock_preview_index`] with the orientation /
	/// origin context the renderer's preview uses, so the visual
	/// "open slot" and the actual insertion landing position are
	/// always the same `usize`. Returns `None` on mobile (the dock is
	/// grid-aligned, no preview, drops just append) or when the
	/// cursor's position would push the dock pill past the icon
	/// grid's width (the constraint guard rejects both the preview
	/// and the drop).
	fn dock_drop_index( &self, drop_x: f32, drop_y: f32, origin: &DragOrigin ) -> Option<usize>
	{
		let is_landscape = self.screen_width > self.screen_height;
		if !is_landscape { return None; }
		let adds_item = !matches!( origin, DragOrigin::Dock { .. } );
		homescreen::dock_preview_index(
			drop_x, drop_y,
			self.screen_width, self.screen_height,
			self.config.dock.items.len(),
			adds_item,
			is_landscape,
		)
	}

	/// Insert `app_id` into the dock at slot `at`, or append when
	/// `at` is `None`. Returns `true` on mutation.
	///
	/// Resolution by `(at, orientation)`:
	///
	/// - `Some(k)` — insert at gap `k`, clamped to the current dock
	///   length. The shell sets this to the live drop-preview index
	///   so the icon lands in the slot the user saw open.
	/// - `None` on mobile — append. Mobile's grid-aligned dock has
	///   no slot preview, the drop is always tail-pinned.
	/// - `None` on desktop — reject. The renderer only sets `at` to
	///   `None` when the cursor is outside the band or accepting the
	///   drop would push the dock pill past the icon grid; either
	///   way the drop is invalid.
	///
	/// Capacity and duplicate guards mirror the previous
	/// `dock_push_checked`: full dock or already-pinned id reject
	/// before the insert ever happens.
	pub( crate ) fn dock_insert_checked( &mut self, app_id: &str, at: Option<usize> ) -> bool
	{
		let is_landscape = self.screen_width > self.screen_height;
		let cap          = homescreen::dock_capacity( self.screen_width, self.screen_height, is_landscape );
		if self.config.dock.items.len() >= cap { return false; }
		let already = self.config.dock.items.iter()
			.any( |DockItem::App { app_id: a }| a == app_id );
		if already { return false; }
		let item = DockItem::App { app_id: app_id.to_string() };
		match ( at, is_landscape )
		{
			( Some( idx ), _ ) =>
			{
				let clamped = idx.min( self.config.dock.items.len() );
				self.config.dock.items.insert( clamped, item );
				true
			}
			( None, false ) =>
			{
				self.config.dock.items.push( item );
				true
			}
			( None, true ) => false,
		}
	}

	/// Place `app_id` into the current grid surface at `(col, row)` if
	/// the cell is empty. Routes to mobile / desktop based on the
	/// display mode, and creates a first mobile page on demand so the
	/// grid can accept drops on a freshly-installed shell.
	pub( crate ) fn grid_place( &mut self, app_id: &str, col: u32, row: u32 ) -> bool
	{
		if self.use_mobile_layout()
		{
			if self.config.mobile.pages.is_empty()
			{
				self.config.mobile.pages.push( Page::default() );
			}
			let page_idx = self.current_page.min( self.config.mobile.pages.len() - 1 );
			let page     = &mut self.config.mobile.pages[ page_idx ];
			if cell_occupied( &page.items, col, row ) { return false; }
			page.items.push( GridItem::App {
				app_id: app_id.to_string(),
				cell:   [ col, row ],
			} );
			true
		} else {
			if cell_occupied( &self.config.desktop.items, col, row ) { return false; }
			self.config.desktop.items.push( GridItem::App {
				app_id: app_id.to_string(),
				cell:   [ col, row ],
			} );
			true
		}
	}

	/// Return `true` if the visible grid (mobile current page or
	/// desktop) already has an item at `(col, row)`.
	pub( crate ) fn grid_cell_occupied( &self, col: u32, row: u32 ) -> bool
	{
		if self.use_mobile_layout()
		{
			let Some( page ) = self.config.mobile.pages.get( self.current_page ) else
			{
				return false;
			};
			cell_occupied( &page.items, col, row )
		} else {
			cell_occupied( &self.config.desktop.items, col, row )
		}
	}

	/// Move an existing mobile-grid item from `(src_page, src_idx)`
	/// onto cell `(col, row)` of `dst_page`. Same-page moves keep the
	/// item's Vec slot and just rewrite its cell; cross-page moves
	/// (triggered by edge-drag navigation advancing `current_page`
	/// mid-drag) remove from the source Vec and append to the
	/// destination Vec. Collisions on the destination cell are
	/// rejected in both cases (no swap in this phase).
	pub( crate ) fn grid_mobile_move( &mut self, src_page: usize, src_idx: usize, dst_page: usize, col: u32, row: u32 ) -> bool
	{
		// Validate the source slot.
		let Some( src ) = self.config.mobile.pages.get( src_page ) else
		{
			return false;
		};
		if src_idx >= src.items.len() { return false; }

		if src_page == dst_page
		{
			let page = &self.config.mobile.pages[ src_page ];
			// Dropping on the item's own cell is a no-op; accept
			// quietly so we don't log a spurious rejection.
			let GridItem::App { cell, .. } = &page.items[ src_idx ];
			if cell[ 0 ] == col && cell[ 1 ] == row { return false; }
			// Check collision excluding the moving item itself.
			let collision = page.items.iter().enumerate().any( |( i, it )|
			{
				let GridItem::App { cell, .. } = it;
				i != src_idx && cell[ 0 ] == col && cell[ 1 ] == row
			} );
			if collision { return false; }
			let GridItem::App { cell, .. } = &mut self.config.mobile.pages[ src_page ].items[ src_idx ];
			*cell = [ col, row ];
			true
		} else {
			// Cross-page: destination must exist and the target cell
			// be empty. Remove from source Vec and append to
			// destination Vec.
			let Some( dst ) = self.config.mobile.pages.get( dst_page ) else
			{
				return false;
			};
			if cell_occupied( &dst.items, col, row ) { return false; }
			let GridItem::App { app_id, .. } =
				self.config.mobile.pages[ src_page ].items.remove( src_idx );
			self.config.mobile.pages[ dst_page ].items.push( GridItem::App {
				app_id,
				cell: [ col, row ],
			} );
			true
		}
	}

	/// Move an existing desktop-grid item to a new cell.
	pub( crate ) fn grid_desktop_move( &mut self, item_idx: usize, col: u32, row: u32 ) -> bool
	{
		let items = &mut self.config.desktop.items;
		if item_idx >= items.len() { return false; }
		let GridItem::App { cell, .. } = &items[ item_idx ];
		if cell[ 0 ] == col && cell[ 1 ] == row { return false; }
		let collision = items.iter().enumerate().any( |( i, it )|
		{
			let GridItem::App { cell, .. } = it;
			i != item_idx && cell[ 0 ] == col && cell[ 1 ] == row
		} );
		if collision { return false; }
		let GridItem::App { cell, .. } = &mut items[ item_idx ];
		*cell = [ col, row ];
		true
	}

	/// Reorder the dock by moving the item at `from_idx`.
	///
	/// `at` is the gap-index from the live drop preview (desktop):
	/// `Some(k)` means "land between the original items `k-1` and
	/// `k`" — equivalent to `dock_items.insert(k)` in the
	/// pre-remove array. The function maps that to a post-remove
	/// insertion index by decrementing when `k > from_idx`, since
	/// removing the moving item shifts every later slot left by one.
	///
	/// `None` is the mobile fallback (the grid-aligned dock has no
	/// preview): round `drop_x` to the nearest item slot, same logic
	/// the original implementation used.
	pub( crate ) fn dock_reorder( &mut self, from_idx: usize, drop_x: f32, at: Option<usize> ) -> bool
	{
		let len = self.config.dock.items.len();
		if from_idx >= len { return false; }
		let target = match at
		{
			Some( gap ) =>
			{
				let g = gap.min( len );
				if g > from_idx { g - 1 } else { g }
			}
			None =>
			{
				// Mobile fallback. Geometry literals match
				// [`homescreen::dock_item_anchor`]'s portrait values
				// (the only orientation that still hits this branch
				// — desktop always passes `Some(gap)`).
				let icon    = 52.0_f32;
				let spacing = 20.0_f32;
				let count   = len + 1;
				let row_w   = count as f32 * icon + ( count.saturating_sub( 1 ) as f32 ) * spacing;
				let row_left = ( self.screen_width as f32 - row_w ) * 0.5;
				let first_item_x = row_left + icon + spacing;
				let slot_w  = icon + spacing;
				let raw     = ( drop_x - first_item_x ) / slot_w;
				raw.round().clamp( 0.0, ( len.saturating_sub( 1 ) ) as f32 ) as usize
			}
		};
		let target = target.min( len.saturating_sub( 1 ) );
		if target == from_idx { return false; }
		let item = self.config.dock.items.remove( from_idx );
		self.config.dock.items.insert( target, item );
		true
	}

	/// Build an [`EditMenu`] for the item at `origin`, resolving the
	/// display name from the launcher's app list and the on-screen
	/// anchor from the shared homescreen geometry helpers. Returns
	/// `None` if the origin is stale (index out of range) so callers
	/// can fall back cleanly.
	pub( crate ) fn build_edit_menu( &self, origin: &DragOrigin, app_id: &str ) -> Option<EditMenu>
	{
		let app_name = self.launcher.apps.iter()
			.find( |a| a.app_id == app_id )
			.map( |a| a.name.clone() )
			.unwrap_or_else( || app_id.to_string() );
		let anchor = match origin
		{
			DragOrigin::Launcher => return None,
			DragOrigin::Dock { index } =>
			{
				homescreen::dock_item_anchor(
					*index,
					self.config.dock.items.len(),
					self.screen_width,
					self.screen_height,
				)
			}
			DragOrigin::Running { app_id: a } =>
			{
				let pinned: Vec<&str> = self.config.dock.items.iter()
					.map( |DockItem::App { app_id }| app_id.as_str() )
					.collect();
				let temps: Vec<&str> = self.running_apps.in_order().iter()
					.filter( |id| !pinned.contains( &id.as_str() ) )
					.map( |s| s.as_str() )
					.collect();
				let temp_idx = temps.iter().position( |id| id == &a.as_str() )?;
				let pinned_count = self.config.dock.items.len();
				homescreen::dock_temp_anchor(
					temp_idx, pinned_count, temps.len(),
					self.screen_width, self.screen_height,
				)
			}
			DragOrigin::GridMobile { page, index } =>
			{
				let p = self.config.mobile.pages.get( *page )?;
				let GridItem::App { cell, .. } = p.items.get( *index )?;
				homescreen::grid_cell_anchor(
					cell[ 0 ], cell[ 1 ],
					self.screen_width,
					self.screen_height,
				)
			}
			DragOrigin::GridDesktop { index } =>
			{
				let GridItem::App { cell, .. } = self.config.desktop.items.get( *index )?;
				homescreen::grid_cell_anchor(
					cell[ 0 ], cell[ 1 ],
					self.screen_width,
					self.screen_height,
				)
			}
		};
		let running = self.running_apps.is_running( app_id );
		Some( EditMenu {
			origin: origin.clone(),
			app_id: app_id.to_string(),
			app_name,
			anchor,
			running,
		} )
	}

	/// Remove the item identified by `origin` from the config. No-op
	/// if the index is stale (e.g. a concurrent reflow shifted
	/// things).
	pub( crate ) fn remove_by_origin( &mut self, origin: &DragOrigin )
	{
		match origin
		{
			DragOrigin::Launcher => {}
			DragOrigin::Dock { index } =>
			{
				if *index < self.config.dock.items.len()
				{
					self.config.dock.items.remove( *index );
				}
			}
			DragOrigin::GridMobile { page, index } =>
			{
				if let Some( p ) = self.config.mobile.pages.get_mut( *page )
				{
					if *index < p.items.len()
					{
						p.items.remove( *index );
					}
				}
			}
			DragOrigin::GridDesktop { index } =>
			{
				if *index < self.config.desktop.items.len()
				{
					self.config.desktop.items.remove( *index );
				}
			}
			DragOrigin::Running { .. } => {}
		}
	}
}

/// Return `true` if `items` already has an entry at `(col, row)`.
pub( crate ) fn cell_occupied( items: &[GridItem], col: u32, row: u32 ) -> bool
{
	items.iter().any( |GridItem::App { cell, .. }| cell[0] == col && cell[1] == row )
}

impl Crustace
{
	/// Predict whether a drop at `(x, y)` would succeed, mirroring
	/// [`Self::apply_drop`]'s accept / reject decisions without
	/// mutating any state. Used by `view()` to dim the drag ghost
	/// over invalid drop locations so the user sees the rejection
	/// before the release.
	///
	/// Returns `true` when no drag is in flight or the drag has not
	/// yet crossed the threshold — the "neutral" pre-move state, not
	/// a rejection. Callers that want to render a rejection cue
	/// should treat `false` as "would be rejected"; the pre-move
	/// case correctly leaves the ghost at full opacity / the cursor
	/// at default.
	pub( crate ) fn drop_would_succeed( &self, x: f32, y: f32 ) -> bool
	{
		let Some( ref drag ) = self.dragging_item else { return true; };
		if !self.drag_moved { return true; }
		let target = homescreen::drop_target(
			self.screen_width, self.screen_height,
			self.config.dock.items.len(), x, y,
		);
		match ( &drag.origin, target )
		{
			( _, DropTarget::None ) => false,

			// Anything → Dock: capacity, duplicate and at-resolves
			// gating (mirrors `dock_insert_checked` for new items;
			// `dock_reorder` always succeeds for an in-dock origin so
			// we treat that path as accept).
			( DragOrigin::Dock { .. }, DropTarget::Dock ) => true,
			( _, DropTarget::Dock ) =>
			{
				let at = self.dock_drop_index( x, y, &drag.origin );
				self.dock_can_insert_predict( &drag.app_id, at )
			}

			// Anything → Grid: cell must be free; for grid-origin
			// drags the moving item is excluded so dropping on its
			// own cell is treated as no-op (rejected). Cross-page
			// mobile moves enforce a free destination cell.
			( DragOrigin::Launcher, DropTarget::Grid { col, row } ) =>
			{
				!self.grid_cell_occupied( col, row )
			}
			( DragOrigin::Dock { .. }, DropTarget::Grid { col, row } ) =>
			{
				!self.grid_cell_occupied( col, row )
			}
			( DragOrigin::GridMobile { page, index }, DropTarget::Grid { col, row } ) =>
			{
				self.grid_mobile_move_would_succeed( *page, *index, col, row )
			}
			( DragOrigin::GridDesktop { index }, DropTarget::Grid { col, row } ) =>
			{
				self.grid_desktop_move_would_succeed( *index, col, row )
			}
			( DragOrigin::Running { .. }, _ ) => false,
		}
	}

	/// Non-mutating mirror of [`Self::dock_insert_checked`]'s gating.
	fn dock_can_insert_predict( &self, app_id: &str, at: Option<usize> ) -> bool
	{
		let is_landscape = self.screen_width > self.screen_height;
		let cap          = homescreen::dock_capacity( self.screen_width, self.screen_height, is_landscape );
		if self.config.dock.items.len() >= cap { return false; }
		let already = self.config.dock.items.iter()
			.any( |DockItem::App { app_id: a }| a == app_id );
		if already { return false; }
		matches!( ( at, is_landscape ), ( Some( _ ), _ ) | ( None, false ) )
	}

	/// Non-mutating mirror of [`Self::grid_mobile_move`]'s gating.
	fn grid_mobile_move_would_succeed( &self, src_page: usize, src_idx: usize, col: u32, row: u32 ) -> bool
	{
		let Some( src ) = self.config.mobile.pages.get( src_page ) else { return false; };
		if src_idx >= src.items.len() { return false; }
		let dst_page = self.current_page;
		if src_page == dst_page
		{
			let GridItem::App { cell, .. } = &src.items[ src_idx ];
			if cell[ 0 ] == col && cell[ 1 ] == row { return false; }
			!src.items.iter().enumerate().any( |( i, it )|
			{
				let GridItem::App { cell, .. } = it;
				i != src_idx && cell[ 0 ] == col && cell[ 1 ] == row
			} )
		} else {
			let Some( dst ) = self.config.mobile.pages.get( dst_page ) else { return false; };
			!cell_occupied( &dst.items, col, row )
		}
	}

	/// Non-mutating mirror of [`Self::grid_desktop_move`]'s gating.
	fn grid_desktop_move_would_succeed( &self, item_idx: usize, col: u32, row: u32 ) -> bool
	{
		let items = &self.config.desktop.items;
		if item_idx >= items.len() { return false; }
		let GridItem::App { cell, .. } = &items[ item_idx ];
		if cell[ 0 ] == col && cell[ 1 ] == row { return false; }
		!items.iter().enumerate().any( |( i, it )|
		{
			let GridItem::App { cell, .. } = it;
			i != item_idx && cell[ 0 ] == col && cell[ 1 ] == row
		} )
	}
}
