//! 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;

use bevy::color::Color;
use bevy::prelude::*;
use solitaire_core::{KlondikePile, Tableau};
use solitaire_core::Card;

use crate::card_animation::CardAnimation;
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
use crate::game_plugin::GameMutation;
use crate::layout::{Layout, LayoutSystem};

mod anim;
mod highlights;
mod labels;
mod layout;
mod stock;
mod sync;

use anim::*;
use highlights::*;
use labels::*;
use layout::*;
use stock::*;
use sync::*;

use crate::resources::GameStateResource;
use crate::settings_plugin::SettingsChangedEvent;
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,
};

/// 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);

/// Canonical outer index of `s` in [`CardImageSet::faces`].
///
/// Derived from the upstream `card_game::Suit` discriminants (0..=3 in
/// `Suit::SUITS` order), so every reader and writer of `faces` computes
/// the same layout from the same source. Three hand-rolled copies of this
/// mapping once lived in card_plugin and theme/plugin and were one
/// reorder away from drawing the wrong art.
pub(crate) const fn suit_index(s: solitaire_core::Suit) -> usize {
    s as usize
}

/// Canonical inner index of `r` in [`CardImageSet::faces`] — upstream
/// `card_game::Rank` discriminants are 1..=13 in `Rank::RANKS` order.
pub(crate) const fn rank_index(r: solitaire_core::Rank) -> usize {
    r as usize - 1
}

/// 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]`.
    ///
    /// Layout is pinned to the upstream declaration order — index with
    /// [`suit_index`] / [`rank_index`], never a hand-rolled match.
    /// Suit order: `Suit::SUITS` (Spades=0, Hearts=1, Clubs=2, Diamonds=3).
    /// Rank order: `Rank::RANKS` (Ace=0 … 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;

/// System set for everything that paints the board: card sprites, pile
/// markers, shadows, highlights, badges. Members mutate `Sprite` /
/// `Transform` on board entities and run as a deterministic chain (see the
/// registration in [`CardPlugin`]'s `build`); table-plugin marker painters
/// order themselves after this set. UI-domain systems that touch `Sprite`/
/// `Transform` on non-board entities (HUD text pulses, modal cards) declare
/// `.ambiguous_with(BoardVisuals)` instead — the entity domains are
/// disjoint by design (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct BoardVisuals;

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,
                ),
            )
            // Layout recompute (UpdateOnResize) always precedes board
            // painting, and the painters run as ONE deterministic chain in
            // data-flow order: layout refinement → card authority → anims →
            // shadows → highlights → indicators → resize snapping → labels.
            // Every painter mutates card/marker Sprite+Transform, so without
            // the chain each pair is a scheduler ambiguity (#143). All
            // members are cheap and mostly change-gated; sequential
            // execution is not a cost that matters here.
            .configure_sets(
                Update,
                LayoutSystem::UpdateOnResize.before(BoardVisuals),
            )
            .add_systems(
                Update,
                (
                    update_tableau_fan_frac,
                    resync_cards_on_settings_change,
                    sync_cards_on_change,
                    start_flip_anim,
                    tick_flip_anim,
                    update_drag_shadow,
                    update_card_shadows_on_drag,
                    handle_right_click,
                    tick_right_click_highlights,
                    clear_right_click_highlights_on_state_change,
                    clear_right_click_highlights_on_pause,
                    tick_hint_highlight,
                    update_stock_empty_indicator,
                    update_stock_count_badge
                        .run_if(resource_changed::<GameStateResource>),
                    collect_resize_events,
                    snap_cards_on_window_resize,
                    resize_android_corner_labels,
                )
                    .chain()
                    .in_set(BoardVisuals)
                    .after(GameMutation),
            );

        app.add_systems(PostUpdate, rebuild_card_entity_index);
    }
}

#[cfg(test)]
mod tests;
