//! PNG-based card rendering.
//!
//! Card entities are synced with [`GameStateResource`] on every
//! [`StateChangedEvent`]: missing cards are spawned, present cards are
//! repositioned/updated in place, and stale cards are despawned.
//!
//! When [`CardImageSet`] is available, each face-up card renders its own
//! 120×168 px `Handle<Image>` chosen from the 52 per-card PNGs loaded from
//! `assets/cards/faces/{rank}_{suit}.png`. A solid-colour `Sprite` with a
//! `Text2d` rank+suit overlay is used as a fallback when `CardImageSet` is
//! absent (e.g. in tests running under `MinimalPlugins`).

use std::collections::{HashMap, HashSet};

use bevy::color::Color;
use bevy::prelude::*;
use bevy::sprite::Anchor;
use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Card, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};

use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
use crate::card_animation::CardAnimation;
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation;
use crate::layout::{Layout, LayoutResource, LayoutSystem};
use crate::pause_plugin::PausedResource;
use crate::platform::USE_TOUCH_UI_LAYOUT;
use crate::resources::{DragState, GameStateResource};
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker};
use crate::ui_theme::{
    CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_ALPHA_IDLE, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
    CARD_SHADOW_OFFSET_DRAG, CARD_SHADOW_OFFSET_IDLE, CARD_SHADOW_PADDING_DRAG,
    CARD_SHADOW_PADDING_IDLE, STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TEXT_PRIMARY_HC,
    TYPE_BODY, Z_STOCK_BADGE,
};

/// Per-card vertical step for face-down tableau cards, as a fraction of
/// card height. Smaller than [`crate::layout::TABLEAU_FAN_FRAC`] because face-down cards
/// don't need their full body shown — only the back-pattern strip is
/// visible. Public so `input_plugin` can mirror the exact sprite layout
/// when hit-testing tableau columns; any drift between this and the
/// renderer creates a visible offset between the card face and where
/// clicks land.
///
/// Matches `layout::TABLEAU_FACEDOWN_FAN_FRAC` (0.14). Both constants must
/// stay in sync; the layout constant drives the adaptive LayoutResource value
/// used at runtime, while this one is the minimum floor used by
/// `update_tableau_fan_frac` when computing proportional updates.
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;

/// Fraction of card height used as a tiny offset between stacked cards in
/// non-tableau piles, so stacking is visible. Public so other plugins
/// (e.g. input_plugin's drag-rejection tween) can compute the resting
/// `Transform.translation.z` for a card at a given stack index without
/// drifting from the value used by [`card_positions`].
// Must exceed the highest child local-z of any card entity (0.02 for the
// Android corner label) so every card's sprite covers all children of the
// card below it.  Raising from 0.003 → 0.025 fixes corner labels on
// foundation piles bleeding through when a 2 sits on an Ace.
pub const STACK_FAN_FRAC: f32 = 0.025;

/// Per-card horizontal fan step for the Draw-Three waste, in logical pixels.
///
/// Derived from the actual tableau column spacing (`Tableau2.x − Tableau1.x`)
/// rather than a fixed fraction of card width, so the fan scales with the
/// platform's `H_GAP_DIVISOR` (desktop ≈ 1.25×cw spacing, Android ≈ 1.03×cw).
/// Public so `input_plugin` can hit-test the fanned waste cards at the exact
/// x-offsets the renderer uses; any drift makes a click on the top fanned card
/// land on the card beneath it.
pub fn waste_fan_step(layout: &Layout) -> f32 {
    tableau_col_step(layout) * 0.224
}

/// Horizontal distance between adjacent tableau columns (`Tableau2.x −
/// Tableau1.x`), in logical pixels. The face-down stock is rendered one column
/// step left of the waste, and the Draw-Three waste fan ([`waste_fan_step`]) is
/// a fraction of it. Public so hit-testing mirrors the renderer exactly.
pub fn tableau_col_step(layout: &Layout) -> f32 {
    let t1 = layout
        .pile_positions
        .get(&KlondikePile::Tableau(Tableau::Tableau1))
        .copied()
        .unwrap_or_default();
    let t2 = layout
        .pile_positions
        .get(&KlondikePile::Tableau(Tableau::Tableau2))
        .copied()
        .unwrap_or_default();
    (t2.x - t1.x).abs()
}

/// Font size as a fraction of card width.
const FONT_SIZE_FRAC: f32 = 0.28;

/// Font-size fraction for the large-print readability overlay on touch HUD layouts.
/// Spawned on top of PNG face cards to make the rank+suit legible at phone
/// scale, where the baked-in PNG corner text is only ~10 px physical.
const FONT_SIZE_FRAC_MOBILE: f32 = 0.35;

/// Card-face background — Terminal `#1a1a1a` (BG_ELEVATED).
pub const CARD_FACE_COLOUR: Color = Color::srgb(0.102, 0.102, 0.102);
/// Suit colour for hearts + diamonds — saturated red `#e35353`.
/// 2-colour traditional pairing (the "Microsoft Solitaire on dark
/// mode" feel) replacing the brief 4-colour-deck experiment that
/// shipped between v0.21.0 and this commit. Brighter and more
/// saturated than the v0.21.0 pink `#fb9fb1` so the cards read as
/// a "real solitaire deck" rather than a Terminal-pastel theme.
/// Visually distinct from `ACCENT_PRIMARY` (`#a54242` brick red,
/// darker) so chrome and suit don't read as the same hue.
pub const RED_SUIT_COLOUR: Color = Color::srgb(0.890, 0.325, 0.325);
/// High-contrast variant of [`RED_SUIT_COLOUR`] — `#ff6868`. Lifted
/// luminance for the Settings → Accessibility → High-contrast mode
/// toggle. Pre-2-colour-revert this was `#ff8aa0` (pink-salmon)
/// matching the v0.21.0 pink default; rebumped to a brighter red
/// so it reads as "more chromatic" than the new saturated default,
/// not "less saturated." Independent of `RED_SUIT_COLOUR_CBM`
/// (lime) — high-contrast is *additive* over the default colour
/// palette; CBM is a *replacement* of red with a hue-distinct
/// alternative. The two modes can stack; CBM wins when both are on
/// because the CBM lime is itself a high-contrast colour.
pub const RED_SUIT_COLOUR_HC: Color = Color::srgb(1.000, 0.408, 0.408);
/// Suit colour for spades + clubs — near-white `#e8e8e8`. Brighter
/// than `TEXT_PRIMARY` (`#d0d0d0`, foreground gray) so the
/// "black suit" reads as a distinct, chromatic-neutral counterpart
/// to the new saturated red, not as "the same gray as body text."
/// `TEXT_PRIMARY_HC` (`#f5f5f5`) is still brighter for the
/// high-contrast boost path.
pub const BLACK_SUIT_COLOUR: Color = Color::srgb(0.910, 0.910, 0.910);

/// Pre-loaded [`Handle<Image>`]s for card face and back PNG textures.
///
/// Loaded once at startup by [`load_card_images`].  When this resource is
/// present, card sprites use the PNG artwork; otherwise they fall back to
/// solid-colour sprites (used in tests with `MinimalPlugins`).
#[derive(Resource)]
pub struct CardImageSet {
    /// Per-card face images indexed by `[suit][rank]`.
    ///
    /// Suit order: Clubs=0, Diamonds=1, Hearts=2, Spades=3.
    /// Rank order: Ace=0, Two=1 … King=12.
    pub faces: [[Handle<Image>; 13]; 4],
    /// One handle per unlockable card-back design (indices 0–4). These
    /// correspond to the legacy `assets/cards/backs/back_N.png` art, indexed
    /// by `Settings::selected_card_back`. Used as a fallback when the active
    /// theme does not provide its own back (see [`Self::theme_back`]).
    pub backs: [Handle<Image>; 5],
    /// Back image supplied by the currently-active card theme, if any.
    ///
    /// Populated by `theme::plugin::apply_theme_to_card_image_set` whenever
    /// a `CardTheme` finishes loading. The face-down render path in
    /// [`card_sprite`] prefers this handle over the legacy `backs[]` array,
    /// so a theme switch swaps both faces *and* the back without the player
    /// needing to touch the legacy `selected_card_back` picker. `None` means
    /// the active theme did not declare a back asset (or no theme has loaded
    /// yet); in that case [`card_sprite`] falls back to the legacy array.
    pub theme_back: Option<Handle<Image>>,
}

/// Suit-colour swap for red-suit cards in colour-blind mode — Terminal
/// `#acc267` (lime). Replaces `RED_SUIT_COLOUR` (pink) when CBM is on,
/// providing a hue-distinct alternative that survives the most common
/// red/green deficiencies. Pre-Terminal this was a *face tint*; the new
/// design moves CBM differentiation into the suit glyph colour itself
/// and keeps the face uniformly `CARD_FACE_COLOUR` regardless of CBM.
///
/// The CBM swap is lime (not the `ACCENT_PRIMARY` brick-red) because
/// the primary accent is itself in the red family — using it for
/// "the not-red CBM alternative" would defeat the purpose. Lime is
/// the next-best non-red base16-eighties accent; deuteranopia and
/// protanopia readers see it as visibly distinct from pink.
const RED_SUIT_COLOUR_CBM: Color = Color::srgb(0.675, 0.761, 0.404);

/// Returns the fallback card-back colour for the given unlocked card-back
/// index. Production renders backs from PNG artwork; this fallback only
/// fires under `MinimalPlugins` (tests). Mirrors the 5 accent colours
/// from `card_face_svg::BACK_ACCENTS` so the test-environment back lives
/// in the same hue family as the on-disk PNG art for that index.
fn card_back_colour(selected_card_back: usize) -> Color {
    match selected_card_back {
        0 => Color::srgb(0.647, 0.259, 0.259), // #a54242 brick red (Terminal canonical, ACCENT_PRIMARY)
        1 => Color::srgb(0.675, 0.761, 0.404), // #acc267 lime
        2 => Color::srgb(0.882, 0.639, 0.933), // #e1a3ee lavender
        3 => Color::srgb(0.984, 0.624, 0.694), // #fb9fb1 pink
        _ => Color::srgb(0.867, 0.698, 0.435), // #ddb26f gold (4+)
    }
}

/// Marker component linking a Bevy entity to its `solitaire_core::Card`.
#[derive(Component, Debug, Clone)]
pub struct CardEntity {
    pub card: Card,
}

/// Cached signature of the inputs that determine a card entity's *child*
/// visuals (drop-shadow, border frame, and the rank/suit label / large-print
/// corner overlay). Stored on each card so [`update_card_entity`] can skip the
/// expensive `despawn_related::<Children>()` + child respawn when nothing about
/// the appearance changed — the common case during a move, where only the
/// card's position changes. Without this guard every `StateChangedEvent`
/// rebuilds all 52 cards' children (≈250 entity spawn/despawns plus 52 `Text2d`
/// glyph re-layouts) in a single frame, which stutters the slide animation on
/// high-resolution devices.
///
/// The children depend only on these four inputs; the card identity is fixed
/// per entity, and the face/back *image* is handled by the always-refreshed
/// `Sprite` (a cheap handle swap), so theme/card-back changes need no child
/// rebuild.
#[derive(Component, Clone, Copy, PartialEq)]
struct CardChildrenKey {
    face_up: bool,
    card_size: Vec2,
    color_blind: bool,
    high_contrast: bool,
}

/// Query data read by the card-sync systems for each live card entity:
/// its id, card identity, current transform, any in-flight curve animation,
/// and its cached child-appearance key. Factored into an alias to keep the
/// system signatures readable (and satisfy clippy's `type_complexity`).
type CardSyncData = (
    Entity,
    &'static CardEntity,
    &'static Transform,
    Option<&'static CardAnimation>,
    Option<&'static CardChildrenKey>,
);

/// Render-side index mapping each live board card to its [`CardEntity`].
///
/// Maintained exclusively by [`rebuild_card_entity_index`] in `PostUpdate`,
/// after the card-sync authority ([`sync_cards_on_change`]) and the resize
/// re-snap have flushed their spawn/despawn `Commands`. Consumers treat it as
/// read-only and must still call `Query::get(entity)` for components beyond the
/// `Entity` id (the map yields only the id).
///
/// Keyed by `Card`, which is unique across all live board entities in
/// single-deck Klondike. Transient entities (drag shadow, labels) carry no
/// `CardEntity` component, so the rebuild — which scans `&CardEntity` — never
/// records them.
#[derive(Resource, Debug, Default)]
pub struct CardEntityIndex(pub HashMap<Card, Entity>);

impl CardEntityIndex {
    /// Resolve a card to its live entity, if one is currently spawned.
    #[inline]
    pub fn get(&self, card: &Card) -> Option<Entity> {
        self.0.get(card).copied()
    }
}

/// Marker for the text child inside a card entity.
#[derive(Component, Debug)]
pub struct CardLabel;

/// Marker for the large-print rank+suit corner overlay used by touch HUD layouts.
///
/// Spawned on top of PNG face cards (face-up only) at font size
/// [`FONT_SIZE_FRAC_MOBILE`] so the rank and suit character are
/// readable at phone scale. Only exists when `CardImageSet` is present
/// (the fallback solid-colour path uses a plain `CardLabel` instead).
#[derive(Component, Debug, Clone)]
struct AndroidCornerLabel(pub String);

/// Solid-colour background sprite behind [`AndroidCornerLabel`].
///
/// Covers the card art's own small corner rank/suit text so only the
/// large overlay is visible. Sized at [`FONT_SIZE_FRAC_MOBILE`]-derived
/// dimensions and coloured [`CARD_FACE_COLOUR`] to match the card face.
#[derive(Component, Debug, Clone, Copy)]
struct AndroidCornerBg;

type AndroidCornerBgFilter = (With<AndroidCornerBg>, Without<AndroidCornerLabel>);

/// Marker component indicating the card is currently highlighted as a hint.
/// `remaining` counts down in real seconds; the highlight is removed when it
/// reaches zero and the card sprite colour is restored to its normal value.
#[derive(Component, Debug, Clone)]
pub struct HintHighlight {
    /// Seconds remaining before the highlight is cleared.
    pub remaining: f32,
}

/// Countdown (seconds) until the `HintHighlight` on a card entity is removed.
///
/// Inserted alongside `HintHighlight` by the hint-visual system. When the timer
/// reaches zero both `HintHighlight` and `HintHighlightTimer` are removed from
/// the entity and the sprite colour is restored.
#[derive(Component, Debug, Clone)]
pub struct HintHighlightTimer(pub f32);

/// Marker on a `PileMarker` entity that is highlighted because the right-clicked
/// card can legally be placed there.
#[derive(Component, Debug)]
pub struct RightClickHighlight;

/// Countdown (seconds) until this right-click destination highlight despawns.
///
/// Inserted alongside `RightClickHighlight` so that highlights auto-clear after
/// 1.5 s even if the player does not make a move or click again. The existing
/// clear-on-state-change and clear-on-pause logic still fires early when
/// appropriate.
#[derive(Component, Debug, Clone)]
pub struct RightClickHighlightTimer(pub f32);

/// Marker placed on the child `Text2d` entity that shows "↺" on the stock pile
/// marker when the stock pile is empty.
#[derive(Component, Debug)]
pub struct StockEmptyLabel;

/// Marker on the chip-background sprite of the stock-pile remaining-count
/// badge.
///
/// The badge is spawned as a *top-level* world entity (not parented to the
/// stock [`PileMarker`]) and its `Transform` is recomputed each frame from
/// `LayoutResource` so it tracks the stock pile through window resizes.
/// The chip sits in the bottom-right corner of the stock pile and is hidden
/// while the stock is empty — the existing `↺` overlay
/// ([`StockEmptyLabel`]) covers the recycle hint instead, so the two
/// indicators never render simultaneously.
#[derive(Component, Debug)]
pub struct StockCountBadge;

/// Marker on the `Text2d` child of [`StockCountBadge`] showing the numeric
/// count of cards remaining in the stock pile.
///
/// Update systems query this component to write the new count in place rather
/// than despawning and respawning the text entity each tick.
#[derive(Component, Debug)]
pub struct StockCountBadgeText;

// ---------------------------------------------------------------------------
// Task #34 — Card-flip animation
// ---------------------------------------------------------------------------

/// Phase of the two-stage flip animation.
#[derive(Debug, Clone, PartialEq)]
pub enum FlipPhase {
    /// Scale X from 1.0 → 0.0 (hiding the back face).
    ScalingDown,
    /// Scale X from 0.0 → 1.0 (revealing the front face).
    ScalingUp,
}

/// Drives a 2-phase "card flip" animation on `CardEntity` entities.
///
/// The animation squashes X to 0, swaps the sprite to the face-up colour,
/// then expands X back to 1. Total duration is `2 × FLIP_HALF_SECS`.
#[derive(Component, Debug, Clone)]
pub struct CardFlipAnim {
    /// Seconds elapsed in the current phase.
    pub timer: f32,
    /// Which half of the flip we are in.
    pub phase: FlipPhase,
}

/// Duration of each half of the flip animation (scale-down or scale-up).
const FLIP_HALF_SECS: f32 = 0.08;

// ---------------------------------------------------------------------------
// Task #38 — Drag-elevation shadow
// ---------------------------------------------------------------------------

/// Marker component for the semi-transparent shadow sprite shown while dragging.
#[derive(Component, Debug)]
pub struct ShadowEntity;

/// Marker component for the per-card drop-shadow child sprite.
///
/// Every `CardEntity` owns exactly one `CardShadow` child whose `Sprite` is a
/// neutral-black halo painted slightly down-and-right of the card. Idle state
/// uses [`CARD_SHADOW_OFFSET_IDLE`] / [`CARD_SHADOW_ALPHA_IDLE`]; while the
/// parent card is being dragged the shadow is pushed to the deeper
/// [`CARD_SHADOW_OFFSET_DRAG`] / [`CARD_SHADOW_ALPHA_DRAG`] values so the
/// stack reads as "lifted" off the felt.
#[derive(Component, Debug)]
pub struct CardShadow;

/// Marker on the thin contrasting border sprite spawned behind face-down cards.
///
/// Face-down cards use `back_0.png` which is near-black (`#1a1a1a`). On the
/// dark-green felt the edges are nearly invisible. This child sprite — slightly
/// larger than the card, rendered at local z=-0.01 so it peeks out as a thin
/// frame — gives every face-down card a visible perimeter.
#[derive(Component, Debug)]
pub struct CardBackFrame;

/// Fill colour for the face-down card border frame. Light-medium gray so it
/// reads as a clear "edge" without competing with the suit colours on face-up
/// cards. Brightened from `0.38` to `0.48` (≈ #7a7a7a) after a Pixel_7 smoke
/// test showed face-down `back_0.png` (≈ #1a1a1a) was nearly invisible against
/// the very dark `#151515` felt — the old gray was too close to the back fill
/// to define a crisp perimeter.
const CARD_BACK_FRAME_COLOR: Color = Color::srgb(0.48, 0.48, 0.48);

/// Extra width/height (in world units) added to each side of the card to form
/// the visible border. Widened from `3.0` to `6.0` so the frame peeks out as a
/// clearly readable perimeter at phone density (420 dpi) rather than a hairline.
const CARD_BACK_FRAME_PADDING: f32 = 6.0;

/// Returns the `(offset, padding, alpha)` triple used to paint a per-card
/// shadow given whether its parent card is currently part of the dragged
/// stack. Pulled out as a pure helper so the shadow tuning can be unit-tested
/// without spinning up a Bevy app.
///
/// `is_dragged = false` → resting `(IDLE, IDLE, IDLE)`
/// `is_dragged = true`  → lifted  `(DRAG, DRAG, DRAG)`
pub fn card_shadow_params(is_dragged: bool) -> (Vec2, Vec2, f32) {
    if is_dragged {
        (
            CARD_SHADOW_OFFSET_DRAG,
            CARD_SHADOW_PADDING_DRAG,
            CARD_SHADOW_ALPHA_DRAG,
        )
    } else {
        (
            CARD_SHADOW_OFFSET_IDLE,
            CARD_SHADOW_PADDING_IDLE,
            CARD_SHADOW_ALPHA_IDLE,
        )
    }
}

/// Builds the `Sprite` used for a per-card shadow at the resting state. The
/// alpha and size both use the idle tokens; `update_card_shadows_on_drag`
/// retunes them at runtime when the parent card joins / leaves the dragged
/// stack.
fn card_shadow_sprite(card_size: Vec2) -> Sprite {
    let (_offset, padding, alpha) = card_shadow_params(false);
    Sprite {
        color: CARD_SHADOW_COLOR.with_alpha(alpha),
        custom_size: Some(card_size + padding),
        ..default()
    }
}

/// Builds the `Transform` used for a per-card shadow at the resting state.
/// Local — it is parented to the card entity, so positions are relative.
fn card_shadow_transform() -> Transform {
    let (offset, _padding, _alpha) = card_shadow_params(false);
    Transform::from_xyz(offset.x, offset.y, CARD_SHADOW_LOCAL_Z)
}

/// Spawns a single `CardShadow` child under the given card entity builder.
/// Extracted so `spawn_card_entity` and `update_card_entity` can share the
/// exact same shadow recipe — we never want one path to drift from the other.
fn add_card_shadow_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
    parent.spawn((
        CardShadow,
        card_shadow_sprite(card_size),
        card_shadow_transform(),
        Visibility::default(),
    ));
}

/// Spawns a `CardBackFrame` child behind a card entity to give every card a
/// thin perimeter against the dark felt, regardless of face state.
fn add_card_back_frame_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
    parent.spawn((
        CardBackFrame,
        Sprite {
            color: CARD_BACK_FRAME_COLOR,
            custom_size: Some(card_size + Vec2::splat(CARD_BACK_FRAME_PADDING)),
            ..default()
        },
        Transform::from_xyz(0.0, 0.0, -0.01),
        Visibility::default(),
    ));
}

/// Throttle interval for resize-driven card snap work, in seconds.
///
/// `WindowResized` fires once per pixel of drag, so a fast corner-drag can
/// produce dozens of events per frame. Re-running the per-card snap logic
/// (52 cards × sprite/transform/font_size touches) for every event is the
/// dominant cost of resize lag. We coalesce pending work and apply it at most
/// once per [`RESIZE_THROTTLE_SECS`] (~20 Hz). The user still sees updates
/// during a sustained drag, and the layout always catches up to the final
/// size when the drag stops because the pending size is held until applied.
const RESIZE_THROTTLE_SECS: f32 = 0.05;

/// Holds the latest pending window size from `WindowResized` events plus a
/// timestamp for the last applied snap, so the resize-snap work can be
/// rate-limited to ~20 Hz during sustained drags.
#[derive(Resource, Debug, Default)]
pub struct ResizeThrottle {
    /// Latest unapplied window size from `WindowResized`. `None` when there is
    /// nothing to apply.
    pub pending: Option<Vec2>,
    /// `Time::elapsed_secs()` value at the moment of the most recent applied
    /// snap. `0.0` until the first apply.
    pub last_applied_secs: f32,
}

/// Pure helper used by the throttled resize-snap system: returns `true` when
/// a pending resize should be flushed given the current `now_secs` and the
/// last-applied timestamp. Throttle interval is [`RESIZE_THROTTLE_SECS`].
///
/// Extracted so the rate-limit logic can be unit-tested without spinning up
/// a full Bevy app.
fn should_apply_resize(now_secs: f32, last_applied_secs: f32) -> bool {
    (now_secs - last_applied_secs) >= RESIZE_THROTTLE_SECS
}

/// Renders cards by reading `GameStateResource` on `StateChangedEvent`.
pub struct CardPlugin;

impl Plugin for CardPlugin {
    fn build(&self, app: &mut App) {
        // PostStartup ensures TablePlugin's Startup system has inserted
        // LayoutResource before we try to read it.
        //
        // `handle_right_click` reads `ButtonInput<MouseButton>`. Under
        // `MinimalPlugins` (tests) this resource is absent by default, so we
        // ensure it exists here. Under `DefaultPlugins` the call is a no-op.
        app.init_resource::<ButtonInput<MouseButton>>()
            .init_resource::<ResizeThrottle>()
            .init_resource::<CardEntityIndex>()
            .add_message::<SettingsChangedEvent>()
            .add_message::<CardFlippedEvent>()
            .add_message::<CardFaceRevealedEvent>()
            .add_systems(Startup, load_card_images)
            .add_systems(
                PostStartup,
                (
                    // Fill the tableau fan before the first render so the
                    // cold-start deal already uses the full viewport height.
                    fill_tableau_fan_on_startup.before(sync_cards_startup),
                    sync_cards_startup,
                    update_stock_empty_indicator_startup,
                ),
            )
            .add_systems(
                Update,
                (
                    update_tableau_fan_frac
                        .after(GameMutation)
                        .before(sync_cards_on_change),
                    sync_cards_on_change.after(GameMutation),
                    resync_cards_on_settings_change.before(sync_cards_on_change),
                    start_flip_anim.after(GameMutation),
                    tick_flip_anim,
                    update_drag_shadow,
                    update_card_shadows_on_drag.after(sync_cards_on_change),
                    tick_hint_highlight,
                    handle_right_click,
                    tick_right_click_highlights,
                    clear_right_click_highlights_on_state_change.after(GameMutation),
                    clear_right_click_highlights_on_pause,
                    update_stock_empty_indicator.after(GameMutation),
                    update_stock_count_badge
                        .after(GameMutation)
                        .run_if(resource_changed::<GameStateResource>),
                    collect_resize_events.after(LayoutSystem::UpdateOnResize),
                    snap_cards_on_window_resize.after(collect_resize_events),
                ),
            );

        app.add_systems(Update, resize_android_corner_labels);
        app.add_systems(PostUpdate, rebuild_card_entity_index);
    }
}

/// Rebuild the [`CardEntityIndex`] from the live `CardEntity` set.
///
/// Runs in `PostUpdate` so that all spawn/despawn `Commands` issued by
/// [`sync_cards_on_change`] and [`snap_cards_on_window_resize`] in `Update`
/// have been flushed at the `Update -> PostUpdate` apply-deferred boundary.
/// Rebuilding from scratch (rather than incrementally patching at every
/// spawn/despawn site — waste cards churn on every draw) keeps a single writer
/// and makes a stale entry structurally impossible.
///
/// Gated to changed frames only: `Changed<CardEntity>` fires the frame a card
/// is spawned, `RemovedComponents<CardEntity>` the frame one is despawned. The
/// `card` field is write-once (never mutated in place), so card-reposition
/// frames don't trip `Changed` and correctly skip the O(52) rebuild.
fn rebuild_card_entity_index(
    mut index: ResMut<CardEntityIndex>,
    cards: Query<(Entity, &CardEntity)>,
    changed: Query<(), Changed<CardEntity>>,
    removed: RemovedComponents<CardEntity>,
) {
    if changed.is_empty() && removed.is_empty() {
        return;
    }
    let map = &mut index.0;
    map.clear();
    for (entity, ce) in &cards {
        map.insert(ce.card.clone(), entity);
    }
}

/// Returns the relative asset path for a card face PNG.
///
/// The path format is `cards/faces/classic/{RANK}{SUIT}.png`, e.g. `QS.png`
/// for the Queen of Spades. Both `load_card_images` and the unit tests use
/// this function so the filename formula is tested in isolation from the
/// asset-loading machinery.
///
/// Note: this function verifies only the **code-side mapping**. If the PNG
/// file at the returned path contains wrong artwork (e.g. `QS.png` has a
/// diamond watermark baked in), that is an **asset content bug** and must be
/// fixed by replacing the file — no code change can correct it.
fn card_face_asset_path(rank: Rank, suit: Suit) -> String {
    const SUIT_CHARS: [&str; 4] = ["C", "D", "H", "S"];
    const RANK_STRS: [&str; 13] = [
        "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K",
    ];
    let suit_idx = match suit {
        Suit::Clubs => 0,
        Suit::Diamonds => 1,
        Suit::Hearts => 2,
        Suit::Spades => 3,
    };
    let rank_idx = match rank {
        Rank::Ace => 0,
        Rank::Two => 1,
        Rank::Three => 2,
        Rank::Four => 3,
        Rank::Five => 4,
        Rank::Six => 5,
        Rank::Seven => 6,
        Rank::Eight => 7,
        Rank::Nine => 8,
        Rank::Ten => 9,
        Rank::Jack => 10,
        Rank::Queen => 11,
        Rank::King => 12,
    };
    format!(
        "cards/faces/classic/{}{}.png",
        RANK_STRS[rank_idx], SUIT_CHARS[suit_idx]
    )
}

/// Loads card face and back PNGs at startup via [`AssetServer`] and inserts
/// [`CardImageSet`].
///
/// Faces: `assets/cards/faces/{RANK}{SUIT}.png`  (e.g. `AC.png`, `10H.png`)
/// Backs: `assets/cards/backs/back_{0..4}.png`
///
/// Under `MinimalPlugins` (tests) `AssetServer` is absent, so the system
/// returns without inserting `CardImageSet` and the plugin falls back to
/// solid-colour sprites.
fn load_card_images(asset_server: Option<Res<AssetServer>>, mut commands: Commands) {
    let Some(asset_server) = asset_server else {
        return;
    };

    const SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
    const RANKS: [Rank; 13] = [
        Rank::Ace,
        Rank::Two,
        Rank::Three,
        Rank::Four,
        Rank::Five,
        Rank::Six,
        Rank::Seven,
        Rank::Eight,
        Rank::Nine,
        Rank::Ten,
        Rank::Jack,
        Rank::Queen,
        Rank::King,
    ];

    let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|si| {
        std::array::from_fn(|ri| asset_server.load(card_face_asset_path(RANKS[ri], SUITS[si])))
    });
    let backs =
        std::array::from_fn(|i| asset_server.load(format!("cards/backs/classic/back_{i}.png")));
    commands.insert_resource(CardImageSet {
        faces,
        backs,
        // Populated by the theme plugin once a `CardTheme` finishes loading.
        // Until then the legacy back fallback (`backs[selected_card_back]`)
        // is used.
        theme_back: None,
    });
}

/// Builds the [`Sprite`] for a card, using PNG artwork when [`CardImageSet`] is
/// available and falling back to a solid-colour sprite in tests.
fn card_sprite(
    card: &Card,
    face_up: bool,
    card_size: Vec2,
    back_colour: Color,
    card_images: Option<&CardImageSet>,
    selected_back: usize,
) -> Sprite {
    if let Some(set) = card_images {
        let image = if face_up {
            let suit_idx = match card.suit() {
                Suit::Clubs => 0,
                Suit::Diamonds => 1,
                Suit::Hearts => 2,
                Suit::Spades => 3,
            };
            let rank_idx = match card.rank() {
                Rank::Ace => 0,
                Rank::Two => 1,
                Rank::Three => 2,
                Rank::Four => 3,
                Rank::Five => 4,
                Rank::Six => 5,
                Rank::Seven => 6,
                Rank::Eight => 7,
                Rank::Nine => 8,
                Rank::Ten => 9,
                Rank::Jack => 10,
                Rank::Queen => 11,
                Rank::King => 12,
            };
            set.faces[suit_idx][rank_idx].clone()
        } else if let Some(theme_back) = &set.theme_back {
            // Active theme provides its own back — always wins over the
            // legacy `selected_card_back` picker, so a theme switch swaps
            // faces *and* the back. The picker is treated as informational
            // only while a theme back is active (see settings_plugin).
            theme_back.clone()
        } else {
            let idx = selected_back.min(set.backs.len() - 1);
            set.backs[idx].clone()
        };
        Sprite {
            image,
            color: Color::WHITE,
            custom_size: Some(card_size),
            ..default()
        }
    } else {
        // Terminal aesthetic: face background is uniformly CARD_FACE_COLOUR
        // regardless of colour-blind mode (CBM differentiation now lives in
        // the suit glyph colour, applied by `text_colour`, not the face
        // background). Pre-Terminal this branch dispatched through a
        // separate `face_colour(card, color_blind)` helper.
        let body_colour = if face_up {
            CARD_FACE_COLOUR
        } else {
            back_colour
        };
        Sprite {
            color: body_colour,
            custom_size: Some(card_size),
            ..default()
        }
    }
}

/// When card-back selection changes in Settings, re-render all cards so the
/// new back colour is applied immediately (without waiting for a state change).
fn resync_cards_on_settings_change(
    mut setting_events: MessageReader<SettingsChangedEvent>,
    mut state_events: MessageWriter<StateChangedEvent>,
) {
    if setting_events.read().next().is_some() {
        state_events.write(StateChangedEvent);
    }
}

/// Render the initial deal. Runs in `PostStartup`, so all `Startup` systems
/// (including `TablePlugin::setup_table` which inserts `LayoutResource`)
/// have already completed.
#[allow(clippy::too_many_arguments)]
fn sync_cards_startup(
    commands: Commands,
    game: Res<GameStateResource>,
    layout: Option<Res<LayoutResource>>,
    slide_dur: Option<Res<EffectiveSlideDuration>>,
    settings: Option<Res<SettingsResource>>,
    entities: Query<CardSyncData>,
    card_images: Option<Res<CardImageSet>>,
    font_res: Option<Res<FontResource>>,
) {
    if let Some(layout) = layout {
        let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
        let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
        let back_colour = card_back_colour(selected_back);
        let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
        let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
        let font_handle = font_res.as_ref().map(|r| &r.0);
        sync_cards(
            commands,
            &game.0,
            &layout.0,
            slide_secs,
            back_colour,
            color_blind,
            high_contrast,
            &entities,
            card_images.as_deref(),
            selected_back,
            font_handle,
        );
    }
}

#[allow(clippy::too_many_arguments)]
fn sync_cards_on_change(
    mut events: MessageReader<StateChangedEvent>,
    commands: Commands,
    game: Res<GameStateResource>,
    layout: Option<Res<LayoutResource>>,
    slide_dur: Option<Res<EffectiveSlideDuration>>,
    settings: Option<Res<SettingsResource>>,
    entities: Query<CardSyncData>,
    card_images: Option<Res<CardImageSet>>,
    font_res: Option<Res<FontResource>>,
) {
    if events.read().next().is_none() {
        return;
    }
    if let Some(layout) = layout {
        let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
        let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
        let back_colour = card_back_colour(selected_back);
        let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
        let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
        let font_handle = font_res.as_ref().map(|r| &r.0);
        sync_cards(
            commands,
            &game.0,
            &layout.0,
            slide_secs,
            back_colour,
            color_blind,
            high_contrast,
            &entities,
            card_images.as_deref(),
            selected_back,
            font_handle,
        );
    }
}

#[allow(clippy::too_many_arguments)]
fn sync_cards(
    mut commands: Commands,
    game: &GameState,
    layout: &Layout,
    slide_secs: f32,
    back_colour: Color,
    color_blind: bool,
    high_contrast: bool,
    entities: &Query<CardSyncData>,
    card_images: Option<&CardImageSet>,
    selected_back: usize,
    font_handle: Option<&Handle<Font>>,
) {
    let positions = card_positions(game, layout);

    // The waste buffer card exists only to keep its entity alive while the new
    // top card's slide animation plays — it must never be visible to the player.
    // Without this, the buffer sits at waste_base uncovered during the animation
    // and its rank/suit peek behind the incoming card.
    let waste_buffer_id: Option<Card> = {
        let visible = match game.draw_mode() {
            DrawStockConfig::DrawOne => 1_usize,
            DrawStockConfig::DrawThree => 3_usize,
        };
        let waste_cards = game.waste_cards();
        (waste_cards.len() > visible)
            .then_some(waste_cards)
            .and_then(|w| w.get(w.len().saturating_sub(visible + 1)).cloned())
            .map(|(c, _face_up)| c)
    };

    // Map Card -> (Entity, current_translation, anim_end) for in-place
    // updates.  `anim_end` is `Some(end_xy)` when a curve-based `CardAnimation`
    // is currently driving the card (e.g. a drag-rejection return tween).
    //
    // In the position loop below we compare `anim_end` against the new game-
    // state target position to decide whether to honour or cancel the tween:
    //   • end ≈ target  → animation is still heading to the right place; let
    //                      it finish (skip the snap/slide path).
    //   • end ≠ target  → the game state has changed (e.g. a new game started
    //                      while the win-cascade was mid-flight); cancel the
    //                      stale `CardAnimation` and apply the new position.
    let mut existing: HashMap<Card, (Entity, Vec3, Option<Vec2>, Option<CardChildrenKey>)> =
        HashMap::new();
    for (entity, marker, transform, anim, children_key) in entities.iter() {
        existing.insert(
            marker.card.clone(),
            (
                entity,
                transform.translation,
                anim.map(|a| a.end),
                children_key.copied(),
            ),
        );
    }

    let live_ids: HashSet<Card> = positions.iter().map(|(c, _, _)| c.0.clone()).collect();

    // Despawn any entity whose card is no longer tracked.
    for (card, (entity, _, _, _)) in &existing {
        if !live_ids.contains(card) {
            commands.entity(*entity).despawn();
        }
    }

    // For each card in the current state: spawn or update its entity, then
    // apply visibility. The waste buffer card is hidden so it cannot peek
    // behind the incoming top card during the draw slide animation.
    for ((card, face_up), position, z) in positions {
        let entity = match existing.get(&card) {
            Some(&(entity, cur, anim_end, children_key)) => {
                // If a CardAnimation is in flight, check whether its destination
                // still matches the game-state target.  If the game moved the card
                // elsewhere (e.g. new game started during a win-cascade scatter),
                // cancel the stale tween so the card snaps/slides to its new home.
                let has_anim = match anim_end {
                    Some(end_xy) if (end_xy - position).length() > 2.0 => {
                        commands.entity(entity).remove::<CardAnimation>();
                        false
                    }
                    Some(_) => true,
                    None => false,
                };
                update_card_entity(
                    &mut commands,
                    entity,
                    &card,
                    face_up,
                    position,
                    z,
                    layout,
                    slide_secs,
                    back_colour,
                    color_blind,
                    high_contrast,
                    cur,
                    has_anim,
                    children_key,
                    card_images,
                    selected_back,
                    font_handle,
                );
                entity
            }
            None => spawn_card_entity(
                &mut commands,
                &card,
                face_up,
                position,
                z,
                layout,
                back_colour,
                color_blind,
                high_contrast,
                card_images,
                selected_back,
                font_handle,
            ),
        };
        let visibility = if waste_buffer_id.as_ref() == Some(&card) {
            Visibility::Hidden
        } else {
            Visibility::Inherited
        };
        commands.entity(entity).insert(visibility);
    }
}

/// Returns an ordered vec of ((card, face_up), position, z) for every card in the game.
fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2, f32)> {
    let mut out: Vec<((Card, bool), Vec2, f32)> = Vec::with_capacity(52);
    let piles = [
        (KlondikePile::Stock, true),
        (KlondikePile::Stock, false),
        (KlondikePile::Foundation(Foundation::Foundation1), false),
        (KlondikePile::Foundation(Foundation::Foundation2), false),
        (KlondikePile::Foundation(Foundation::Foundation3), false),
        (KlondikePile::Foundation(Foundation::Foundation4), false),
        (KlondikePile::Tableau(Tableau::Tableau1), false),
        (KlondikePile::Tableau(Tableau::Tableau2), false),
        (KlondikePile::Tableau(Tableau::Tableau3), false),
        (KlondikePile::Tableau(Tableau::Tableau4), false),
        (KlondikePile::Tableau(Tableau::Tableau5), false),
        (KlondikePile::Tableau(Tableau::Tableau6), false),
        (KlondikePile::Tableau(Tableau::Tableau7), false),
    ];

    // Draw-Three waste fan step, proportional to the column spacing so it scales
    // with the platform's H_GAP_DIVISOR. Shared with input_plugin's hit-test via
    // `waste_fan_step` so the two never drift (a drift puts the top fanned card's
    // click target on the card beneath it).
    let waste_fan_step = waste_fan_step(layout);

    for (pile_type, is_stock_area) in piles {
        let Some(mut base) = layout.pile_positions.get(&pile_type).copied() else {
            continue;
        };
        if matches!(pile_type, KlondikePile::Stock) && is_stock_area {
            base.x -= tableau_col_step(layout);
        }
        let is_tableau = matches!(pile_type, KlondikePile::Tableau(_));
        let is_waste = matches!(pile_type, KlondikePile::Stock) && !is_stock_area;
        let cards = if matches!(pile_type, KlondikePile::Stock) {
            if is_stock_area {
                game.stock_cards()
            } else {
                game.waste_cards()
            }
        } else {
            game.pile(pile_type)
        };

        // Tableau uses a two-speed fan: face-down cards are packed tighter
        // than face-up cards so the visible (playable) portion stands out.
        // Non-tableau piles stack with a negligible offset.
        //
        // Waste pile: only the top N cards are rendered to prevent bleed-through
        // while new cards animate in from the stock. Draw-One shows 1; Draw-Three
        // shows up to 3 fanned in X (matching the standard Klondike presentation).
        let render_start = if is_waste {
            let visible = match game.draw_mode() {
                DrawStockConfig::DrawOne => 1_usize,
                DrawStockConfig::DrawThree => 3_usize,
            };
            // Render one extra card so that the card sliding off the waste
            // during a draw animation is still present in the world at z=0
            // (hidden under the stack) rather than vanishing mid-tween.
            cards.len().saturating_sub(visible + 1)
        } else {
            0
        };

        let mut y_offset = 0.0_f32;
        let rendered_len = cards[render_start..].len();
        for (slot, (card, face_up)) in cards[render_start..].iter().enumerate() {
            let x_offset = if is_waste && matches!(game.draw_mode(), DrawStockConfig::DrawThree) {
                // When len > visible, slot 0 is a hidden buffer card kept at
                // x=0 to prevent a flash during the draw tween. When len ≤
                // visible (small pile), every card is visible and should fan
                // normally — no card is hidden, so the shift is 0.
                let visible = 3_usize;
                let hidden = rendered_len.saturating_sub(visible);
                slot.saturating_sub(hidden) as f32 * waste_fan_step
            } else {
                0.0
            };
            let pos = Vec2::new(base.x + x_offset, base.y + y_offset);
            let z = 1.0 + (slot as f32) * STACK_FAN_FRAC;
            out.push(((card.clone(), *face_up), pos, z));
            if is_tableau {
                let step = if *face_up {
                    layout.tableau_fan_frac
                } else {
                    layout.tableau_facedown_fan_frac
                };
                y_offset -= layout.card_size.y * step;
            }
        }
    }
    out
}

fn all_cards(game: &GameState) -> Vec<(Card, bool)> {
    let mut cards: Vec<(Card, bool)> = Vec::with_capacity(52);
    cards.extend(game.stock_cards());
    cards.extend(game.waste_cards());
    for foundation in [
        Foundation::Foundation1,
        Foundation::Foundation2,
        Foundation::Foundation3,
        Foundation::Foundation4,
    ] {
        cards.extend(game.pile(KlondikePile::Foundation(foundation)));
    }
    for tableau in [
        Tableau::Tableau1,
        Tableau::Tableau2,
        Tableau::Tableau3,
        Tableau::Tableau4,
        Tableau::Tableau5,
        Tableau::Tableau6,
        Tableau::Tableau7,
    ] {
        cards.extend(game.pile(KlondikePile::Tableau(tableau)));
    }
    cards
}

#[allow(clippy::too_many_arguments)]
fn spawn_card_entity(
    commands: &mut Commands,
    card: &Card,
    face_up: bool,
    pos: Vec2,
    z: f32,
    layout: &Layout,
    back_colour: Color,
    color_blind: bool,
    high_contrast: bool,
    card_images: Option<&CardImageSet>,
    selected_back: usize,
    font_handle: Option<&Handle<Font>>,
) -> Entity {
    let sprite = card_sprite(
        card,
        face_up,
        layout.card_size,
        back_colour,
        card_images,
        selected_back,
    );

    let mut entity = commands.spawn((
        CardEntity { card: card.clone() },
        sprite,
        Transform::from_xyz(pos.x, pos.y, z),
        Visibility::default(),
    ));
    let entity_id = entity.id();
    // Every card gets a subtle drop-shadow child so the play surface reads
    // as physical instead of flat. Spawned in idle state; the drag-tracking
    // system retunes its offset / alpha when this card joins the dragged
    // stack.
    entity.with_children(|b| {
        add_card_shadow_child(b, layout.card_size);
    });
    // Every card gets a thin border frame so it reads as a distinct
    // rectangle against the dark felt, regardless of face state.
    entity.with_children(|b| {
        add_card_back_frame_child(b, layout.card_size);
    });
    // When PNG faces are loaded the rank/suit are baked into the image.
    // Only spawn the Text2d overlay in the solid-colour fallback (tests).
    // On Android we additionally spawn a large-print corner label even in
    // image mode so the rank/suit are legible at phone scale.
    if card_images.is_none() {
        entity.with_children(|b| {
            b.spawn((
                CardLabel,
                Text2d::new(label_for(card)),
                TextFont {
                    font_size: layout.card_size.x * FONT_SIZE_FRAC,
                    ..default()
                },
                TextColor(text_colour(card, color_blind, high_contrast)),
                Transform::from_xyz(0.0, 0.0, 0.01),
                label_visibility(face_up),
            ));
        });
    }
    if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
        entity.with_children(|b| {
            add_android_corner_label(
                b,
                card,
                face_up,
                layout.card_size,
                color_blind,
                high_contrast,
                font_handle,
            );
        });
    }
    // Record the appearance signature so subsequent `update_card_entity` calls
    // can skip rebuilding these children until one of the inputs changes.
    entity.insert(CardChildrenKey {
        face_up,
        card_size: layout.card_size,
        color_blind,
        high_contrast,
    });
    entity_id
}

#[allow(clippy::too_many_arguments)]
fn update_card_entity(
    commands: &mut Commands,
    entity: Entity,
    card: &Card,
    face_up: bool,
    pos: Vec2,
    z: f32,
    layout: &Layout,
    slide_secs: f32,
    back_colour: Color,
    color_blind: bool,
    high_contrast: bool,
    cur: Vec3,
    has_card_animation: bool,
    existing_children_key: Option<CardChildrenKey>,
    card_images: Option<&CardImageSet>,
    selected_back: usize,
    font_handle: Option<&Handle<Font>>,
) {
    let target = Vec3::new(pos.x, pos.y, z);

    // Always refresh the visual appearance.
    commands.entity(entity).insert(card_sprite(
        card,
        face_up,
        layout.card_size,
        back_colour,
        card_images,
        selected_back,
    ));

    // Skip the snap/slide path entirely when a curve-based `CardAnimation`
    // is driving this card (e.g. the drag-rejection return tween). Writing
    // `Transform` here would race that animation each frame and cause a
    // visible jump. The animation system snaps the final position itself
    // when it completes.
    if !has_card_animation {
        // Slide to the new position when it differs meaningfully; snap otherwise.
        if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
            // Lift the card immediately on the first frame of the animation so
            // it never appears behind a card that is already resting at the
            // destination slot. `advance_card_anims` will maintain this lift
            // throughout the tween and snap to `target` (without lift) on
            // completion.
            let start = Vec3::new(cur.x, cur.y, z + CARD_ANIM_Z_LIFT);
            commands
                .entity(entity)
                .insert(Transform::from_translation(start))
                .insert(CardAnim {
                    start,
                    target,
                    elapsed: 0.0,
                    duration: slide_secs,
                    delay: 0.0,
                });
        } else {
            commands
                .entity(entity)
                .remove::<CardAnim>()
                .insert(Transform::from_xyz(pos.x, pos.y, z));
        }
    }

    // Rebuild the card's child visuals (drop-shadow, border frame, and the
    // rank/suit label / large-print corner overlay) only when an input that
    // affects them actually changed. The child set depends solely on
    // `CardChildrenKey`; the face/back image is carried by the always-refreshed
    // `Sprite` above, so theme/card-back swaps need no child rebuild. Skipping
    // this on a position-only move avoids despawning and respawning the child
    // entities (incl. a `Text2d` glyph re-layout) for all 52 cards on every
    // `StateChangedEvent` — the spike that stuttered the slide animation on
    // high-resolution devices.
    let new_children_key = CardChildrenKey {
        face_up,
        card_size: layout.card_size,
        color_blind,
        high_contrast,
    };
    if existing_children_key != Some(new_children_key) {
        commands.entity(entity).despawn_related::<Children>();
        commands.entity(entity).with_children(|b| {
            add_card_shadow_child(b, layout.card_size);
        });
        commands.entity(entity).with_children(|b| {
            add_card_back_frame_child(b, layout.card_size);
        });
        if card_images.is_none() {
            commands.entity(entity).with_children(|b| {
                b.spawn((
                    CardLabel,
                    Text2d::new(label_for(card)),
                    TextFont {
                        font_size: layout.card_size.x * FONT_SIZE_FRAC,
                        ..default()
                    },
                    TextColor(text_colour(card, color_blind, high_contrast)),
                    Transform::from_xyz(0.0, 0.0, 0.01),
                    label_visibility(face_up),
                ));
            });
        }
        if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
            commands.entity(entity).with_children(|b| {
                add_android_corner_label(
                    b,
                    card,
                    face_up,
                    layout.card_size,
                    color_blind,
                    high_contrast,
                    font_handle,
                );
            });
        }
        commands.entity(entity).insert(new_children_key);
    }
}

fn label_for(card: &Card) -> String {
    let rank = match card.rank() {
        Rank::Ace => "A",
        Rank::Two => "2",
        Rank::Three => "3",
        Rank::Four => "4",
        Rank::Five => "5",
        Rank::Six => "6",
        Rank::Seven => "7",
        Rank::Eight => "8",
        Rank::Nine => "9",
        Rank::Ten => "10",
        Rank::Jack => "J",
        Rank::Queen => "Q",
        Rank::King => "K",
    };
    let suit = match card.suit() {
        Suit::Clubs => "C",
        Suit::Diamonds => "D",
        Suit::Hearts => "H",
        Suit::Spades => "S",
    };
    format!("{rank}{suit}")
}

/// Suit colour for the rank/suit overlay rendered atop the constant
/// fallback sprite (only fires under `MinimalPlugins` — production
/// renders the suit glyph baked into the PNG). 2-colour traditional
/// pairing — hearts + diamonds share the saturated red, clubs +
/// spades share the near-white. Two accessibility flags compose:
///
/// - `color_blind`: red-suit cards swap to `RED_SUIT_COLOUR_CBM`
///   (lime) — the "Settings toggle swaps red→lime" half of the
///   design system's colour-blind support. CBM is a hue-replacement
///   for red, so HC has no further effect on red when CBM is on
///   (the lime is itself a high-luminance colour).
/// - `high_contrast`: when CBM is off, red suits boost to
///   `RED_SUIT_COLOUR_HC` (`#ff6868`); black suits boost from
///   `#e8e8e8` (near-white) to `#f5f5f5` (`TEXT_PRIMARY_HC`).
///
/// The other half of CBM support (always-on filled-vs-outlined
/// glyph differentiation for ♥♠ vs ♦♣) is baked into the PNG art
/// and has no constant-fallback equivalent.
fn text_colour(card: &Card, color_blind: bool, high_contrast: bool) -> Color {
    if card.suit().is_red() {
        if color_blind {
            // CBM lime wins — the colour-blind swap replaces the
            // red hue entirely, and the lime is already high-
            // luminance, so an HC boost on top has nothing to do.
            RED_SUIT_COLOUR_CBM
        } else if high_contrast {
            RED_SUIT_COLOUR_HC
        } else {
            RED_SUIT_COLOUR
        }
    } else if high_contrast {
        TEXT_PRIMARY_HC
    } else {
        BLACK_SUIT_COLOUR
    }
}

fn label_visibility(face_up: bool) -> Visibility {
    if face_up {
        Visibility::Inherited
    } else {
        Visibility::Hidden
    }
}

/// Rank+suit string for the readability overlay on touch HUD layouts.
/// Uses Unicode suit glyphs (♠♥♦♣ — U+2660–U+2666, covered by FiraMono).
fn mobile_label_for(card: &Card) -> String {
    let rank = match card.rank() {
        Rank::Ace => "A",
        Rank::Two => "2",
        Rank::Three => "3",
        Rank::Four => "4",
        Rank::Five => "5",
        Rank::Six => "6",
        Rank::Seven => "7",
        Rank::Eight => "8",
        Rank::Nine => "9",
        Rank::Ten => "10",
        Rank::Jack => "J",
        Rank::Queen => "Q",
        Rank::King => "K",
    };
    let suit = match card.suit() {
        Suit::Clubs => "♣",
        Suit::Diamonds => "♦",
        Suit::Hearts => "♥",
        Suit::Spades => "♠",
    };
    format!("{rank}{suit}")
}

/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
/// face-up cards. The background sprite covers the card art's own small
/// corner text so only the large overlay is visible.
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
/// face-up cards using FiraMono (passed via `font_handle`) so that the
/// suit Unicode glyphs U+2660–U+2666 render correctly. Without an explicit
/// font handle Bevy falls back to its built-in face which does not include
/// those glyphs, causing a coloured missing-glyph rectangle to appear in
/// the text colour — the root cause of the "red square on face-down cards"
/// visual bug (the box bleeds through near the card edge at z=0.02).
fn add_android_corner_label(
    parent: &mut ChildSpawnerCommands,
    card: &Card,
    face_up: bool,
    card_size: Vec2,
    color_blind: bool,
    high_contrast: bool,
    font_handle: Option<&Handle<Font>>,
) {
    if !face_up {
        return;
    }
    let font_size = card_size.x * FONT_SIZE_FRAC_MOBILE;
    let inset = 3.0_f32;
    // Background covers ~3 monospace chars wide × 1 line tall.
    // FiraMono char width ≈ 0.6 × font_size; 2.0× gives room for "10♠"
    // (3 chars = 1.8× font_size) plus a small margin.
    let bg_w = font_size * 2.0;
    let bg_h = font_size * 1.25;

    // Background covers the PNG's baked-in small corner text (top-left).
    // Classic PNG cards have a white face, so the background must be white too.
    // (CARD_FACE_COLOUR is the Terminal theme's dark face colour — wrong here.)
    parent.spawn((
        AndroidCornerBg,
        Sprite {
            color: Color::WHITE,
            custom_size: Some(Vec2::new(bg_w, bg_h)),
            ..default()
        },
        Transform::from_xyz(
            -card_size.x / 2.0 + inset + bg_w / 2.0,
            card_size.y / 2.0 - inset - bg_h / 2.0,
            0.015,
        ),
    ));
    // Cover the matching rotated baked-in text at the bottom-right corner.
    parent.spawn((
        AndroidCornerBg,
        Sprite {
            color: Color::WHITE,
            custom_size: Some(Vec2::new(bg_w, bg_h)),
            ..default()
        },
        Transform::from_xyz(
            card_size.x / 2.0 - inset - bg_w / 2.0,
            -card_size.y / 2.0 + inset + bg_h / 2.0,
            0.015,
        ),
    ));

    // Large rank+suit text drawn on top of the background. FiraMono must be
    // wired here explicitly — the suit glyphs (U+2660–U+2666) are not in
    // Bevy's built-in font and render as a coloured rectangle without it.
    //
    // Classic PNG cards have a white face: red suits stay the same saturated
    // red, but black suits must use a dark colour (CARD_FACE_COLOUR ≈ #1a1a1a)
    // rather than the near-white BLACK_SUIT_COLOUR designed for the dark
    // Terminal theme background.
    let text_col = if card.suit().is_red() {
        if color_blind {
            RED_SUIT_COLOUR_CBM
        } else if high_contrast {
            RED_SUIT_COLOUR_HC
        } else {
            RED_SUIT_COLOUR
        }
    } else {
        CARD_FACE_COLOUR
    };
    let label_text = mobile_label_for(card);
    parent.spawn((
        AndroidCornerLabel(label_text.clone()),
        CardLabel,
        Text2d::new(label_text),
        TextFont {
            font: font_handle.cloned().unwrap_or_default(),
            font_size,
            ..default()
        },
        TextColor(text_col),
        Anchor::TOP_LEFT,
        Transform::from_xyz(-card_size.x / 2.0 + inset, card_size.y / 2.0 - inset, 0.02),
    ));
}

// ---------------------------------------------------------------------------
// Task #34 — Card-flip animation systems
// ---------------------------------------------------------------------------

/// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity.
///
/// Skipped when `EffectiveSlideDuration::slide_secs == 0.0` (Instant speed).
fn start_flip_anim(
    mut events: MessageReader<CardFlippedEvent>,
    slide_dur: Option<Res<EffectiveSlideDuration>>,
    mut commands: Commands,
    card_entities: Query<(Entity, &CardEntity)>,
) {
    if slide_dur.is_some_and(|d| d.slide_secs == 0.0) {
        // Instant animation speed — skip the flip effect entirely.
        events.clear();
        return;
    }

    for CardFlippedEvent(flipped_card) in events.read() {
        for (entity, marker) in &card_entities {
            if marker.card == *flipped_card {
                commands.entity(entity).insert(CardFlipAnim {
                    timer: 0.0,
                    phase: FlipPhase::ScalingDown,
                });
                break;
            }
        }
    }
}

/// Advances `CardFlipAnim` each frame, modifying `Transform::scale.x`.
///
/// - Phase `ScalingDown`: lerps scale.x from 1.0 → 0.0 over `FLIP_HALF_SECS`.
/// - At the midpoint the phase switches to `ScalingUp`, scale.x resets to 0,
///   and a `CardFaceRevealedEvent` is fired so audio plays in sync with the reveal.
/// - Phase `ScalingUp`:  lerps scale.x from 0.0 → 1.0 over `FLIP_HALF_SECS`.
/// - When complete the component is removed and scale.x is restored to 1.0.
fn tick_flip_anim(
    mut commands: Commands,
    time: Res<Time>,
    mut anims: Query<(Entity, &CardEntity, &mut Transform, &mut CardFlipAnim)>,
    mut reveal_events: MessageWriter<CardFaceRevealedEvent>,
) {
    let dt = time.delta_secs();
    for (entity, card_entity, mut transform, mut anim) in &mut anims {
        anim.timer += dt;
        match anim.phase {
            FlipPhase::ScalingDown => {
                let t = (anim.timer / FLIP_HALF_SECS).min(1.0);
                transform.scale.x = 1.0 - t;
                if t >= 1.0 {
                    anim.phase = FlipPhase::ScalingUp;
                    anim.timer = 0.0;
                    transform.scale.x = 0.0;
                    // Fire the reveal event exactly once, at the phase transition,
                    // so the flip sound is synchronised with the visual face reveal.
                    reveal_events.write(CardFaceRevealedEvent(card_entity.card.clone()));
                }
            }
            FlipPhase::ScalingUp => {
                let t = (anim.timer / FLIP_HALF_SECS).min(1.0);
                transform.scale.x = t;
                if t >= 1.0 {
                    transform.scale.x = 1.0;
                    commands.entity(entity).remove::<CardFlipAnim>();
                }
            }
        }
    }
}

// ---------------------------------------------------------------------------
// Task #38 — Drag-elevation shadow
// ---------------------------------------------------------------------------

/// Maintains a single `ShadowEntity` while cards are being dragged.
///
/// - If a drag is active, spawns (or repositions) a semi-transparent dark
///   sprite behind the top dragged card.
/// - If no drag is active, despawns the shadow entity.
fn update_drag_shadow(
    mut commands: Commands,
    drag: Res<DragState>,
    layout: Option<Res<LayoutResource>>,
    card_entities: Query<(&CardEntity, &Transform)>,
    card_index: Res<CardEntityIndex>,
    mut shadow: Local<Option<Entity>>,
) {
    if drag.is_idle() {
        // No drag in progress — remove shadow if it exists.
        if let Some(e) = shadow.take() {
            commands.entity(e).despawn();
        }
        return;
    }

    let Some(layout) = layout else { return };
    let card_w = layout.0.card_size.x;
    let card_h = layout.0.card_size.y;

    // Find the world position of the first (top) dragged card.
    let top_pos = drag.cards.first().and_then(|first_card| {
        card_index
            .get(first_card)
            .and_then(|entity| card_entities.get(entity).ok())
            .map(|(_, t)| t.translation)
    });

    let Some(top_pos) = top_pos else { return };

    // Shadow is slightly larger, offset behind-and-below, at a z slightly
    // below the dragged cards.
    let shadow_pos = top_pos + Vec3::new(-4.0, 4.0, -1.0);

    match *shadow {
        Some(e) => {
            // Reposition the existing shadow.
            commands
                .entity(e)
                .insert(Transform::from_translation(shadow_pos));
        }
        None => {
            // Spawn a new shadow sprite. Alpha tracks the per-card
            // CARD_SHADOW_ALPHA_DRAG token so the Terminal palette's
            // "no box-shadow" policy disables this stack shadow in
            // lockstep with the per-card shadows. Re-enabling shadows
            // is then a one-line change in `ui_theme`, not a hunt
            // through plugin code.
            let e = commands
                .spawn((
                    ShadowEntity,
                    Sprite {
                        color: CARD_SHADOW_COLOR.with_alpha(CARD_SHADOW_ALPHA_DRAG),
                        custom_size: Some(Vec2::new(card_w + 8.0, card_h + 8.0)),
                        ..default()
                    },
                    Transform::from_translation(shadow_pos),
                    Visibility::default(),
                ))
                .id();
            *shadow = Some(e);
        }
    }
}

/// Snaps every per-card [`CardShadow`] between its idle and lifted tunings
/// based on whether the parent [`CardEntity`] is currently in
/// [`DragState::cards`]. Runs every frame; the transition is an instant snap
/// (no lerp) — the existing shake / settle feedback already handles motion
/// at drag-end, so an additional shadow tween would compete with those cues.
///
/// The shadow size is rebuilt from the parent card's current `Sprite`
/// `custom_size` plus the appropriate padding, so the resize handler does
/// not need to pre-tune shadow sizes for the drag state — this system fixes
/// the geometry within one frame.
fn update_card_shadows_on_drag(
    drag: Res<DragState>,
    cards: Query<(&CardEntity, &Sprite, &Children), Without<CardShadow>>,
    mut shadows: Query<(&mut Sprite, &mut Transform), With<CardShadow>>,
) {
    let dragged: HashSet<&Card> = drag.cards.iter().collect();

    for (card_entity, card_sprite, children) in cards.iter() {
        let is_dragged = dragged.contains(&card_entity.card);
        let (offset, padding, alpha) = card_shadow_params(is_dragged);
        let Some(card_size) = card_sprite.custom_size else {
            continue;
        };

        for child in children.iter() {
            let Ok((mut shadow_sprite, mut shadow_transform)) = shadows.get_mut(child) else {
                continue;
            };
            shadow_sprite.color = CARD_SHADOW_COLOR.with_alpha(alpha);
            shadow_sprite.custom_size = Some(card_size + padding);
            shadow_transform.translation.x = offset.x;
            shadow_transform.translation.y = offset.y;
            shadow_transform.translation.z = CARD_SHADOW_LOCAL_Z;
        }
    }
}

// ---------------------------------------------------------------------------
// Task #28 — Hint highlight tick system
// ---------------------------------------------------------------------------

/// Counts down `HintHighlight::remaining` each frame. When it reaches zero,
/// removes both `HintHighlight` and `HintHighlightTimer` (if present) and
/// resets the card sprite to its normal face-up colour.
fn tick_hint_highlight(
    time: Res<Time>,
    mut commands: Commands,
    mut query: Query<(Entity, &mut HintHighlight, &mut Sprite, &CardEntity)>,
    game: Res<GameStateResource>,
    settings: Option<Res<SettingsResource>>,
    card_images: Option<Res<CardImageSet>>,
) {
    let back_idx = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
    let use_images = card_images.is_some();
    for (entity, mut hint, mut sprite, card_entity) in query.iter_mut() {
        hint.remaining -= time.delta_secs();
        if hint.remaining <= 0.0 {
            // Restore the normal sprite colour.
            // When image-based rendering is active, WHITE is the neutral tint;
            // otherwise restore the solid colour appropriate to the card state.
            sprite.color = if use_images {
                Color::WHITE
            } else {
                let is_face_up = all_cards(&game.0)
                    .iter()
                    .find(|(c, _face_up)| *c == card_entity.card)
                    .is_some_and(|(_, face_up)| *face_up);
                if is_face_up {
                    CARD_FACE_COLOUR
                } else {
                    card_back_colour(back_idx)
                }
            };
            commands
                .entity(entity)
                .remove::<HintHighlight>()
                .remove::<HintHighlightTimer>();
        }
    }
}

// ---------------------------------------------------------------------------
// Task #46 — Right-click legal destination highlights
// ---------------------------------------------------------------------------

/// Lime tint applied to a `PileMarker` sprite when it is a legal
/// destination for the right-clicked card. Same RGB as the design-
/// system [`STATE_SUCCESS`] token at 60% alpha. Spelled as a literal
/// because `Alpha::with_alpha` is not yet a `const` trait method on
/// stable; the tracking test below pins the RGB to `STATE_SUCCESS`
/// so a palette swap can't drift the two apart silently.
const RIGHT_CLICK_HIGHLIGHT_COLOUR: Color = Color::srgba(0.675, 0.761, 0.404, 0.6);

/// Counts down `RightClickHighlightTimer` each frame and clears the highlight
/// when the timer expires.
///
/// This is a fallback expiry: highlights also clear immediately on
/// `StateChangedEvent` (move made) or when the game is paused, whichever comes
/// first. The 1.5 s timer ensures highlights always disappear even if the
/// player takes no further action.
fn tick_right_click_highlights(
    mut commands: Commands,
    time: Res<Time>,
    paused: Option<Res<PausedResource>>,
    mut highlights: Query<
        (Entity, &mut RightClickHighlightTimer, &mut Sprite),
        With<RightClickHighlight>,
    >,
) {
    if paused.is_some_and(|p| p.0) {
        return;
    }
    let dt = time.delta_secs();
    for (entity, mut timer, mut sprite) in &mut highlights {
        timer.0 -= dt;
        if timer.0 <= 0.0 {
            // Restore the pile marker to its default colour before removing
            // the highlight marker component.
            sprite.color = PILE_MARKER_DEFAULT_COLOUR;
            commands
                .entity(entity)
                .remove::<RightClickHighlight>()
                .remove::<RightClickHighlightTimer>();
        }
    }
}

/// Removes the `RightClickHighlight` marker from every highlighted pile and
/// resets its sprite colour to `PILE_MARKER_DEFAULT_COLOUR`.
///
/// Shared by the on-state-change and on-pause clear systems to avoid
/// duplicating the removal logic.
fn clear_right_click_highlights(
    commands: &mut Commands,
    highlighted: &Query<Entity, With<RightClickHighlight>>,
    pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite)>,
) {
    for entity in highlighted.iter() {
        commands.entity(entity).remove::<RightClickHighlight>();
    }
    for (_entity, _, mut sprite) in pile_markers.iter_mut() {
        if sprite.color == RIGHT_CLICK_HIGHLIGHT_COLOUR {
            sprite.color = PILE_MARKER_DEFAULT_COLOUR;
        }
    }
}

/// Clears all right-click destination highlights whenever any game-state
/// mutation succeeds (`StateChangedEvent` fires).
///
/// This ensures stale highlights do not linger after a card is moved.
fn clear_right_click_highlights_on_state_change(
    mut events: MessageReader<StateChangedEvent>,
    mut commands: Commands,
    highlighted: Query<Entity, With<RightClickHighlight>>,
    mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
) {
    if events.read().next().is_none() {
        return;
    }
    clear_right_click_highlights(&mut commands, &highlighted, &mut pile_markers);
}

/// Clears all right-click destination highlights when the game is paused
/// (`PausedResource` changes to `true`).
///
/// Prevents highlighted pile markers from remaining visible behind the pause
/// overlay.
fn clear_right_click_highlights_on_pause(
    paused: Option<Res<PausedResource>>,
    mut commands: Commands,
    highlighted: Query<Entity, With<RightClickHighlight>>,
    mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
) {
    let Some(paused) = paused else { return };
    if paused.is_changed() && paused.0 {
        clear_right_click_highlights(&mut commands, &highlighted, &mut pile_markers);
    }
}

/// Handles right-click: highlights legal destination piles for the clicked card,
/// and clears highlights on any subsequent right- or left-click.
///
/// This system lives in `CardPlugin` to keep `InputPlugin` untouched.
#[allow(clippy::too_many_arguments)]
fn handle_right_click(
    buttons: Option<Res<ButtonInput<MouseButton>>>,
    paused: Option<Res<PausedResource>>,
    drag: Res<DragState>,
    windows: Query<&Window, With<bevy::window::PrimaryWindow>>,
    cameras: Query<(&Camera, &GlobalTransform)>,
    layout: Option<Res<LayoutResource>>,
    game: Res<GameStateResource>,
    mut commands: Commands,
    mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
    card_entities: Query<(Entity, &CardEntity, &Transform)>,
    highlighted: Query<Entity, With<RightClickHighlight>>,
) {
    if paused.is_some_and(|p| p.0) {
        return;
    }

    let Some(buttons) = buttons else { return };
    let left_pressed = buttons.just_pressed(MouseButton::Left);
    let right_pressed = buttons.just_pressed(MouseButton::Right);

    // Clear existing highlights on any click.
    if left_pressed || right_pressed {
        for entity in &highlighted {
            commands.entity(entity).remove::<RightClickHighlight>();
        }
        for (_entity, _, mut sprite) in &mut pile_markers {
            if sprite.color == RIGHT_CLICK_HIGHLIGHT_COLOUR {
                sprite.color = PILE_MARKER_DEFAULT_COLOUR;
            }
        }
    }

    // Only proceed for right-clicks while not dragging.
    if !right_pressed || !drag.is_idle() {
        return;
    }

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

    // Convert cursor to world-space position.
    let Some(world) = cursor_world_pos(&windows, &cameras) else {
        return;
    };

    // Find the topmost face-up card under the cursor.
    let Some(card) = find_top_card_at(world, &game.0, &layout.0, &card_entities) else {
        return;
    };

    let Some(source_pile) = game.0.pile_containing_card(card.clone()) else {
        return;
    };

    // Tint piles that legally accept the card.
    for (entity, pile_marker, mut sprite) in &mut pile_markers {
        let legal = game.0.can_move_cards(&source_pile, &pile_marker.0, 1);
        if legal {
            sprite.color = RIGHT_CLICK_HIGHLIGHT_COLOUR;
            commands
                .entity(entity)
                .insert(RightClickHighlight)
                .insert(RightClickHighlightTimer(1.5));
        }
    }
}

/// Converts cursor position to 2-D world coordinates.
fn cursor_world_pos(
    windows: &Query<&Window, With<bevy::window::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()
}

/// Returns the topmost face-up `Card` under `cursor` by checking axis-aligned
/// bounding rectangles of all card sprites, picking the highest Z.
fn find_top_card_at(
    cursor: Vec2,
    game: &GameState,
    layout: &Layout,
    card_entities: &Query<(Entity, &CardEntity, &Transform)>,
) -> Option<Card> {
    let half = layout.card_size / 2.0;
    let mut best: Option<(f32, Card)> = None;

    for (_, card_entity, transform) in card_entities.iter() {
        let pos = transform.translation.truncate();
        if cursor.x < pos.x - half.x
            || cursor.x > pos.x + half.x
            || cursor.y < pos.y - half.y
            || cursor.y > pos.y + half.y
        {
            continue;
        }
        let found = all_cards(game)
            .into_iter()
            .find(|(c, face_up)| *c == card_entity.card && *face_up);
        if let Some((card, _)) = found {
            let z = transform.translation.z;
            if best.as_ref().is_none_or(|(bz, _)| z > *bz) {
                best = Some((z, card));
            }
        }
    }
    best.map(|(_, card)| card)
}

// ---------------------------------------------------------------------------
// Task #28 — Stock-empty visual indicator
// ---------------------------------------------------------------------------

/// Sprite colour applied to the stock `PileMarker` when the stock pile is empty,
/// to signal to the player that there are no more cards to draw. Pure white
/// at 0.4 alpha — a deliberate brightness-boost over the default marker so
/// the "empty" state is more visible, not less. Not derived from a palette
/// token: this is a sprite tint, not chrome colour.
const STOCK_EMPTY_DIM_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.4);

/// Sprite colour applied to the stock `PileMarker` when cards remain in
/// stock. Aliased to [`PILE_MARKER_DEFAULT_COLOUR`] so it tracks the rest
/// of the engine's idle pile-marker tint automatically.
const STOCK_NORMAL_COLOUR: Color = PILE_MARKER_DEFAULT_COLOUR;

/// Shared logic for updating the stock pile marker's dim state and "↺" label.
///
/// If the stock pile is empty the marker sprite is dimmed to
/// `STOCK_EMPTY_DIM_COLOUR` and a child `Text2d` with `StockEmptyLabel` is
/// spawned (if not already present). When the stock is non-empty the marker is
/// restored to `STOCK_NORMAL_COLOUR` and any `StockEmptyLabel` children are
/// despawned.
fn apply_stock_empty_indicator<F: bevy::ecs::query::QueryFilter>(
    commands: &mut Commands,
    game: &GameState,
    pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite), F>,
    label_children: &Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
    layout: &Layout,
    font: Handle<Font>,
) {
    let stock_empty = game.stock_cards().is_empty();

    for (entity, pile_marker, mut sprite) in pile_markers.iter_mut() {
        if pile_marker.0 != KlondikePile::Stock {
            continue;
        }

        if stock_empty {
            // Dim the marker sprite.
            sprite.color = STOCK_EMPTY_DIM_COLOUR;

            // Spawn the "↺" label only if one does not already exist.
            let already_has_label = label_children
                .iter()
                .any(|(_, parent)| parent.parent() == entity);
            if !already_has_label {
                let font_size = layout.card_size.x * 0.4;
                commands.entity(entity).with_children(|b| {
                    b.spawn((
                        StockEmptyLabel,
                        Text2d::new("↺"),
                        TextFont {
                            font: font.clone(),
                            font_size,
                            ..default()
                        },
                        TextColor(TEXT_PRIMARY.with_alpha(0.7)),
                        Transform::from_xyz(0.0, 0.0, 0.1),
                    ));
                });
            }
        } else {
            // Restore normal brightness.
            sprite.color = STOCK_NORMAL_COLOUR;

            // Despawn any existing "↺" label children.
            for (label_entity, parent) in label_children.iter() {
                if parent.parent() == entity {
                    commands.entity(label_entity).despawn();
                }
            }
        }
    }
}

/// Runs at `PostStartup` to apply the stock-empty indicator for the initial
/// game state (before any `StateChangedEvent` fires).
fn update_stock_empty_indicator_startup(
    mut commands: Commands,
    game: Res<GameStateResource>,
    layout: Option<Res<LayoutResource>>,
    font_res: Option<Res<FontResource>>,
    mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
    label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
) {
    let Some(layout) = layout else { return };
    let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
    apply_stock_empty_indicator(
        &mut commands,
        &game.0,
        &mut pile_markers,
        &label_children,
        &layout.0,
        font,
    );
}

/// Runs each `Update` tick when a `StateChangedEvent` arrives, keeping the
/// stock pile marker dim state and "↺" label in sync with the current stock.
fn update_stock_empty_indicator(
    mut events: MessageReader<StateChangedEvent>,
    mut commands: Commands,
    game: Res<GameStateResource>,
    layout: Option<Res<LayoutResource>>,
    font_res: Option<Res<FontResource>>,
    mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
    label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
) {
    if events.read().next().is_none() {
        return;
    }
    let Some(layout) = layout else { return };
    let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
    apply_stock_empty_indicator(
        &mut commands,
        &game.0,
        &mut pile_markers,
        &label_children,
        &layout.0,
        font,
    );
}

// ---------------------------------------------------------------------------
// Stock-pile remaining-count badge
//
// Shows a small "N" chip pinned to the bottom-right corner of the stock pile so
// the player can see how many cards remain before the next recycle. The
// existing `StockEmptyLabel` (`↺` overlay) covers the empty-stock case, so
// the badge hides itself when the stock has zero cards — the two indicators
// never render at the same time.
// ---------------------------------------------------------------------------

/// Inset (in pixels) from the bottom-right corner of the stock pile sprite to
/// the centre of the count badge. Anchoring to the bottom-right keeps the chip
/// clear of the rank/suit pip in the card's top-left corner. Both components
/// move the centre *inward* from that corner: `x` is subtracted from the right
/// edge, `y` is added to the bottom edge. The `x` magnitude must satisfy
/// `x >= STOCK_BADGE_SIZE.x / 2` so the badge right edge stays inside the stock
/// pile and never overlaps the adjacent waste pile — critical on Android where
/// `H_GAP_DIVISOR = 32` gives an inter-pile gap of only ~4 px.
const STOCK_BADGE_INSET: Vec2 = Vec2::new(20.0, 8.0);

/// Width / height of the badge background sprite, in world pixels. Sized so
/// a 2-digit count (max "24") fits comfortably with `TYPE_BODY` (14 pt) text.
const STOCK_BADGE_SIZE: Vec2 = Vec2::new(34.0, 20.0);

/// Returns the count of cards currently in the stock pile.
///
/// Pure helper extracted so the count source is identical between the spawn
/// system, the update system, and the unit tests.
fn stock_card_count(game: &GameState) -> usize {
    game.stock_cards().len()
}

/// Returns the world-space `Vec3` for the centre of the stock-count badge,
/// given the current `Layout`. The badge sits at the bottom-right corner of
/// the stock pile sprite, inset by [`STOCK_BADGE_INSET`], so it stays clear of
/// the rank/suit pip in the card's top-left corner.
fn stock_badge_translation(layout: &Layout) -> Vec3 {
    // Empty layouts don't contain a Stock entry — fall back to origin so
    // the badge stays in a deterministic spot until the layout is filled.
    let pile_pos = layout
        .pile_positions
        .get(&KlondikePile::Stock)
        .copied()
        .unwrap_or(Vec2::ZERO);
    let half = layout.card_size * 0.5;
    // Anchor to the bottom-right corner, then move the centre inward.
    let x = pile_pos.x + half.x - STOCK_BADGE_INSET.x;
    let y = pile_pos.y - half.y + STOCK_BADGE_INSET.y;
    Vec3::new(x, y, Z_STOCK_BADGE)
}

/// Spawns the stock-count badge entity (background sprite + child text)
/// into the world. Called once, when the badge does not yet exist.
fn spawn_stock_count_badge(
    commands: &mut Commands,
    layout: &Layout,
    font: Option<&Handle<Font>>,
    count: usize,
) {
    let translation = stock_badge_translation(layout);
    let visibility = if count == 0 {
        Visibility::Hidden
    } else {
        Visibility::Inherited
    };
    let text_font = TextFont {
        font: font.cloned().unwrap_or_default(),
        font_size: TYPE_BODY,
        ..default()
    };

    commands
        .spawn((
            StockCountBadge,
            Sprite {
                color: STOCK_BADGE_BG,
                custom_size: Some(STOCK_BADGE_SIZE),
                ..default()
            },
            Transform::from_translation(translation),
            visibility,
        ))
        .with_children(|b| {
            b.spawn((
                StockCountBadgeText,
                Text2d::new(format!("{count}")),
                text_font,
                TextColor(STOCK_BADGE_FG),
                // Slightly above the chip background so the digits aren't
                // occluded by the sprite they sit on.
                Transform::from_xyz(0.0, 0.0, 0.1),
            ));
        });
}

/// Spawns the stock-pile remaining-count badge if it does not yet exist,
/// and otherwise updates its text and visibility in place.
///
/// Visibility rule: hidden when the stock is empty (the existing `↺`
/// `StockEmptyLabel` overlay covers that state), shown when one or more
/// cards remain.
///
/// Position is recomputed from `LayoutResource` every tick so the badge
/// follows the stock pile across `WindowResized` layout updates without
/// needing a dedicated resize handler.
#[allow(clippy::too_many_arguments)]
fn update_stock_count_badge(
    mut commands: Commands,
    game: Option<Res<GameStateResource>>,
    layout: Option<Res<LayoutResource>>,
    font: Option<Res<FontResource>>,
    mut badges: Query<(Entity, &mut Transform, &mut Visibility), With<StockCountBadge>>,
    children: Query<&Children, With<StockCountBadge>>,
    mut texts: Query<&mut Text2d, With<StockCountBadgeText>>,
) {
    let Some(game) = game else { return };
    let Some(layout) = layout else { return };

    let count = stock_card_count(&game.0);
    let translation = stock_badge_translation(&layout.0);
    let target_visibility = if count == 0 {
        Visibility::Hidden
    } else {
        Visibility::Inherited
    };

    if badges.is_empty() {
        spawn_stock_count_badge(&mut commands, &layout.0, font.as_ref().map(|f| &f.0), count);
        return;
    }

    for (entity, mut transform, mut visibility) in badges.iter_mut() {
        transform.translation = translation;
        if *visibility != target_visibility {
            *visibility = target_visibility;
        }
        // Update the child text to reflect the latest count. The text node
        // is created at spawn time, so under normal operation we always
        // have exactly one child here.
        if let Ok(badge_children) = children.get(entity) {
            for child in badge_children.iter() {
                if let Ok(mut text) = texts.get_mut(child) {
                    let new = format!("{count}");
                    if text.0 != new {
                        text.0 = new;
                    }
                }
            }
        }
    }
}

/// Coalesces every `WindowResized` event arriving this frame into the latest
/// pending size on [`ResizeThrottle`].
///
/// `WindowResized` fires per pixel of resize drag, so a fast corner drag can
/// emit many events per frame. Reading `.last()` keeps only the final size —
/// every frame's snap target is the most recent window size, never a stale
/// one. Pending stays set across frames until the throttled applier consumes
/// it; that's how we still flush the final "release" position when the user
/// stops dragging.
fn collect_resize_events(
    mut events: MessageReader<WindowResized>,
    mut throttle: ResMut<ResizeThrottle>,
) {
    if let Some(ev) = events.read().last() {
        throttle.pending = Some(Vec2::new(ev.width, ev.height));
    }
}

/// Snaps every card sprite to its target position, size, and (in the
/// fallback Text2d label path) font size when the window is resized.
///
/// **In-place mutation only.** Resize is the hot path — events fire per
/// pixel of drag, so this system cannot afford the despawn/respawn churn
/// `update_card_entity` does. We mutate `Sprite.custom_size`, `Transform`,
/// and child `TextFont.font_size` directly, leaving the card image handle,
/// suit/rank, and `CardLabel` entity untouched. Cards keep their identity
/// across resizes; only their size and position change. The full repaint
/// path lives in [`update_card_entity`] and is still used by every non-resize
/// caller (deals, moves, flips, settings toggles).
///
/// **Throttled to ~20 Hz.** [`ResizeThrottle::pending`] is consumed at most
/// once per [`RESIZE_THROTTLE_SECS`]. When events stop arriving, the next
/// tick past the throttle window flushes the final size and clears
/// `pending`, so the steady-state always matches the user's release size.
///
/// **Cancels in-flight slides.** Any `CardAnim` is removed so a mid-slide
/// tween is not retargeted relative to the previous card-size's position.
///
/// The "↺" stock-empty label's `font_size` is derived from
/// `layout.card_size.x`, so this system also reapplies the stock indicator —
/// otherwise the label would not rescale on resize.
///
/// Scheduled after [`collect_resize_events`] (which itself runs after
/// `LayoutSystem::UpdateOnResize`) so `LayoutResource` reflects the latest
/// window size before we read it.
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn snap_cards_on_window_resize(
    mut commands: Commands,
    time: Res<Time>,
    mut throttle: ResMut<ResizeThrottle>,
    game: Option<Res<GameStateResource>>,
    layout: Option<Res<LayoutResource>>,
    card_images: Option<Res<CardImageSet>>,
    font_res: Option<Res<FontResource>>,
    entities: Query<
        (Entity, &CardEntity, &mut Sprite, &mut Transform),
        (
            Without<CardLabel>,
            Without<CardShadow>,
            Without<CardBackFrame>,
        ),
    >,
    label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
    shadow_query: Query<
        &mut Sprite,
        (
            With<CardShadow>,
            Without<CardEntity>,
            Without<PileMarker>,
            Without<CardBackFrame>,
        ),
    >,
    frame_query: Query<
        &mut Sprite,
        (
            With<CardBackFrame>,
            Without<CardEntity>,
            Without<CardShadow>,
            Without<PileMarker>,
        ),
    >,
    mut pile_markers: Query<
        (Entity, &PileMarker, &mut Sprite),
        (
            Without<CardEntity>,
            Without<CardShadow>,
            Without<CardBackFrame>,
        ),
    >,
    label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
) {
    if throttle.pending.is_none() {
        return;
    }
    let now = time.elapsed_secs();
    if !should_apply_resize(now, throttle.last_applied_secs) {
        return;
    }

    let Some(game) = game else {
        // Nothing to apply — clear pending so we don't busy-loop.
        throttle.pending = None;
        return;
    };
    let Some(layout) = layout else {
        throttle.pending = None;
        return;
    };

    resize_cards_in_place(
        &mut commands,
        &game.0,
        &layout.0,
        card_images.as_deref(),
        entities,
        label_query,
        shadow_query,
        frame_query,
    );

    let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
    apply_stock_empty_indicator(
        &mut commands,
        &game.0,
        &mut pile_markers,
        &label_children,
        &layout.0,
        font,
    );

    throttle.last_applied_secs = now;
    throttle.pending = None;
}

/// In-place "size-only" sibling of [`sync_cards`]: walks every existing card
/// entity, updates `Sprite.custom_size` and the snap-`Transform` to match the
/// fresh layout, and (in fallback solid-colour mode) also updates the child
/// `TextFont.font_size` of any `CardLabel`. No despawning, no `Sprite`
/// replacement, no children rebuild — that's the entire point of this path.
///
/// Called only from the resize handler. Game-state changes (deals, moves,
/// flips, settings toggles) still flow through [`sync_cards`] /
/// [`update_card_entity`], which handle add/remove/repaint correctly.
///
/// Any in-flight `CardAnim` slide is removed so a mid-tween card is not
/// retargeted relative to the previous card-size's position.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn resize_cards_in_place(
    commands: &mut Commands,
    game: &GameState,
    layout: &Layout,
    card_images: Option<&CardImageSet>,
    mut entities: Query<
        (Entity, &CardEntity, &mut Sprite, &mut Transform),
        (
            Without<CardLabel>,
            Without<CardShadow>,
            Without<CardBackFrame>,
        ),
    >,
    mut label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
    mut shadow_query: Query<
        &mut Sprite,
        (
            With<CardShadow>,
            Without<CardEntity>,
            Without<PileMarker>,
            Without<CardBackFrame>,
        ),
    >,
    mut frame_query: Query<
        &mut Sprite,
        (
            With<CardBackFrame>,
            Without<CardEntity>,
            Without<CardShadow>,
            Without<PileMarker>,
        ),
    >,
) {
    let positions = card_positions(game, layout);
    let pos_by_id: HashMap<Card, (Vec2, f32)> = positions
        .into_iter()
        .map(|((c, _face_up), p, z)| (c, (p, z)))
        .collect();

    for (entity, marker, mut sprite, mut transform) in entities.iter_mut() {
        let Some(&(pos, z)) = pos_by_id.get(&marker.card) else {
            continue;
        };
        sprite.custom_size = Some(layout.card_size);
        transform.translation.x = pos.x;
        transform.translation.y = pos.y;
        transform.translation.z = z;
        // Cancel any in-flight slide so it doesn't retarget from a stale
        // mid-animation position computed against the previous card size.
        commands.entity(entity).remove::<CardAnim>();
    }

    // Resize every per-card shadow halo to match the new card size. Both
    // idle and drag states scale with the card body, so we preserve the
    // *current* padding (idle vs drag) by keeping the alpha as-is and only
    // recomputing the geometry. The drag-tracking system runs every frame
    // and will retune offset / alpha / padding-mode within one frame if the
    // drag state diverges from the resized geometry.
    let idle_padding = CARD_SHADOW_PADDING_IDLE;
    let drag_padding = CARD_SHADOW_PADDING_DRAG;
    for mut shadow_sprite in shadow_query.iter_mut() {
        // Choose padding based on the shadow's current alpha — preserves
        // a lifted shadow's larger halo across resize without needing to
        // plumb DragState through the resize handler.
        let alpha = shadow_sprite.color.alpha();
        let padding = if alpha >= CARD_SHADOW_ALPHA_DRAG - 0.001 {
            drag_padding
        } else {
            idle_padding
        };
        shadow_sprite.custom_size = Some(layout.card_size + padding);
    }

    // Only the solid-colour fallback path uses CardLabel/Text2d overlays;
    // when PNG faces are loaded the rank/suit are baked into the image and
    // there is nothing to resize on the label side.
    if card_images.is_none() {
        let new_font_size = layout.card_size.x * FONT_SIZE_FRAC;
        for mut font in label_query.iter_mut() {
            font.font_size = new_font_size;
        }
    }

    // Resize every face-down border frame to match the new card size.
    let frame_size = layout.card_size + Vec2::splat(CARD_BACK_FRAME_PADDING);
    for mut frame_sprite in frame_query.iter_mut() {
        frame_sprite.custom_size = Some(frame_size);
    }
}

/// Updates font size and top-left anchor transform of every
/// [`AndroidCornerLabel`] entity when `LayoutResource` changes (orientation
/// change or any window resize). The full despawn/respawn path in
/// `update_card_entity` already handles game-state changes; this system
/// covers the resize-only path where children are mutated in place.
fn resize_android_corner_labels(
    layout: Res<LayoutResource>,
    card_images: Option<Res<CardImageSet>>,
    mut text_query: Query<(
        &AndroidCornerLabel,
        &mut Text2d,
        &mut TextFont,
        &mut Transform,
    )>,
    mut bg_query: Query<(&mut Sprite, &mut Transform), AndroidCornerBgFilter>,
) {
    if !layout.is_changed() || card_images.is_none() {
        return;
    }
    let font_size = layout.0.card_size.x * FONT_SIZE_FRAC_MOBILE;
    let inset = 3.0_f32;
    let bg_w = font_size * 2.0;
    let bg_h = font_size * 1.25;
    let text_x = -layout.0.card_size.x / 2.0 + inset;
    let text_y = layout.0.card_size.y / 2.0 - inset;

    for (label, mut text2d, mut font, mut transform) in text_query.iter_mut() {
        text2d.0 = label.0.clone();
        font.font_size = font_size;
        transform.translation.x = text_x;
        transform.translation.y = text_y;
    }
    for (mut sprite, mut transform) in bg_query.iter_mut() {
        sprite.custom_size = Some(Vec2::new(bg_w, bg_h));
        transform.translation.x = text_x + bg_w / 2.0;
        transform.translation.y = text_y - bg_h / 2.0;
    }
}

/// Adjusts `LayoutResource.tableau_fan_frac` (and the face-down companion) so
/// the deepest tableau column fills the available vertical space at every stage
/// of play. Runs after every `StateChangedEvent`.
///
/// Depth is measured across *all* cards in a column, weighting each face-down
/// card by the fixed face-down/face-up step ratio. Counting the face-down
/// portion — not just the face-up tail — is what fills the lower screen on a
/// fresh deal (the deepest column is then six face-down cards under one face-up
/// one): the earlier face-up-only depth was 1, so the fan never spread and the
/// bottom half of a near-square viewport (e.g. an unfolded foldable) sat empty.
///
/// Deeper columns drive the fraction down so everything still fits the window;
/// [`crate::layout::TABLEAU_FAN_FRAC`] floors it to the desktop feel and
/// [`MAX_DYNAMIC_FAN_FRAC`] caps it so a near-empty column doesn't fling its few
/// cards far apart.
fn update_tableau_fan_frac(
    mut events: MessageReader<StateChangedEvent>,
    game: Option<Res<GameStateResource>>,
    mut layout: Option<ResMut<LayoutResource>>,
) {
    if events.read().next().is_none() {
        return;
    }
    let Some(game) = game else {
        return;
    };
    let Some(layout) = layout.as_mut() else {
        return;
    };
    crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
}

/// PostStartup sibling of [`update_tableau_fan_frac`]. The initial deal is
/// inserted directly as `GameStateResource` at startup without a
/// `StateChangedEvent`, so the event-driven system never fires for it. This
/// runs once, before [`sync_cards_startup`] renders, so the very first board
/// (cold start) already fills the viewport — otherwise a fresh deal on a tall /
/// near-square screen (e.g. an unfolded foldable) renders with the unspread fan
/// and a large empty band below the tableau until the first move.
fn fill_tableau_fan_on_startup(
    game: Option<Res<GameStateResource>>,
    mut layout: Option<ResMut<LayoutResource>>,
) {
    let Some(game) = game else {
        return;
    };
    let Some(layout) = layout.as_mut() else {
        return;
    };
    crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
}


#[cfg(test)]
mod tests;
