//! Renders the static table: felt background and empty pile markers.
//!
//! Pile markers are translucent rectangles that sit beneath any cards. They
//! remain visible only where a pile is empty, so the player can see where to
//! drop cards. All geometry comes from `LayoutResource`.

use bevy::prelude::*;
use bevy::window::WindowResized;
use solitaire_core::KlondikePile;
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::Suit;

use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::game_plugin::GameMutation;
use crate::hud_plugin::HudVisibility;
use crate::layout::{
    Layout, LayoutResource, LayoutSystem, TABLE_COLOUR, apply_dynamic_tableau_fan, compute_layout,
};
use crate::resources::GameStateResource;
use crate::safe_area::SafeAreaInsets;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
use crate::ui_theme::TEXT_PRIMARY;
#[cfg(test)]
use solitaire_data::Theme;

/// Default tint applied to every empty-pile marker sprite. Pure white
/// at 15% alpha — soft enough that the marker reads as a "hint of a
/// slot" rather than a panel, but discernible even against a very dark
/// felt background under bright ambient light (the old 8% alpha vanished
/// on a #151515 felt during on-device Android testing).
///
/// Re-exported as the source of truth for `cursor_plugin::MARKER_DEFAULT`,
/// which used to duplicate the literal alongside a "kept in sync" doc
/// comment. Pulling both call sites through this const makes drift a
/// compile error instead of a stale comment.
pub const PILE_MARKER_DEFAULT_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.15);

/// Tint applied to the thin outline rectangle sitting behind every
/// empty-pile marker. A slightly brighter white at 28% alpha gives the
/// slot a defined edge — the standard solitaire "empty pile" affordance —
/// without competing with real cards. Rendered as a marginally larger
/// child rectangle one z-step behind the fill, so the fill overlaps it
/// and only a hairline frame remains visible.
const PILE_MARKER_OUTLINE_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.28);

/// Width in logical pixels of the visible outline frame around an empty
/// pile marker (the outline rect is this much larger on each side).
const PILE_MARKER_OUTLINE_WIDTH: f32 = 2.0;

/// Holds pre-loaded [`Handle<Image>`]s for the 5 selectable table backgrounds.
///
/// Loaded once at startup by [`load_background_images`].  Index 0 is the
/// default; indices 1–4 are unlockable.
#[derive(Resource)]
pub struct BackgroundImageSet {
    /// One handle per background slot (indices 0–4).
    pub handles: Vec<Handle<Image>>,
}

/// Z-depth used for the background — below everything.
const Z_BACKGROUND: f32 = -10.0;
/// Z-depth used for pile markers — below cards (which start at 0) but above
/// the background.
const Z_PILE_MARKER: f32 = -1.0;

/// Marker component for the table felt background.
#[derive(Component, Debug)]
pub struct TableBackground;

/// Marker component attached to each of the 13 empty-pile placeholders.
#[derive(Component, Debug, Clone)]
pub struct PileMarker(pub KlondikePile);

/// Attached to a `PileMarker` entity when it has been temporarily tinted gold
/// as a hint destination. Stores the remaining countdown and the original sprite
/// colour so it can be restored when the timer expires.
#[derive(Component, Debug, Clone)]
pub struct HintPileHighlight {
    /// Seconds remaining before the pile marker colour is restored.
    pub timer: f32,
    /// The sprite colour the marker had before the hint tint was applied.
    pub original_color: Color,
}

/// Registers the table background and pile-marker rendering.
pub struct TablePlugin;

/// Set wrapping the pile-marker painter chain (theme, hint highlights,
/// visibility). Runs after [`crate::card_plugin::BoardVisuals`]; chrome-fx
/// systems that touch `Visibility` on UI entities declare themselves
/// ambiguous with it (#143).
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct MarkerVisuals;

impl Plugin for TablePlugin {
    fn build(&self, app: &mut App) {
        // Register WindowResized so the plugin works under MinimalPlugins in
        // tests. Under DefaultPlugins, bevy_window has already registered it
        // and this call is a no-op.
        app.add_message::<WindowResized>()
            .add_message::<SettingsChangedEvent>()
            .add_message::<HintVisualEvent>()
            .add_message::<StateChangedEvent>()
            .add_systems(Startup, load_background_images.before(setup_table))
            .add_systems(Startup, setup_table)
            .add_systems(
                Update,
                (
                    on_safe_area_changed.before(LayoutSystem::UpdateOnResize),
                    on_window_resized.in_set(LayoutSystem::UpdateOnResize),
                    // Marker painters: deterministic chain after the card
                    // paint pipeline — markers and cards share Sprite/
                    // Transform access (#143).
                    (
                        apply_theme_on_settings_change,
                        apply_hint_pile_highlight,
                        tick_hint_pile_highlights,
                        sync_pile_marker_visibility.after(GameMutation),
                    )
                        .chain()
                        .in_set(MarkerVisuals)
                        .after(crate::card_plugin::BoardVisuals),
                ),
            );
    }
}

/// Loads the 5 background PNG files at startup via the Bevy `AssetServer` and
/// stores their [`Handle<Image>`]s in [`BackgroundImageSet`].
fn load_background_images(asset_server: Option<Res<AssetServer>>, mut commands: Commands) {
    let Some(asset_server) = asset_server else {
        // AssetServer absent (e.g. MinimalPlugins in tests) — insert an
        // empty set so setup_table can proceed using a default handle.
        commands.insert_resource(BackgroundImageSet {
            handles: Vec::new(),
        });
        return;
    };
    let handles = (0..5)
        .map(|i| asset_server.load(format!("backgrounds/bg_{i}.png")))
        .collect();
    commands.insert_resource(BackgroundImageSet { handles });
}

/// Returns the felt colour for a given theme.
///
/// Only used in tests — the runtime path now picks a PNG image via
/// [`BackgroundImageSet`] rather than a solid colour.
#[cfg(test)]
fn theme_colour(theme: &Theme) -> Color {
    match theme {
        Theme::Green => Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]),
        Theme::Blue => Color::srgb(0.059, 0.196, 0.322),
        Theme::Dark => Color::srgb(0.08, 0.08, 0.10),
    }
}

/// Effective table background colour: unlocked background index overrides the
/// Theme when `selected_background > 0`.
///
/// Only used in tests — the runtime path now picks a PNG image via
/// [`BackgroundImageSet`] rather than a solid colour.
#[cfg(test)]
fn effective_background_colour(theme: &Theme, selected_background: usize) -> Color {
    match selected_background {
        0 => theme_colour(theme),
        1 => Color::srgb(0.25, 0.18, 0.10), // dark wood
        2 => Color::srgb(0.05, 0.08, 0.22), // navy
        3 => Color::srgb(0.30, 0.05, 0.08), // burgundy
        _ => Color::srgb(0.12, 0.12, 0.14), // charcoal (4+)
    }
}

fn default_window_size(window: &Window) -> Vec2 {
    Vec2::new(window.resolution.width(), window.resolution.height())
}

fn setup_table(
    mut commands: Commands,
    windows: Query<&Window>,
    existing_camera: Query<(), With<Camera>>,
    settings: Option<Res<SettingsResource>>,
    bg_images: Option<Res<BackgroundImageSet>>,
    safe_area: Option<Res<SafeAreaInsets>>,
    hud_vis: Option<Res<HudVisibility>>,
) {
    // Only spawn a camera if one does not already exist (e.g. a parent app
    // may have added one in tests). Use the felt-green clear colour so the
    // background reads as green even before the background PNG finishes
    // loading (which is asynchronous and can lag by several frames on
    // Android).
    if existing_camera.is_empty() {
        commands.spawn((
            Camera2d,
            Camera {
                clear_color: ClearColorConfig::Custom(Color::srgb(
                    TABLE_COLOUR[0],
                    TABLE_COLOUR[1],
                    TABLE_COLOUR[2],
                )),
                ..default()
            },
        ));
    }

    let (window_size, scale) = windows
        .iter()
        .next()
        .map_or((Vec2::new(1280.0, 800.0), 1.0f32), |w| {
            (default_window_size(w), w.scale_factor())
        });
    // Safe-area insets arrive from JNI asynchronously; they are almost always
    // 0 here (populated ~frame 2-3). on_safe_area_changed fires when they
    // arrive and issues a synthetic WindowResized to re-snap all game objects.
    let insets = safe_area.as_deref().copied().unwrap_or_default();
    let safe_area_top = insets.top / scale;
    let safe_area_bottom = insets.bottom / scale;
    let hud_visible = hud_vis.as_deref().copied().unwrap_or_default() == HudVisibility::Visible;
    let layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible);

    let selected_bg = settings.as_ref().map_or(0, |s| s.0.selected_background);

    let image_handle = bg_images
        .as_ref()
        .and_then(|set| set.handles.get(selected_bg).cloned())
        .unwrap_or_default();

    spawn_background(&mut commands, window_size, image_handle);
    spawn_pile_markers(&mut commands, &layout);
    commands.insert_resource(LayoutResource(layout));
}

/// Spawns the felt background sprite using a PNG image handle.
///
/// The sprite covers the window at twice the window size so brief resize gaps
/// are never visible.  The image is tinted `Color::WHITE` (no tint) so the PNG
/// pixel data is rendered as-is.
fn spawn_background(commands: &mut Commands, window_size: Vec2, image: Handle<Image>) {
    // Spawn a sprite covering the window. We give it the window size plus
    // headroom so resizing up doesn't expose edges before the resize handler
    // runs.
    commands.spawn((
        Sprite {
            image,
            color: Color::WHITE,
            custom_size: Some(window_size * 2.0),
            ..default()
        },
        Transform::from_xyz(0.0, 0.0, Z_BACKGROUND),
        TableBackground,
    ));
}

/// Reacts to settings changes by updating the background sprite's image handle.
///
/// When [`BackgroundImageSet`] is available the selected PNG handle is applied
/// directly (color is kept at `Color::WHITE` so the PNG pixel data shows
/// unmodified).  If the resource is not yet ready the sprite is left unchanged.
fn apply_theme_on_settings_change(
    mut events: MessageReader<SettingsChangedEvent>,
    mut backgrounds: Query<&mut Sprite, With<TableBackground>>,
    bg_images: Option<Res<BackgroundImageSet>>,
) {
    let Some(ev) = events.read().last() else {
        return;
    };
    let Some(set) = bg_images else {
        // BackgroundImageSet not ready yet — leave sprite unchanged.
        return;
    };
    let selected = ev.0.selected_background;
    let Some(handle) = set.handles.get(selected).cloned() else {
        return;
    };
    for mut sprite in &mut backgrounds {
        sprite.image = handle.clone();
        sprite.color = Color::WHITE;
    }
}

/// Returns the single-letter suit symbol used on empty foundation markers.
///
/// Matches the same ASCII convention used by `CardPlugin` for card labels.
pub fn suit_symbol(suit: &Suit) -> &'static str {
    match suit {
        Suit::Spades => "S",
        Suit::Hearts => "H",
        Suit::Diamonds => "D",
        Suit::Clubs => "C",
    }
}

fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
    let marker_colour = PILE_MARKER_DEFAULT_COLOUR;
    let marker_size = layout.card_size;
    let font_size = layout.card_size.x * 0.28;

    let mut piles: Vec<KlondikePile> = Vec::with_capacity(12);
    piles.push(KlondikePile::Stock);
    for foundation in FOUNDATIONS {
        piles.push(KlondikePile::Foundation(foundation));
    }
    for tableau in TABLEAUS {
        piles.push(KlondikePile::Tableau(tableau));
    }

    for pile in piles {
        let pos = layout.pile_positions[&pile];
        let mut entity = commands.spawn((
            Sprite {
                color: marker_colour,
                custom_size: Some(marker_size),
                ..default()
            },
            Transform::from_xyz(pos.x, pos.y, Z_PILE_MARKER),
            PileMarker(pile),
        ));

        // Outline frame: a marginally larger rectangle sitting one z-step
        // behind the fill. The fill overlaps its centre, leaving only a
        // hairline border visible — a defined slot edge without an extra
        // asset or 9-slice. Untagged so the `PileMarker` count is unchanged.
        let outline_size = marker_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
        entity.with_children(|b| {
            b.spawn((
                Sprite {
                    color: PILE_MARKER_OUTLINE_COLOUR,
                    custom_size: Some(outline_size),
                    ..default()
                },
                Transform::from_xyz(0.0, 0.0, -0.05),
            ));
        });

        // Tableau markers show "K" (only a King may start an empty column).
        // Foundation markers show "A" (only an Ace may claim an empty slot).
        // Neither label carries a suit because any suit may start any slot.
        match &pile {
            KlondikePile::Tableau(_) => {
                entity.with_children(|b| {
                    b.spawn((
                        Text2d::new("K"),
                        TextFont {
                            font_size,
                            ..default()
                        },
                        TextColor(TEXT_PRIMARY.with_alpha(0.35)),
                        Transform::from_xyz(0.0, 0.0, 0.1),
                    ));
                });
            }
            KlondikePile::Foundation(_) => {
                entity.with_children(|b| {
                    b.spawn((
                        Text2d::new("A"),
                        TextFont {
                            font_size,
                            ..default()
                        },
                        TextColor(TEXT_PRIMARY.with_alpha(0.35)),
                        Transform::from_xyz(0.0, 0.0, 0.1),
                    ));
                });
            }
            _ => {}
        }
    }
}

#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn on_window_resized(
    mut events: MessageReader<WindowResized>,
    safe_area: Option<Res<SafeAreaInsets>>,
    windows: Query<&Window>,
    hud_vis: Option<Res<HudVisibility>>,
    game: Option<Res<GameStateResource>>,
    mut layout_res: Option<ResMut<LayoutResource>>,
    mut backgrounds: Query<
        (&mut Sprite, &mut Transform),
        (With<TableBackground>, Without<PileMarker>),
    >,
    mut markers: Query<
        (&PileMarker, &mut Sprite, &mut Transform, Option<&Children>),
        Without<TableBackground>,
    >,
    mut marker_outlines: Query<
        &mut Sprite,
        (Without<PileMarker>, Without<TableBackground>, Without<Text2d>),
    >,
    mut marker_labels: Query<&mut TextFont, With<Text2d>>,
) {
    let Some(ev) = events.read().last() else {
        return;
    };
    let window_size = Vec2::new(ev.width, ev.height);
    let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
    let insets = safe_area.as_deref().copied().unwrap_or_default();
    let safe_area_top = insets.top / scale;
    let safe_area_bottom = insets.bottom / scale;
    let hud_visible = hud_vis.as_deref().copied().unwrap_or_default() == HudVisibility::Visible;
    // Android surface sizes after fold/unfold are unreliable (winit does not
    // forward every content-rect change), so make each relayout observable
    // in logcat — this is the primary evidence channel for foldable bugs.
    #[cfg(target_os = "android")]
    info!(
        "layout: resize to {:.0}x{:.0} (insets top={:.0} bottom={:.0}, hud={hud_visible})",
        window_size.x, window_size.y, insets.top, insets.bottom
    );
    let mut new_layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible);

    // compute_layout sizes the fan for a worst-case column; refine it to the
    // current deal so a resize (incl. the Android safe-area-inset resize that
    // fires in the first few frames, and fold/unfold on foldables) keeps the
    // tableau filling the viewport instead of snapping back to the sparse
    // worst-case fan.
    if let Some(game) = game.as_ref() {
        apply_dynamic_tableau_fan(&game.0, &mut new_layout);
    }

    if let Some(layout_res) = layout_res.as_deref_mut() {
        layout_res.0 = new_layout.clone();
    }

    for (mut sprite, mut transform) in &mut backgrounds {
        sprite.custom_size = Some(window_size * 2.0);
        transform.translation.x = 0.0;
        transform.translation.y = 0.0;
    }

    for (marker, mut sprite, mut transform, children) in &mut markers {
        if let Some(pos) = new_layout.pile_positions.get(&marker.0) {
            sprite.custom_size = Some(new_layout.card_size);
            transform.translation.x = pos.x;
            transform.translation.y = pos.y;
            // Children carry the outline frame and the "A"/"K" watermark.
            // They are sized at spawn from that layout's card size, so they
            // must be re-derived here too or a resize (fold/unfold, rotation)
            // leaves them at the stale size — visible as oversized grey
            // frames on empty piles.
            let outline_size =
                new_layout.card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
            let font_size = new_layout.card_size.x * 0.28;
            for child in children.into_iter().flatten() {
                if let Ok(mut outline) = marker_outlines.get_mut(*child) {
                    outline.custom_size = Some(outline_size);
                }
                if let Ok(mut label) = marker_labels.get_mut(*child) {
                    label.font_size = font_size;
                }
            }
        }
    }

    // Card sprites are repositioned by `card_plugin::snap_cards_on_window_resize`
    // running `.after(LayoutSystem::UpdateOnResize)` — that system snaps card
    // transforms directly to the new layout instead of going through
    // `StateChangedEvent → sync_cards → CardAnim` which would retarget the
    // slide tween every frame during a corner drag (the visible "snap back
    // and forth" jitter).
}

/// Bridges the asynchronous safe-area inset update into the synchronous
/// window-resize pipeline. When Android's JNI delivers the real inset values
/// (typically frame 2-3 of a fresh launch), this system writes a synthetic
/// `WindowResized` event carrying the current window size. `on_window_resized`
/// (which runs in `LayoutSystem::UpdateOnResize`) will then recompute the
/// layout with the correct `safe_area_top`, update `LayoutResource` and the
/// pile markers, and `snap_cards_on_window_resize` (running after the set)
/// will snap card sprites to the corrected positions.
fn on_safe_area_changed(
    safe_area: Option<Res<SafeAreaInsets>>,
    windows: Query<(Entity, &Window)>,
    mut resize_events: MessageWriter<WindowResized>,
) {
    let Some(safe_area) = safe_area else {
        return;
    };
    if !safe_area.is_changed() {
        return;
    }
    let Some((entity, window)) = windows.iter().next() else {
        return;
    };
    resize_events.write(WindowResized {
        window: entity,
        width: window.resolution.width(),
        height: window.resolution.height(),
    });
}

// ---------------------------------------------------------------------------
// Task #6 — Hint pile-marker highlight
// ---------------------------------------------------------------------------

/// Gold tint applied to a `PileMarker` sprite when it is the current
/// hint destination. Same RGB as the design-system [`STATE_WARNING`]
/// token (`#ddb26f`) so the in-game "look here" colour is the same hue
/// as every other warning/attention signal in the UI. 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_WARNING` so a future palette swap can't drift the two apart.
const HINT_PILE_HIGHLIGHT_COLOUR: Color = Color::srgb(0.867, 0.698, 0.435);

/// Listens for `HintVisualEvent` and tints the matching `PileMarker` entity
/// gold for 2 s, storing the original colour in `HintPileHighlight` so it can
/// be restored when the timer expires.
///
/// If the pile marker already has a `HintPileHighlight` from a previous hint
/// press, the timer is reset to 2 s without changing `original_color`.
fn apply_hint_pile_highlight(
    mut events: MessageReader<HintVisualEvent>,
    mut commands: Commands,
    mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite, Option<&HintPileHighlight>)>,
) {
    for ev in events.read() {
        for (entity, pile_marker, mut sprite, existing) in pile_markers.iter_mut() {
            if pile_marker.0 != ev.dest_pile {
                continue;
            }
            let original_color = existing.map_or(sprite.color, |h| h.original_color);
            sprite.color = HINT_PILE_HIGHLIGHT_COLOUR;
            commands.entity(entity).insert(HintPileHighlight {
                timer: 2.0,
                original_color,
            });
        }
    }
}

/// Counts down `HintPileHighlight::timer` each frame and restores the original
/// pile marker colour when the timer expires.
fn tick_hint_pile_highlights(
    mut commands: Commands,
    time: Res<Time>,
    mut pile_markers: Query<(Entity, &mut Sprite, &mut HintPileHighlight)>,
) {
    let dt = time.delta_secs();
    for (entity, mut sprite, mut highlight) in pile_markers.iter_mut() {
        highlight.timer -= dt;
        if highlight.timer <= 0.0 {
            sprite.color = highlight.original_color;
            commands.entity(entity).remove::<HintPileHighlight>();
        }
    }
}

/// Hides pile-marker sprites for piles that have a card on top, shows them
/// for empty piles. Implements the "remain visible only where a pile is
/// empty" invariant declared in this module's top-level doc comment but
/// previously not enforced — markers always rendered, and the resulting
/// translucent rectangle bled through the rounded corners of any card sat
/// on top, producing visible "gray L" artifacts at each card corner.
///
/// Runs every Update tick guarded by `game.is_changed()` so the work is
/// skipped on idle frames. Bevy's resource change-detection sets the
/// changed flag on every state mutation (draw, move, undo, recycle, new
/// game), which covers every transition that flips a pile from
/// empty-to-occupied or vice versa.
fn sync_pile_marker_visibility(
    game: Option<Res<GameStateResource>>,
    mut markers: Query<(&PileMarker, &mut Visibility)>,
) {
    let Some(game) = game else {
        return;
    };
    if !game.is_changed() {
        return;
    }
    for (pile_marker, mut visibility) in markers.iter_mut() {
        let is_empty = pile_cards(&game.0, &pile_marker.0).is_empty();
        *visibility = if is_empty {
            Visibility::Inherited
        } else {
            Visibility::Hidden
        };
    }
}

fn pile_cards(
    game: &solitaire_core::game_state::GameState,
    pile: &KlondikePile,
) -> Vec<(solitaire_core::Card, bool)> {
    match pile {
        KlondikePile::Stock => {
            let stock = game.stock_cards();
            if stock.is_empty() {
                game.waste_cards()
            } else {
                stock
            }
        }
        _ => game.pile(*pile),
    }
}



#[cfg(test)]
mod tests {
    use super::*;
    use crate::game_plugin::GamePlugin;
    use solitaire_core::{Foundation, Tableau};

    /// Minimal headless app — omits windowing so pile markers are spawned with
    /// the default 1280×800 layout and no camera is created.
    fn headless_app() -> App {
        let mut app = App::new();
        app.add_plugins(MinimalPlugins)
            .add_plugins(GamePlugin)
            .add_plugins(TablePlugin);
        app.update();
        app
    }

    #[test]
    fn table_plugin_spawns_twelve_pile_markers() {
        let mut app = headless_app();
        let count = app
            .world_mut()
            .query::<&PileMarker>()
            .iter(app.world())
            .count();
        assert_eq!(count, 12);
    }

    #[test]
    fn table_plugin_spawns_one_background() {
        let mut app = headless_app();
        let count = app
            .world_mut()
            .query::<&TableBackground>()
            .iter(app.world())
            .count();
        assert_eq!(count, 1);
    }

    #[test]
    fn table_plugin_inserts_layout_resource() {
        let app = headless_app();
        assert!(app.world().get_resource::<LayoutResource>().is_some());
    }

    #[test]
    fn every_pile_marker_has_unique_type() {
        let mut app = headless_app();
        let mut types: Vec<KlondikePile> = app
            .world_mut()
            .query::<&PileMarker>()
            .iter(app.world())
            .map(|m| m.0)
            .collect();
        types.sort_by_key(|p| format!("{p:?}"));
        types.dedup();
        assert_eq!(types.len(), 12);
    }

    #[test]
    fn resize_updates_marker_outline_and_watermark_children() {
        // Regression (found on Galaxy Fold 7, v0.41.0): `on_window_resized`
        // resized the marker fill sprite but never its children, so after a
        // fold/unfold the outline frame and "A"/"K" watermark stayed at
        // spawn-time size — rendering as oversized grey slabs on empty piles.
        let mut app = headless_app();
        // Shrink the window well below the 1280×800 spawn default.
        app.world_mut().write_message(WindowResized {
            window: Entity::PLACEHOLDER,
            width: 640.0,
            height: 480.0,
        });
        app.update();

        let card_size = app.world().resource::<LayoutResource>().0.card_size;
        let expected_outline = card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
        let expected_font = card_size.x * 0.28;

        let marker_children: Vec<Entity> = {
            let mut q = app.world_mut().query::<(&PileMarker, &Children)>();
            q.iter(app.world())
                .flat_map(|(_, c)| c.iter().collect::<Vec<_>>())
                .collect()
        };
        assert!(
            !marker_children.is_empty(),
            "pile markers must have outline/watermark children"
        );
        let mut outlines = 0;
        let mut labels = 0;
        for child in marker_children {
            if let Some(sprite) = app.world().entity(child).get::<Sprite>() {
                assert_eq!(
                    sprite.custom_size,
                    Some(expected_outline),
                    "outline child must track the resized card size",
                );
                outlines += 1;
            }
            if let Some(font) = app.world().entity(child).get::<TextFont>() {
                assert!(
                    (font.font_size - expected_font).abs() < 0.01,
                    "watermark font must track the resized card size",
                );
                labels += 1;
            }
        }
        assert_eq!(outlines, 12, "all 12 markers carry an outline child");
        assert!(labels >= 11, "tableau + foundation markers carry watermarks");
    }

    #[test]
    fn pile_markers_hide_when_pile_is_occupied() {
        // After a fresh deal: the 7 tableau piles + the stock pile are
        // occupied; the 4 foundation piles are empty. The visibility-by-
        // occupancy system must hide the first 8 markers and keep the
        // last 4 visible. This implements
        // the "remain visible only where a pile is empty" invariant
        // in the module-level doc comment that was previously
        // declared but not enforced — pile markers used to always
        // render, and the resulting translucent rectangle bled through
        // the rounded corners of any card sat on top.
        let mut app = headless_app();
        // headless_app() runs one tick; run another so
        // sync_pile_marker_visibility has a chance to fire (it runs
        // in Update, after Startup spawns the markers and the game
        // state populates).
        app.update();

        let mut q = app.world_mut().query::<(&PileMarker, &Visibility)>();
        let mut hidden_piles: Vec<KlondikePile> = Vec::new();
        let mut visible_piles: Vec<KlondikePile> = Vec::new();
        for (marker, visibility) in q.iter(app.world()) {
            if matches!(visibility, Visibility::Hidden) {
                hidden_piles.push(marker.0);
            } else {
                visible_piles.push(marker.0);
            }
        }

        // 8 occupied piles after a fresh deal: stock + 7 tableau.
        assert_eq!(
            hidden_piles.len(),
            8,
            "stock + 7 tableau piles should hide their markers post-deal",
        );
        assert!(hidden_piles.contains(&KlondikePile::Stock));
        for tableau in [
            Tableau::Tableau1,
            Tableau::Tableau2,
            Tableau::Tableau3,
            Tableau::Tableau4,
            Tableau::Tableau5,
            Tableau::Tableau6,
            Tableau::Tableau7,
        ] {
            assert!(
                hidden_piles.contains(&KlondikePile::Tableau(tableau)),
                "{tableau:?} marker should be hidden — it has cards",
            );
        }

        // 4 empty piles: foundations only.
        assert_eq!(visible_piles.len(), 4);
        for foundation in [
            Foundation::Foundation1,
            Foundation::Foundation2,
            Foundation::Foundation3,
            Foundation::Foundation4,
        ] {
            assert!(visible_piles.contains(&KlondikePile::Foundation(foundation)));
        }
    }

    // -----------------------------------------------------------------------
    // Pure-function tests (no Bevy app required)
    // -----------------------------------------------------------------------

    #[test]
    fn all_three_themes_produce_distinct_colours() {
        let green = theme_colour(&Theme::Green);
        let blue = theme_colour(&Theme::Blue);
        let dark = theme_colour(&Theme::Dark);
        assert_ne!(green, blue, "Green and Blue must differ");
        assert_ne!(green, dark, "Green and Dark must differ");
        assert_ne!(blue, dark, "Blue and Dark must differ");
    }

    #[test]
    fn effective_background_index_0_matches_theme_colour() {
        for theme in [Theme::Green, Theme::Blue, Theme::Dark] {
            let expected = theme_colour(&theme);
            let actual = effective_background_colour(&theme, 0);
            assert_eq!(
                expected, actual,
                "index 0 must always return the theme colour for {:?}",
                theme
            );
        }
    }

    #[test]
    fn effective_background_indices_1_through_3_are_distinct_from_theme() {
        // Non-zero indices override the theme with a fixed colour.
        let theme_green = theme_colour(&Theme::Green);
        for idx in 1..=3 {
            let eff = effective_background_colour(&Theme::Green, idx);
            assert_ne!(
                eff, theme_green,
                "index {idx} must override the theme colour"
            );
        }
    }

    #[test]
    fn effective_background_index_4_falls_through_to_charcoal() {
        // All indices ≥ 4 share the same charcoal fallback.
        let c4 = effective_background_colour(&Theme::Green, 4);
        let c5 = effective_background_colour(&Theme::Green, 5);
        let c99 = effective_background_colour(&Theme::Green, 99);
        assert_eq!(c4, c5, "indices 4 and 5 must share the charcoal fallback");
        assert_eq!(c4, c99, "index 99 must share the charcoal fallback");
    }

    // -----------------------------------------------------------------------
    // suit_symbol pure-function tests (Task #35)
    // -----------------------------------------------------------------------

    #[test]
    fn suit_symbol_returns_correct_letters() {
        assert_eq!(suit_symbol(&Suit::Spades), "S");
        assert_eq!(suit_symbol(&Suit::Hearts), "H");
        assert_eq!(suit_symbol(&Suit::Diamonds), "D");
        assert_eq!(suit_symbol(&Suit::Clubs), "C");
    }

    // -----------------------------------------------------------------------
    // Task #6 — HintPileHighlight timer and colour pure-function tests
    // -----------------------------------------------------------------------

    /// The HINT_PILE_HIGHLIGHT_COLOUR constant must be visibly distinct from the
    /// default pile marker colour so the player can see which pile is highlighted.
    #[test]
    fn hint_pile_highlight_colour_is_distinct_from_default() {
        assert_ne!(
            HINT_PILE_HIGHLIGHT_COLOUR, PILE_MARKER_DEFAULT_COLOUR,
            "HINT_PILE_HIGHLIGHT_COLOUR must differ from the default pile marker colour"
        );
    }

    /// `HINT_PILE_HIGHLIGHT_COLOUR` is spelled as a literal because
    /// `Alpha::with_alpha` is not a `const` trait method on stable.
    /// This test pins its RGB to the design-system `STATE_WARNING`
    /// token so a future palette swap that updates the token but
    /// forgets the hint highlight fails loudly here.
    #[test]
    fn hint_pile_highlight_rgb_tracks_state_warning_token() {
        use crate::ui_theme::STATE_WARNING;
        let hint = HINT_PILE_HIGHLIGHT_COLOUR.to_srgba();
        let warning = STATE_WARNING.to_srgba();
        assert!((hint.red - warning.red).abs() < 1e-6);
        assert!((hint.green - warning.green).abs() < 1e-6);
        assert!((hint.blue - warning.blue).abs() < 1e-6);
    }

    /// A freshly-created HintPileHighlight has a positive timer countdown.
    #[test]
    fn hint_pile_highlight_timer_starts_positive() {
        let h = HintPileHighlight {
            timer: 2.0,
            original_color: PILE_MARKER_DEFAULT_COLOUR,
        };
        assert!(
            h.timer > 0.0,
            "HintPileHighlight timer must start positive, got {}",
            h.timer
        );
    }

    /// Ticking the timer past its initial value results in a non-positive (expired)
    /// countdown.
    #[test]
    fn hint_pile_highlight_timer_expires_after_full_duration() {
        let mut remaining = 2.0_f32;
        remaining -= 2.5; // 2.5 s elapsed on a 2.0 s timer
        assert!(
            remaining <= 0.0,
            "timer must be expired after ticking past its initial value, got {}",
            remaining
        );
    }

    /// `original_color` is preserved through the highlight lifecycle so colour
    /// can be correctly restored on expiry.
    #[test]
    fn hint_pile_highlight_preserves_original_color() {
        let original = Color::srgb(0.1, 0.3, 0.5);
        let h = HintPileHighlight {
            timer: 2.0,
            original_color: original,
        };
        assert_eq!(
            h.original_color, original,
            "original_color must be stored without modification"
        );
    }

    /// The hint colour must read as "gold-ish" — red dominant, green
    /// close behind, blue noticeably lower — so a player intuitively
    /// associates the highlight with attention/warning. Bounds are
    /// loose enough to accommodate the Terminal palette's muted gold
    /// (`STATE_WARNING`, `#ddb26f`) while still rejecting a stray
    /// red, green, or neutral grey if someone refactors badly.
    /// Exact-RGB tracking lives in
    /// `hint_pile_highlight_rgb_tracks_state_warning_token`.
    #[test]
    fn hint_pile_highlight_colour_is_gold() {
        let Srgba {
            red, green, blue, ..
        } = HINT_PILE_HIGHLIGHT_COLOUR.to_srgba();
        assert!(
            red >= 0.7,
            "gold hint colour must have red ≥ 0.7, got {red}"
        );
        assert!(
            green >= 0.5,
            "gold hint colour must have green ≥ 0.5, got {green}"
        );
        assert!(
            blue <= 0.6,
            "gold hint colour must have blue ≤ 0.6, got {blue}"
        );
        assert!(
            red > blue,
            "gold hint colour must be warmer than cool, got r={red} b={blue}"
        );
        assert!(
            green > blue,
            "gold hint colour must be warmer than cool, got g={green} b={blue}"
        );
    }

    #[test]
    fn suit_symbol_all_four_are_distinct() {
        let symbols: Vec<&str> = Suit::SUITS
            .iter()
            .map(suit_symbol)
            .collect();
        let unique: std::collections::HashSet<&&str> = symbols.iter().collect();
        assert_eq!(unique.len(), 4, "all four suit symbols must be distinct");
    }
}
