//! Keyboard + mouse input for the game board.
//!
//! All systems exit immediately when `PausedResource(true)` — no moves,
//! draws, undos, or drags are processed while the pause overlay is showing.
//!
//! Keyboard:
//! - `U` → `UndoRequestEvent`
//! - `N` → `NewGameRequestEvent { seed: None }` (cancels Time Attack if active)
//! - `D` / `Space` → `DrawRequestEvent`
//! - `Esc` → handled by `PausePlugin` (overlay toggle + paused flag)
//!
//! Mouse:
//! - Left-click on the stock pile (face-down deck) → `DrawRequestEvent`
//!   (the waste card is left free to play: double-click to auto-move, or drag)
//! - Left-press-drag-release on a face-up card → `MoveRequestEvent` between
//!   the origin pile and whatever pile the cursor is over at release.
//!   On rejection, the drag cards snap back to their origin via a
//!   `StateChangedEvent` re-sync.

use std::collections::HashMap;

use bevy::ecs::system::SystemParam;
use bevy::input::ButtonInput;
use bevy::input::touch::{TouchInput, TouchPhase, Touches};
use bevy::math::{Vec2, Vec3};
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
#[cfg(not(target_os = "android"))]
use bevy::window::{MonitorSelection, WindowMode};
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::{Card, Suit};
use solitaire_core::game_state::GameState;

use crate::auto_complete_plugin::AutoCompleteState;
use crate::card_animation::tuning::AnimationTuning;
use crate::card_animation::{CardAnimation, MotionCurve};
use crate::card_plugin::{
    CardEntity, CardEntityIndex, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, waste_fan_step,
};
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{
    DrawRequestEvent, ForfeitRequestEvent, HintVisualEvent, InfoToastEvent, MoveRejectedEvent,
    MoveRequestEvent, NewGameRequestEvent, StartZenRequestEvent, StateChangedEvent,
    UndoRequestEvent,
};
use crate::game_plugin::{ConfirmNewGameScreen, GameMutation, RestorePromptScreen};
use crate::layout::{Layout, LayoutResource};
use crate::pause_plugin::PausedResource;
use crate::progress_plugin::ProgressResource;
use crate::radial_menu::RightClickRadialState;
use crate::replay_playback::ReplayPlaybackState;
use crate::resources::{DragState, GameInputConsumedResource, GameStateResource, HintCycleIndex};
use crate::selection_plugin::SelectionState;
use crate::settings_plugin::SettingsResource;
use crate::time_attack_plugin::TimeAttackResource;
use crate::touch_selection_plugin::TouchSelectionState;
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING};
use solitaire_core::DrawStockConfig;

/// System-set labels used to anchor external systems relative to the touch
/// drag pipeline without duplicating the internal chain ordering.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub enum TouchDragSet {
    /// After `touch_start_drag` has run — drag state is populated if a card was touched.
    AfterStartDrag,
    /// Before `touch_end_drag` runs — drag state has not yet been cleared.
    BeforeEndDrag,
}

/// Z-depth used for cards while being dragged — above all resting cards.
const DRAG_Z: f32 = 500.0;
/// Relative Z step between cards inside a dragged stack.
///
/// Must stay at least as large as [`STACK_FAN_FRAC`], otherwise Android's
/// per-card corner overlay children (`local_z = 0.02`) can bleed above the
/// card body stacked directly above them while dragging.
const DRAG_STACK_Z_STEP: f32 = STACK_FAN_FRAC;

fn dragged_card_z(index: usize) -> f32 {
    DRAG_Z + index as f32 * DRAG_STACK_Z_STEP
}

/// Solver budgets used by the H-key hint system.
///
/// A Bevy resource so tests can inject tighter budgets to exercise the
/// heuristic-fallback path. Production initialises this to the same default
/// 100k move / 200k state budgets the new-game retry loop uses.
#[derive(Resource, Debug, Clone, Copy)]
pub struct HintSolverConfig {
    /// Maximum solver moves before giving up (inconclusive).
    pub moves_budget: u64,
    /// Maximum unique solver states before giving up (inconclusive).
    pub states_budget: u64,
}

impl Default for HintSolverConfig {
    fn default() -> Self {
        Self {
            moves_budget: solitaire_core::DEFAULT_SOLVE_MOVES_BUDGET,
            states_budget: solitaire_core::DEFAULT_SOLVE_STATES_BUDGET,
        }
    }
}

/// Registers keyboard, mouse, and touch input systems.
///
/// Mouse drag pipeline (ordered, left-to-right):
/// `start_drag` → `follow_drag` → `end_drag`
///
/// Touch drag pipeline (ordered, interleaved with mouse):
/// `touch_start_drag` → `touch_follow_drag` → `touch_end_drag`
///
/// Both pipelines share [`DragState`]. Only one can be active at a time —
/// the second checks `drag.is_idle()` before proceeding, and mouse drags
/// check `drag.active_touch_id.is_none()`.
///
/// All drag systems run before [`GameMutation`] so move events are consumed
/// in the same frame they are emitted.
pub struct InputPlugin;

impl Plugin for InputPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<HintCycleIndex>()
            .init_resource::<HintSolverConfig>()
            .init_resource::<crate::pending_hint::PendingHintTask>()
            .init_resource::<GameInputConsumedResource>()
            // The drag systems resolve cards via `CardEntityIndex`; `CardPlugin`
            // owns and rebuilds it, but init here too so `InputPlugin` is
            // self-sufficient in tests (idempotent if already registered).
            .init_resource::<CardEntityIndex>()
            .add_message::<StartZenRequestEvent>()
            .add_message::<InfoToastEvent>()
            .add_message::<ForfeitRequestEvent>()
            .add_message::<HintVisualEvent>()
            .add_systems(
                Update,
                (
                    handle_keyboard_core,
                    handle_keyboard_hint,
                    handle_keyboard_forfeit,
                    handle_stock_click,
                    handle_touch_stock_tap,
                    handle_double_click,
                    // Mouse drag pipeline.
                    start_drag,
                    follow_drag,
                    end_drag.before(GameMutation),
                    // Touch drag pipeline (parallel path through DragState).
                    touch_start_drag.in_set(TouchDragSet::AfterStartDrag),
                    touch_follow_drag,
                    handle_double_tap, // before touch_end_drag: reads drag state pre-clear
                    touch_end_drag
                        .after(TouchDragSet::BeforeEndDrag)
                        .before(GameMutation),
                )
                    .chain(),
            )
            .add_systems(Update, reset_hint_cycle_on_state_change);
        // F11 fullscreen toggle is desktop-only; Android windows are always full-screen.
        #[cfg(not(target_os = "android"))]
        app.add_systems(Update, handle_fullscreen);
        app
            // Async hint pipeline: state-change drop runs before the
            // poll system so a move applied this frame cancels any
            // in-flight task before its result can be surfaced.
            .add_systems(
                Update,
                (
                    crate::pending_hint::drop_pending_hint_on_state_change,
                    crate::pending_hint::poll_pending_hint_task,
                )
                    .chain(),
            );
    }
}

/// Bundles the event writers needed by the core keyboard handler.
///
/// Keeping these in a [`SystemParam`] avoids hitting Bevy's 16-parameter limit.
#[derive(SystemParam)]
struct CoreKeyboardMessages<'w> {
    undo: MessageWriter<'w, UndoRequestEvent>,
    new_game: MessageWriter<'w, NewGameRequestEvent>,
    info_toast: MessageWriter<'w, InfoToastEvent>,
    draw: MessageWriter<'w, DrawRequestEvent>,
}

/// Handles the core keyboard shortcuts: U (undo), N (new game), Z (zen mode),
/// D / Space (draw).
///
/// `N` fires `NewGameRequestEvent` straight through; the existing
/// `handle_new_game` flow shows the `ConfirmNewGameScreen` modal when
/// the current game is in progress, so a single press surfaces a real
/// Confirm / Cancel UI instead of a "press N again" toast. `Shift+N`
/// keeps the keyboard power-user bypass by setting `confirmed: true`.
///
/// While the confirm modal or the restore prompt is already open, the
/// system skips the N branch so those modals' own input handlers can
/// process N (cancel / start-new-game) without us re-firing a request
/// the same frame.
#[allow(clippy::too_many_arguments)]
fn handle_keyboard_core(
    keys: Res<ButtonInput<KeyCode>>,
    paused: Option<Res<PausedResource>>,
    progress: Option<Res<ProgressResource>>,
    mut ev: CoreKeyboardMessages<'_>,
    mut time_attack: Option<ResMut<TimeAttackResource>>,
    selection: Option<Res<SelectionState>>,
    mut zen_requests: MessageReader<StartZenRequestEvent>,
    confirm_screens: Query<(), With<ConfirmNewGameScreen>>,
    restore_prompts: Query<(), With<RestorePromptScreen>>,
    replay_state: Option<Res<ReplayPlaybackState>>,
) {
    if paused.is_some_and(|p| p.0) {
        return;
    }

    // During replay playback (Playing or Completed) all game-input shortcuts
    // are suppressed. The replay overlay owns Space (pause/resume) and the
    // arrow keys (step). Letting game input through would mutate
    // `GameStateResource` and corrupt replay determinism.
    if replay_state.is_some_and(|r| !matches!(*r, ReplayPlaybackState::Inactive)) {
        return;
    }

    if keys.just_pressed(KeyCode::KeyU) {
        ev.undo.write(UndoRequestEvent);
    }

    if keys.just_pressed(KeyCode::KeyN) {
        // If a Time Attack session is running, cancel it and start a Classic game.
        if let Some(ref mut session) = time_attack
            && session.active
        {
            session.active = false;
            session.remaining_secs = 0.0;
            ev.info_toast
                .write(InfoToastEvent("Time Attack ended".to_string()));
            ev.new_game.write(NewGameRequestEvent {
                seed: None,
                mode: Some(solitaire_core::game_state::GameMode::Classic),
                confirmed: false,
            });
            return;
        }

        // The confirm modal and restore prompt own N while they're up —
        // they cancel / accept respectively. Skipping here prevents us
        // from firing a fresh request the same frame those modals close.
        if !confirm_screens.is_empty() || !restore_prompts.is_empty() {
            // intentional: defer to those modals' input handlers.
        } else {
            let shift_held = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
            ev.new_game.write(NewGameRequestEvent {
                seed: None,
                mode: None,
                // Shift+N skips the confirm modal for keyboard power-users;
                // bare N falls through `handle_new_game`'s active-game check
                // and shows the modal when a game is in progress.
                confirmed: shift_held,
            });
        }
    }

    let zen_clicked = zen_requests.read().count() > 0;
    if keys.just_pressed(KeyCode::KeyZ) || zen_clicked {
        // Zen / Challenge / Time Attack are gated to level >= CHALLENGE_UNLOCK_LEVEL.
        // X is gated separately by ChallengePlugin. Either Z or the HUD
        // Modes-popover "Zen" row reaches this branch.
        let level = progress.as_ref().map_or(0, |p| p.0.level);
        if level >= CHALLENGE_UNLOCK_LEVEL {
            ev.new_game.write(NewGameRequestEvent {
                seed: None,
                mode: Some(solitaire_core::game_state::GameMode::Zen),
                confirmed: false,
            });
        } else {
            ev.info_toast.write(InfoToastEvent(format!(
                "Zen mode unlocks at level {CHALLENGE_UNLOCK_LEVEL}"
            )));
        }
    }

    // Space draws only when no card is keyboard-selected; when a card IS selected,
    // SelectionPlugin handles Space to execute the move.
    let space_draws = keys.just_pressed(KeyCode::Space)
        && selection.as_ref().is_none_or(|s| s.selected_pile.is_none());
    if keys.just_pressed(KeyCode::KeyD) || space_draws {
        ev.draw.write(DrawRequestEvent);
    }
    // Esc is handled by `PausePlugin` (overlay toggle + paused flag).
}

/// Handles the H key: spawn an async solver task on
/// `AsyncComputeTaskPool` whose result `pending_hint::poll_pending_hint_task`
/// turns into hint visuals one frame later.
///
/// Median solve time is ~2 ms but pathological positions can hit the
/// default solve budget at ~120 ms; running synchronously
/// (the v0.17.0 behaviour) blocked the main thread on the same frame
/// the player pressed H. Cancel-on-replace lives in
/// `PendingHintTask::spawn` — a fresh H press while a previous task
/// is in flight drops the previous task's handle.
///
/// Special-cases: when the game is already won, surface a "Game won!"
/// toast instead of asking the solver. The poll system handles the
/// "no legal moves" toast on the heuristic fallback path so the
/// handler here only needs to dispatch.
fn handle_keyboard_hint(
    keys: Res<ButtonInput<KeyCode>>,
    paused: Option<Res<PausedResource>>,
    game: Option<Res<GameStateResource>>,
    layout: Option<Res<LayoutResource>>,
    solver_config: Res<HintSolverConfig>,
    mut pending_hint: ResMut<crate::pending_hint::PendingHintTask>,
    mut info_toast: MessageWriter<InfoToastEvent>,
) {
    if paused.is_some_and(|p| p.0) {
        return;
    }
    if !keys.just_pressed(KeyCode::KeyH) {
        return;
    }

    let Some(ref g) = game else { return };

    if g.0.is_won() {
        info_toast.write(InfoToastEvent(
            "Game won! Press N for a new game".to_string(),
        ));
        return;
    }

    let Some(_layout_res) = layout else { return };

    pending_hint.spawn(
        g.0.clone(),
        solver_config.moves_budget,
        solver_config.states_budget,
    );
}

/// Heuristic hint helper used by `pending_hint::poll_pending_hint_task`
/// when the solver returns `Inconclusive` or `Unwinnable`.
///
/// Picks the hint at `HintCycleIndex % hints.len()` (wrapping) and
/// advances the index so successive H presses on a stuck position
/// cycle through every legal move. Returns `None` when no legal move
/// exists at all — the caller surfaces a "No hints available" toast.
pub fn find_heuristic_hint(
    game: &GameState,
    hint_cycle: &mut HintCycleIndex,
) -> Option<(KlondikePile, KlondikePile)> {
    let hints = all_hints(game);
    if hints.is_empty() {
        return None;
    }
    let idx = hint_cycle.0 % hints.len();
    hint_cycle.0 = hint_cycle.0.wrapping_add(1);
    let (from, to) = hints[idx];
    Some((from, to))
}

/// Apply the visual + toast effects for a single chosen hint move.
///
/// Shared between the solver-driven and heuristic-driven hint paths so
/// both produce identical player-facing feedback. Called from
/// `pending_hint::poll_pending_hint_task` once the async solver task
/// resolves.
pub fn emit_hint_visuals(
    game: &GameState,
    from: &KlondikePile,
    to: &KlondikePile,
    commands: &mut Commands,
    mut card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
    info_toast: &mut MessageWriter<InfoToastEvent>,
    hint_visual: &mut MessageWriter<HintVisualEvent>,
) {
    // When the hint points at the stock (draw suggestion) there is no
    // face-up card to highlight — show a toast instead.
    // If the stock is empty, pressing D will recycle the waste rather
    // than draw a card, so the toast text must reflect that.
    if *from == KlondikePile::Stock {
        let stock_empty = game.stock_cards().is_empty();
        let msg = if stock_empty {
            "Hint: recycle waste (D)".to_string()
        } else {
            "Hint: draw from stock (D)".to_string()
        };
        info_toast.write(InfoToastEvent(msg));
        return;
    }

    // Find the top face-up card in the source pile and highlight it.
    let source_cards = pile_cards(game, from);
    let top_card = source_cards.last().filter(|(_, face_up)| *face_up).map(|(c, _)| c.clone());
    if let Some(card) = top_card {
        for (entity, card_entity, mut sprite) in card_entities.iter_mut() {
            if card_entity.card == card {
                // Tint the card gold without replacing the Sprite (which would
                // discard the image handle set by CardImageSet). Uses the
                // design-system `STATE_WARNING` token so the source-card
                // tint matches the destination pile highlight, both of
                // which signal "look here" for the hint.
                sprite.color = STATE_WARNING;
                commands
                    .entity(entity)
                    .insert(HintHighlight { remaining: 2.0 })
                    .insert(HintHighlightTimer(2.0));
                break;
            }
        }
        // Emit HintVisualEvent so the destination pile marker is also
        // tinted gold for 2 s.
        hint_visual.write(HintVisualEvent {
            source_card: card,
            dest_pile: *to,
        });
    }

    // Fire an informational toast describing where the hinted card should
    // move so the player always sees the suggestion in text. When the
    // destination foundation already claims a suit, surface that suit so the
    // player keeps thinking in suit terms; otherwise fall back to "foundation".
    let msg = match to {
        KlondikePile::Foundation(_) => {
            let claimed = game.pile(*to).first().map(|(c, _)| c.suit());
            if let Some(suit) = claimed {
                let suit_name = match suit {
                    Suit::Clubs => "Clubs",
                    Suit::Diamonds => "Diamonds",
                    Suit::Hearts => "Hearts",
                    Suit::Spades => "Spades",
                };
                format!("Hint: move to {suit_name} foundation")
            } else {
                "Hint: move to foundation".to_string()
            }
        }
        KlondikePile::Tableau(col) => {
            format!("Hint: move to tableau (col {})", tableau_number(*col))
        }
        _ => "Hint: move card".to_string(),
    };
    info_toast.write(InfoToastEvent(msg));
}

/// Handles the G key: fires `ForfeitRequestEvent` so `PausePlugin`
/// can spawn the `ForfeitConfirmScreen` modal.
///
/// Replaces a prior double-press toast countdown with a real
/// Cancel / Yes-forfeit modal — the same code path the Pause modal's
/// Forfeit button takes. The "no game to forfeit" check (won state,
/// missing resource) lives in `handle_forfeit_request` so it can
/// surface a toast; here we only gate on whether the player is paused
/// (in which case the pause modal's Forfeit button is the entry
/// point).
fn handle_keyboard_forfeit(
    keys: Res<ButtonInput<KeyCode>>,
    paused: Option<Res<PausedResource>>,
    mut requests: MessageWriter<ForfeitRequestEvent>,
) {
    if paused.is_some_and(|p| p.0) {
        return;
    }
    if !keys.just_pressed(KeyCode::KeyG) {
        return;
    }
    requests.write(ForfeitRequestEvent);
}

/// Resets [`HintCycleIndex`] to `0` whenever the game state changes or a new
/// game is requested so the next H press always starts cycling from the first
/// hint of the new position.
///
/// Listening to both events ensures the reset happens immediately on
/// `NewGameRequestEvent`, one frame before the `StateChangedEvent` that the
/// game plugin fires after dealing — preventing a stale hint from the previous
/// game being shown when H is pressed in that gap frame.
fn reset_hint_cycle_on_state_change(
    mut state_events: MessageReader<StateChangedEvent>,
    mut new_game_events: MessageReader<NewGameRequestEvent>,
    mut hint_cycle: ResMut<HintCycleIndex>,
) {
    if state_events.read().next().is_some() || new_game_events.read().next().is_some() {
        hint_cycle.0 = 0;
    }
}

/// `F11` toggles between borderless-fullscreen and windowed mode.
/// Not gated by the pause flag — the player can always resize the window.
#[cfg(not(target_os = "android"))]
fn handle_fullscreen(
    keys: Res<ButtonInput<KeyCode>>,
    mut windows: Query<&mut Window, With<PrimaryWindow>>,
    mut toast: MessageWriter<InfoToastEvent>,
) {
    if !keys.just_pressed(KeyCode::F11) {
        return;
    }
    let Ok(mut window) = windows.single_mut() else {
        return;
    };
    let new_mode = match window.mode {
        WindowMode::Windowed => WindowMode::BorderlessFullscreen(MonitorSelection::Current),
        _ => WindowMode::Windowed,
    };
    window.mode = new_mode;
    let label = match window.mode {
        WindowMode::Windowed => "Fullscreen: off",
        _ => "Fullscreen: on",
    };
    toast.write(InfoToastEvent(label.to_string()));
}

fn handle_stock_click(
    buttons: Res<ButtonInput<MouseButton>>,
    drag: Res<DragState>,
    paused: Option<Res<PausedResource>>,
    windows: Query<&Window, With<PrimaryWindow>>,
    cameras: Query<(&Camera, &GlobalTransform)>,
    layout: Option<Res<LayoutResource>>,
    mut draw: MessageWriter<DrawRequestEvent>,
) {
    if paused.is_some_and(|p| p.0) {
        return;
    }
    if !buttons.just_pressed(MouseButton::Left) || !drag.is_idle() {
        return;
    }
    let Some(layout) = layout else {
        return;
    };
    let Some(world) = cursor_world(&windows, &cameras) else {
        return;
    };

    // `pile_positions[Stock]` is the waste column (col_x(1)).  card_plugin renders the
    // face-down deck one column to the left via `base.x -= tableau_col_step`, placing it
    // at Tableau1's x (col_x(0)).  Only the deck draws — clicking the waste card must
    // leave it free to be played (double-click to auto-move, or drag); hit-testing the
    // waste slot here would intercept that click and draw the next card instead.
    let Some(&waste_pos) = layout.0.pile_positions.get(&KlondikePile::Stock) else {
        return;
    };
    let Some(&t1_pos) = layout
        .0
        .pile_positions
        .get(&KlondikePile::Tableau(Tableau::Tableau1))
    else {
        return;
    };
    let deck_pos = Vec2::new(t1_pos.x, waste_pos.y);
    if point_in_rect(world, deck_pos, layout.0.card_size) {
        draw.write(DrawRequestEvent);
    }
}

/// Fires [`DrawRequestEvent`] when the player taps the stock pile on a touch screen.
///
/// Uses `TouchPhase::Started` (the finger-down moment) for instant responsiveness
/// — since the stock cannot be dragged, there is no ambiguity between a tap and
/// the start of a drag on this pile. Does nothing while a drag is in progress.
fn handle_touch_stock_tap(
    mut touch_events: MessageReader<TouchInput>,
    paused: Option<Res<PausedResource>>,
    cameras: Query<(&Camera, &GlobalTransform)>,
    layout: Option<Res<LayoutResource>>,
    drag: Res<DragState>,
    mut draw: MessageWriter<DrawRequestEvent>,
    mut game_consumed: ResMut<GameInputConsumedResource>,
) {
    if paused.is_some_and(|p| p.0) {
        return;
    }
    if !drag.is_idle() {
        return;
    }
    let Some(layout) = layout else { return };

    for event in touch_events.read() {
        if event.phase != TouchPhase::Started {
            continue;
        }
        let Some(world) = touch_to_world(&cameras, event.position) else {
            continue;
        };
        let Some(&waste_pos) = layout.0.pile_positions.get(&KlondikePile::Stock) else {
            continue;
        };
        let Some(&t1_pos) = layout
            .0
            .pile_positions
            .get(&KlondikePile::Tableau(Tableau::Tableau1))
        else {
            continue;
        };
        let deck_pos = Vec2::new(t1_pos.x, waste_pos.y);
        // Only the face-down deck draws; tapping the waste card leaves it free to
        // play (double-tap to auto-move, or drag).
        if point_in_rect(world, deck_pos, layout.0.card_size) {
            draw.write(DrawRequestEvent);
            game_consumed.0 = true;
            break; // one draw per tap frame
        }
    }
}

/// Begins a mouse drag: records the press position and the cards that would be
/// dragged. Cards are **not** elevated yet — that happens in [`follow_drag`]
/// once the drag threshold is crossed.
#[allow(clippy::too_many_arguments)]
fn start_drag(
    buttons: Res<ButtonInput<MouseButton>>,
    touches: Option<Res<Touches>>,
    paused: Option<Res<PausedResource>>,
    auto_complete: Option<Res<AutoCompleteState>>,
    windows: Query<&Window, With<PrimaryWindow>>,
    cameras: Query<(&Camera, &GlobalTransform)>,
    layout: Option<Res<LayoutResource>>,
    game: Res<GameStateResource>,
    mut drag: ResMut<DragState>,
) {
    if paused.is_some_and(|p| p.0) {
        return;
    }
    if auto_complete.is_some_and(|ac| ac.active) {
        return;
    }
    // Only start a new drag when idle (no touch drag running either).
    if !buttons.just_pressed(MouseButton::Left) || !drag.is_idle() {
        return;
    }
    // On platforms where Winit simulates a MouseButton::Left press from the
    // first touch, this guard ensures touch_start_drag (which runs after this
    // system) claims the drag state instead of the mouse path. Without it the
    // card is tracked via cursor_world (updated from the simulated mouse
    // position) rather than the Touches resource, which can be one frame
    // behind the actual finger position on Android.
    if touches
        .as_ref()
        .is_some_and(|t| t.iter_just_pressed().next().is_some())
    {
        return;
    }
    let Some(layout) = layout else { return };
    let Some(world) = cursor_world(&windows, &cameras) else {
        return;
    };

    // Don't pick up the stock — that is handled by handle_stock_click.
    let Some((pile, stack_index, card_ids)) = find_draggable_at(world, &game.0, &layout.0) else {
        return;
    };

    let bottom_pos = card_position(&game.0, &layout.0, &pile, stack_index);

    // Store as a pending drag. We do NOT elevate the cards yet — the visual
    // lift happens in follow_drag once the threshold is crossed.
    drag.cards = card_ids;
    drag.origin_pile = Some(pile);
    drag.cursor_offset = bottom_pos - world;
    drag.origin_z = DRAG_Z;
    drag.press_pos = world;
    drag.committed = false;
    drag.active_touch_id = None;
}

/// Moves dragged cards with the mouse cursor each frame.
///
/// If the drag has not yet been committed (threshold not crossed), checks
/// whether the cursor has moved far enough from the press position to commit.
/// On commit, cards are elevated to `DRAG_Z` and dimmed. Does nothing for
/// touch-driven drags (`drag.active_touch_id.is_some()`).
#[allow(clippy::too_many_arguments)]
fn follow_drag(
    windows: Query<&Window, With<PrimaryWindow>>,
    cameras: Query<(&Camera, &GlobalTransform)>,
    mut drag: ResMut<DragState>,
    layout: Option<Res<LayoutResource>>,
    tuning: Res<AnimationTuning>,
    mut card_transforms: Query<(&CardEntity, &mut Transform, &mut Sprite)>,
    card_index: Res<CardEntityIndex>,
) {
    // Skip if idle or if a touch drag is running.
    if drag.is_idle() || drag.active_touch_id.is_some() {
        return;
    }
    let Some(layout) = layout else { return };
    let Some(world) = cursor_world(&windows, &cameras) else {
        // Cursor left the window mid-drag. Cancel a pending drag; let a
        // committed drag freeze at the last known position.
        if !drag.committed {
            drag.clear();
        }
        return;
    };

    // Check drag threshold on the first frames after press.
    if !drag.committed {
        // Use screen-space distance (world ≈ screen for 2-D games with no
        // camera zoom, which is our case).
        let moved = world.distance(drag.press_pos);
        if moved < tuning.drag_threshold_px {
            return; // Still within tap zone — don't start visual drag yet.
        }

        // Threshold crossed → commit.
        drag.committed = true;

        // Elevate cards: push to DRAG_Z and dim slightly so the board
        // beneath stays readable.
        for (i, card) in drag.cards.iter().enumerate() {
            if let Some(entity) = card_index.get(card)
                && let Ok((_, mut transform, mut sprite)) = card_transforms.get_mut(entity)
            {
                transform.translation.z = dragged_card_z(i);
                sprite.color.set_alpha(0.85);
            }
        }
    }

    // Move cards to the cursor.
    let bottom_pos = world + drag.cursor_offset;
    let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac;

    for (i, card) in drag.cards.iter().enumerate() {
        if let Some(entity) = card_index.get(card)
            && let Ok((_, mut transform, _)) = card_transforms.get_mut(entity)
        {
            transform.translation.x = bottom_pos.x;
            transform.translation.y = bottom_pos.y + fan * i as f32;
        }
    }
}

#[allow(clippy::too_many_arguments)]
fn end_drag(
    buttons: Res<ButtonInput<MouseButton>>,
    paused: Option<Res<PausedResource>>,
    windows: Query<&Window, With<PrimaryWindow>>,
    cameras: Query<(&Camera, &GlobalTransform)>,
    layout: Option<Res<LayoutResource>>,
    game: Res<GameStateResource>,
    mut drag: ResMut<DragState>,
    mut moves: MessageWriter<MoveRequestEvent>,
    mut rejected: MessageWriter<MoveRejectedEvent>,
    mut changed: MessageWriter<StateChangedEvent>,
    mut commands: Commands,
    card_entities: Query<(Entity, &CardEntity, &Transform)>,
    card_index: Res<CardEntityIndex>,
) {
    if paused.is_some_and(|p| p.0) {
        drag.clear();
        return;
    }
    // Only handle mouse releases; touch releases are handled by touch_end_drag.
    if !buttons.just_released(MouseButton::Left) || drag.is_idle() {
        return;
    }
    if drag.active_touch_id.is_some() {
        return; // Touch-driven drag — not ours to handle.
    }

    // If the drag was never committed (user tapped without moving far enough),
    // treat it as a click: cancel the pending drag and exit. We deliberately
    // do NOT fire `StateChangedEvent` here — `start_drag` only mutates the
    // `DragState` resource on press, never card transforms, so an uncommitted
    // drag has no visual side effect to undo.
    //
    // Firing one would race a CardAnim that's already in flight on the same
    // card. Specifically: on a successful double-click, `handle_double_click`
    // fires `MoveRequestEvent`, `start_drag` picks the card up the same
    // frame (uncommitted), and `handle_move` queues a `StateChangedEvent` →
    // `sync_cards_on_change` starts a slide animation. When the player
    // releases the button mid-slide, `end_drag` would fire a second
    // `StateChangedEvent`, `sync_cards_on_change` would see the card mid-
    // animation (`cur != target`), and replace the in-flight CardAnim with
    // a fresh one — restarting the slide and reading on screen as the move
    // animation playing twice.
    if !drag.committed {
        drag.clear();
        return;
    }
    let Some(layout) = layout else {
        return;
    };
    let Some(origin) = drag.origin_pile else {
        drag.clear();
        return;
    };
    let count = drag.cards.len();

    let world = cursor_world(&windows, &cameras);
    let target = world.and_then(|w| find_drop_target(w, &game.0, &layout.0, &origin));

    // Whether we fire a MoveRequestEvent or not, always trigger a resync so
    // the dragged cards snap back to their resting positions if the move is
    // rejected (or never fired). When the cursor was over a real pile but
    // the placement is illegal, fire MoveRejectedEvent so AudioPlugin can
    // play card_invalid.wav.
    let mut fired = false;
    if let Some(target) = target
        && target != origin
    {
        let ok = game.0.can_move_cards(&origin, &target, count);
        if ok {
            moves.write(MoveRequestEvent {
                from: origin,
                to: target,
                count,
            });
            fired = true;
        } else {
            rejected.write(MoveRejectedEvent {
                from: origin,
                to: target,
                count,
            });
            // Smoothly glide each dragged card from its drop-time
            // transform back to its resting slot in the origin pile.
            // The audio cue (card_invalid.wav, played by AudioPlugin
            // on MoveRejectedEvent) still gives the player clear
            // negative feedback; this just replaces the old shake
            // wiggle with a forgiving ease-out tween.
            //
            // `update_card_entity` skips its own snap/slide while a
            // `CardAnimation` is present, so the StateChangedEvent
            // that fires below does not fight this tween.
            let origin_cards = pile_cards(&game.0, &origin);
            if !origin_cards.is_empty() {
                for card in &drag.cards {
                    let Some(stack_index) =
                        origin_cards.iter().position(|(c, _)| c == card)
                    else {
                        continue;
                    };
                    let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
                    if let Some(entity) = card_index.get(card)
                        && let Ok((_, _, transform)) = card_entities.get(entity)
                    {
                        let drag_pos = transform.translation.truncate();
                        let drag_z = transform.translation.z;
                        let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
                        commands.entity(entity).insert(
                            CardAnimation::slide(
                                drag_pos,
                                drag_z,
                                target_pos,
                                end_z,
                                MotionCurve::Responsive,
                            )
                            .with_duration(MOTION_DRAG_REJECT_SECS),
                        );
                    }
                }
            }
        }
    }

    drag.clear();

    // Either the move succeeded (GamePlugin will also fire StateChangedEvent)
    // or it didn't — in both cases we emit one so cards resync to the current
    // game state. Duplicate events are harmless.
    changed.write(StateChangedEvent);
    let _ = fired;
}

// ---------------------------------------------------------------------------
// Touch drag pipeline
// ---------------------------------------------------------------------------

/// Begins a touch drag when a finger first touches a face-up card.
///
/// Mirrors [`start_drag`] but uses [`TouchInput`] events instead of mouse
/// buttons. Records the touch ID in [`DragState`] so only this finger drives
/// the drag — other fingers are ignored.
fn touch_start_drag(
    mut touch_events: MessageReader<TouchInput>,
    paused: Option<Res<PausedResource>>,
    auto_complete: Option<Res<AutoCompleteState>>,
    cameras: Query<(&Camera, &GlobalTransform)>,
    layout: Option<Res<LayoutResource>>,
    game: Res<GameStateResource>,
    mut drag: ResMut<DragState>,
) {
    if paused.is_some_and(|p| p.0) {
        return;
    }
    if auto_complete.is_some_and(|ac| ac.active) {
        return;
    }
    // Only one drag at a time.
    if !drag.is_idle() {
        return;
    }
    let Some(layout) = layout else { return };

    for event in touch_events.read() {
        if event.phase != TouchPhase::Started {
            continue;
        }
        let Some(world) = touch_to_world(&cameras, event.position) else {
            continue;
        };
        let Some((pile, stack_index, card_ids)) = find_draggable_at(world, &game.0, &layout.0)
        else {
            continue;
        };

        let bottom_pos = card_position(&game.0, &layout.0, &pile, stack_index);

        drag.cards = card_ids;
        drag.origin_pile = Some(pile);
        drag.cursor_offset = bottom_pos - world;
        drag.origin_z = DRAG_Z;
        drag.press_pos = event.position; // screen-space for threshold comparison
        drag.committed = false;
        drag.active_touch_id = Some(event.id);
        // Process only the first touch that landed on a card.
        break;
    }
}

/// Moves touch-dragged cards with the active finger each frame.
///
/// Checks the drag threshold on the first frames after the touch began and
/// commits (elevates cards) once exceeded. Does nothing for mouse drags.
#[allow(clippy::too_many_arguments)]
fn touch_follow_drag(
    touches: Option<Res<Touches>>,
    cameras: Query<(&Camera, &GlobalTransform)>,
    mut drag: ResMut<DragState>,
    layout: Option<Res<LayoutResource>>,
    tuning: Res<AnimationTuning>,
    mut card_transforms: Query<(&CardEntity, &mut Transform, &mut Sprite)>,
    card_index: Res<CardEntityIndex>,
) {
    let Some(active_id) = drag.active_touch_id else {
        return; // Mouse drag or idle.
    };
    let Some(touches) = touches else { return };
    let Some(layout) = layout else { return };

    // Look up the driving touch.
    let Some(touch) = touches.iter().find(|t| t.id() == active_id) else {
        // Touch no longer active — will be cleaned up by touch_end_drag.
        return;
    };

    let Some(world) = touch_to_world(&cameras, touch.position()) else {
        return;
    };

    if !drag.committed {
        // Compare screen-space distance from the original press position.
        let moved = touch.position().distance(drag.press_pos);
        if moved < tuning.drag_threshold_px {
            return;
        }

        drag.committed = true;

        for (i, card) in drag.cards.iter().enumerate() {
            if let Some(entity) = card_index.get(card)
                && let Ok((_, mut transform, mut sprite)) = card_transforms.get_mut(entity)
            {
                transform.translation.z = dragged_card_z(i);
                sprite.color.set_alpha(0.85);
            }
        }
    }

    let bottom_pos = world + drag.cursor_offset;
    let fan = -layout.0.card_size.y * layout.0.tableau_fan_frac;

    for (i, card) in drag.cards.iter().enumerate() {
        if let Some(entity) = card_index.get(card)
            && let Ok((_, mut transform, _)) = card_transforms.get_mut(entity)
        {
            transform.translation.x = bottom_pos.x;
            transform.translation.y = bottom_pos.y + fan * i as f32;
        }
    }
}

/// Resolves a touch drag when the finger lifts or is cancelled.
///
/// Mirrors [`end_drag`] but reads [`TouchInput`] events instead of mouse
/// buttons. Uncommitted drags (tap gestures) are cancelled cleanly.
#[allow(clippy::too_many_arguments)]
fn touch_end_drag(
    mut touch_events: MessageReader<TouchInput>,
    paused: Option<Res<PausedResource>>,
    cameras: Query<(&Camera, &GlobalTransform)>,
    layout: Option<Res<LayoutResource>>,
    game: Res<GameStateResource>,
    mut drag: ResMut<DragState>,
    mut moves: MessageWriter<MoveRequestEvent>,
    mut rejected: MessageWriter<MoveRejectedEvent>,
    mut changed: MessageWriter<StateChangedEvent>,
    mut commands: Commands,
    card_entities: Query<(Entity, &CardEntity, &Transform)>,
    card_index: Res<CardEntityIndex>,
) {
    let Some(active_id) = drag.active_touch_id else {
        return; // Mouse drag or idle.
    };

    if paused.is_some_and(|p| p.0) {
        drag.clear();
        return;
    }

    for event in touch_events.read() {
        if event.id != active_id {
            continue;
        }
        if !matches!(event.phase, TouchPhase::Ended | TouchPhase::Canceled) {
            continue;
        }

        // Uncommitted tap — cancel cleanly. No StateChangedEvent: nothing
        // changed. The mouse path (end_drag) follows the same convention.
        if !drag.committed {
            drag.clear();
            return;
        }

        let Some(origin) = drag.origin_pile else {
            drag.clear();
            return;
        };
        let count = drag.cards.len();

        // Find the drop target using the finger's lift position.
        let world = touch_to_world(&cameras, event.position);
        let Some(layout) = layout.as_ref() else {
            drag.clear();
            changed.write(StateChangedEvent);
            return;
        };
        let target = world.and_then(|w| find_drop_target(w, &game.0, &layout.0, &origin));

        let mut fired = false;
        if let Some(target) = target
            && target != origin
        {
            let ok = game.0.can_move_cards(&origin, &target, count);
            if ok {
                moves.write(MoveRequestEvent {
                    from: origin,
                    to: target,
                    count,
                });
                fired = true;
            } else {
                rejected.write(MoveRejectedEvent {
                    from: origin,
                    to: target,
                    count,
                });
                // Smoothly glide each dragged card from its drop-time
                // transform back to its resting slot. See `end_drag`
                // (mouse path) for the full rationale; the touch path
                // mirrors it exactly so finger and mouse rejection
                // feel identical.
                let origin_cards = pile_cards(&game.0, &origin);
                if !origin_cards.is_empty() {
                    for card in &drag.cards {
                        let Some(stack_index) =
                            origin_cards.iter().position(|(c, _)| c == card)
                        else {
                            continue;
                        };
                        let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
                        if let Some(entity) = card_index.get(card)
                            && let Ok((_, _, transform)) = card_entities.get(entity)
                        {
                            let drag_pos = transform.translation.truncate();
                            let drag_z = transform.translation.z;
                            let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
                            commands.entity(entity).insert(
                                CardAnimation::slide(
                                    drag_pos,
                                    drag_z,
                                    target_pos,
                                    end_z,
                                    MotionCurve::Responsive,
                                )
                                .with_duration(MOTION_DRAG_REJECT_SECS),
                            );
                        }
                    }
                }
            }
        }

        drag.clear();
        changed.write(StateChangedEvent);
        let _ = fired;
        return;
    }
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Converts the mouse cursor position to world-space 2-D coordinates.
///
/// **Invariant:** assumes a single un-zoomed 2-D camera whose viewport exactly
/// covers the primary window (centre at world origin, 1 logical pixel = 1 world
/// unit). Hit-testing in `find_draggable_at` / `find_drop_target` relies on
/// this 1:1 mapping. Do not add camera zoom or offset this without auditing
/// every call site of `cursor_world` and `touch_to_world`.
fn cursor_world(
    windows: &Query<&Window, With<PrimaryWindow>>,
    cameras: &Query<(&Camera, &GlobalTransform)>,
) -> Option<Vec2> {
    let window = windows.single().ok()?;
    let cursor = window.cursor_position()?;
    let (camera, camera_transform) = cameras.single().ok()?;
    camera.viewport_to_world_2d(camera_transform, cursor).ok()
}

/// Converts a touch screen position (logical pixels, top-left origin) to
/// world-space 2-D coordinates using the primary camera.
///
/// Shares the same 1:1 viewport invariant as [`cursor_world`] — see that
/// function's doc for the constraints.
///
/// Returns `None` if no camera is present or the projection fails.
fn touch_to_world(cameras: &Query<(&Camera, &GlobalTransform)>, screen_pos: Vec2) -> Option<Vec2> {
    let (camera, camera_transform) = cameras.single().ok()?;
    camera
        .viewport_to_world_2d(camera_transform, screen_pos)
        .ok()
}

/// Axis-aligned rectangle hit-test with a center and full size.
fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
    let half = size / 2.0;
    point.x >= center.x - half.x
        && point.x <= center.x + half.x
        && point.y >= center.y - half.y
        && point.y <= center.y + half.y
}

/// Where a card at `stack_index` in pile `pile` would be rendered.
///
/// For tableau columns the per-card fan step depends on the face-up state of
/// every preceding card — face-down cards step by `layout.tableau_facedown_fan_frac`,
/// face-up cards by `layout.tableau_fan_frac`. Mirrors `card_plugin::card_positions`
/// exactly; any drift creates an offset between the visible card face and
/// where clicks land.
fn card_position(
    game: &GameState,
    layout: &Layout,
    pile: &KlondikePile,
    stack_index: usize,
) -> Vec2 {
    let base = layout.pile_positions[pile];
    if matches!(pile, KlondikePile::Tableau(_)) {
        let mut y_offset = 0.0_f32;
        for (_, face_up) in pile_cards(game, pile).iter().take(stack_index) {
            let step = if *face_up {
                layout.tableau_fan_frac
            } else {
                layout.tableau_facedown_fan_frac
            };
            y_offset -= layout.card_size.y * step;
        }
        Vec2::new(base.x, base.y + y_offset)
    } else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
        // In Draw-Three mode the top 3 waste cards are fanned in X to match
        // card_plugin::card_positions(). Hit-testing uses the same `waste_fan_step`
        // so clicking the visually rightmost (top) card actually registers — a
        // fixed `card_size.x * 0.28` matched the renderer on desktop but drifted
        // on Android (tighter column spacing), shifting the top card's hit target
        // onto the card beneath it.
        let pile_len = game.waste_cards().len();
        let visible_start = pile_len.saturating_sub(3);
        let slot = stack_index.saturating_sub(visible_start) as f32;
        Vec2::new(base.x + slot * waste_fan_step(layout), base.y)
    } else {
        base
    }
}

/// Given a world-space cursor, find the topmost draggable card. Returns
/// `(pile, bottom_stack_index, card_ids_bottom_to_top)`.
fn find_draggable_at(
    cursor: Vec2,
    game: &GameState,
    layout: &Layout,
) -> Option<(KlondikePile, usize, Vec<Card>)> {
    // Search order: waste, foundations, tableau. Stock is skipped (click-to-draw).
    // Within a pile, we consider cards top-down because the visual top card is drawn last.
    let piles = [
        KlondikePile::Stock,
        KlondikePile::Foundation(Foundation::Foundation1),
        KlondikePile::Foundation(Foundation::Foundation2),
        KlondikePile::Foundation(Foundation::Foundation3),
        KlondikePile::Foundation(Foundation::Foundation4),
        KlondikePile::Tableau(Tableau::Tableau1),
        KlondikePile::Tableau(Tableau::Tableau2),
        KlondikePile::Tableau(Tableau::Tableau3),
        KlondikePile::Tableau(Tableau::Tableau4),
        KlondikePile::Tableau(Tableau::Tableau5),
        KlondikePile::Tableau(Tableau::Tableau6),
        KlondikePile::Tableau(Tableau::Tableau7),
    ];

    for pile in piles {
        let pile_cards = pile_cards(game, &pile);
        if pile_cards.is_empty() {
            continue;
        }

        let is_tableau = matches!(pile, KlondikePile::Tableau(_));

        // Iterate from topmost to bottommost so the first hit is the one
        // visually on top.
        for i in (0..pile_cards.len()).rev() {
            let (_, face_up) = pile_cards[i];
            if !face_up {
                continue;
            }
            let pos = card_position(game, layout, &pile, i);
            if !point_in_rect(cursor, pos, layout.card_size) {
                continue;
            }

            // Picked a face-up card. Determine drag range:
            //   - Tableau: cards [i..len), must all be face-up (guaranteed
            //     because tableau never has face-down above face-up).
            //   - Waste / Foundation: only the top card is draggable.
            let (start, end) = if is_tableau {
                (i, pile_cards.len())
            } else {
                if i != pile_cards.len() - 1 {
                    // Non-top card on a non-tableau pile — not draggable; skip
                    // this pile and continue searching remaining piles.
                    break;
                }
                (i, i + 1)
            };
            let cards: Vec<Card> = pile_cards[start..end].iter().map(|(c, _)| c.clone()).collect();
            return Some((pile, start, cards));
        }
    }
    None
}

/// Pick the drop-target pile whose extended rectangle contains `cursor`.
/// Returns `None` if the cursor is outside every pile's rectangle.
fn find_drop_target(
    cursor: Vec2,
    game: &GameState,
    layout: &Layout,
    origin: &KlondikePile,
) -> Option<KlondikePile> {
    let piles = [
        KlondikePile::Foundation(Foundation::Foundation1),
        KlondikePile::Foundation(Foundation::Foundation2),
        KlondikePile::Foundation(Foundation::Foundation3),
        KlondikePile::Foundation(Foundation::Foundation4),
        KlondikePile::Tableau(Tableau::Tableau1),
        KlondikePile::Tableau(Tableau::Tableau2),
        KlondikePile::Tableau(Tableau::Tableau3),
        KlondikePile::Tableau(Tableau::Tableau4),
        KlondikePile::Tableau(Tableau::Tableau5),
        KlondikePile::Tableau(Tableau::Tableau6),
        KlondikePile::Tableau(Tableau::Tableau7),
    ];

    for pile in piles {
        let (center, size) = pile_drop_rect(&pile, layout, game);
        if point_in_rect(cursor, center, size) {
            // Skip origin — dropping onto the source is a no-op.
            if pile == *origin {
                continue;
            }
            return Some(pile);
        }
    }
    None
}

/// Bounding rect used for drop detection. For tableaus this extends
/// downward to cover the entire visible fan of cards.
fn pile_drop_rect(pile: &KlondikePile, layout: &Layout, game: &GameState) -> (Vec2, Vec2) {
    let center = layout.pile_positions[pile];
    if matches!(pile, KlondikePile::Tableau(_)) {
        let card_count = pile_cards(game, pile).len();
        if card_count > 1 {
            let fan = -layout.card_size.y * layout.tableau_fan_frac;
            let bottom_card_center_y = center.y + fan * (card_count - 1) as f32;
            let top_edge = center.y + layout.card_size.y / 2.0;
            let bottom_edge = bottom_card_center_y - layout.card_size.y / 2.0;
            let span_height = top_edge - bottom_edge;
            let new_center_y = (top_edge + bottom_edge) / 2.0;
            return (
                Vec2::new(center.x, new_center_y),
                Vec2::new(layout.card_size.x, span_height),
            );
        }
    }
    (center, layout.card_size)
}

// ---------------------------------------------------------------------------
// Task #27 — Double-click / double-tap to auto-move
// ---------------------------------------------------------------------------

/// Maximum seconds between two clicks to count as a double-click.
const DOUBLE_CLICK_WINDOW: f32 = 0.35;

/// Duration of the lime flash applied to moved cards when a tap
/// auto-move succeeds. Short enough not to linger, long enough to register
/// during the card animation (~0.3 s).
const DOUBLE_TAP_FLASH_SECS: f32 = 0.35;

/// Find the best legal destination for `card` — Foundation first, then Tableau.
///
/// Returns `None` if no legal move exists from the card's current location.
pub fn best_destination(card: &Card, game: &GameState) -> Option<KlondikePile> {
    let source = game.pile_containing_card(card.clone())?;

    for foundation in FOUNDATIONS {
        let dest = KlondikePile::Foundation(foundation);
        if game.can_move_cards(&source, &dest, 1) {
            return Some(dest);
        }
    }
    for tableau in TABLEAUS {
        let dest = KlondikePile::Tableau(tableau);
        if game.can_move_cards(&source, &dest, 1) {
            return Some(dest);
        }
    }
    None
}

/// Find the best tableau column onto which the stack rooted at `bottom_card`
/// can be legally placed, excluding the stack's own source pile.
///
/// Returns `(destination, stack_count)` if a legal target exists, or `None`
/// if the stack cannot move anywhere. Only tableau destinations are considered
/// because multi-card stacks cannot go to foundations.
pub fn best_tableau_destination_for_stack(
    _bottom_card: &Card,
    from: &KlondikePile,
    game: &GameState,
    stack_count: usize,
) -> Option<(KlondikePile, usize)> {
    for tableau in TABLEAUS {
        let dest = KlondikePile::Tableau(tableau);
        if game.can_move_cards(from, &dest, stack_count) {
            return Some((dest, stack_count));
        }
    }
    None
}

/// System that detects double-clicks on face-up cards and fires `MoveRequestEvent`
/// to the best legal destination.
///
/// Move priority:
/// 1. Move the single **top** card to its best foundation (or tableau) destination.
/// 2. If no single-card move exists and the clicked card is the base of a
///    multi-card face-up stack, move the whole stack to the best tableau column.
///
/// When a multi-card stack double-click finds no legal destination (Priority 2
/// returns `None`), fires `MoveRejectedEvent` with `from == to == pile` so the
/// invalid-move sound plays and the source pile cards shake as feedback.
#[allow(clippy::too_many_arguments)]
fn handle_double_click(
    buttons: Res<ButtonInput<MouseButton>>,
    paused: Option<Res<PausedResource>>,
    time: Res<Time>,
    drag: Res<DragState>,
    windows: Query<&Window, With<PrimaryWindow>>,
    cameras: Query<(&Camera, &GlobalTransform)>,
    layout: Option<Res<LayoutResource>>,
    game: Res<GameStateResource>,
    mut last_click: Local<HashMap<Card, f32>>,
    mut moves: MessageWriter<MoveRequestEvent>,
    mut rejected: MessageWriter<MoveRejectedEvent>,
) {
    if paused.is_some_and(|p| p.0) {
        return;
    }
    if !buttons.just_pressed(MouseButton::Left) || !drag.is_idle() {
        return;
    }
    let Some(layout) = layout else { return };
    let Some(world) = cursor_world(&windows, &cameras) else {
        return;
    };

    // Identify which card (or stack base) was clicked (must be face-up and draggable).
    let Some((pile, stack_index, card_ids)) = find_draggable_at(world, &game.0, &layout.0) else {
        return;
    };

    // The topmost card in the draggable run — used as the double-click key.
    let Some(top_card) = card_ids.last() else {
        return;
    };
    let top_index = stack_index + card_ids.len() - 1;
    let pile_cards = pile_cards(&game.0, &pile);
    let Some((pile_top_card, pile_top_face_up)) = pile_cards.get(top_index) else {
        return;
    };
    if !*pile_top_face_up || pile_top_card != top_card {
        return;
    }

    let now = time.elapsed_secs();
    let prev = last_click
        .get(top_card)
        .copied()
        .unwrap_or(f32::NEG_INFINITY);

    if now - prev <= DOUBLE_CLICK_WINDOW {
        // Double-click confirmed.
        last_click.remove(top_card);

        // Priority 1: move the single top card (foundation preferred, then tableau).
        if let Some(dest) = best_destination(top_card, &game.0) {
            moves.write(MoveRequestEvent {
                from: pile,
                to: dest,
                count: 1,
            });
            return;
        }

        // Priority 2: if the player clicked the base of a multi-card face-up
        // stack (card_ids.len() > 1), try moving the whole stack to another
        // tableau column.
        if card_ids.len() > 1
            && let Some((bottom_card, _)) = pile_cards.get(stack_index)
            && let Some((dest, count)) =
                best_tableau_destination_for_stack(bottom_card, &pile, &game.0, card_ids.len())
        {
            moves.write(MoveRequestEvent {
                from: pile,
                to: dest,
                count,
            });
            return;
        }

        // Both priorities failed — play the invalid-move sound and shake
        // the source pile as feedback. `MoveRejectedEvent` with
        // `from == to` routes the shake to the source pile (which
        // `start_shake_anim` reads from `ev.to`). Pre-fix, this branch
        // only fired for multi-card stacks, so a double-click on a
        // single card with no legal destination did nothing — no
        // sound, no shake. Now both single-card and stack misses get
        // the same feedback.
        rejected.write(MoveRejectedEvent {
            from: pile,
            to: pile,
            count: card_ids.len(),
        });
    } else {
        // Single click — record the time.
        last_click.insert(top_card.clone(), now);
    }
}

// ---------------------------------------------------------------------------
// Tap-to-move (touch equivalent of mouse auto-move)
// ---------------------------------------------------------------------------

/// Fires `MoveRequestEvent` when the player taps a face-up card without
/// dragging — the touch equivalent of the mouse auto-move flow.
///
/// Must run **before** `touch_end_drag` in the system chain. At
/// `TouchPhase::Ended` the drag state still holds `active_touch_id`,
/// `cards`, and `origin_pile`; once `touch_end_drag` fires those fields
/// are cleared and the tap/drag distinction is permanently lost.
///
/// Move priority:
/// 1. Single top card to its best foundation (or tableau).
/// 2. Whole face-up run to best tableau column when no single-card move exists.
/// 3. `MoveRejectedEvent` for audio + shake feedback when no legal move found.
#[allow(clippy::too_many_arguments)]
fn handle_double_tap(
    mut touch_events: MessageReader<TouchInput>,
    paused: Option<Res<PausedResource>>,
    radial: Option<Res<RightClickRadialState>>,
    auto_complete: Option<Res<AutoCompleteState>>,
    drag: Res<DragState>,
    game: Res<GameStateResource>,
    settings: Option<Res<SettingsResource>>,
    mut touch_selection: Option<ResMut<TouchSelectionState>>,
    mut moves: MessageWriter<MoveRequestEvent>,
    mut rejected: MessageWriter<MoveRejectedEvent>,
    mut toast: MessageWriter<InfoToastEvent>,
    mut commands: Commands,
    mut card_sprites: Query<(Entity, &CardEntity, &mut Sprite)>,
) {
    use solitaire_data::settings::TouchInputMode;

    if paused.is_some_and(|p| p.0) {
        return;
    }
    // Long-press opened the radial — let radial_handle_release_or_cancel own
    // the finger-lift event.
    if radial.is_some_and(|r| r.is_active()) {
        return;
    }
    // Auto-complete owns all moves during its sequence.
    if auto_complete.is_some_and(|ac| ac.active) {
        return;
    }

    let Some(active_id) = drag.active_touch_id else {
        return;
    };
    if drag.committed {
        return;
    }

    let tap_to_select = settings
        .as_ref()
        .is_some_and(|s| s.0.touch_input_mode == TouchInputMode::TapToSelect);

    for event in touch_events.read() {
        if event.id != active_id || event.phase != TouchPhase::Ended {
            continue;
        }

        // Uncommitted touch ended = pure tap.
        let Some(top_card) = drag.cards.last() else {
            return;
        };
        let Some(ref tapped_pile) = drag.origin_pile else {
            return;
        };
        let pile_cards = pile_cards(&game.0, tapped_pile);
        if pile_cards.is_empty() {
            return;
        }

        let Some((found_card, found_face_up)) =
            pile_cards.iter().find(|(c, _)| c == top_card)
        else {
            return;
        };
        if !*found_face_up {
            return;
        }

        // --- Tap-to-select mode ---
        if tap_to_select {
            if let Some(ref mut sel) = touch_selection {
                if let Some((ref source_pile, ref source_cards)) = sel.selected.clone() {
                    // Second tap: this is the destination.
                    if tapped_pile == source_pile {
                        // Re-tap on selected source → cancel.
                        sel.clear();
                        return;
                    }
                    // Attempt the move. MoveRequestEvent carries validation;
                    // a rejection will fire MoveRejectedEvent automatically.
                    moves.write(MoveRequestEvent {
                        from: *source_pile,
                        to: *tapped_pile,
                        count: source_cards.len(),
                    });
                    sel.clear();
                    return;
                }
                // First tap: select the source, then nudge the player.
                sel.set(*tapped_pile, drag.cards.clone());
                toast.write(InfoToastEvent("Tap a pile to move".into()));
            }
            return;
        }

        // --- One-tap auto-move (original behaviour) ---

        // Priority 1: move single top card.
        if let Some(dest) = best_destination(found_card, &game.0) {
            for (entity, ce, mut sprite) in card_sprites.iter_mut() {
                if ce.card == *top_card {
                    sprite.color = STATE_SUCCESS;
                    commands.entity(entity).insert(HintHighlight {
                        remaining: DOUBLE_TAP_FLASH_SECS,
                    });
                    break;
                }
            }
            moves.write(MoveRequestEvent {
                from: *tapped_pile,
                to: dest,
                count: 1,
            });
            return;
        }

        // Priority 2: move whole face-up stack to best tableau column.
        if drag.cards.len() > 1 {
            let stack_index = pile_cards.len() - drag.cards.len();
            if let Some((bottom_card, _)) = pile_cards.get(stack_index)
                && let Some((dest, count)) = best_tableau_destination_for_stack(
                    bottom_card,
                    tapped_pile,
                    &game.0,
                    drag.cards.len(),
                )
            {
                for (entity, ce, mut sprite) in card_sprites.iter_mut() {
                    if drag.cards.contains(&ce.card) {
                        sprite.color = STATE_SUCCESS;
                        commands.entity(entity).insert(HintHighlight {
                            remaining: DOUBLE_TAP_FLASH_SECS,
                        });
                    }
                }
                moves.write(MoveRequestEvent {
                    from: *tapped_pile,
                    to: dest,
                    count,
                });
                return;
            }
        }

        rejected.write(MoveRejectedEvent {
            from: *tapped_pile,
            to: *tapped_pile,
            count: drag.cards.len(),
        });
    }
}

// ---------------------------------------------------------------------------
// Task #28 — Hint system helpers
// ---------------------------------------------------------------------------

/// Build the complete list of legal moves available in `game`, ordered so that
/// upstream `klondike` priorities are preserved.
///
/// Each entry is `(from, to)` — the source and destination piles a hint
/// should highlight. Only single-card moves are surfaced; multi-card tableau
/// runs are filtered out by [`hint_piles`]. The list may be empty when no
/// move exists at all (game is stuck).
///
/// This is the backing data for the cycling hint system: the H key steps
/// through `hints[HintCycleIndex % hints.len()]` on each press.
pub fn all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
    if game.has_test_pile_overrides() {
        return legacy_all_hints(game);
    }

    game.possible_instructions()
        .into_iter()
        .filter_map(|instruction| hint_piles(game, instruction))
        .collect()
}

/// Project a [`KlondikeInstruction`] to the `(source, destination)` piles a
/// hint should highlight, or `None` for a no-op or multi-card move.
///
/// Delegates the instruction→pile decode to the single owner of that mapping,
/// [`GameState::instruction_to_piles`], and keeps only single-card moves
/// (`count == 1`) — the hint highlight can represent exactly one source card.
pub(crate) fn hint_piles(
    game: &GameState,
    instruction: KlondikeInstruction,
) -> Option<(KlondikePile, KlondikePile)> {
    match game.instruction_to_piles(instruction)? {
        (from, to, 1) => Some((from, to)),
        _ => None,
    }
}

/// Legacy hint enumeration used only when test pile overrides are active.
///
/// `possible_instructions()` reflects the internal upstream `Session` state.
/// In test fixtures that inject synthetic piles via `set_test_*`, these
/// synthetic piles can diverge from the session state; this fallback preserves
/// deterministic test semantics in those fixtures.
fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
    let sources: Vec<KlondikePile> = {
        let mut s = vec![KlondikePile::Stock];
        for tableau in TABLEAUS {
            s.push(KlondikePile::Tableau(tableau));
        }
        s
    };

    let mut hints: Vec<(KlondikePile, KlondikePile)> = Vec::new();

    // Pass 1 — foundation moves (highest priority, shown first).
    for from in &sources {
        let from_pile = pile_cards(game, from);
        let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else {
            continue;
        };
        for foundation in FOUNDATIONS {
            let dest = KlondikePile::Foundation(foundation);
            if game.can_move_cards(from, &dest, 1) {
                hints.push((*from, dest));
                break;
            }
        }
    }

    // Pass 2 — tableau moves (deduplicated by source pile so we don't
    // repeat the same source card multiple times for different destinations).
    for from in &sources {
        let from_pile = pile_cards(game, from);
        let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else {
            continue;
        };
        let already_has_foundation_hint = hints
            .iter()
            .any(|(f, t)| f == from && matches!(t, KlondikePile::Foundation(_)));
        if already_has_foundation_hint {
            continue;
        }
        for tableau in TABLEAUS {
            let dest = KlondikePile::Tableau(tableau);
            if game.can_move_cards(from, &dest, 1) {
                hints.push((*from, dest));
                break;
            }
        }
    }

    // Pass 2b — Foundation → Tableau moves (only when the rule allows it).
    // Foundation piles are excluded from Pass 1 & 2's source list because they
    // should never hint Foundation→Foundation. Here we handle the return path
    // separately so the guarded `take_from_foundation` rule is respected.
    if game.take_from_foundation {
        for foundation in FOUNDATIONS {
            let from = KlondikePile::Foundation(foundation);
            let from_pile = pile_cards(game, &from);
            let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else {
                continue;
            };
            for tableau in TABLEAUS {
                let dest = KlondikePile::Tableau(tableau);
                if game.can_move_cards(&from, &dest, 1) {
                    hints.push((from, dest));
                    break;
                }
            }
        }
    }

    // Pass 3 — suggest drawing from the stock when no other hint was found.
    if hints.is_empty() {
        let stock_cards = game.stock_cards();
        let waste_cards = game.waste_cards();
        let stock_non_empty = !stock_cards.is_empty();
        let waste_can_recycle = stock_cards.is_empty() && !waste_cards.is_empty();
        if stock_non_empty || waste_can_recycle {
            // Stock→Waste is not a real pile-to-pile move, but we reuse the
            // pair to signal "draw". The H handler only reads `from` to
            // locate the card to highlight; we point at the stock pile.
            hints.push((KlondikePile::Stock, KlondikePile::Stock));
        }
    }

    hints
}

fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
    match pile {
        KlondikePile::Stock => game.waste_cards(),
        _ => game.pile(*pile),
    }
}



const fn tableau_number(tableau: Tableau) -> u8 {
    match tableau {
        Tableau::Tableau1 => 1,
        Tableau::Tableau2 => 2,
        Tableau::Tableau3 => 3,
        Tableau::Tableau4 => 4,
        Tableau::Tableau5 => 5,
        Tableau::Tableau6 => 6,
        Tableau::Tableau7 => 7,
    }
}

/// Find one valid move in the current game state.
///
/// Returns `(from, to)` for the first legal move found, or `None` if
/// no move is available. This is a convenience wrapper over [`all_hints`].
pub fn find_hint(game: &GameState) -> Option<(KlondikePile, KlondikePile)> {
    all_hints(game).into_iter().next()
}

// `Vec3` is referenced only via the `DRAG_Z` constant; keep the import silenced
// when the compiler can't see it used.
#[allow(dead_code)]
const _VEC3_REFERENCED: Option<Vec3> = None;

#[cfg(test)]
mod tests;
