use super::*;
use chrono::NaiveDate;
use solitaire_core::{DrawStockConfig, game_state::GameMode};
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
use solitaire_core::{Rank, Suit};
use solitaire_data::Replay;

/// Build a minimal but well-formed [`Replay`] with `move_count` no-op
/// `RotateStock` entries. Tests only ever read `replay.moves.len()`
/// (denominator of the progress indicator), so the move kind is
/// irrelevant beyond producing the right count.
fn synthetic_replay(move_count: usize) -> Replay {
    Replay::new(
        42,
        DrawStockConfig::DrawOne,
        GameMode::Classic,
        120,
        1_000,
        NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
        (0..move_count)
            .map(|_| KlondikeInstruction::RotateStock)
            .collect(),
    )
}

/// Build a test app that has the overlay plugin but **not** the
/// playback plugin — tests insert `ReplayPlaybackState` manually so
/// they can drive every state transition deterministically.
fn headless_app() -> App {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
        .add_plugins(ReplayOverlayPlugin);
    app.init_resource::<ReplayPlaybackState>();
    app
}

/// Count `ReplayOverlayRoot` entities in the world — the overlay's
/// presence/absence is the spawn-test's primary observable.
fn overlay_root_count(app: &mut App) -> usize {
    app.world_mut()
        .query::<&ReplayOverlayRoot>()
        .iter(app.world())
        .count()
}

/// Read the current text content of the unique progress-text entity.
fn progress_text(app: &mut App) -> String {
    let mut q = app
        .world_mut()
        .query_filtered::<&Text, With<ReplayOverlayProgressText>>();
    q.iter(app.world())
        .next()
        .map(|t| t.0.clone())
        .unwrap_or_default()
}

/// Read the current text content of the unique banner-label entity.
fn banner_text(app: &mut App) -> String {
    let mut q = app
        .world_mut()
        .query_filtered::<&Text, With<ReplayOverlayBannerText>>();
    q.iter(app.world())
        .next()
        .map(|t| t.0.clone())
        .unwrap_or_default()
}

/// Set the playback resource without going through the playback core.
fn set_state(app: &mut App, state: ReplayPlaybackState) {
    app.world_mut().insert_resource(state);
}

/// Find the unique `ReplayStopButton` entity for the click-handler
/// test. There must be exactly one.
fn stop_button_entity(app: &mut App) -> Entity {
    let mut q = app
        .world_mut()
        .query_filtered::<Entity, With<ReplayStopButton>>();
    q.iter(app.world())
        .next()
        .expect("Stop button must exist while overlay is spawned")
}

/// Going `Inactive → Playing` spawns exactly one overlay root and
/// the banner label reads "▌ replay".
#[test]
fn overlay_spawns_when_playback_starts() {
    let mut app = headless_app();
    // First update with the default `Inactive` resource — overlay
    // must not exist yet.
    app.update();
    assert_eq!(overlay_root_count(&mut app), 0);

    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();

    assert_eq!(
        overlay_root_count(&mut app),
        1,
        "exactly one ReplayOverlayRoot must spawn on Inactive → Playing",
    );
    assert_eq!(banner_text(&mut app), "\u{258C} replay");
}

/// The progress-text entity reads `"Move {cursor} of {total}"` for a
/// well-formed `Playing` state.
#[test]
fn overlay_progress_text_reflects_cursor() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 5,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();

    assert_eq!(progress_text(&mut app), "MOVE 5/10");
}

/// Pressing the Stop button resets the state back to `Inactive` and
/// the next frame's `react_to_state_change` despawns the overlay.
/// Mirrors the synthetic `Interaction::Pressed` insertion pattern
/// used elsewhere in the engine for headless click tests.
#[test]
fn overlay_stop_button_click_clears_playback() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(overlay_root_count(&mut app), 1);

    let stop = stop_button_entity(&mut app);
    app.world_mut()
        .entity_mut(stop)
        .insert(Interaction::Pressed);
    // Tick once: the click handler runs late in the frame and resets
    // the state to `Inactive`.
    app.update();

    // State must be back to Inactive.
    let state = app.world().resource::<ReplayPlaybackState>();
    assert!(
        matches!(state, ReplayPlaybackState::Inactive),
        "Stop click must reset ReplayPlaybackState to Inactive; got {state:?}",
    );

    // One more tick — `react_to_state_change` sees the resource
    // change to Inactive and despawns the overlay.
    app.update();
    assert_eq!(
        overlay_root_count(&mut app),
        0,
        "overlay must despawn the frame after state returns to Inactive",
    );
}

/// Lifecycle: the floating progress chip spawns alongside the
/// banner overlay when playback starts, and despawns when
/// playback ends. (Position correctness needs `LayoutResource`,
/// which isn't set up in this headless fixture; the lifecycle
/// test below is what's load-bearing for the spawn/despawn
/// pairing.)
#[test]
fn floating_chip_spawns_and_despawns_with_overlay() {
    let mut app = headless_app();
    // Inactive → no chip.
    app.update();
    assert_eq!(
        app.world_mut()
            .query::<&ReplayFloatingProgressChip>()
            .iter(app.world())
            .count(),
        0,
        "no floating chip while playback is Inactive",
    );

    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(5),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(
        app.world_mut()
            .query::<&ReplayFloatingProgressChip>()
            .iter(app.world())
            .count(),
        1,
        "floating chip must spawn when playback starts",
    );

    set_state(&mut app, ReplayPlaybackState::Inactive);
    app.update();
    assert_eq!(
        app.world_mut()
            .query::<&ReplayFloatingProgressChip>()
            .iter(app.world())
            .count(),
        0,
        "floating chip must despawn when playback ends",
    );
}

/// Manually flipping the resource back to `Inactive` (e.g. via the
/// playback core's auto-clear after `Completed`) tears the overlay
/// down without any further input.
#[test]
fn overlay_despawns_when_playback_returns_to_inactive() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(3),
            cursor: 1,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(overlay_root_count(&mut app), 1);

    set_state(&mut app, ReplayPlaybackState::Inactive);
    app.update();

    assert_eq!(
        overlay_root_count(&mut app),
        0,
        "overlay must despawn on Playing → Inactive transition",
    );
}

/// On `Playing → Completed` the banner label updates in place rather
/// than respawning. The overlay must still be present, and the label
/// must read "▌ replay complete".
#[test]
fn overlay_text_changes_on_completed() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(7),
            cursor: 7,
            secs_to_next: 0.0,
            paused: false,
        },
    );
    app.update();
    assert_eq!(banner_text(&mut app), "\u{258C} replay");

    set_state(&mut app, ReplayPlaybackState::Completed);
    app.update();

    assert_eq!(
        overlay_root_count(&mut app),
        1,
        "overlay must remain spawned while in Completed state",
    );
    assert_eq!(
        banner_text(&mut app),
        "\u{258C} replay complete",
        "banner label must swap on Playing → Completed",
    );
}

/// Read the current `Node.width` of the unique scrub-fill entity as
/// a percentage. Assertions can then compare against expected
/// `cursor / total` ratios without poking ECS internals at the call
/// site.
fn scrub_fill_pct(app: &mut App) -> f32 {
    let mut q = app
        .world_mut()
        .query_filtered::<&Node, With<ReplayOverlayScrubFill>>();
    let node = q
        .iter(app.world())
        .next()
        .expect("scrub-fill node must exist while overlay is spawned");
    match node.width {
        Val::Percent(p) => p,
        other => panic!("scrub fill width must be Val::Percent; got {other:?}"),
    }
}

/// Pure-helper guard. Locks in the four corners of `scrub_pct` so a
/// future refactor of `ReplayPlaybackState::progress()` can't
/// silently regress the visual cue: `Inactive → 0 %`,
/// `Playing { cursor: 0, total: N } → 0 %`,
/// `Playing { cursor: N/2, total: N } → 50 %`,
/// `Completed → 100 %`.
#[test]
fn scrub_pct_covers_state_corners() {
    assert_eq!(scrub_pct(&ReplayPlaybackState::Inactive), 0.0);
    assert_eq!(scrub_pct(&ReplayPlaybackState::Completed), 100.0);
    assert_eq!(
        scrub_pct(&ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        }),
        0.0,
    );
    assert_eq!(
        scrub_pct(&ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 5,
            secs_to_next: 0.5,
            paused: false,
        }),
        50.0,
    );
    assert_eq!(
        scrub_pct(&ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 10,
            secs_to_next: 0.5,
            paused: false,
        }),
        100.0,
    );
}

/// Read the current text content of the unique GAME-caption entity.
fn game_caption_text(app: &mut App) -> String {
    let mut q = app
        .world_mut()
        .query_filtered::<&Text, With<ReplayOverlayGameCaption>>();
    q.iter(app.world())
        .next()
        .map(|t| t.0.clone())
        .unwrap_or_default()
}

/// Pure-helper guard. `Inactive` / `Completed` carry no replay
/// reference so the caption is `None`; `Playing` formats the
/// recorded-date as `GAME #YYYY-DDD` with a 3-digit zero-padded
/// ordinal. Locks all three branches so a future refactor can't
/// silently regress the identifier shape.
#[test]
fn format_game_caption_covers_state_corners() {
    assert_eq!(format_game_caption(&ReplayPlaybackState::Inactive), None);
    assert_eq!(format_game_caption(&ReplayPlaybackState::Completed), None);

    // 2026-05-02 is the 122nd day of 2026 (Jan = 31, Feb = 28,
    // Mar = 31, Apr = 30, May 2 = 122). Synthetic_replay always
    // uses this date so the assertion is stable.
    assert_eq!(
        format_game_caption(&ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 5,
            secs_to_next: 0.5,
            paused: false,
        }),
        Some("GAME #2026-122".to_string()),
    );

    // Single-digit ordinal must zero-pad to three digits — pin
    // the format string in case someone simplifies to `{}-{}`.
    let mut early_january = synthetic_replay(10);
    early_january.recorded_at = NaiveDate::from_ymd_opt(2026, 1, 5).expect("valid date");
    assert_eq!(
        format_game_caption(&ReplayPlaybackState::Playing {
            replay: early_january,
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        }),
        Some("GAME #2026-005".to_string()),
    );
}

/// End-to-end: spawning the overlay paints the GAME caption with
/// the active replay's recorded date in `YYYY-DDD` form. Caption
/// is empty for `Completed` since the replay is consumed.
#[test]
fn overlay_game_caption_shows_replay_date() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(game_caption_text(&mut app), "GAME #2026-122");

    // Caption empties out on Playing → Completed because
    // `format_game_caption` returns None and the spawn-path
    // helper falls through to `unwrap_or_default()`.
    // The overlay itself stays spawned in `Completed`.
    set_state(&mut app, ReplayPlaybackState::Completed);
    app.update();
    assert_eq!(
        overlay_root_count(&mut app),
        1,
        "overlay must remain spawned while in Completed state",
    );
}

/// End-to-end: the spawn path must paint the scrub fill at the
/// initial cursor's percentage, and the per-frame `update_scrub_fill`
/// system must repaint it as the cursor advances. Mirrors the shape
/// of `overlay_progress_text_reflects_cursor`.
#[test]
fn overlay_scrub_fill_tracks_cursor() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(8),
            cursor: 2,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(
        scrub_fill_pct(&mut app),
        25.0,
        "spawn-time fill must reflect the initial cursor",
    );

    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(8),
            cursor: 6,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(
        scrub_fill_pct(&mut app),
        75.0,
        "update_scrub_fill must repaint width on cursor advance",
    );

    set_state(&mut app, ReplayPlaybackState::Completed);
    app.update();
    assert_eq!(
        scrub_fill_pct(&mut app),
        100.0,
        "Completed state must read as a fully-filled track",
    );
}

// -----------------------------------------------------------------------
// win_move_marker_pct + ReplayOverlayWinMoveMarker spawn behaviour
// -----------------------------------------------------------------------

fn win_marker_count(app: &mut App) -> usize {
    app.world_mut()
        .query::<&ReplayOverlayWinMoveMarker>()
        .iter(app.world())
        .count()
}

#[test]
fn win_move_marker_pct_is_none_for_inactive() {
    assert_eq!(win_move_marker_pct(&ReplayPlaybackState::Inactive), None);
}

#[test]
fn win_move_marker_pct_is_none_for_completed() {
    // `Completed` carries no replay so the marker has no data to
    // anchor against — the overlay treats this as "no marker".
    assert_eq!(win_move_marker_pct(&ReplayPlaybackState::Completed), None);
}

#[test]
fn win_move_marker_pct_is_none_when_replay_lacks_field() {
    // Synthetic replay constructor leaves win_move_index as None
    // (legacy / pre-`ab857bb` path).
    let state = ReplayPlaybackState::Playing {
        replay: synthetic_replay(10),
        cursor: 0,
        secs_to_next: 0.5,
        paused: false,
    };
    assert_eq!(win_move_marker_pct(&state), None);
}

#[test]
fn win_move_marker_pct_is_some_at_correct_position() {
    // 10 moves, win at index 9 → marker sits at 90 % of the track.
    // Matches the recording semantic: cursor reaches the marker
    // exactly when the about-to-apply move IS the win move.
    let state = ReplayPlaybackState::Playing {
        replay: synthetic_replay(10).with_win_move_index(Some(9)),
        cursor: 0,
        secs_to_next: 0.5,
        paused: false,
    };
    assert_eq!(win_move_marker_pct(&state), Some(90.0));
}

#[test]
fn win_move_marker_pct_clamps_to_track_bounds() {
    // Defensive: if a malformed replay carried `win_move_index >=
    // total`, the marker must still sit on the track, not past it.
    let state = ReplayPlaybackState::Playing {
        replay: synthetic_replay(5).with_win_move_index(Some(99)),
        cursor: 0,
        secs_to_next: 0.5,
        paused: false,
    };
    assert_eq!(win_move_marker_pct(&state), Some(100.0));
}

#[test]
fn marker_spawned_when_replay_has_win_move_index() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(8).with_win_move_index(Some(7)),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(
        win_marker_count(&mut app),
        1,
        "marker entity must spawn when replay carries Some(win_move_index)"
    );
}

#[test]
fn marker_not_spawned_when_replay_lacks_win_move_index() {
    let mut app = headless_app();
    // Default constructor → win_move_index: None (legacy replay).
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(8),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(
        win_marker_count(&mut app),
        0,
        "no marker should spawn for a replay pre-dating the field"
    );
}

#[test]
fn marker_despawns_when_replay_state_returns_to_inactive() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(8).with_win_move_index(Some(7)),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(win_marker_count(&mut app), 1);

    set_state(&mut app, ReplayPlaybackState::Inactive);
    app.update();
    assert_eq!(
        win_marker_count(&mut app),
        0,
        "marker must despawn with the rest of the overlay tree"
    );
}

/// The WIN MOVE marker carries `HighContrastBackground::with_hc(
/// STATE_SUCCESS, STATE_SUCCESS_HC)` so the lime bumps to brighter
/// lime under HC mode rather than to a neutral gray. Pin the
/// presence of the marker so a future refactor can't accidentally
/// drop it and silently regress HC legibility.
#[test]
fn win_move_marker_carries_hc_background_marker() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(8).with_win_move_index(Some(7)),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();

    let mut q = app
        .world_mut()
        .query_filtered::<&HighContrastBackground, With<ReplayOverlayWinMoveMarker>>();
    let marker = q
        .iter(app.world())
        .next()
        .expect("WIN MOVE marker must carry HighContrastBackground");
    assert_eq!(
        marker.default_color, STATE_SUCCESS,
        "default colour must be STATE_SUCCESS"
    );
    assert_eq!(
        marker.hc_color, STATE_SUCCESS_HC,
        "HC colour must be STATE_SUCCESS_HC (brighter lime, not gray)"
    );
}

// -----------------------------------------------------------------------
// scrub_notch_positions + ReplayOverlayScrubNotch spawn behaviour
// -----------------------------------------------------------------------

fn scrub_notch_count(app: &mut App) -> usize {
    app.world_mut()
        .query::<&ReplayOverlayScrubNotch>()
        .iter(app.world())
        .count()
}

/// Pure-helper guard. Locks in the five-notch ladder at the
/// quarter-marks. A future simplification to fewer notches (or a
/// shift to non-quarter spacing) must touch this test, surfacing
/// the visual change at review time.
#[test]
fn scrub_notch_positions_are_quarter_marks() {
    assert_eq!(
        scrub_notch_positions(),
        [0.0, 25.0, 50.0, 75.0, 100.0],
        "scrub notches must sit at the five quarter-mark percentages",
    );
}

/// Five notch entities spawn alongside the rest of the overlay
/// tree on `Inactive → Playing`. Cardinality matches
/// `scrub_notch_positions().len()`.
#[test]
fn scrub_notches_spawn_with_overlay() {
    let mut app = headless_app();
    app.update();
    assert_eq!(scrub_notch_count(&mut app), 0);

    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(
        scrub_notch_count(&mut app),
        scrub_notch_positions().len(),
        "exactly one notch entity per quarter-mark must spawn",
    );
}

/// Each spawned notch carries `HighContrastBackground` so the
/// existing `update_high_contrast_backgrounds` system bumps
/// `BORDER_SUBTLE` → `BORDER_SUBTLE_HC` under HC mode.
/// Five-of-five — every notch tagged.
#[test]
fn scrub_notches_carry_high_contrast_background_marker() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();

    let count = app
        .world_mut()
        .query_filtered::<&HighContrastBackground, With<ReplayOverlayScrubNotch>>()
        .iter(app.world())
        .count();
    assert_eq!(
        count,
        scrub_notch_positions().len(),
        "every notch must carry HighContrastBackground for HC repaint coverage",
    );
}

/// The 1 px scrub track also carries `HighContrastBackground` so
/// the unfilled portion bumps under HC. The fill (ACCENT_PRIMARY,
/// brick-red) doesn't need a marker — accent colours are
/// already saturated and don't need an HC variant.
#[test]
fn scrub_track_carries_high_contrast_background_marker() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();

    // Track is the parent Node of the scrub-fill. Find it by
    // walking up from `ReplayOverlayScrubFill` to its parent.
    let world = app.world_mut();
    let mut fill_q = world.query_filtered::<Entity, With<ReplayOverlayScrubFill>>();
    let fill = fill_q
        .iter(world)
        .next()
        .expect("scrub fill must exist while overlay is spawned");
    let mut parent_q = world.query::<&ChildOf>();
    let parent = parent_q
        .get(world, fill)
        .map(|p| p.parent())
        .expect("scrub fill must have a parent (the track)");
    let mut hc_q = world.query::<&HighContrastBackground>();
    assert!(
        hc_q.get(world, parent).is_ok(),
        "scrub track Node (parent of scrub fill) must carry HighContrastBackground",
    );
}

/// Notches share the overlay tree's lifecycle — they despawn on
/// `Playing → Inactive` along with the banner root.
#[test]
fn scrub_notches_despawn_with_overlay() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(scrub_notch_count(&mut app), 5);

    set_state(&mut app, ReplayPlaybackState::Inactive);
    app.update();
    assert_eq!(
        scrub_notch_count(&mut app),
        0,
        "notches must despawn with the rest of the overlay tree",
    );
}

fn scrub_notch_label_count(app: &mut App) -> usize {
    app.world_mut()
        .query::<&ReplayOverlayScrubNotchLabel>()
        .iter(app.world())
        .count()
}

/// Returns the rendered text of every `ReplayOverlayScrubNotchLabel`
/// in left-to-right order — the iteration order isn't guaranteed by
/// the ECS query, so callers needing a stable order must sort.
fn scrub_notch_label_texts(app: &mut App) -> Vec<String> {
    let world = app.world_mut();
    let mut q = world.query_filtered::<&Text, With<ReplayOverlayScrubNotchLabel>>();
    q.iter(world).map(|t| t.0.clone()).collect()
}

/// Pure-helper guard for the label strings. Pairs with
/// `scrub_notch_positions_are_quarter_marks` — same length, same
/// order, so `labels[i]` belongs at `positions[i]`.
#[test]
fn scrub_notch_labels_are_quarter_mark_percents() {
    assert_eq!(
        scrub_notch_labels(),
        ["0%", "25%", "50%", "75%", "100%"],
        "scrub notch labels must read as the five quarter-mark percentages",
    );
    assert_eq!(
        scrub_notch_labels().len(),
        scrub_notch_positions().len(),
        "labels and positions must remain paired one-to-one",
    );
}

/// Five label entities spawn alongside the rest of the overlay.
/// Cardinality matches `scrub_notch_labels().len()`.
#[test]
fn scrub_notch_labels_spawn_with_overlay() {
    let mut app = headless_app();
    app.update();
    assert_eq!(scrub_notch_label_count(&mut app), 0);

    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(
        scrub_notch_label_count(&mut app),
        scrub_notch_labels().len(),
        "exactly one label entity per notch must spawn",
    );
}

/// Each spawned label carries one of the helper's strings — pins
/// the spawn-path against drift between the helper and the actual
/// painted text.
#[test]
fn scrub_notch_labels_carry_helper_strings() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();

    let mut texts = scrub_notch_label_texts(&mut app);
    texts.sort();
    let mut expected: Vec<String> = scrub_notch_labels().iter().map(|s| s.to_string()).collect();
    expected.sort();
    assert_eq!(
        texts, expected,
        "spawned label texts must equal the helper's strings (set equality, ECS order is not guaranteed)",
    );
}

/// Labels share the overlay tree's lifecycle — they despawn on
/// `Playing → Inactive` along with the banner root.
#[test]
fn scrub_notch_labels_despawn_with_overlay() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(scrub_notch_label_count(&mut app), 5);

    set_state(&mut app, ReplayPlaybackState::Inactive);
    app.update();
    assert_eq!(
        scrub_notch_label_count(&mut app),
        0,
        "labels must despawn with the rest of the overlay tree",
    );
}

fn keybind_footer_count(app: &mut App) -> usize {
    app.world_mut()
        .query::<&ReplayOverlayKeybindFooter>()
        .iter(app.world())
        .count()
}

/// Returns every `Text` rendered as a descendant of the
/// keybind-footer row. Used to assert the mode + hint texts
/// appear inside the footer without requiring per-text markers.
fn keybind_footer_text_set(app: &mut App) -> Vec<String> {
    let world = app.world_mut();
    // Find the footer entity, then walk its descendants for `Text`.
    let mut footer_q = world.query_filtered::<Entity, With<ReplayOverlayKeybindFooter>>();
    let Some(footer) = footer_q.iter(world).next() else {
        return Vec::new();
    };
    let mut child_q = world.query::<&Children>();
    let Ok(children) = child_q.get(world, footer) else {
        return Vec::new();
    };
    let child_entities: Vec<Entity> = children.iter().collect();
    let mut text_q = world.query::<&Text>();
    child_entities
        .into_iter()
        .filter_map(|e| text_q.get(world, e).ok().map(|t| t.0.clone()))
        .collect()
}

/// Pure-helper guards for the static text strings. Pin both
/// helpers so a future refactor that reformats the mode line
/// or extends the hint with un-wired keybinds fails at the
/// helper test rather than at visual review.
#[test]
fn keybind_footer_helpers_carry_expected_text() {
    assert_eq!(
        keybind_footer_mode_text(),
        "\u{258C} NORMAL \u{2502} replay",
        "mode line must read as the cursor-block + NORMAL + bar + replay motif",
    );
    assert_eq!(
        keybind_footer_hint_text(),
        "[SPACE] pause/resume \u{00B7} [ESC] stop \u{00B7} [\u{2190}\u{2192}] step",
        "hint text must list all three wired keybind groups (Space → pause/resume, Esc → stop, ←→ → step) separated by middle dots",
    );
}

/// Footer entity spawns alongside the rest of the overlay tree
/// on `Inactive → Playing`. Cardinality is exactly one — the
/// footer is a singleton row, not a per-keybind multiple.
#[test]
fn keybind_footer_spawns_with_overlay() {
    let mut app = headless_app();
    app.update();
    assert_eq!(keybind_footer_count(&mut app), 0);

    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(
        keybind_footer_count(&mut app),
        1,
        "exactly one keybind-footer row must spawn with the overlay",
    );
}

/// Spawned footer carries both helper strings as direct-child
/// `Text` content — pins the spawn-path against drift between
/// the helpers and the actual painted text.
#[test]
fn keybind_footer_paints_helper_strings() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();

    let texts = keybind_footer_text_set(&mut app);
    assert!(
        texts.contains(&keybind_footer_mode_text().to_string()),
        "footer must contain the mode-line text; got {texts:?}",
    );
    assert!(
        texts.contains(&keybind_footer_hint_text().to_string()),
        "footer must contain the keybind-hint text; got {texts:?}",
    );
}

/// Spawned footer carries `HighContrastBorder` so the existing
/// `apply_high_contrast_borders` system bumps the 1 px top
/// border under HC mode. Without this the footer reads as
/// floating loose under HC.
#[test]
fn keybind_footer_carries_high_contrast_border_marker() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();

    let mut q = app
        .world_mut()
        .query_filtered::<&HighContrastBorder, With<ReplayOverlayKeybindFooter>>();
    let marker = q.iter(app.world()).next();
    assert!(
        marker.is_some(),
        "footer must carry HighContrastBorder so `apply_high_contrast_borders` picks it up under HC mode",
    );
}

/// Footer shares the overlay tree's lifecycle — it despawns on
/// `Playing → Inactive` along with the banner root.
#[test]
fn keybind_footer_despawns_with_overlay() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(keybind_footer_count(&mut app), 1);

    set_state(&mut app, ReplayPlaybackState::Inactive);
    app.update();
    assert_eq!(
        keybind_footer_count(&mut app),
        0,
        "footer must despawn with the rest of the overlay tree",
    );
}

/// Notches are independent of `win_move_index` — a replay with no
/// win marker still gets the full five-notch ladder (notches give
/// quarter-mark anchor points; the win marker is an additional
/// overlay on top of them, not a replacement).
#[test]
fn scrub_notches_spawn_even_without_win_marker() {
    let mut app = headless_app();
    // Default constructor → win_move_index: None.
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(8),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(
        scrub_notch_count(&mut app),
        5,
        "notches and win marker are independent — no marker doesn't drop the notches",
    );
}

// -----------------------------------------------------------------------
// Move Log panel: helpers + spawn cardinality + lifecycle
// -----------------------------------------------------------------------

fn move_log_panel_count(app: &mut App) -> usize {
    app.world_mut()
        .query::<&ReplayOverlayMoveLogPanel>()
        .iter(app.world())
        .count()
}

fn move_log_header_text(app: &mut App) -> String {
    let mut q = app
        .world_mut()
        .query_filtered::<&Text, With<ReplayOverlayMoveLogHeader>>();
    q.iter(app.world())
        .next()
        .map(|t| t.0.clone())
        .unwrap_or_default()
}

fn move_log_active_row_text(app: &mut App) -> String {
    let mut q = app
        .world_mut()
        .query_filtered::<&Text, With<ReplayOverlayMoveLogActiveRow>>();
    q.iter(app.world())
        .next()
        .map(|t| t.0.clone())
        .unwrap_or_default()
}

/// Pile formatter pins the "lowercase + 1-indexed" contract.
/// `Foundation(2)` displays as `"foundation 3"` rather than
/// the underlying 0-index — players see human-friendly numbers.
#[test]
fn format_pile_uses_one_indexed_lowercase_names() {
    assert_eq!(format_pile(&KlondikePile::Stock), "waste");
    assert_eq!(
        format_pile(&KlondikePile::Foundation(Foundation::Foundation1)),
        "foundation 1"
    );
    assert_eq!(
        format_pile(&KlondikePile::Foundation(Foundation::Foundation3)),
        "foundation 3"
    );
    assert_eq!(
        format_pile(&KlondikePile::Tableau(Tableau::Tableau1)),
        "tableau 1"
    );
    assert_eq!(
        format_pile(&KlondikePile::Tableau(Tableau::Tableau7)),
        "tableau 7"
    );
}

/// Move-body formatter renders `RotateStock` as a label. The
/// `Dst*` variants render as a `→ to` arrow, but their pile-stack
/// source types are runtime-only and not constructible from this
/// crate, so only the stock-cycle label is asserted here; the
/// arrow path is exercised end-to-end through the move-log
/// integration tests.
#[test]
fn format_move_body_handles_stock_cycle() {
    assert_eq!(
        format_move_body(&KlondikeInstruction::RotateStock),
        "stock cycle"
    );
}

/// Header text covers all three state branches:
/// `Playing` → `▌ MOVE LOG · N/M`,
/// `Completed` → `▌ MOVE LOG · COMPLETE`,
/// `Inactive` → empty.
#[test]
fn format_move_log_header_covers_state_branches() {
    let playing = ReplayPlaybackState::Playing {
        replay: synthetic_replay(10),
        cursor: 3,
        secs_to_next: 0.5,
        paused: false,
    };
    assert_eq!(
        format_move_log_header(&playing),
        "\u{258C} MOVE LOG \u{00B7} 3/10"
    );
    assert_eq!(
        format_move_log_header(&ReplayPlaybackState::Completed),
        "\u{258C} MOVE LOG \u{00B7} COMPLETE",
    );
    assert_eq!(format_move_log_header(&ReplayPlaybackState::Inactive), "");
}

/// Active-row text is empty at cursor 0 (no move applied yet)
/// and populated otherwise. The displayed index is 1-based —
/// when cursor=N, the most-recently-applied move is at
/// `replay.moves[N - 1]` and the row reads `"N | ..."`.
#[test]
fn format_active_move_row_handles_cursor_zero_and_positive() {
    let cursor_zero = ReplayPlaybackState::Playing {
        replay: synthetic_replay(10),
        cursor: 0,
        secs_to_next: 0.5,
        paused: false,
    };
    assert_eq!(
        format_active_move_row(&cursor_zero),
        "",
        "cursor=0 means no move applied yet; row stays empty",
    );

    let cursor_three = ReplayPlaybackState::Playing {
        replay: synthetic_replay(10),
        cursor: 3,
        secs_to_next: 0.5,
        paused: false,
    };
    // synthetic_replay produces all StockClicks, so the body
    // is "stock cycle". The displayed index is 3 (cursor),
    // matching the most-recently-applied move at moves[2].
    // Active row carries the `▶` focus prefix; prev rows
    // (kth-recent for k>1) don't.
    assert_eq!(
        format_active_move_row(&cursor_three),
        "\u{25B6} 3 \u{2502} stock cycle",
        "active row must read `▶ cursor │ {{move body}}` with the 1-based displayed index",
    );
}

/// Move-log panel spawns alongside the rest of the overlay
/// tree on `Inactive → Playing`. Cardinality is exactly one
/// (singleton bottom-edge panel).
#[test]
fn move_log_panel_spawns_with_overlay() {
    let mut app = headless_app();
    app.update();
    assert_eq!(move_log_panel_count(&mut app), 0);

    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(
        move_log_panel_count(&mut app),
        1,
        "exactly one move-log panel must spawn with the overlay",
    );
}

/// Spawned panel's header reads `▌ MOVE LOG · N/M` matching
/// the helper output for the active state. Pins the spawn-path
/// against drift between the helper and the actual painted
/// text.
#[test]
fn move_log_panel_header_paints_helper_string() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(8),
            cursor: 2,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(
        move_log_header_text(&mut app),
        "\u{258C} MOVE LOG \u{00B7} 2/8",
    );
}

/// Active-row text repaints when the cursor advances. Drives
/// the resource through cursor=0 → cursor=2 transitions and
/// asserts the row text follows.
#[test]
fn move_log_active_row_repaints_on_cursor_advance() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(
        move_log_active_row_text(&mut app),
        "",
        "cursor=0 must paint an empty row",
    );

    // Advance cursor to 2 (most-recently-applied move is moves[1]).
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 2,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(
        move_log_active_row_text(&mut app),
        "\u{25B6} 2 \u{2502} stock cycle",
        "active row must repaint to the cursor's position when state changes (with ▶ prefix)",
    );
}

/// `format_kth_recent_row` covers the active-row helper for
/// `k=1` and the prev-row helpers for `k>1`. Pins the "k larger
/// than cursor returns empty" branch so under-filled panels
/// early in a replay don't paint stale text.
#[test]
fn format_kth_recent_row_handles_in_range_and_out_of_range() {
    let state_at_three = ReplayPlaybackState::Playing {
        replay: synthetic_replay(10),
        cursor: 3,
        secs_to_next: 0.5,
        paused: false,
    };
    // k=1 → active (most recent applied). cursor=3 → display=3.
    assert_eq!(
        format_kth_recent_row(&state_at_three, 1),
        "3 \u{2502} stock cycle",
    );
    // k=2 → row above active. display=2.
    assert_eq!(
        format_kth_recent_row(&state_at_three, 2),
        "2 \u{2502} stock cycle",
    );
    // k=3 → second-prev row. display=1.
    assert_eq!(
        format_kth_recent_row(&state_at_three, 3),
        "1 \u{2502} stock cycle",
    );
    // k=4 — exceeds cursor, no history that far back.
    assert_eq!(
        format_kth_recent_row(&state_at_three, 4),
        "",
        "k > cursor must return empty (panel under-fills gracefully)",
    );
    // k=0 — degenerate, no kth-most-recent for k=0.
    assert_eq!(format_kth_recent_row(&state_at_three, 0), "");
}

fn move_log_prev_row_count(app: &mut App) -> usize {
    app.world_mut()
        .query::<&ReplayOverlayMoveLogPrevRow>()
        .iter(app.world())
        .count()
}

fn move_log_prev_row_text_at_offset(app: &mut App, offset: u8) -> String {
    let world = app.world_mut();
    let mut q = world.query::<(&ReplayOverlayMoveLogPrevRow, &Text)>();
    for (row, text) in q.iter(world) {
        if row.offset == offset {
            return text.0.clone();
        }
    }
    String::new()
}

/// `MOVE_LOG_PREV_ROWS` prev rows spawn with the panel — one
/// per offset 1..=N. Cardinality matches the constant.
#[test]
fn move_log_prev_rows_spawn_with_panel() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 3,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(
        move_log_prev_row_count(&mut app),
        MOVE_LOG_PREV_ROWS,
        "exactly MOVE_LOG_PREV_ROWS prev rows must spawn with the panel",
    );
}

/// Each prev row's text at spawn time matches the helper
/// output for its offset. Pins the spawn path against drift
/// between marker offset and rendered text.
#[test]
fn move_log_prev_rows_paint_helper_strings_at_spawn() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 5,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();

    // offset 1 → k=2 → display=4
    assert_eq!(
        move_log_prev_row_text_at_offset(&mut app, 1),
        "4 \u{2502} stock cycle",
    );
    // offset 2 → k=3 → display=3
    assert_eq!(
        move_log_prev_row_text_at_offset(&mut app, 2),
        "3 \u{2502} stock cycle",
    );
}

/// Prev rows repaint as the cursor advances. Drives the
/// resource through cursor=2 → cursor=5 and asserts the texts
/// follow.
#[test]
fn move_log_prev_rows_repaint_on_cursor_advance() {
    let mut app = headless_app();
    // Start at cursor=2: offset 1 → k=2 → display=1, offset 2 → k=3 → empty (k > cursor).
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 2,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(
        move_log_prev_row_text_at_offset(&mut app, 1),
        "1 \u{2502} stock cycle",
    );
    assert_eq!(
        move_log_prev_row_text_at_offset(&mut app, 2),
        "",
        "offset 2 (k=3) must be empty when cursor=2 (no history that far back)",
    );

    // Advance to cursor=5 — both offsets now have history.
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 5,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(
        move_log_prev_row_text_at_offset(&mut app, 1),
        "4 \u{2502} stock cycle",
        "offset 1 must repaint to k=2 of new cursor (display=4)",
    );
    assert_eq!(
        move_log_prev_row_text_at_offset(&mut app, 2),
        "3 \u{2502} stock cycle",
    );
}

fn move_log_next_row_count(app: &mut App) -> usize {
    app.world_mut()
        .query::<&ReplayOverlayMoveLogNextRow>()
        .iter(app.world())
        .count()
}

fn move_log_next_row_text_at_offset(app: &mut App, offset: u8) -> String {
    let world = app.world_mut();
    let mut q = world.query::<(&ReplayOverlayMoveLogNextRow, &Text)>();
    for (row, text) in q.iter(world) {
        if row.offset == offset {
            return text.0.clone();
        }
    }
    String::new()
}

/// `format_kth_next_row` covers the about-to-apply preview
/// for `k=1` (the very next move) and beyond. Pins the
/// "k=0 returns empty" + "out-of-range returns empty" cases
/// alongside in-range correctness.
#[test]
fn format_kth_next_row_handles_in_range_and_out_of_range() {
    let state_at_three = ReplayPlaybackState::Playing {
        replay: synthetic_replay(10),
        cursor: 3,
        secs_to_next: 0.5,
        paused: false,
    };
    // k=1 → moves[3], display=4
    assert_eq!(
        format_kth_next_row(&state_at_three, 1),
        "4 \u{2502} stock cycle",
    );
    // k=2 → moves[4], display=5
    assert_eq!(
        format_kth_next_row(&state_at_three, 2),
        "5 \u{2502} stock cycle",
    );
    // k=8 — moves[10], out of range for a 10-move replay.
    assert_eq!(
        format_kth_next_row(&state_at_three, 8),
        "",
        "k beyond moves.len() must return empty (panel under-fills late in replay)",
    );
    // k=0 — degenerate.
    assert_eq!(format_kth_next_row(&state_at_three, 0), "");
}

/// `MOVE_LOG_NEXT_ROWS` next rows spawn with the panel —
/// one per offset 1..=N. Cardinality matches the constant.
#[test]
fn move_log_next_rows_spawn_with_panel() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 3,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(
        move_log_next_row_count(&mut app),
        MOVE_LOG_NEXT_ROWS,
        "exactly MOVE_LOG_NEXT_ROWS next rows must spawn with the panel",
    );
}

/// Each next row's text at spawn time matches the helper
/// output for its offset.
#[test]
fn move_log_next_rows_paint_helper_strings_at_spawn() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 5,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();

    // offset 1 → moves[5], display=6
    assert_eq!(
        move_log_next_row_text_at_offset(&mut app, 1),
        "6 \u{2502} stock cycle",
    );
    // offset 2 → moves[6], display=7
    assert_eq!(
        move_log_next_row_text_at_offset(&mut app, 2),
        "7 \u{2502} stock cycle",
    );
}

/// Next rows under-fill late in the replay. With a 10-move
/// replay at cursor=9: offset 1 → moves[9] (display 10),
/// offset 2 → moves[10] (out of range, empty).
#[test]
fn move_log_next_rows_underfill_at_replay_end() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 9,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(
        move_log_next_row_text_at_offset(&mut app, 1),
        "10 \u{2502} stock cycle",
        "offset 1 (k=1) must populate when cursor < moves.len()",
    );
    assert_eq!(
        move_log_next_row_text_at_offset(&mut app, 2),
        "",
        "offset 2 (k=2) must be empty when cursor + k - 1 >= moves.len()",
    );
}

/// Active row sits inside a wrapper Node with
/// `BackgroundColor(ACCENT_PRIMARY)` so it reads as "current
/// focus" against the panel background. Validates the wrapper
/// is present and carries the expected colour.
#[test]
fn active_row_wrapper_carries_accent_primary_background() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 3,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();

    // Find the active-row Text entity, then walk to its
    // parent — that's the wrapper Node which should carry
    // the highlight BackgroundColor.
    let world = app.world_mut();
    let mut row_q = world.query_filtered::<Entity, With<ReplayOverlayMoveLogActiveRow>>();
    let row = row_q
        .iter(world)
        .next()
        .expect("active row Text entity must exist while overlay is spawned");
    let mut parent_q = world.query::<&ChildOf>();
    let parent = parent_q
        .get(world, row)
        .map(|p| p.parent())
        .expect("active row must have a parent (the highlight wrapper)");
    let mut bg_q = world.query::<&BackgroundColor>();
    let bg = bg_q
        .get(world, parent)
        .expect("active row's parent must carry BackgroundColor (highlight)");
    assert_eq!(
        bg.0, ACCENT_PRIMARY,
        "active-row wrapper background must be ACCENT_PRIMARY for the focus highlight",
    );
}

/// Active-row Text uses TEXT_PRIMARY_HC for legible contrast
/// against the brick-red ACCENT_PRIMARY background. Without
/// this the default TEXT_PRIMARY (#d0d0d0) on red would have
/// borderline contrast; the HC variant (#f5f5f5) keeps the
/// row readable.
#[test]
fn active_row_text_uses_high_contrast_color_for_highlight() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 3,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();

    let world = app.world_mut();
    let mut q = world.query_filtered::<&TextColor, With<ReplayOverlayMoveLogActiveRow>>();
    let color = q
        .iter(world)
        .next()
        .expect("active row TextColor must exist");
    assert_eq!(
        color.0, TEXT_PRIMARY_HC,
        "active row text colour must be TEXT_PRIMARY_HC for contrast against the highlight",
    );
}

/// Active-row text starts with the `▶` focus marker prefix.
/// Pure-helper guard — pins the prefix so a future refactor
/// dropping it has to also update this test.
#[test]
fn active_row_format_includes_focus_prefix() {
    let state = ReplayPlaybackState::Playing {
        replay: synthetic_replay(10),
        cursor: 5,
        secs_to_next: 0.5,
        paused: false,
    };
    let row = format_active_move_row(&state);
    assert!(
        row.starts_with('\u{25B6}'),
        "active-row format must start with ▶ focus marker; got {row:?}",
    );
    // Cursor=0 still returns empty, never just the prefix.
    let cursor_zero = ReplayPlaybackState::Playing {
        replay: synthetic_replay(10),
        cursor: 0,
        secs_to_next: 0.5,
        paused: false,
    };
    assert_eq!(
        format_active_move_row(&cursor_zero),
        "",
        "cursor=0 must return empty (no stray prefix on empty row)",
    );
}

/// Panel shares the overlay tree's lifecycle — it despawns on
/// `Playing → Inactive` along with the banner root.
#[test]
fn move_log_panel_despawns_with_overlay() {
    let mut app = headless_app();
    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(10),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(move_log_panel_count(&mut app), 1);

    set_state(&mut app, ReplayPlaybackState::Inactive);
    app.update();
    assert_eq!(
        move_log_panel_count(&mut app),
        0,
        "panel must despawn with the rest of the overlay tree",
    );
}

// -----------------------------------------------------------------------
// pause_button_label + pause / step click handlers + keyboard accelerator
// -----------------------------------------------------------------------

/// Read the current text content of the unique pause / resume button.
fn pause_button_text(app: &mut App) -> String {
    let world = app.world_mut();
    let mut button_q = world.query_filtered::<&Children, With<ReplayPauseButton>>();
    let children: Vec<Entity> = button_q
        .iter(world)
        .next()
        .map(|c| c.iter().collect())
        .unwrap_or_default();
    let mut text_q = world.query::<&Text>();
    for child in children {
        if let Ok(text) = text_q.get(world, child) {
            return text.0.clone();
        }
    }
    String::new()
}

/// Find the unique entity carrying the given button marker.
fn unique_button<M: Component>(app: &mut App) -> Entity {
    let world = app.world_mut();
    let mut q = world.query_filtered::<Entity, With<M>>();
    q.iter(world).next().expect("button entity must exist")
}

fn pressed_paused_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
    ReplayPlaybackState::Playing {
        replay: synthetic_replay(replay_len),
        cursor,
        secs_to_next: 0.5,
        paused: true,
    }
}

fn running_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
    ReplayPlaybackState::Playing {
        replay: synthetic_replay(replay_len),
        cursor,
        secs_to_next: 0.5,
        paused: false,
    }
}

#[test]
fn pause_button_label_reads_pause_when_running() {
    assert_eq!(pause_button_label(&running_state(5, 0)), "Pause");
}

#[test]
fn pause_button_label_reads_resume_when_paused() {
    assert_eq!(pause_button_label(&pressed_paused_state(5, 0)), "Resume");
}

#[test]
fn pause_button_label_is_empty_off_state() {
    assert_eq!(pause_button_label(&ReplayPlaybackState::Inactive), "");
    assert_eq!(pause_button_label(&ReplayPlaybackState::Completed), "");
}

#[test]
fn pause_button_text_swaps_when_state_pauses() {
    let mut app = headless_app();
    set_state(&mut app, running_state(5, 0));
    app.update();
    assert_eq!(pause_button_text(&mut app), "Pause");

    set_state(&mut app, pressed_paused_state(5, 0));
    app.update();
    assert_eq!(
        pause_button_text(&mut app),
        "Resume",
        "label must repaint to Resume on the frame the state pauses"
    );
}

#[test]
fn pause_button_click_toggles_paused_flag() {
    let mut app = headless_app();
    set_state(&mut app, running_state(5, 0));
    app.update();

    let button = unique_button::<ReplayPauseButton>(&mut app);
    app.world_mut()
        .entity_mut(button)
        .insert(Interaction::Pressed);
    app.update();

    match app.world().resource::<ReplayPlaybackState>() {
        ReplayPlaybackState::Playing { paused, .. } => {
            assert!(*paused, "click must flip running → paused");
        }
        other => panic!("expected Playing, got {other:?}"),
    }
}

#[test]
fn step_button_click_advances_cursor_while_paused() {
    let mut app = headless_app();
    set_state(&mut app, pressed_paused_state(5, 0));
    app.update();

    let button = unique_button::<ReplayStepButton>(&mut app);
    app.world_mut()
        .entity_mut(button)
        .insert(Interaction::Pressed);
    app.update();

    match app.world().resource::<ReplayPlaybackState>() {
        ReplayPlaybackState::Playing { cursor, paused, .. } => {
            assert_eq!(*cursor, 1, "step must advance the cursor by exactly one");
            assert!(*paused, "step must leave the paused flag untouched");
        }
        other => panic!("expected Playing, got {other:?}"),
    }
}

#[test]
fn step_button_click_is_noop_while_running() {
    let mut app = headless_app();
    set_state(&mut app, running_state(5, 0));
    app.update();

    let button = unique_button::<ReplayStepButton>(&mut app);
    app.world_mut()
        .entity_mut(button)
        .insert(Interaction::Pressed);
    app.update();

    match app.world().resource::<ReplayPlaybackState>() {
        ReplayPlaybackState::Playing { cursor, paused, .. } => {
            assert_eq!(*cursor, 0, "running-step must not race the tick loop");
            assert!(!*paused);
        }
        other => panic!("expected Playing, got {other:?}"),
    }
}

/// Pressing Esc while a replay is playing resets the state to
/// `Inactive` (same end-state as clicking the Stop button).
/// Mirrors `space_keyboard_toggles_paused_flag` for the stop
/// accelerator.
#[test]
fn esc_keyboard_stops_active_replay() {
    let mut app = headless_app();
    // The keyboard handler reads `Option<Res<ButtonInput<KeyCode>>>`
    // and no-ops when missing — provide it for this test.
    app.init_resource::<ButtonInput<KeyCode>>();
    set_state(&mut app, running_state(5, 0));
    app.update();
    assert_eq!(overlay_root_count(&mut app), 1);

    app.world_mut()
        .resource_mut::<ButtonInput<KeyCode>>()
        .press(KeyCode::Escape);
    app.update();

    assert!(
        matches!(
            app.world().resource::<ReplayPlaybackState>(),
            ReplayPlaybackState::Inactive
        ),
        "Esc must reset state to Inactive while replay is Playing",
    );

    // One more tick — `react_to_state_change` despawns the overlay
    // in response to the state going Inactive.
    app.update();
    assert_eq!(
        overlay_root_count(&mut app),
        0,
        "overlay must despawn the frame after Esc stops the replay",
    );
}

/// Esc is a no-op when the replay isn't `Playing` — covers
/// `Inactive` (no replay attached) and `Completed` (auto-clear
/// underway). The handler must stay quiet so the global Esc
/// listeners (pause modal, etc.) own those frames.
#[test]
fn esc_keyboard_is_noop_when_not_playing() {
    let mut app = headless_app();
    app.init_resource::<ButtonInput<KeyCode>>();
    // Resource defaults to Inactive — no replay attached.
    app.update();

    app.world_mut()
        .resource_mut::<ButtonInput<KeyCode>>()
        .press(KeyCode::Escape);
    app.update();

    // State stays Inactive — no spurious mutation.
    assert!(matches!(
        app.world().resource::<ReplayPlaybackState>(),
        ReplayPlaybackState::Inactive
    ));
}

/// The keybind-footer hint text now lists both wired
/// accelerators (Space + Esc). Lock the format so a future edit
/// that drops one or the other has to also update this test.
#[test]
fn keybind_footer_hint_lists_space_and_esc() {
    let hint = keybind_footer_hint_text();
    assert!(
        hint.contains("[SPACE]"),
        "hint must surface the Space accelerator; got {hint:?}",
    );
    assert!(
        hint.contains("[ESC]"),
        "hint must surface the Esc accelerator; got {hint:?}",
    );
}

/// Hint must also list the arrow-key step accelerators.
/// Pinned separately from the Space + Esc test so a future
/// regression that drops only the arrows is caught here even
/// if the Space + Esc check still passes.
#[test]
fn keybind_footer_hint_lists_arrow_steps() {
    let hint = keybind_footer_hint_text();
    assert!(
        hint.contains("\u{2190}\u{2192}"),
        "hint must surface the ←→ step accelerators; got {hint:?}",
    );
    assert!(
        hint.contains("step"),
        "hint must label the arrow accelerators as 'step' \
         (matches what's wired — single-move step, not continuous scrub); got {hint:?}",
    );
}

/// Pressing → while paused advances the cursor by exactly one
/// — same end-state as clicking the on-screen Step button.
#[test]
fn arrow_right_keyboard_advances_cursor_while_paused() {
    let mut app = headless_app();
    app.init_resource::<ButtonInput<KeyCode>>();
    set_state(&mut app, pressed_paused_state(5, 0));
    app.update();

    app.world_mut()
        .resource_mut::<ButtonInput<KeyCode>>()
        .press(KeyCode::ArrowRight);
    app.update();

    match app.world().resource::<ReplayPlaybackState>() {
        ReplayPlaybackState::Playing { cursor, paused, .. } => {
            assert_eq!(
                *cursor, 1,
                "→ must advance the cursor by exactly one while paused",
            );
            assert!(*paused, "→ must leave the paused flag untouched",);
        }
        other => panic!("expected Playing, got {other:?}"),
    }
}

/// Pressing → while running is a no-op — the existing
/// `step_replay_playback` guard prevents racing the tick loop.
#[test]
fn arrow_right_keyboard_is_noop_while_running() {
    let mut app = headless_app();
    app.init_resource::<ButtonInput<KeyCode>>();
    set_state(&mut app, running_state(5, 0));
    app.update();

    app.world_mut()
        .resource_mut::<ButtonInput<KeyCode>>()
        .press(KeyCode::ArrowRight);
    app.update();

    match app.world().resource::<ReplayPlaybackState>() {
        ReplayPlaybackState::Playing { cursor, paused, .. } => {
            assert_eq!(*cursor, 0, "→ must not race the tick loop");
            assert!(!*paused);
        }
        other => panic!("expected Playing, got {other:?}"),
    }
}

/// Pressing ← while paused with cursor > 0 decrements the
/// cursor by exactly one. The corresponding game-state reversal
/// happens when `handle_undo` reads the dispatched
/// `UndoRequestEvent` — that's covered in the playback core's
/// integration test, not here.
#[test]
fn arrow_left_keyboard_decrements_cursor_while_paused() {
    let mut app = headless_app();
    app.init_resource::<ButtonInput<KeyCode>>();
    // Start paused at cursor=3 so there's room to step backwards.
    set_state(&mut app, pressed_paused_state(5, 3));
    app.update();

    app.world_mut()
        .resource_mut::<ButtonInput<KeyCode>>()
        .press(KeyCode::ArrowLeft);
    app.update();

    match app.world().resource::<ReplayPlaybackState>() {
        ReplayPlaybackState::Playing { cursor, paused, .. } => {
            assert_eq!(
                *cursor, 2,
                "← must decrement the cursor by exactly one while paused",
            );
            assert!(*paused, "← must leave the paused flag untouched",);
        }
        other => panic!("expected Playing, got {other:?}"),
    }
}

/// Pressing ← at cursor 0 is a no-op (nothing to rewind past).
#[test]
fn arrow_left_keyboard_is_noop_at_cursor_zero() {
    let mut app = headless_app();
    app.init_resource::<ButtonInput<KeyCode>>();
    set_state(&mut app, pressed_paused_state(5, 0));
    app.update();

    app.world_mut()
        .resource_mut::<ButtonInput<KeyCode>>()
        .press(KeyCode::ArrowLeft);
    app.update();

    match app.world().resource::<ReplayPlaybackState>() {
        ReplayPlaybackState::Playing { cursor, .. } => {
            assert_eq!(*cursor, 0, "← at cursor 0 must be a no-op");
        }
        other => panic!("expected Playing, got {other:?}"),
    }
}

/// Holding → for one full repeat interval fires a second step
/// after the initial just_pressed. Drives `Time::delta_secs`
/// via `TimeUpdateStrategy::ManualDuration` so the test is
/// deterministic.
#[test]
fn arrow_right_keyboard_repeats_while_held() {
    use bevy::time::TimeUpdateStrategy;
    use std::time::Duration;

    let mut app = headless_app();
    app.init_resource::<ButtonInput<KeyCode>>();
    // Drive each frame as a SCRUB_REPEAT_INTERVAL_SECS step so
    // every update past the just_pressed crosses the threshold.
    app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
        SCRUB_REPEAT_INTERVAL_SECS,
    )));
    // Start paused at cursor 0 so there's room to step forward.
    set_state(&mut app, pressed_paused_state(10, 0));
    app.update();

    // Press the key (just_pressed fires once → cursor 1).
    app.world_mut()
        .resource_mut::<ButtonInput<KeyCode>>()
        .press(KeyCode::ArrowRight);
    app.update();
    let cursor_after_press = match app.world().resource::<ReplayPlaybackState>() {
        ReplayPlaybackState::Playing { cursor, .. } => *cursor,
        _ => panic!("expected Playing"),
    };
    assert_eq!(
        cursor_after_press, 1,
        "just_pressed must fire once on the press frame",
    );

    // Hold (no new just_pressed; held → accumulator crosses
    // threshold next frame → second fire).
    app.world_mut()
        .resource_mut::<ButtonInput<KeyCode>>()
        .clear_just_pressed(KeyCode::ArrowRight);
    app.update();
    let cursor_after_hold = match app.world().resource::<ReplayPlaybackState>() {
        ReplayPlaybackState::Playing { cursor, .. } => *cursor,
        _ => panic!("expected Playing"),
    };
    assert!(
        cursor_after_hold >= 2,
        "held key must fire at least one repeat after the threshold; got cursor={cursor_after_hold}",
    );
}

/// Releasing the key resets the per-key accumulator so the
/// next fresh press fires immediately rather than at half-
/// interval. Validates the `else { reset to 0 }` branch.
#[test]
fn arrow_keyboard_release_resets_accumulator() {
    use bevy::time::TimeUpdateStrategy;
    use std::time::Duration;

    let mut app = headless_app();
    app.init_resource::<ButtonInput<KeyCode>>();
    // Drive sub-threshold ticks so the accumulator builds but
    // never fires while held.
    let half_interval = SCRUB_REPEAT_INTERVAL_SECS * 0.5;
    app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
        half_interval,
    )));
    set_state(&mut app, pressed_paused_state(10, 5));
    app.update();

    // Hold for a sub-threshold tick (no fire expected: no
    // just_pressed, accumulator at 0.05s < 0.1s threshold).
    app.world_mut()
        .resource_mut::<ButtonInput<KeyCode>>()
        .press(KeyCode::ArrowRight);
    app.world_mut()
        .resource_mut::<ButtonInput<KeyCode>>()
        .clear_just_pressed(KeyCode::ArrowRight);
    app.update();

    // Release (the else-branch should reset right_held_secs
    // to 0). Then verify by holding for another sub-threshold
    // tick — if the accumulator reset properly, no fire.
    app.world_mut()
        .resource_mut::<ButtonInput<KeyCode>>()
        .release(KeyCode::ArrowRight);
    app.update();
    let hold = app.world().resource::<ReplayScrubKeyHold>();
    assert_eq!(
        hold.right_held_secs, 0.0,
        "release must reset the per-key accumulator to 0",
    );
}

/// Pressing ← while running is a no-op — same hard-gate
/// rationale as the forward-step paused-only check.
#[test]
fn arrow_left_keyboard_is_noop_while_running() {
    let mut app = headless_app();
    app.init_resource::<ButtonInput<KeyCode>>();
    set_state(&mut app, running_state(5, 3));
    app.update();

    app.world_mut()
        .resource_mut::<ButtonInput<KeyCode>>()
        .press(KeyCode::ArrowLeft);
    app.update();

    match app.world().resource::<ReplayPlaybackState>() {
        ReplayPlaybackState::Playing { cursor, paused, .. } => {
            assert_eq!(*cursor, 3, "← must not race the tick loop");
            assert!(!*paused);
        }
        other => panic!("expected Playing, got {other:?}"),
    }
}

#[test]
fn space_keyboard_toggles_paused_flag() {
    let mut app = headless_app();
    // The keyboard handler reads `Option<Res<ButtonInput<KeyCode>>>`
    // and no-ops when missing — provide it for this test.
    app.init_resource::<ButtonInput<KeyCode>>();
    set_state(&mut app, running_state(5, 0));
    app.update();

    app.world_mut()
        .resource_mut::<ButtonInput<KeyCode>>()
        .press(KeyCode::Space);
    app.update();

    match app.world().resource::<ReplayPlaybackState>() {
        ReplayPlaybackState::Playing { paused, .. } => {
            assert!(*paused, "Space must toggle running → paused");
        }
        other => panic!("expected Playing, got {other:?}"),
    }
}

/// The tableau dim layer spawns alongside the banner when playback
/// starts and despawns when the replay ends. Mirrors
/// `floating_chip_spawns_and_despawns_with_overlay` for the dim layer.
#[test]
fn dim_layer_spawns_and_despawns_with_overlay() {
    let mut app = headless_app();

    // Inactive → no dim layer yet.
    app.update();
    assert_eq!(
        app.world_mut()
            .query::<&ReplayTableauDimLayer>()
            .iter(app.world())
            .count(),
        0,
        "no dim layer while playback is Inactive",
    );

    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(5),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(
        app.world_mut()
            .query::<&ReplayTableauDimLayer>()
            .iter(app.world())
            .count(),
        1,
        "dim layer must spawn when playback starts",
    );

    set_state(&mut app, ReplayPlaybackState::Inactive);
    app.update();
    assert_eq!(
        app.world_mut()
            .query::<&ReplayTableauDimLayer>()
            .iter(app.world())
            .count(),
        0,
        "dim layer must despawn when playback ends",
    );
}

/// The dim layer is a full-screen node (100 % × 100 %) at a lower
/// z-index than the replay chrome (z = Z_REPLAY_DIM < Z_REPLAY_OVERLAY).
/// Lock the z-ordering so a future refactor of the z constants can't
/// silently flip the intended stacking.
#[test]
fn dim_layer_z_is_below_replay_chrome() {
    const { assert!(Z_REPLAY_DIM < Z_REPLAY_OVERLAY) }
}

// -----------------------------------------------------------------------
// Mini-tableau preview tests
// -----------------------------------------------------------------------

fn mini_tableau_panel_count(app: &mut App) -> usize {
    app.world_mut()
        .query::<&ReplayMiniTableauPanel>()
        .iter(app.world())
        .count()
}

/// Mini-tableau panel spawns alongside the other overlay surfaces
/// when playback starts and despawns when it ends.
#[test]
fn mini_tableau_panel_spawns_and_despawns_with_overlay() {
    let mut app = headless_app();

    app.update();
    assert_eq!(
        mini_tableau_panel_count(&mut app),
        0,
        "no mini-tableau panel while playback is Inactive",
    );

    set_state(
        &mut app,
        ReplayPlaybackState::Playing {
            replay: synthetic_replay(5),
            cursor: 0,
            secs_to_next: 0.5,
            paused: false,
        },
    );
    app.update();
    assert_eq!(
        mini_tableau_panel_count(&mut app),
        1,
        "mini-tableau panel must spawn when playback starts",
    );

    set_state(&mut app, ReplayPlaybackState::Inactive);
    app.update();
    assert_eq!(
        mini_tableau_panel_count(&mut app),
        0,
        "mini-tableau panel must despawn when playback ends",
    );
}

/// `format_rank_short` maps every `Rank` variant to a single ASCII
/// character except Ten which maps to `"T"`.
#[test]
fn format_rank_short_all_ranks() {
    assert_eq!(format_rank_short(Rank::Ace), "A");
    assert_eq!(format_rank_short(Rank::Two), "2");
    assert_eq!(format_rank_short(Rank::Three), "3");
    assert_eq!(format_rank_short(Rank::Four), "4");
    assert_eq!(format_rank_short(Rank::Five), "5");
    assert_eq!(format_rank_short(Rank::Six), "6");
    assert_eq!(format_rank_short(Rank::Seven), "7");
    assert_eq!(format_rank_short(Rank::Eight), "8");
    assert_eq!(format_rank_short(Rank::Nine), "9");
    assert_eq!(format_rank_short(Rank::Ten), "T");
    assert_eq!(format_rank_short(Rank::Jack), "J");
    assert_eq!(format_rank_short(Rank::Queen), "Q");
    assert_eq!(format_rank_short(Rank::King), "K");
}

/// `format_suit_glyph` returns the FiraMono-covered Unicode suit
/// glyphs for each `Suit` variant (U+2660–U+2666 confirmed on Android).
#[test]
fn format_suit_glyph_all_suits() {
    assert_eq!(format_suit_glyph(Suit::Spades), "\u{2660}");
    assert_eq!(format_suit_glyph(Suit::Hearts), "\u{2665}");
    assert_eq!(format_suit_glyph(Suit::Diamonds), "\u{2666}");
    assert_eq!(format_suit_glyph(Suit::Clubs), "\u{2663}");
}

/// `format_foundations_row` with a freshly-dealt game (all empty).
#[test]
fn format_foundations_row_empty_board() {
    let game = solitaire_core::game_state::GameState::new_with_mode(
        42,
        DrawStockConfig::DrawOne,
        GameMode::Classic,
    );
    assert_eq!(format_foundations_row(&game), "F: -- -- -- --");
}

/// `format_stock_waste_row` with a freshly-dealt game: stock has
/// 24 cards, waste is empty.
#[test]
fn format_stock_waste_row_initial_state() {
    let game = solitaire_core::game_state::GameState::new_with_mode(
        42,
        DrawStockConfig::DrawOne,
        GameMode::Classic,
    );
    let text = format_stock_waste_row(&game);
    assert!(
        text.starts_with("STK:"),
        "row must start with STK: prefix; got {text:?}",
    );
    assert!(
        text.contains("WST:--"),
        "waste must show -- on a fresh deal; got {text:?}",
    );
}
