//! Keyboard-driven card selection and full keyboard drag-and-drop.
//!
//! ## Two-mode flow
//!
//! Selection works as a small state machine across two resources:
//!
//! 1. [`SelectionState`] tracks the *source-pick* mode. `Tab` / `Shift+Tab`
//!    cycles a focus through piles that have a face-up draggable top card.
//!    The focused card is decorated with an accent-coloured [`SelectionHighlight`].
//!
//! 2. [`KeyboardDragState`] tracks the *destination-pick* mode. Pressing
//!    `Enter` while a pile is focused enters
//!    [`KeyboardDragState::Lifted`] — the cards are visually "lifted" by
//!    populating [`crate::resources::DragState`] (cards / origin_pile /
//!    cursor_offset / origin_z / `active_touch_id = Some(KEYBOARD_DRAG_TOUCH_ID)`
//!    sentinel so mouse handlers ignore the keyboard-driven drag), and the
//!    arrow keys (or `Tab` / `Shift+Tab`) cycle through *legal* destination
//!    piles only. A second `Enter` confirms the move; `Esc` cancels back to
//!    source-pick mode.
//!
//! ## Mutual exclusion with mouse drag
//!
//! While a mouse drag is in progress (`DragState` non-empty *and* not the
//! keyboard sentinel) all keyboard input is ignored. Conversely, while the
//! keyboard drag is active, mouse handlers in `input_plugin` short-circuit
//! because they check `DragState.is_idle()` before starting a new drag and
//! the mouse-up / drag-update systems explicitly skip `DragState` entries
//! whose `active_touch_id.is_some()`.
//!
//! ## Why a separate resource
//!
//! Keeping the lift state out of `SelectionState` lets `Esc` cancel the
//! lift without losing the source focus — a single Esc reverts to
//! source-pick, a second Esc clears the source focus. It also lets HUD
//! widgets that already read `SelectionState::selected_pile` keep working
//! unchanged whether the player is in source-pick or destination-pick mode.

use bevy::input::ButtonInput;
use bevy::prelude::*;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::Card;
use solitaire_core::game_state::GameState;

use crate::card_plugin::CardEntityIndex;
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
use crate::game_plugin::GameMutation;
use crate::input_plugin::{best_destination, best_tableau_destination_for_stack};
use crate::layout::LayoutResource;
use crate::pause_plugin::PausedResource;
use crate::resources::{DragState, GameStateResource};
use crate::ui_theme::{ACCENT_PRIMARY, STATE_SUCCESS, STATE_WARNING};

// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------

/// Tracks which pile currently has keyboard focus.
///
/// `None` means no pile is selected.
#[derive(Resource, Debug, Default)]
pub struct SelectionState {
    /// The pile whose top face-up card is currently selected, or `None`.
    pub selected_pile: Option<KlondikePile>,
}

/// Sentinel value used in [`crate::resources::DragState::active_touch_id`]
/// to mark a `DragState` populated by the keyboard-drag flow rather than a
/// real mouse or touch drag.
///
/// Mouse handlers in `input_plugin` already skip `DragState` entries whose
/// `active_touch_id.is_some()`, so this value provides clean mutual
/// exclusion without changing `DragState`'s shape.
pub const KEYBOARD_DRAG_TOUCH_ID: u64 = u64::MAX;

/// Two-state machine for the keyboard drag flow. `Idle` is the resting
/// state; while `Lifted`, the player is choosing a destination pile with
/// the arrow keys.
///
/// See the [module-level docs](self) for the full state machine.
#[derive(Resource, Debug, Default, Clone, PartialEq, Eq)]
pub enum KeyboardDragState {
    /// No keyboard drag in progress. `Tab` / `Enter` operate on
    /// [`SelectionState`].
    #[default]
    Idle,
    /// Source pile is lifted; arrow keys / `Tab` cycle through
    /// `legal_destinations` and `Enter` fires the move.
    Lifted {
        /// Pile the cards were lifted from.
        source_pile: KlondikePile,
        /// Number of cards lifted (1 for waste / foundation, full face-up
        /// run length for a tableau column).
        count: usize,
        /// Cards being lifted, in the same bottom-to-top order
        /// `DragState.cards` expects.
        cards: Vec<Card>,
        /// Pre-computed list of piles the lifted stack can legally be
        /// placed on. Always at least one entry while in this variant —
        /// if no legal destinations exist the state machine refuses to
        /// enter `Lifted` in the first place.
        legal_destinations: Vec<KlondikePile>,
        /// Cursor into `legal_destinations`. Always `< legal_destinations.len()`.
        destination_index: usize,
    },
}

impl KeyboardDragState {
    /// Returns the currently focused destination pile while [`Lifted`], or
    /// `None` while [`Idle`].
    ///
    /// [`Lifted`]: KeyboardDragState::Lifted
    /// [`Idle`]: KeyboardDragState::Idle
    pub fn focused_destination(&self) -> Option<&KlondikePile> {
        match self {
            Self::Idle => None,
            Self::Lifted {
                legal_destinations,
                destination_index,
                ..
            } => legal_destinations.get(*destination_index),
        }
    }

    /// Returns `true` when the keyboard drag is in the `Lifted` state.
    pub fn is_lifted(&self) -> bool {
        matches!(self, Self::Lifted { .. })
    }
}

/// System set label for the key-handling system.
///
/// `PausePlugin` registers `toggle_pause` before this set so it can read
/// [`SelectionState`] before `handle_selection_keys` clears it on Escape.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct SelectionKeySet;

/// Marker component placed on the outline sprite used as the keyboard-selection
/// highlight.
///
/// Exactly one entity with this marker should exist at any time. It is
/// despawned when the selection is cleared.
#[derive(Component, Debug)]
pub struct SelectionHighlight;

/// Registers the keyboard selection resources and systems.
pub struct SelectionPlugin;

impl Plugin for SelectionPlugin {
    fn build(&self, app: &mut App) {
        // `CardEntityIndex` is owned and kept current by `CardPlugin`; this
        // call is a no-op there. It is declared here so `update_selection_highlight`
        // can read it via `Res<>` even in harnesses that omit `CardPlugin`.
        app.init_resource::<SelectionState>()
            .init_resource::<KeyboardDragState>()
            .init_resource::<CardEntityIndex>()
            .add_systems(
                Update,
                (
                    handle_selection_keys
                        .in_set(SelectionKeySet)
                        .before(GameMutation),
                    clear_selection_on_state_change.after(GameMutation),
                    update_selection_highlight.after(GameMutation).run_if(
                        resource_changed::<SelectionState>
                            .or(resource_changed::<KeyboardDragState>)
                            .or(resource_changed::<GameStateResource>),
                    ),
                ),
            );
    }
}

// ---------------------------------------------------------------------------
// Pile cycle order
// ---------------------------------------------------------------------------

/// The ordered list of piles that are considered for keyboard cycling.
///
/// Order: Waste → Foundation slots 0–3 → Tableau 0–6.
fn cycled_piles() -> Vec<KlondikePile> {
    let mut piles = vec![KlondikePile::Stock];
    for foundation in [
        Foundation::Foundation1,
        Foundation::Foundation2,
        Foundation::Foundation3,
        Foundation::Foundation4,
    ] {
        piles.push(KlondikePile::Foundation(foundation));
    }
    for tableau in [
        Tableau::Tableau1,
        Tableau::Tableau2,
        Tableau::Tableau3,
        Tableau::Tableau4,
        Tableau::Tableau5,
        Tableau::Tableau6,
        Tableau::Tableau7,
    ] {
        piles.push(KlondikePile::Tableau(tableau));
    }
    piles
}

/// Given a list of *available* piles and the currently selected pile, return
/// the next pile in cycling order, wrapping around.
///
/// If `current` is `None` the first available pile is returned.
/// If `available` is empty, `None` is returned.
pub fn cycle_next_pile(
    available: &[KlondikePile],
    current: Option<&KlondikePile>,
) -> Option<KlondikePile> {
    if available.is_empty() {
        return None;
    }
    let order = cycled_piles();

    let Some(cur) = current else {
        // No current selection: return the first available pile in cycle order.
        return order.iter().find(|p| available.contains(p)).cloned();
    };

    // Find the position of `cur` inside the ordered list, then scan forward
    // for the next available pile (wrapping).
    let cur_pos = order.iter().position(|p| p == cur);
    let start = cur_pos.map_or(0, |pos| pos + 1);

    // Search from `start` forward, wrapping around, skipping `cur`.
    let n = order.len();
    for offset in 0..n {
        let candidate = &order[(start + offset) % n];
        if available.contains(candidate) {
            return Some(*candidate);
        }
    }
    None
}

/// Returns `true` when cycling from `current` to `next` wraps around the
/// available list — i.e., `next` appears at or before `current` in the global
/// cycle order defined by [`cycled_piles`].
///
/// Both `current` and `next` must be `Some`; if either is `None` this returns
/// `false`.
fn did_wrap(
    available: &[KlondikePile],
    current: Option<&KlondikePile>,
    next: Option<&KlondikePile>,
) -> bool {
    let (Some(cur), Some(nxt)) = (current, next) else {
        return false;
    };
    let order = cycled_piles();
    // Position of each pile within the *available* subset, ordered by the
    // global cycle order.
    let pos_in_available = |target: &KlondikePile| -> Option<usize> {
        order
            .iter()
            .filter(|p| available.contains(p))
            .position(|p| p == target)
    };
    match (pos_in_available(cur), pos_in_available(nxt)) {
        (Some(cur_pos), Some(nxt_pos)) => nxt_pos <= cur_pos,
        _ => false,
    }
}

// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------

/// Handles `Tab` / `Enter` / `Space` / arrow keys / `Escape` for keyboard
/// card selection and keyboard drag-and-drop.
///
/// Source-pick mode (`KeyboardDragState::Idle`):
/// - `Tab` / `Shift+Tab` cycles `SelectionState` through draggable piles.
/// - `Enter` lifts the focused pile into `KeyboardDragState::Lifted`.
/// - `Space` is the legacy auto-move accelerator (foundation-first, then
///   best tableau target). Preserved so power users keep their muscle
///   memory; the new lift-and-pick flow is what `Enter` does.
/// - `Esc` clears `SelectionState`.
///
/// Destination-pick mode (`KeyboardDragState::Lifted`):
/// - `ArrowRight` / `ArrowDown` / `Tab` advance to the next legal
///   destination, wrapping at the end.
/// - `ArrowLeft` / `ArrowUp` / `Shift+Tab` move to the previous legal
///   destination.
/// - `Enter` confirms — fires `MoveRequestEvent` and returns to `Idle`.
/// - `Esc` cancels — clears the `DragState` and returns to source-pick
///   mode with `SelectionState` intact.
#[allow(clippy::too_many_arguments)]
fn handle_selection_keys(
    keys: Res<ButtonInput<KeyCode>>,
    paused: Option<Res<PausedResource>>,
    game: Res<GameStateResource>,
    mut selection: ResMut<SelectionState>,
    mut kbd_drag: ResMut<KeyboardDragState>,
    mut drag: ResMut<DragState>,
    mut moves: MessageWriter<MoveRequestEvent>,
    mut info_toast: MessageWriter<InfoToastEvent>,
) {
    if paused.is_some_and(|p| p.0) {
        return;
    }

    // Mutual exclusion with mouse drag — if a real mouse / touch drag is
    // running, swallow keyboard input. The keyboard-driven lift uses the
    // sentinel `active_touch_id`, so only that case may proceed.
    if !drag.is_idle() && drag.active_touch_id != Some(KEYBOARD_DRAG_TOUCH_ID) {
        return;
    }

    // ---------------------------------------------------------------------
    // Lifted (destination-pick) mode.
    // ---------------------------------------------------------------------
    if let KeyboardDragState::Lifted {
        source_pile,
        count,
        cards: _,
        legal_destinations,
        destination_index,
    } = &mut *kbd_drag
    {
        let shift_held = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);

        // Cycle destinations forward / backward.
        let advance = keys.just_pressed(KeyCode::ArrowRight)
            || keys.just_pressed(KeyCode::ArrowDown)
            || (keys.just_pressed(KeyCode::Tab) && !shift_held);
        let retreat = keys.just_pressed(KeyCode::ArrowLeft)
            || keys.just_pressed(KeyCode::ArrowUp)
            || (keys.just_pressed(KeyCode::Tab) && shift_held);

        if advance {
            let n = legal_destinations.len();
            if n > 0 {
                *destination_index = (*destination_index + 1) % n;
            }
            return;
        }
        if retreat {
            let n = legal_destinations.len();
            if n > 0 {
                *destination_index = (*destination_index + n - 1) % n;
            }
            return;
        }

        // Confirm — fire MoveRequestEvent.
        if keys.just_pressed(KeyCode::Enter) {
            if let Some(dest) = legal_destinations.get(*destination_index).cloned() {
                moves.write(MoveRequestEvent {
                    from: *source_pile,
                    to: dest,
                    count: *count,
                });
            }
            // Whether or not we fired, leave Lifted: a subsequent
            // `StateChangedEvent` will also reset us via
            // `clear_selection_on_state_change`, but explicit reset is
            // cleaner and lets the state-change clear handle the
            // SelectionState side.
            *kbd_drag = KeyboardDragState::Idle;
            drag.clear();
            return;
        }

        // Cancel back to source-pick mode — keep SelectionState focused.
        if keys.just_pressed(KeyCode::Escape) {
            *kbd_drag = KeyboardDragState::Idle;
            drag.clear();
            return;
        }

        // No other keys do anything while lifted.
        return;
    }

    // ---------------------------------------------------------------------
    // Idle (source-pick) mode.
    // ---------------------------------------------------------------------

    // Build the list of piles that currently have a face-up draggable top card.
    let available: Vec<KlondikePile> = {
        let all = [
            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),
        ];
        all.into_iter()
            .filter(|p| pile_cards(&game.0, p).last().is_some_and(|c| c.1))
            .collect()
    };

    // Tab — cycle selection.
    if keys.just_pressed(KeyCode::Tab) {
        let next = cycle_next_pile(&available, selection.selected_pile.as_ref());
        if next.is_none() {
            info_toast.write(InfoToastEvent("No cards to select".to_string()));
        } else if selection.selected_pile.is_some()
            && did_wrap(&available, selection.selected_pile.as_ref(), next.as_ref())
        {
            info_toast.write(InfoToastEvent("Back to first card".to_string()));
        }
        selection.selected_pile = next;
        return;
    }

    // Escape — clear selection.
    if keys.just_pressed(KeyCode::Escape) {
        selection.selected_pile = None;
        return;
    }

    // Space — legacy auto-move accelerator. Foundation-first, then best
    // tableau stack target. Preserved so the muscle memory built around
    // `Tab` → `Space` keeps working; `Enter` is now the lift trigger.
    if keys.just_pressed(KeyCode::Space)
        && let Some(ref pile) = selection.selected_pile
    {
        let selected_cards = pile_cards(&game.0, pile);
        let Some((card, _)) = selected_cards.last().filter(|c| c.1) else {
            return;
        };
        // Priority 1: foundation move (single card).
        if let Some(dest) = try_foundation_dest(card, &game.0) {
            moves.write(MoveRequestEvent {
                from: *pile,
                to: dest,
                count: 1,
            });
            selection.selected_pile = None;
            return;
        }
        // Priority 2: tableau stack move.
        let run_len = face_up_run_len(&selected_cards);
        let bottom_card = selected_cards
            .get(selected_cards.len().saturating_sub(run_len))
            .map(|(c, _)| c.clone());
        if let Some(bottom) = bottom_card
            && let Some((dest, count)) =
                best_tableau_destination_for_stack(&bottom, pile, &game.0, run_len)
        {
            moves.write(MoveRequestEvent {
                from: *pile,
                to: dest,
                count,
            });
            selection.selected_pile = None;
            return;
        }
        // Fallback for non-tableau sources.
        if let Some(dest) = best_destination(card, &game.0) {
            moves.write(MoveRequestEvent {
                from: *pile,
                to: dest,
                count: 1,
            });
            selection.selected_pile = None;
        }
        return;
    }

    // Enter — lift the focused pile into destination-pick mode.
    if keys.just_pressed(KeyCode::Enter)
        && let Some(ref source) = selection.selected_pile
    {
        let source_cards = pile_cards(&game.0, source);
        if source_cards.is_empty() {
            return;
        }
        // Determine the lift range: tableau lifts the full face-up run, all
        // other sources lift only the top card.
        let run_len = face_up_run_len(&source_cards);
        let count = if matches!(source, KlondikePile::Tableau(_)) {
            run_len.max(1)
        } else {
            1
        };
        let start = source_cards.len().saturating_sub(count);
        let lifted_cards: Vec<Card> =
            source_cards[start..].iter().map(|(c, _)| c.clone()).collect();
        let Some((bottom, _)) = source_cards.get(start) else {
            return;
        };
        let legal = legal_destinations_for(bottom, source, &game.0, count);
        if legal.is_empty() {
            info_toast.write(InfoToastEvent("No legal moves for this card".to_string()));
            return;
        }

        // Populate `DragState` with the keyboard sentinel so the existing
        // mouse-drag systems treat this as "not their drag".
        drag.cards = lifted_cards.clone();
        drag.origin_pile = Some(*source);
        drag.cursor_offset = Vec2::ZERO;
        drag.origin_z = 1.0;
        drag.press_pos = Vec2::ZERO;
        drag.committed = false;
        drag.active_touch_id = Some(KEYBOARD_DRAG_TOUCH_ID);

        *kbd_drag = KeyboardDragState::Lifted {
            source_pile: *source,
            count,
            cards: lifted_cards,
            legal_destinations: legal,
            destination_index: 0,
        };
    }
}

// ---------------------------------------------------------------------------
// Legal-destination enumeration
// ---------------------------------------------------------------------------

/// Enumerate every pile that the lifted stack rooted at `bottom` can be
/// legally placed on, excluding the source pile itself.
///
/// Foundations are returned first (in slot order 0..4), then tableau
/// columns (in column order 0..7). Foundations only accept single-card
/// stacks, matching the existing rules engine.
///
/// The order is deliberate: the first entry is the most "obvious" target
/// (the lowest foundation or column number) which becomes the default
/// destination after a lift. Players who want a different column simply
/// press the right-arrow key once or twice.
pub(crate) fn legal_destinations_for(
    _bottom: &Card,
    source: &KlondikePile,
    game: &GameState,
    stack_count: usize,
) -> Vec<KlondikePile> {
    let mut out = Vec::new();
    if stack_count == 1 {
        for foundation in [
            Foundation::Foundation1,
            Foundation::Foundation2,
            Foundation::Foundation3,
            Foundation::Foundation4,
        ] {
            let dest = KlondikePile::Foundation(foundation);
            if game.can_move_cards(source, &dest, 1) {
                out.push(dest);
            }
        }
    }
    for tableau in [
        Tableau::Tableau1,
        Tableau::Tableau2,
        Tableau::Tableau3,
        Tableau::Tableau4,
        Tableau::Tableau5,
        Tableau::Tableau6,
        Tableau::Tableau7,
    ] {
        let dest = KlondikePile::Tableau(tableau);
        if game.can_move_cards(source, &dest, stack_count) {
            out.push(dest);
        }
    }
    out
}

// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------

/// Count the contiguous face-up cards at the top of `cards`.
///
/// Walks backwards from the last element and stops at the first face-down card
/// (or when the slice is exhausted). Returns at least `1` when the top card is
/// face-up; returns `0` for an empty slice or when the top card is face-down.
fn face_up_run_len(cards: &[(Card, bool)]) -> usize {
    let mut count = 0;
    for (_, face_up) in cards.iter().rev() {
        if *face_up {
            count += 1;
        } else {
            break;
        }
    }
    count
}

/// Find the best foundation destination for `card` — returns the first
/// foundation pile that legally accepts the card, or `None`.
///
/// This is intentionally separated from [`best_destination`] so the Enter
/// handler can attempt a foundation move first and fall through to a
/// multi-card stack move rather than accepting a single-card tableau move.
fn try_foundation_dest(
    card: &Card,
    game: &GameState,
) -> Option<KlondikePile> {
    let source = game.pile_containing_card(card.clone())?;
    for foundation in [
        Foundation::Foundation1,
        Foundation::Foundation2,
        Foundation::Foundation3,
        Foundation::Foundation4,
    ] {
        let dest = KlondikePile::Foundation(foundation);
        if game.can_move_cards(&source, &dest, 1) {
            return Some(dest);
        }
    }
    None
}

/// Clears the selection whenever the game state changes.
///
/// Without this, an undo or a rejected move could leave `selected_pile`
/// pointing at a pile whose top card changed, causing the highlight to
/// trail a different card than the player expects.
///
/// Also resets [`KeyboardDragState`] back to `Idle` and clears any
/// keyboard-driven [`DragState`] population — the lifted cards have just
/// moved (or been undone) so the cached `legal_destinations` are stale.
fn clear_selection_on_state_change(
    mut state_events: MessageReader<StateChangedEvent>,
    mut selection: ResMut<SelectionState>,
    mut kbd_drag: ResMut<KeyboardDragState>,
    mut drag: ResMut<DragState>,
) {
    if state_events.read().next().is_some() {
        selection.selected_pile = None;
        if matches!(*kbd_drag, KeyboardDragState::Lifted { .. }) {
            *kbd_drag = KeyboardDragState::Idle;
            // Only clear DragState if it's the keyboard sentinel — never
            // stomp a real mouse / touch drag.
            if drag.active_touch_id == Some(KEYBOARD_DRAG_TOUCH_ID) {
                drag.clear();
            }
        }
    }
}

/// Maintains the `SelectionHighlight` outline sprite.
///
/// When a pile is selected (source-pick mode), an accent-coloured sprite is placed
/// at the selected card's position. While
/// [`KeyboardDragState::Lifted`] the source highlight tints gold and a
/// second highlight follows the focused destination's top card — visually
/// telling the player "these cards will move to that pile when you press
/// Enter".
///
/// All highlights are despawned and respawned every frame so an undo /
/// rejected move can never leave a stale outline behind.
fn update_selection_highlight(
    mut commands: Commands,
    selection: Res<SelectionState>,
    kbd_drag: Res<KeyboardDragState>,
    game: Res<GameStateResource>,
    layout: Option<Res<LayoutResource>>,
    card_index: Res<CardEntityIndex>,
    highlights: Query<Entity, With<SelectionHighlight>>,
) {
    // Always despawn any existing highlight first.
    for entity in &highlights {
        commands.entity(entity).despawn();
    }
    let Some(layout) = layout else {
        return;
    };
    let card_size = layout.0.card_size;

    // Highlight tints follow the Terminal palette's semantic state
    // tokens: ACCENT_PRIMARY focus/selection while picking the source, gold
    // attention/commitment once the cards are lifted, lime valid-move
    // tint on the destination. Alphas are kept non-zero so the card
    // face beneath remains readable through the wash.
    let lifted = kbd_drag.is_lifted();
    let source_color = if lifted {
        STATE_WARNING.with_alpha(0.6)
    } else {
        ACCENT_PRIMARY.with_alpha(0.5)
    };
    let dest_color = STATE_SUCCESS.with_alpha(0.6);

    // Resolve the source pile from KeyboardDragState (when lifted) or
    // SelectionState (otherwise). Lifted takes precedence so the gold
    // outline follows the actual lifted cards.
    let source_pile: Option<KlondikePile> = match &*kbd_drag {
        KeyboardDragState::Lifted { source_pile, .. } => Some(*source_pile),
        KeyboardDragState::Idle => selection.selected_pile,
    };

    if let Some(ref pile) = source_pile
        && let Some(card) = top_face_up_card(pile, &game.0)
    {
        spawn_highlight_on_card(
            &mut commands,
            &card_index,
            &card,
            card_size,
            source_color,
        );
    }

    // Destination highlight while lifted.
    if let Some(dest) = kbd_drag.focused_destination() {
        // For non-empty piles, anchor on the top card. For empty piles
        // (e.g. an empty tableau column), no card exists to anchor to;
        // skip — the source highlight already conveys that the player is
        // in destination-pick mode and the focused index is observable
        // via the resource.
        if let Some(card) = top_face_up_card(dest, &game.0) {
            spawn_highlight_on_card(
                &mut commands,
                &card_index,
                &card,
                card_size,
                dest_color,
            );
        }
    }
}

/// Returns the top face-up card on `pile`, or `None` if the pile is
/// empty or its top card is face-down.
fn top_face_up_card(pile: &KlondikePile, game: &GameState) -> Option<Card> {
    pile_cards(game, pile)
        .last()
        .filter(|(_, up)| *up)
        .map(|(c, _)| c.clone())
}

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

/// Spawn a `SelectionHighlight` sprite as a child of the entity carrying
/// the matching `CardEntity::card`. No-op if no entity matches.
fn spawn_highlight_on_card(
    commands: &mut Commands,
    card_index: &CardEntityIndex,
    card: &Card,
    card_size: Vec2,
    color: Color,
) {
    if let Some(entity) = card_index.get(card) {
        commands.entity(entity).with_children(|b| {
            b.spawn((
                SelectionHighlight,
                Sprite {
                    color,
                    custom_size: Some(card_size + Vec2::splat(4.0)),
                    ..default()
                },
                Transform::from_xyz(0.0, 0.0, -0.01),
                Visibility::default(),
            ));
        });
    }
}

// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    fn piles_from(names: &[&str]) -> Vec<KlondikePile> {
        names
            .iter()
            .map(|&n| match n {
                "Waste" => KlondikePile::Stock,
                "T0" => KlondikePile::Tableau(Tableau::Tableau1),
                "T1" => KlondikePile::Tableau(Tableau::Tableau2),
                "T2" => KlondikePile::Tableau(Tableau::Tableau3),
                _ => KlondikePile::Stock,
            })
            .collect()
    }

    // -----------------------------------------------------------------------
    // Task #68 — cycle_next_pile pure-function tests
    // -----------------------------------------------------------------------

    #[test]
    fn cycle_next_pile_from_none() {
        // With [Waste, Tableau(0), Tableau(1)] available, starting from None → Waste.
        let available = piles_from(&["Waste", "T0", "T1"]);
        let result = cycle_next_pile(&available, None);
        assert_eq!(result, Some(KlondikePile::Stock));
    }

    #[test]
    fn cycle_next_pile_from_waste() {
        // Starting from Waste → Tableau(0).
        let available = piles_from(&["Waste", "T0", "T1"]);
        let result = cycle_next_pile(&available, Some(&KlondikePile::Stock));
        assert_eq!(result, Some(KlondikePile::Tableau(Tableau::Tableau1)));
    }

    #[test]
    fn cycle_next_pile_wraps() {
        // Starting from Tableau(1) → Waste (wraps back to start).
        let available = piles_from(&["Waste", "T0", "T1"]);
        let result = cycle_next_pile(&available, Some(&KlondikePile::Tableau(Tableau::Tableau2)));
        assert_eq!(result, Some(KlondikePile::Stock));
    }

    #[test]
    fn cycle_next_pile_empty_returns_none() {
        let result = cycle_next_pile(&[], None);
        assert!(result.is_none());
    }

    // -----------------------------------------------------------------------
    // Task #59 — wrap detection: 3 piles, Tab ×3 fires wrap on third press
    // -----------------------------------------------------------------------

    /// Simulate three Tab presses over [Waste, Tableau(0), Tableau(1)].
    ///
    /// Press 1: None  → Waste       — no wrap (started from nothing)
    /// Press 2: Waste → Tableau(0)  — no wrap (advancing forward)
    /// Press 3: T(0)  → Tableau(1)  — no wrap (still advancing forward)
    /// (A fourth press would wrap T(1) → Waste.)
    #[test]
    fn wrap_detected_on_third_tab_with_three_piles() {
        let available = piles_from(&["Waste", "T0", "T1"]);

        // Press 1: no current selection → first pile, no wrap.
        let sel1 = cycle_next_pile(&available, None);
        assert_eq!(sel1, Some(KlondikePile::Stock));
        assert!(
            !did_wrap(&available, None, sel1.as_ref()),
            "first Tab should not wrap"
        );

        // Press 2: Waste → Tableau(0), no wrap.
        let sel2 = cycle_next_pile(&available, sel1.as_ref());
        assert_eq!(sel2, Some(KlondikePile::Tableau(Tableau::Tableau1)));
        assert!(
            !did_wrap(&available, sel1.as_ref(), sel2.as_ref()),
            "second Tab should not wrap"
        );

        // Press 3: Tableau(0) → Tableau(1), still no wrap.
        let sel3 = cycle_next_pile(&available, sel2.as_ref());
        assert_eq!(sel3, Some(KlondikePile::Tableau(Tableau::Tableau2)));
        assert!(
            !did_wrap(&available, sel2.as_ref(), sel3.as_ref()),
            "third Tab (T0→T1) should not wrap"
        );

        // Press 4: Tableau(1) → Waste, this IS the wrap.
        let sel4 = cycle_next_pile(&available, sel3.as_ref());
        assert_eq!(sel4, Some(KlondikePile::Stock));
        assert!(
            did_wrap(&available, sel3.as_ref(), sel4.as_ref()),
            "fourth Tab should wrap back to Waste"
        );
    }

    #[test]
    fn cycle_next_pile_single_element_wraps_to_itself() {
        let available = vec![KlondikePile::Stock];
        let result = cycle_next_pile(&available, Some(&KlondikePile::Stock));
        assert_eq!(result, Some(KlondikePile::Stock));
    }

    // -----------------------------------------------------------------------
    // Task #8 — face_up_run_len pure-function tests
    // -----------------------------------------------------------------------

    #[test]
    fn face_up_run_len_empty_slice_is_zero() {
        assert_eq!(face_up_run_len(&[]), 0);
    }

    #[test]
    fn face_up_run_len_all_face_up() {
        use solitaire_core::{Card, Deck, Rank, Suit};
        let cards = vec![
            (Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true),
            (Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), true),
            (Card::new(Deck::Deck1, Suit::Spades, Rank::Jack), true),
        ];
        assert_eq!(face_up_run_len(&cards), 3);
    }

    #[test]
    fn face_up_run_len_mixed_stops_at_face_down() {
        use solitaire_core::{Card, Deck, Rank, Suit};
        let cards = vec![
            (Card::new(Deck::Deck1, Suit::Clubs, Rank::King), false),
            (Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false),
            (Card::new(Deck::Deck1, Suit::Spades, Rank::Jack), true),
            (Card::new(Deck::Deck1, Suit::Diamonds, Rank::Ten), true),
        ];
        // Only the top two cards are face-up.
        assert_eq!(face_up_run_len(&cards), 2);
    }

    #[test]
    fn face_up_run_len_top_card_face_down_is_zero() {
        use solitaire_core::{Card, Deck, Rank, Suit};
        let cards = vec![
            (Card::new(Deck::Deck1, Suit::Clubs, Rank::King), true),
            (Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen), false),
        ];
        assert_eq!(face_up_run_len(&cards), 0);
    }

    #[test]
    fn face_up_run_len_single_face_up_card() {
        use solitaire_core::{Card, Deck, Rank, Suit};
        let cards = vec![(Card::new(Deck::Deck1, Suit::Hearts, Rank::Ace), true)];
        assert_eq!(face_up_run_len(&cards), 1);
    }

    // -----------------------------------------------------------------------
    // Keyboard drag-and-drop — full integration tests
    //
    // Each test runs a `MinimalPlugins` Bevy app with `SelectionPlugin` and
    // builds a deterministic `GameState` so the legal-destination ordering
    // is predictable without depending on the deal RNG.
    // -----------------------------------------------------------------------

    use bevy::ecs::message::Messages;
    use solitaire_core::{Card, Deck, Rank, Suit};
    use solitaire_core::{DrawStockConfig, game_state::GameState};

    /// Build a minimal app with `SelectionPlugin` only — no GamePlugin, no
    /// AssetServer. The `MoveRequestEvent` / `StateChangedEvent` /
    /// `InfoToastEvent` channels are registered manually so the plugin's
    /// systems compile and run.
    fn drag_test_app() -> App {
        let mut app = App::new();
        app.add_plugins(MinimalPlugins);
        app.add_message::<MoveRequestEvent>();
        app.add_message::<StateChangedEvent>();
        app.add_message::<InfoToastEvent>();
        app.init_resource::<DragState>();
        app.init_resource::<ButtonInput<KeyCode>>();
        app.add_plugins(SelectionPlugin);
        app
    }

    /// Build a tableau-only board with deterministic top cards so the
    /// keyboard-cycle order is predictable.
    ///
    /// Layout:
    /// - Tableau(0): 5♣ face-up   (red destinations: 4♥ on T1 face-up below)
    /// - Tableau(1): 6♥ face-up
    /// - Tableau(2): 6♦ face-up
    /// - Tableau(3..7): empty
    /// - Stock / Waste / Foundations: empty
    ///
    /// 5♣ on T0 can legally go to either 6♥ on T1 or 6♦ on T2 (both red,
    /// rank one higher). It cannot go to a foundation (Foundation needs
    /// Ace first). It cannot go to an empty tableau (only Kings).
    /// Empty tableaus T3..T6 only accept Kings, so they are filtered out.
    fn deterministic_state() -> GameState {
        let mut g = GameState::new(0, DrawStockConfig::DrawOne);
        // Clear stock, waste, all tableaus.
        g.set_test_stock_cards(Vec::new());
        g.set_test_waste_cards(Vec::new());
        for tableau in [
            Tableau::Tableau1,
            Tableau::Tableau2,
            Tableau::Tableau3,
            Tableau::Tableau4,
            Tableau::Tableau5,
            Tableau::Tableau6,
            Tableau::Tableau7,
        ] {
            g.set_test_tableau_cards(tableau, Vec::new());
        }
        // Place test cards.
        g.set_test_tableau_cards(
            Tableau::Tableau1,
            vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)],
        );
        g.set_test_tableau_cards(
            Tableau::Tableau2,
            vec![Card::new(Deck::Deck1, Suit::Hearts, Rank::Six)],
        );
        g.set_test_tableau_cards(
            Tableau::Tableau3,
            vec![Card::new(Deck::Deck1, Suit::Diamonds, Rank::Six)],
        );
        g
    }

    fn install_state(app: &mut App, state: GameState) {
        app.insert_resource(GameStateResource(state));
    }

    fn press_key(app: &mut App, key: KeyCode) {
        let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
        input.release(key);
        input.clear();
        input.press(key);
    }

    fn clear_input(app: &mut App) {
        app.world_mut()
            .resource_mut::<ButtonInput<KeyCode>>()
            .clear();
    }

    fn collect_move_events(app: &mut App) -> Vec<MoveRequestEvent> {
        let events = app.world().resource::<Messages<MoveRequestEvent>>();
        let mut cursor = events.get_cursor();
        cursor.read(events).cloned().collect()
    }

    /// Test 1 — Tab in idle state cycles to the first draggable pile.
    ///
    /// On the deterministic board, the first draggable pile in cycle order
    /// is `Tableau(0)` (the 5♣).
    #[test]
    fn tab_in_idle_cycles_to_first_draggable_pile() {
        let mut app = drag_test_app();
        install_state(&mut app, deterministic_state());
        app.update();

        // Initial state: nothing selected, KeyboardDragState::Idle.
        assert!(
            app.world()
                .resource::<SelectionState>()
                .selected_pile
                .is_none()
        );
        assert_eq!(
            *app.world().resource::<KeyboardDragState>(),
            KeyboardDragState::Idle
        );

        press_key(&mut app, KeyCode::Tab);
        app.update();

        let selected = app
            .world()
            .resource::<SelectionState>()
            .selected_pile;
        // The cycle order starts at Waste, but Waste is empty so the next
        // available pile (Tableau(0)) is selected.
        assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1)));
        assert_eq!(
            *app.world().resource::<KeyboardDragState>(),
            KeyboardDragState::Idle
        );
    }

    /// Test 2 — Enter while a source is selected lifts the stack.
    ///
    /// `DragState.cards` must be populated with the lifted card ids and the
    /// keyboard sentinel must be set.
    #[test]
    fn enter_in_source_selected_lifts_the_stack() {
        let mut app = drag_test_app();
        install_state(&mut app, deterministic_state());
        app.update();

        // Manually focus Tableau(0) so we don't depend on Tab.
        app.world_mut()
            .resource_mut::<SelectionState>()
            .selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));

        press_key(&mut app, KeyCode::Enter);
        app.update();

        // Assert KeyboardDragState is Lifted with the right metadata.
        let kbd = app.world().resource::<KeyboardDragState>().clone();
        match kbd {
            KeyboardDragState::Lifted {
                source_pile,
                count,
                cards,
                legal_destinations,
                destination_index,
            } => {
                assert_eq!(source_pile, KlondikePile::Tableau(Tableau::Tableau1));
                assert_eq!(count, 1);
                assert_eq!(cards, vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)]);
                assert!(
                    !legal_destinations.is_empty(),
                    "lifted stack must have at least one legal destination"
                );
                assert_eq!(destination_index, 0);
            }
            other => panic!("expected Lifted, got {other:?}"),
        }

        // DragState must mirror the lifted cards and carry the keyboard sentinel.
        let drag = app.world().resource::<DragState>();
        assert_eq!(
            drag.cards,
            vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)]
        );
        assert_eq!(
            drag.origin_pile,
            Some(KlondikePile::Tableau(Tableau::Tableau1))
        );
        assert_eq!(drag.active_touch_id, Some(KEYBOARD_DRAG_TOUCH_ID));
    }

    /// Test 3 — Arrow keys in `Lifted` cycle through *legal* destinations
    /// only (foundations and tableaus that pass `can_place_on_*`), and
    /// wrap at the end of the list.
    /// Test 4 — Enter while `Lifted` with a destination focused fires
    /// exactly one `MoveRequestEvent` and resets the state machine to
    /// `Idle` with `DragState` cleared.
    #[test]
    fn enter_in_lifted_with_destination_fires_move_request_event() {
        let mut app = drag_test_app();
        install_state(&mut app, deterministic_state());
        app.update();
        app.world_mut()
            .resource_mut::<SelectionState>()
            .selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
        press_key(&mut app, KeyCode::Enter);
        app.update();

        // Sanity: lifted with a focused destination.
        assert!(app.world().resource::<KeyboardDragState>().is_lifted());
        let expected_dest = app
            .world()
            .resource::<KeyboardDragState>()
            .focused_destination()
            .cloned()
            .expect("must have a focused destination after lift");

        // Confirm with Enter.
        clear_input(&mut app);
        press_key(&mut app, KeyCode::Enter);
        app.update();

        let events = collect_move_events(&mut app);
        assert_eq!(events.len(), 1, "exactly one MoveRequestEvent must fire");
        assert_eq!(events[0].from, KlondikePile::Tableau(Tableau::Tableau1));
        assert_eq!(events[0].to, expected_dest);
        assert_eq!(events[0].count, 1);

        // State machine resets.
        assert_eq!(
            *app.world().resource::<KeyboardDragState>(),
            KeyboardDragState::Idle,
            "Enter on lifted must return state machine to Idle",
        );
        assert!(
            app.world().resource::<DragState>().is_idle(),
            "DragState must be cleared after confirming the move",
        );
    }

    /// Test 5 — Esc while `Lifted` cancels back to source-selected with
    /// `SelectionState` intact and `DragState` cleared.
    #[test]
    fn escape_in_lifted_returns_to_source_selected() {
        let mut app = drag_test_app();
        install_state(&mut app, deterministic_state());
        app.update();
        app.world_mut()
            .resource_mut::<SelectionState>()
            .selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
        press_key(&mut app, KeyCode::Enter);
        app.update();
        assert!(app.world().resource::<KeyboardDragState>().is_lifted());

        // Esc cancels.
        clear_input(&mut app);
        press_key(&mut app, KeyCode::Escape);
        app.update();

        assert_eq!(
            *app.world().resource::<KeyboardDragState>(),
            KeyboardDragState::Idle,
            "Esc on lifted must return state machine to Idle",
        );
        assert_eq!(
            app.world().resource::<SelectionState>().selected_pile,
            Some(KlondikePile::Tableau(Tableau::Tableau1)),
            "Esc on lifted must keep SelectionState intact (source-pick mode)",
        );
        assert!(
            app.world().resource::<DragState>().is_idle(),
            "DragState must be cleared after cancelling the lift",
        );
    }

    /// Mouse drag in progress (non-keyboard `active_touch_id`) must
    /// suppress keyboard input — pressing Tab while a real mouse drag is
    /// running must not change `SelectionState`.
    #[test]
    fn keyboard_input_ignored_while_mouse_drag_active() {
        let mut app = drag_test_app();
        install_state(&mut app, deterministic_state());
        app.update();

        // Simulate a real mouse drag by populating DragState without the
        // keyboard sentinel.
        {
            let mut drag = app.world_mut().resource_mut::<DragState>();
            drag.cards = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Five)];
            drag.origin_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
            drag.committed = true;
            drag.active_touch_id = None;
        }

        let before = app
            .world()
            .resource::<SelectionState>()
            .selected_pile;
        press_key(&mut app, KeyCode::Tab);
        app.update();
        let after = app
            .world()
            .resource::<SelectionState>()
            .selected_pile;

        assert_eq!(
            before, after,
            "Tab must not change SelectionState while a mouse drag is in progress",
        );
    }

    /// Esc on a lifted state with no prior state-change does NOT clear
    /// `SelectionState`. A second Esc (now that the state is Idle) does.
    #[test]
    fn double_escape_clears_source_selection() {
        let mut app = drag_test_app();
        install_state(&mut app, deterministic_state());
        app.update();
        app.world_mut()
            .resource_mut::<SelectionState>()
            .selected_pile = Some(KlondikePile::Tableau(Tableau::Tableau1));
        press_key(&mut app, KeyCode::Enter);
        app.update();

        clear_input(&mut app);
        press_key(&mut app, KeyCode::Escape);
        app.update();
        assert_eq!(
            app.world().resource::<SelectionState>().selected_pile,
            Some(KlondikePile::Tableau(Tableau::Tableau1)),
            "first Esc only cancels the lift",
        );

        clear_input(&mut app);
        press_key(&mut app, KeyCode::Escape);
        app.update();
        assert!(
            app.world()
                .resource::<SelectionState>()
                .selected_pile
                .is_none(),
            "second Esc clears the source selection",
        );
    }
}
