#![allow(dead_code)]

use bevy::prelude::*;

use super::*;
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
use crate::replay_playback::{
    ReplayPlaybackState, step_backwards_replay_playback, step_replay_playback,
    stop_replay_playback, toggle_pause_replay_playback,
};
use crate::resources::GameStateResource;

/// Per-arrow-key time-since-last-fire accumulators that drive the
/// continuous-scrub repeat behaviour for held arrow keys. Each
/// frame the key is held, the corresponding accumulator absorbs
/// `time.delta_secs()`; when it exceeds
/// [`SCRUB_REPEAT_INTERVAL_SECS`] the handler fires another step
/// and resets the accumulator.
///
/// `just_pressed` events bypass the accumulator entirely and fire
/// immediately — only *repeat* fires (while held) are gated by
/// the interval. Releases reset the accumulator to 0 so the next
/// fresh press fires immediately rather than at half-interval.
#[derive(Resource, Default, Debug)]
pub(crate) struct ReplayScrubKeyHold {
    pub(crate) left_held_secs: f32,
    pub(crate) right_held_secs: f32,
}

/// Marker on the keybind-hint footer row at the bottom edge of the
/// banner. Carries two `Text` children: a vim-style mode indicator
/// (`▌ NORMAL │ replay`) on the left and the keybind hint
/// (`[SPACE] pause/resume`) on the right. 1 px top border in
/// [`BORDER_SUBTLE`] separates it from the notch-label row above.
///
/// Surfaces the existing Space-key accelerator visually so the
/// UI-first contract from CLAUDE.md §3.3 (every player action has
/// a visible UI control) holds for keyboard accelerators too.
/// Future commits that wire ESC for stop or ← / → for scrub will
/// extend the right-hand text in lockstep — the footer always
/// reflects what's actually wired, never aspirational.
#[derive(Component, Debug)]
pub struct ReplayOverlayKeybindFooter;

/// Marker on the bottom-edge **Move Log** panel — a separate root
/// UI entity (not a child of the banner) that sits anchored to the
/// viewport's bottom edge. Carries a header (`▌ MOVE LOG · N/M`)
/// plus a row showing the most-recently-applied move.
///
/// Spawned by `spawn_overlay` alongside the banner and the
/// floating progress chip; despawned by `react_to_state_change`
/// on the same `Playing → Inactive` transition. Same lifecycle
/// pattern as `ReplayFloatingProgressChip` — a sibling root, not
/// a banner child, because it lives at a different screen anchor.
///
/// First slice of the move-log mockup at
/// `docs/ui-mockups/replay-overlay-mobile.html` § "Move Log Card".
/// Subsequent commits add prev/next rows and scrolling.
#[derive(Component, Debug)]
pub struct ReplayOverlayMoveLogPanel;

/// Marker on the move-log panel's header `Text`. Carries
/// `▌ MOVE LOG · N/M` while a replay is playing; the
/// `update_move_log_header` system repaints it as the cursor
/// advances.
#[derive(Component, Debug)]
pub struct ReplayOverlayMoveLogHeader;

/// Marker on the move-log panel's active-row `Text`. Carries the
/// most-recently-applied move's text (`47 │ waste → tableau 5`)
/// when `cursor > 0`; empty when no moves have been applied yet
/// (initial spawn) or in `Completed`/`Inactive` states. The
/// `update_move_log_active_row` system repaints it as the cursor
/// advances.
#[derive(Component, Debug)]
pub struct ReplayOverlayMoveLogActiveRow;

/// Marker on a "previous move" row above the active row.
/// `offset` is the 1-based distance backwards from the active
/// row: `offset = 1` is the move applied just before the active
/// one (e.g. cursor=47 → row reads "46 │ ..."), `offset = 2` is
/// the one before that, and so on. Up to [`MOVE_LOG_PREV_ROWS`]
/// rows render above the active row.
///
/// Empty text when there isn't enough history (`offset >= cursor`,
/// e.g. cursor=1 has no prev rows; cursor=2 has only the
/// `offset = 1` row populated).
#[derive(Component, Debug)]
pub struct ReplayOverlayMoveLogPrevRow {
    /// Distance backwards from the active row (1-based).
    pub offset: u8,
}

/// Marker on a "next move" row below the active row. `offset`
/// is the 1-based distance forward from the active row:
/// `offset = 1` is the move that will apply next
/// (`replay.moves[cursor]`, displayed as `cursor + 1`),
/// `offset = 2` is the one after that, and so on. Up to
/// [`MOVE_LOG_NEXT_ROWS`] rows render below the active row.
///
/// Empty text when there isn't enough remaining replay
/// (`cursor + offset - 1 >= moves.len()`, e.g. cursor=99 of
/// a 100-move replay shows offset 1 but offset 2 stays empty).
#[derive(Component, Debug)]
pub struct ReplayOverlayMoveLogNextRow {
    /// Distance forward from the active row (1-based).
    pub offset: u8,
}

/// Marker added to every top-level entity spawned by [`spawn_overlay`].
/// `react_to_state_change` uses a single `Query<Entity, With<DespawnWithReplay>>`
/// to despawn all of them, rather than keeping a separate query per
/// entity type. Future sibling overlay surfaces just need this marker
/// at spawn time — no changes to the despawn logic required.
#[derive(Component, Debug)]
pub struct DespawnWithReplay;

/// Marker on the mini-tableau preview panel root. A right-edge-anchored
/// panel that shows a compact summary of the live game state during
/// replay: the four foundation tops and the stock / waste heads.
/// Spawned as a sibling root entity (same lifecycle pattern as
/// [`ReplayOverlayMoveLogPanel`]) at `right: 0`, `top: MINI_TABLEAU_TOP_OFFSET`.
#[derive(Component, Debug)]
pub struct ReplayMiniTableauPanel;

/// Marker on the foundations row `Text` inside the mini-tableau panel.
/// Carries `F: A♠ 7♥ 5♦ K♣` (or `--` for empty slots); repainted by
/// `update_mini_tableau` whenever [`GameStateResource`] changes.
#[derive(Component, Debug)]
pub struct ReplayMiniTableauFoundations;

/// Marker on the stock/waste row `Text` inside the mini-tableau panel.
/// Carries `STK:14 WST:7♥`; repainted by `update_mini_tableau` whenever
/// [`GameStateResource`] changes.
#[derive(Component, Debug)]
pub struct ReplayMiniTableauStockWaste;

// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------

/// Bevy plugin that registers every system needed to drive the replay
/// overlay's lifecycle.
///
/// The plugin is independent of [`crate::replay_playback::ReplayPlaybackPlugin`]
/// — it only reads the shared `ReplayPlaybackState` resource. Tests insert
/// the resource manually and exercise the overlay in isolation.
pub struct ReplayOverlayPlugin;

impl Plugin for ReplayOverlayPlugin {
    fn build(&self, app: &mut App) {
        // The systems are ordered so that, on a single frame:
        //   1. The state-watcher spawns or despawns the overlay if the
        //      `ReplayPlaybackState` resource changed.
        //   2. The completion-text update swaps the banner label when the
        //      state is `Completed`.
        //   3. The progress-text update writes the latest "Move N of M".
        //   4. The Stop-button click handler reads `Interaction::Pressed`
        //      and calls `stop_replay_playback` (which mutates the state).
        // Putting Stop last means a click in frame N is observed by
        // `react_to_state_change` in frame N+1, which then despawns the
        // overlay in response — a clean state-driven loop.
        // Step-button handler dispatches into the same canonical move
        // / draw events that the tick loop fires. Register them
        // defensively here so this plugin can run under
        // `MinimalPlugins` without the playback plugin attached;
        // `add_message` is idempotent so the duplicate registration
        // in production (alongside `replay_playback`) is harmless.
        app.init_resource::<ReplayScrubKeyHold>()
            .add_message::<MoveRequestEvent>()
            .add_message::<DrawRequestEvent>()
            .add_message::<UndoRequestEvent>()
            .add_message::<StateChangedEvent>()
            .add_systems(
                Update,
                (
                    react_to_state_change,
                    update_banner_label,
                    update_progress_text,
                    update_floating_progress_chip,
                    update_scrub_fill,
                    update_move_log_header,
                    update_move_log_active_row,
                    update_move_log_prev_rows,
                    update_move_log_next_rows,
                    update_mini_tableau_foundations,
                    update_mini_tableau_stock_waste,
                    update_pause_button_label,
                    handle_pause_button,
                    handle_step_button,
                    handle_pause_keyboard,
                    handle_stop_keyboard,
                    handle_arrow_keyboard,
                    handle_stop_button,
                )
                    .chain(),
            );
    }
}

// ---------------------------------------------------------------------------
// Spawning
// ---------------------------------------------------------------------------

/// Reads [`ReplayPlaybackState`] every time the resource changes and either
/// spawns or despawns the overlay accordingly. Treats the resource as the
/// single source of truth — the spawn / despawn decision is derived from
/// `is_playing() || is_completed()` rather than tracking previous-state
/// transitions explicitly, which keeps the system stateless.
pub(crate) fn react_to_state_change(
    mut commands: Commands,
    state: Res<ReplayPlaybackState>,
    roots: Query<Entity, With<ReplayOverlayRoot>>,
    despawnable: Query<Entity, With<DespawnWithReplay>>,
    font_res: Option<Res<FontResource>>,
) {
    if !state.is_changed() {
        return;
    }

    let should_be_visible = state.is_playing() || state.is_completed();
    let already_spawned = roots.iter().next().is_some();

    if should_be_visible && !already_spawned {
        spawn_overlay(&mut commands, font_res.as_deref(), &state);
    } else if !should_be_visible && already_spawned {
        // Despawn all sibling root entities in one loop — every entity
        // spawned by `spawn_overlay` carries `DespawnWithReplay` for
        // exactly this purpose.
        for entity in &despawnable {
            commands.entity(entity).despawn();
        }
    }
    // The `should_be_visible && already_spawned` branch is a no-op here —
    // the per-frame text update systems below repaint the banner label
    // and progress readout in place without a respawn.
}

/// Spawns the banner — a flex-row Node anchored to the top edge of the
/// window with three children: the "▌ replay" / "▌ replay complete" label,
/// the centred progress text, and the right-aligned Stop button.
pub(crate) fn spawn_overlay(
    commands: &mut Commands,
    font_res: Option<&FontResource>,
    state: &ReplayPlaybackState,
) {
    let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
    // Clone for the floating chip spawn that runs *after* the
    // banner's `.with_children(|banner| { ... })` closure consumes
    // the original `font_handle`. Cheap — Bevy's `Handle<Font>` is
    // `Arc`-backed, the clone bumps a refcount.
    let font_handle_for_floating = font_handle.clone();
    // Second clone for the scrub-bar label row and keybind footer
    // inside the outer banner closure. The inner top-row closure
    // consumes the original `font_handle` for the progress-chip
    // text, so by the time the outer closure reaches the
    // label-row / footer spawns the original is gone.
    // `font_handle_for_labels` is `.clone()`'d (never moved) inside
    // the labels closure, so it's still alive for the footer
    // spawn afterwards — single shared clone covers both.
    let font_handle_for_labels = font_handle.clone();
    // Third clone for the move-log panel — a separate root
    // entity spawned after the banner closure closes. Mirrors the
    // floating-chip clone reasoning.
    let font_handle_for_move_log = font_handle.clone();
    // Fourth clone for the mini-tableau preview panel.
    let font_handle_for_mini_tableau = font_handle.clone();

    let banner_label = if state.is_completed() {
        "\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention.
    } else {
        "\u{258C} replay" // ▌
    };
    let progress_label = format_progress(state);

    // Tableau dim layer — full-screen scrim at z = Z_REPLAY_DIM (= 54).
    // Spawned first so it sits behind the banner (z=55) and move-log (z=55)
    // in the UI stacking context. World-space sprites (cards, badges) are
    // always below any UI node, so the dim layer darkens the entire
    // gameplay scene without needing to touch card_plugin. No Interaction
    // component — purely visual.
    commands.spawn((
        ReplayTableauDimLayer,
        DespawnWithReplay,
        Node {
            position_type: PositionType::Absolute,
            left: Val::Px(0.0),
            top: Val::Px(0.0),
            width: Val::Percent(100.0),
            height: Val::Percent(100.0),
            ..default()
        },
        BackgroundColor(Color::srgba(0.0, 0.0, 0.0, TABLEAU_DIM_ALPHA)),
        ZIndex(Z_REPLAY_DIM),
        GlobalZIndex(Z_REPLAY_DIM),
    ));

    let banner_bg = Color::srgba(
        BG_ELEVATED_HI.to_srgba().red,
        BG_ELEVATED_HI.to_srgba().green,
        BG_ELEVATED_HI.to_srgba().blue,
        BANNER_ALPHA,
    );

    commands
        .spawn((
            ReplayOverlayRoot,
            DespawnWithReplay,
            Node {
                position_type: PositionType::Absolute,
                left: Val::Px(0.0),
                top: Val::Px(0.0),
                width: Val::Percent(100.0),
                height: Val::Px(BANNER_HEIGHT),
                // Column outer so the content row sits above the 1px
                // scrub bar at the bottom edge.
                flex_direction: FlexDirection::Column,
                ..default()
            },
            BackgroundColor(banner_bg),
            // Pin the banner to its z layer in both the local and the
            // global stacking context — `GlobalZIndex` matters because
            // the overlay is a top-level Node (no parent), and Bevy 0.18
            // has historically had subtle stacking-context drift here.
            ZIndex(Z_REPLAY_OVERLAY),
            GlobalZIndex(Z_REPLAY_OVERLAY),
        ))
        .with_children(|banner| {
            // Top row: the existing content (label / progress / Stop).
            banner
                .spawn(Node {
                    flex_grow: 1.0,
                    flex_direction: FlexDirection::Row,
                    align_items: AlignItems::Center,
                    justify_content: JustifyContent::SpaceBetween,
                    padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
                    column_gap: VAL_SPACE_4,
                    ..default()
                })
                .with_children(|row| {
                    // Left: column with the accent "▌ replay" headline
                    // above and a small `GAME #YYYY-DDD` caption below.
                    // The caption mirrors the mockup's right-anchored
                    // game identifier but stays visually grouped with
                    // the headline so the two pieces of "this is a
                    // replay of game X" read as a single unit.
                    row.spawn(Node {
                        flex_direction: FlexDirection::Column,
                        align_items: AlignItems::FlexStart,
                        row_gap: Val::Px(2.0),
                        ..default()
                    })
                    .with_children(|left| {
                        left.spawn((
                            ReplayOverlayBannerText,
                            Text::new(banner_label),
                            TextFont {
                                font: font_handle.clone(),
                                font_size: TYPE_HEADLINE,
                                ..default()
                            },
                            TextColor(ACCENT_PRIMARY),
                        ));
                        left.spawn((
                            ReplayOverlayGameCaption,
                            Text::new(format_game_caption(state).unwrap_or_default()),
                            TextFont {
                                font: font_handle.clone(),
                                font_size: TYPE_CAPTION,
                                ..default()
                            },
                            TextColor(TEXT_SECONDARY),
                        ));
                    });

                    // Centre: progress readout, wrapped in a 1 px
                    // ACCENT_PRIMARY-bordered chip so it reads as a
                    // discrete callout rather than free-floating
                    // text. No fill — the Terminal aesthetic gets
                    // depth from borders + tonal layering, not
                    // shadows. The marker stays on the inner Text so
                    // `update_progress_text` keeps working unchanged.
                    row.spawn((
                        Node {
                            border: UiRect::all(Val::Px(1.0)),
                            padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
                            ..default()
                        },
                        BorderColor::all(ACCENT_PRIMARY),
                    ))
                    .with_children(|chip| {
                        chip.spawn((
                            ReplayOverlayProgressText,
                            Text::new(progress_label),
                            TextFont {
                                font: font_handle,
                                font_size: TYPE_BODY,
                                ..default()
                            },
                            TextColor(TEXT_PRIMARY),
                        ));
                    });

                    // Right: Stop button. Tertiary variant — the
                    // action is available but not the loudest element
                    // in the banner; the "Replay" primary accent owns
                    // that slot. `spawn_modal_button` gives us hover /
                    // press paint and focus rings for free via the
                    // existing `UiModalPlugin` paint system.
                    row.spawn(Node {
                        flex_direction: FlexDirection::Row,
                        align_items: AlignItems::Center,
                        column_gap: VAL_SPACE_2,
                        ..default()
                    })
                    .with_children(|wrap| {
                        // Pause / Resume label is set from the current
                        // state so a freshly-spawned overlay (which
                        // currently always starts unpaused) reads
                        // "Pause". `update_pause_button_label`
                        // repaints it whenever the state changes.
                        spawn_modal_button(
                            wrap,
                            ReplayPauseButton,
                            pause_button_label(state),
                            None,
                            ButtonVariant::Tertiary,
                            font_res,
                        );
                        spawn_modal_button(
                            wrap,
                            ReplayStepButton,
                            "Step",
                            None,
                            ButtonVariant::Tertiary,
                            font_res,
                        );
                        spawn_modal_button(
                            wrap,
                            ReplayStopButton,
                            "Stop",
                            None,
                            ButtonVariant::Tertiary,
                            font_res,
                        );
                    });
                });

            // Bottom edge: 1px-tall scrub bar. Track in `BORDER_SUBTLE`,
            // fill in `ACCENT_PRIMARY`. The fill width is rewritten by
            // [`update_scrub_fill`] every tick the cursor advances.
            // Initial fill width matches the spawn-time progress so the
            // first-frame paint already reflects state instead of
            // popping from 0 → cursor on the first tick.
            let initial_scrub_pct = scrub_pct(state);
            let win_pct = win_move_marker_pct(state);
            banner
                .spawn((
                    Node {
                        width: Val::Percent(100.0),
                        height: Val::Px(1.0),
                        ..default()
                    },
                    BackgroundColor(BORDER_SUBTLE),
                    // HC marker: bumps the 1 px track from #505050
                    // → #a0a0a0 under high-contrast mode. The track
                    // paints via BackgroundColor (it's a 1 px Node,
                    // not a border on a wider container) so the
                    // BorderColor-targeting HighContrastBorder marker
                    // doesn't apply — HighContrastBackground is the
                    // parallel primitive for this case.
                    HighContrastBackground::with_default(BORDER_SUBTLE),
                ))
                .with_children(|track| {
                    track.spawn((
                        ReplayOverlayScrubFill,
                        Node {
                            width: Val::Percent(initial_scrub_pct),
                            height: Val::Percent(100.0),
                            ..default()
                        },
                        BackgroundColor(ACCENT_PRIMARY),
                    ));
                    // WIN MOVE marker — small green tick anchored at
                    // `win_move_index / total`. Spawned only when the
                    // active replay carries the field; older replays
                    // pre-dating `win_move_index` simply don't get a
                    // marker. Centered vertically on the 1px track via
                    // a 3px-tall node offset 1px above the track top so
                    // 1px sits above and 1px below the track line.
                    if let Some(pct) = win_pct {
                        track.spawn((
                            ReplayOverlayWinMoveMarker,
                            Node {
                                position_type: PositionType::Absolute,
                                left: Val::Percent(pct),
                                top: Val::Px(-1.0),
                                width: Val::Px(2.0),
                                height: Val::Px(3.0),
                                ..default()
                            },
                            BackgroundColor(STATE_SUCCESS),
                            // HC bump: lime → brighter lime so the win
                            // marker reads clearly above the bumped
                            // notch ticks (BORDER_SUBTLE_HC gray) under
                            // high-contrast mode.
                            HighContrastBackground::with_hc(STATE_SUCCESS, STATE_SUCCESS_HC),
                        ));
                    }
                    // Fixed quarter-mark notches: five 1px vertical
                    // ticks at 0 / 25 / 50 / 75 / 100 % that give the
                    // player visual anchor points without needing to
                    // mentally bisect the bar. Painted in
                    // BORDER_SUBTLE — same colour as the unfilled
                    // track — so visibility comes from extending past
                    // the 1px track height (5px tall, anchored 2px
                    // above the track top) rather than colour
                    // contrast. Spawned *after* the WIN MOVE marker
                    // so a notch and the marker landing on the same
                    // percentage paint the marker on top.
                    for pct in scrub_notch_positions() {
                        track.spawn((
                            ReplayOverlayScrubNotch,
                            Node {
                                position_type: PositionType::Absolute,
                                left: Val::Percent(pct),
                                top: Val::Px(-2.0),
                                width: Val::Px(1.0),
                                height: Val::Px(5.0),
                                ..default()
                            },
                            BackgroundColor(BORDER_SUBTLE),
                            // Same HC-paint reasoning as the track
                            // above: 5 px tall × 1 px wide tick mark
                            // paints via BackgroundColor, so
                            // HighContrastBackground (not -Border) is
                            // the right marker.
                            HighContrastBackground::with_default(BORDER_SUBTLE),
                        ));
                    }
                });

            // Third banner row: percentage labels (`0%` / `25%` /
            // `50%` / `75%` / `100%`) under each scrub-bar notch.
            // Sibling of (not child of) the 1px track because labels
            // need their own vertical real estate (TYPE_CAPTION text
            // doesn't fit inside a 1px container). Position math:
            // track Node has `Val::Percent(p)` referencing the
            // banner's full width; this label row also has the
            // banner's full width, so labels at the same
            // percentages line up vertically with their notches.
            let labels = scrub_notch_labels();
            let positions = scrub_notch_positions();
            banner
                .spawn(Node {
                    width: Val::Percent(100.0),
                    height: Val::Px(SCRUB_LABEL_ROW_HEIGHT),
                    position_type: PositionType::Relative,
                    ..default()
                })
                .with_children(|row| {
                    for (i, (label, pct)) in labels.iter().zip(positions.iter()).enumerate() {
                        // Endpoints flush to the row's edges; middle
                        // three labels use the `translateX(-50%)`
                        // pattern for Bevy 0.18 UI: a fixed-width
                        // container is placed at `left: Percent(pct)`
                        // then shifted left by half its own width via
                        // `margin.left: Px(-SCRUB_LABEL_CENTER_WIDTH/2)`.
                        // `Justify::Center` renders the text centred
                        // within the container so the text's visual
                        // centre coincides with the notch line.
                        let (node, justify) = if i == 0 {
                            (
                                Node {
                                    position_type: PositionType::Absolute,
                                    top: Val::Px(2.0),
                                    left: Val::Px(0.0),
                                    ..default()
                                },
                                Justify::Left,
                            )
                        } else if i == labels.len() - 1 {
                            (
                                Node {
                                    position_type: PositionType::Absolute,
                                    top: Val::Px(2.0),
                                    right: Val::Px(0.0),
                                    ..default()
                                },
                                Justify::Right,
                            )
                        } else {
                            (
                                Node {
                                    position_type: PositionType::Absolute,
                                    top: Val::Px(2.0),
                                    left: Val::Percent(*pct),
                                    width: Val::Px(SCRUB_LABEL_CENTER_WIDTH),
                                    margin: UiRect {
                                        left: Val::Px(-SCRUB_LABEL_CENTER_WIDTH / 2.0),
                                        ..default()
                                    },
                                    ..default()
                                },
                                Justify::Center,
                            )
                        };
                        row.spawn((
                            ReplayOverlayScrubNotchLabel,
                            node,
                            Text::new(*label),
                            TextLayout::new_with_justify(justify),
                            TextFont {
                                font: font_handle_for_labels.clone(),
                                font_size: TYPE_CAPTION,
                                ..default()
                            },
                            // TEXT_SECONDARY keeps the subdued visual
                            // hierarchy (caption, not headline) while
                            // staying readable against BG_ELEVATED_HI.
                            TextColor(TEXT_SECONDARY),
                        ));
                    }
                });

            // Fourth banner row: keybind-hint footer. Vim-style
            // mode line on the left (`▌ NORMAL │ replay`), keybind
            // hint on the right (`[SPACE] pause/resume`), 1px top
            // border in BORDER_SUBTLE separating it from the
            // labels row above. Surfaces the existing Space
            // accelerator visually so CLAUDE.md §3.3's UI-first
            // contract holds for keyboard accelerators too.
            banner
                .spawn((
                    ReplayOverlayKeybindFooter,
                    Node {
                        width: Val::Percent(100.0),
                        height: Val::Px(KEYBIND_FOOTER_HEIGHT),
                        flex_direction: FlexDirection::Row,
                        justify_content: JustifyContent::SpaceBetween,
                        align_items: AlignItems::Center,
                        padding: UiRect::horizontal(VAL_SPACE_4),
                        border: UiRect::top(Val::Px(1.0)),
                        ..default()
                    },
                    BorderColor::all(BORDER_SUBTLE),
                    // Marker for `apply_high_contrast_borders`: bumps
                    // the 1 px top border from BORDER_SUBTLE (#505050)
                    // to BORDER_SUBTLE_HC (#a0a0a0) when
                    // `Settings::high_contrast_mode` is on. Without
                    // this the footer reads as floating loose under
                    // HC because the border that visually anchors it
                    // to the labels row above is near-invisible.
                    HighContrastBorder::with_default(BORDER_SUBTLE),
                ))
                .with_children(|footer| {
                    footer.spawn((
                        Text::new(keybind_footer_mode_text()),
                        TextFont {
                            font: font_handle_for_labels.clone(),
                            font_size: TYPE_CAPTION,
                            ..default()
                        },
                        TextColor(TEXT_SECONDARY),
                    ));
                    if SHOW_KEYBOARD_ACCELERATORS {
                        footer.spawn((
                            Text::new(keybind_footer_hint_text()),
                            TextFont {
                                font: font_handle_for_labels.clone(),
                                font_size: TYPE_CAPTION,
                                ..default()
                            },
                            TextColor(TEXT_SECONDARY),
                        ));
                    }
                });
        });

    // Floating progress chip — a 2D world-space `Text2d` rendered
    // above the destination pile of the most-recently-applied move.
    // Sibling of (not child of) the banner overlay because it lives
    // in world-space coordinates, not the UI tree. Spawned hidden;
    // `update_floating_progress_chip` shows + positions it on the
    // first frame the cursor advances past 0. Lifecycle matches
    // the banner overlay — `react_to_state_change` despawns both
    // when the replay state transitions back to `Inactive`.
    commands.spawn((
        ReplayFloatingProgressChip,
        DespawnWithReplay,
        Text2d::new(format_progress(state)),
        TextFont {
            font: font_handle_for_floating,
            font_size: TYPE_BODY,
            ..default()
        },
        TextColor(TEXT_PRIMARY),
        // High Z keeps the chip above every card stack
        // (Z_DROP_OVERLAY = 50, Z_STOCK_BADGE = 30, regular cards
        // stack to the low double digits at most).
        Transform::from_xyz(0.0, 0.0, 100.0),
        Visibility::Hidden,
    ));

    // Move-log panel — a separate root UI entity anchored to the
    // viewport's bottom edge. Carries a `▌ MOVE LOG · N/M` header
    // plus a row showing the most-recently-applied move.
    // Sibling-of-banner pattern (not a banner child) because the
    // panel lives at a different screen anchor and has its own
    // spawn/despawn lifecycle synced via `react_to_state_change`.
    let banner_bg = Color::srgba(
        BG_ELEVATED_HI.to_srgba().red,
        BG_ELEVATED_HI.to_srgba().green,
        BG_ELEVATED_HI.to_srgba().blue,
        BANNER_ALPHA,
    );
    commands
        .spawn((
            ReplayOverlayMoveLogPanel,
            DespawnWithReplay,
            Node {
                position_type: PositionType::Absolute,
                left: Val::Px(0.0),
                bottom: Val::Px(0.0),
                width: Val::Percent(100.0),
                height: Val::Px(MOVE_LOG_PANEL_HEIGHT),
                flex_direction: FlexDirection::Column,
                align_items: AlignItems::FlexStart,
                justify_content: JustifyContent::Center,
                padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
                row_gap: VAL_SPACE_1,
                border: UiRect::top(Val::Px(1.0)),
                ..default()
            },
            BackgroundColor(banner_bg),
            BorderColor::all(BORDER_SUBTLE),
            // Same z-stack rationale as the banner — above gameplay,
            // below modals.
            ZIndex(Z_REPLAY_OVERLAY),
            GlobalZIndex(Z_REPLAY_OVERLAY),
            // HC marker so the top border bumps under HC mode.
            // Without it the panel reads as floating loose because
            // the border that anchors it to the gameplay area above
            // is near-invisible at #505050.
            HighContrastBorder::with_default(BORDER_SUBTLE),
        ))
        .with_children(|panel| {
            // Header row: `▌ MOVE LOG · N/M` in ACCENT_PRIMARY for
            // the cursor-block prefix consistency with the banner
            // headline.
            panel.spawn((
                ReplayOverlayMoveLogHeader,
                Text::new(format_move_log_header(state)),
                TextFont {
                    font: font_handle_for_move_log.clone(),
                    font_size: TYPE_CAPTION,
                    ..default()
                },
                TextColor(ACCENT_PRIMARY),
            ));
            // Prev rows — render above the active row in display
            // order (oldest first), so the active row sits at the
            // bottom of the visible window. Spawn from
            // MOVE_LOG_PREV_ROWS down to 1 (offset 2, then 1) so
            // the highest-offset (oldest) row is topmost in the
            // panel's flex column. Each carries
            // ReplayOverlayMoveLogPrevRow { offset } — the
            // per-frame system reads `offset` and recomputes the
            // text on cursor advance. Painted in TEXT_SECONDARY
            // so the active row stands out from context rows.
            for offset in (1..=MOVE_LOG_PREV_ROWS as u8).rev() {
                panel.spawn((
                    ReplayOverlayMoveLogPrevRow { offset },
                    Text::new(format_kth_recent_row(state, offset as usize + 1)),
                    TextFont {
                        font: font_handle_for_move_log.clone(),
                        font_size: TYPE_BODY,
                        ..default()
                    },
                    TextColor(TEXT_SECONDARY),
                ));
            }
            // Active move row. Wrapped in a Node with an
            // ACCENT_PRIMARY background so the row reads as
            // "current focus" — the player can scan vertically
            // and the highlighted row is the move that just
            // applied. Empty text at spawn time when cursor=0;
            // the per-frame update system populates it as the
            // cursor advances. Text colour is TEXT_PRIMARY_HC
            // (near-white) for contrast against the brick-red
            // background — same trick as the modal-button
            // primary-variant paint.
            panel
                .spawn((
                    Node {
                        width: Val::Percent(100.0),
                        padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
                        ..default()
                    },
                    BackgroundColor(ACCENT_PRIMARY),
                ))
                .with_children(|active| {
                    active.spawn((
                        ReplayOverlayMoveLogActiveRow,
                        Text::new(format_active_move_row(state)),
                        TextFont {
                            font: font_handle_for_move_log.clone(),
                            font_size: TYPE_BODY,
                            ..default()
                        },
                        TextColor(TEXT_PRIMARY_HC),
                    ));
                });
            // Next rows — render below the active row in display
            // order (offset 1 directly below active, then offset
            // 2). Same TEXT_SECONDARY de-emphasis as prev rows so
            // the active row stays the focal point. Empty text
            // late in the replay (when cursor + offset exceeds
            // moves.len()) — the panel under-fills gracefully.
            for offset in 1..=MOVE_LOG_NEXT_ROWS as u8 {
                panel.spawn((
                    ReplayOverlayMoveLogNextRow { offset },
                    Text::new(format_kth_next_row(state, offset as usize)),
                    TextFont {
                        font: font_handle_for_move_log.clone(),
                        font_size: TYPE_BODY,
                        ..default()
                    },
                    TextColor(TEXT_SECONDARY),
                ));
            }
        });

    // Mini-tableau preview panel — right-edge anchor, just below the banner.
    // Compact two-row readout: foundation tops then stock/waste head.
    // Sibling-of-banner pattern (separate root entity, own spawn/despawn).
    let banner_bg = Color::srgba(
        BG_ELEVATED_HI.to_srgba().red,
        BG_ELEVATED_HI.to_srgba().green,
        BG_ELEVATED_HI.to_srgba().blue,
        BANNER_ALPHA,
    );
    commands
        .spawn((
            ReplayMiniTableauPanel,
            DespawnWithReplay,
            Node {
                position_type: PositionType::Absolute,
                right: Val::Px(0.0),
                top: Val::Px(MINI_TABLEAU_TOP_OFFSET),
                padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
                flex_direction: FlexDirection::Column,
                align_items: AlignItems::FlexStart,
                row_gap: VAL_SPACE_1,
                border: UiRect::left(Val::Px(1.0)),
                ..default()
            },
            BackgroundColor(banner_bg),
            BorderColor::all(BORDER_SUBTLE),
            ZIndex(Z_REPLAY_OVERLAY),
            GlobalZIndex(Z_REPLAY_OVERLAY),
            HighContrastBorder::with_default(BORDER_SUBTLE),
        ))
        .with_children(|panel| {
            panel.spawn((
                Text::new("\u{258C} BOARD"),
                TextFont {
                    font: font_handle_for_mini_tableau.clone(),
                    font_size: TYPE_CAPTION,
                    ..default()
                },
                TextColor(ACCENT_PRIMARY),
            ));
            panel.spawn((
                ReplayMiniTableauFoundations,
                Text::new("F: -- -- -- --"),
                TextFont {
                    font: font_handle_for_mini_tableau.clone(),
                    font_size: TYPE_CAPTION,
                    ..default()
                },
                TextColor(TEXT_PRIMARY),
            ));
            panel.spawn((
                ReplayMiniTableauStockWaste,
                Text::new("STK:-- WST:--"),
                TextFont {
                    font: font_handle_for_mini_tableau,
                    font_size: TYPE_CAPTION,
                    ..default()
                },
                TextColor(TEXT_SECONDARY),
            ));
        });
}

/// Pure helper — returns the scrub-fill width as a percentage of the
/// track for the given playback state. `Completed` reads as 100 %;
/// `Inactive` and `Playing` with no progress read as 0 %.
pub(crate) fn scrub_pct(state: &ReplayPlaybackState) -> f32 {
    if state.is_completed() {
        return 100.0;
    }
    match state.progress() {
        Some((_, 0)) | None => 0.0,
        Some((cursor, total)) => {
            let frac = (cursor as f32 / total as f32).clamp(0.0, 1.0);
            frac * 100.0
        }
    }
}

/// Pure helper — returns the fixed scrub-bar notch positions as
/// percentages along the track. Five evenly-spaced notches at the
/// quarter-marks: `[0, 25, 50, 75, 100]`. Function (rather than
/// const) so the unit-test surface is obvious and a future
/// regression — e.g. someone simplifying to three notches — fails
/// at the helper test rather than at visual review.
pub(crate) fn scrub_notch_positions() -> [f32; 5] {
    [0.0, 25.0, 50.0, 75.0, 100.0]
}

/// Pure helper — returns the percentage-label text for each notch,
/// in left-to-right order. Paired with [`scrub_notch_positions`] so
/// `labels[i]` belongs at `positions[i]`. Lifted to a function for
/// the same reason as the positions helper: a clean unit-test
/// surface that fails at a regression (e.g. someone simplifying
/// `100%` → `MAX`) rather than at visual review.
pub(crate) fn scrub_notch_labels() -> [&'static str; 5] {
    ["0%", "25%", "50%", "75%", "100%"]
}

/// Pure helper — returns the vim-style mode indicator text shown on
/// the left side of the keybind-hint footer row. `▌ NORMAL │ replay`
/// matches the `▌replay.tsx` motif from the splash boot-screen and
/// the screen-takeover mockup. The cursor block (`▌`) matches the
/// banner-label prefix; "NORMAL" is the vim mode (mockup parity);
/// "replay" identifies the surface.
pub(crate) fn keybind_footer_mode_text() -> &'static str {
    "\u{258C} NORMAL \u{2502} replay" // ▌ NORMAL │ replay
}

/// Pure helper — returns the keybind-hint text shown on the right
/// side of the keybind-hint footer row. Lists only the keys that
/// are *actually wired* today: the Space accelerator for
/// pause/resume, the ESC accelerator for stop, and the ← / →
/// accelerators for paused single-move stepping. The footer never
/// lists unimplemented keybinds (would lie to users).
pub(crate) fn keybind_footer_hint_text() -> &'static str {
    if SHOW_KEYBOARD_ACCELERATORS {
        "[SPACE] pause/resume \u{00B7} [ESC] stop \u{00B7} [\u{2190}\u{2192}] step" // · separator
    } else {
        ""
    }
}

/// Pure helper — returns the WIN MOVE marker's left-edge position as
/// a percentage of the scrub track, or `None` when no marker should
/// be drawn.
///
/// `None` is returned in any of these cases:
/// - The state isn't `Playing` (no replay attached).
/// - The replay's `win_move_index` is `None` (older replay loaded
///   from disk pre-dating the field).
/// - The replay's move list is empty (shouldn't happen for real wins,
///   but guards the divide-by-zero).
///
/// The percentage clamps to `[0, 100]` so a malformed
/// `win_move_index >= total` (defensive — shouldn't happen) doesn't
/// position the marker outside the track.
pub(crate) fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> {
    let ReplayPlaybackState::Playing { replay, .. } = state else {
        return None;
    };
    let idx = replay.win_move_index?;
    let total = replay.moves.len();
    if total == 0 {
        return None;
    }
    let frac = (idx as f32 / total as f32).clamp(0.0, 1.0);
    Some(frac * 100.0)
}

// ---------------------------------------------------------------------------
// Playback-control button handlers
// ---------------------------------------------------------------------------

/// Pure helper — returns the label the Pause / Resume button should
/// carry for the given state. "Pause" while running, "Resume" while
/// paused, empty otherwise (the button is despawned with the rest of
/// the overlay tree on transitions to `Inactive` / `Completed`, so
/// the empty branch only fires for one frame around state changes).
pub(crate) fn pause_button_label(state: &ReplayPlaybackState) -> &'static str {
    match state {
        ReplayPlaybackState::Playing { paused: true, .. } => "Resume",
        ReplayPlaybackState::Playing { paused: false, .. } => "Pause",
        ReplayPlaybackState::Inactive | ReplayPlaybackState::Completed => "",
    }
}

/// Watches the Stop button for `Interaction::Pressed` transitions. On a
/// click, calls [`stop_replay_playback`] which resets the state to
/// `Inactive`; the next frame's `react_to_state_change` then despawns
/// the overlay.
pub(crate) fn handle_stop_button(
    mut commands: Commands,
    mut state: ResMut<ReplayPlaybackState>,
    buttons: Query<&Interaction, (With<ReplayStopButton>, Changed<Interaction>)>,
) {
    if !buttons.iter().any(|i| *i == Interaction::Pressed) {
        return;
    }
    stop_replay_playback(&mut commands, &mut state);
}

/// Watches the Pause / Resume button for `Interaction::Pressed`
/// transitions. On a click, toggles the `paused` flag via
/// [`toggle_pause_replay_playback`]. The label repaint happens in
/// [`update_pause_button_label`] on the same frame the state mutation
/// flushes.
pub(crate) fn handle_pause_button(
    mut state: ResMut<ReplayPlaybackState>,
    buttons: Query<&Interaction, (With<ReplayPauseButton>, Changed<Interaction>)>,
) {
    if !buttons.iter().any(|i| *i == Interaction::Pressed) {
        return;
    }
    toggle_pause_replay_playback(&mut state);
}

/// Watches the Step button for `Interaction::Pressed` transitions. On
/// a click, advances exactly one move via [`step_replay_playback`].
/// No-op while playback is unpaused (would race the tick loop) — the
/// guard lives inside `step_replay_playback`.
pub(crate) fn handle_step_button(
    mut state: ResMut<ReplayPlaybackState>,
    game: Option<Res<GameStateResource>>,
    mut moves_writer: MessageWriter<MoveRequestEvent>,
    mut draws_writer: MessageWriter<DrawRequestEvent>,
    buttons: Query<&Interaction, (With<ReplayStepButton>, Changed<Interaction>)>,
) {
    if !buttons.iter().any(|i| *i == Interaction::Pressed) {
        return;
    }
    step_replay_playback(
        &mut state,
        game.as_deref(),
        &mut moves_writer,
        &mut draws_writer,
    );
}

/// Repaints the Pause / Resume button's label whenever
/// [`ReplayPlaybackState`] changes. Walks from the marked button
/// entity to its single child [`Text`] so the spawn path doesn't need
/// a second marker on the inner node.
pub(crate) fn update_pause_button_label(
    state: Res<ReplayPlaybackState>,
    buttons: Query<&Children, With<ReplayPauseButton>>,
    mut texts: Query<&mut Text>,
) {
    if !state.is_changed() {
        return;
    }
    let label = pause_button_label(&state);
    if label.is_empty() {
        // Overlay is mid-teardown; the button entity will despawn
        // this frame anyway. Skip the repaint to avoid touching a
        // doomed entity.
        return;
    }
    for children in &buttons {
        for child in children.iter() {
            if let Ok(mut text) = texts.get_mut(child) {
                text.0 = label.to_string();
                break;
            }
        }
    }
}

/// Watches `Space` for the keyboard pause / resume accelerator.
/// UI-first contract from CLAUDE.md §3.3 is satisfied by the on-
/// screen Pause / Resume button; this is the optional accelerator.
/// No-op when the playback isn't `Playing` (e.g. while a modal is
/// open and the player is using `Space` for something else).
pub(crate) fn handle_pause_keyboard(
    keys: Option<Res<ButtonInput<KeyCode>>>,
    mut state: ResMut<ReplayPlaybackState>,
) {
    let Some(keys) = keys else { return };
    if !keys.just_pressed(KeyCode::Space) {
        return;
    }
    toggle_pause_replay_playback(&mut state);
}

/// Watches the arrow keys for the paused step / scrub
/// accelerators. UI-first contract from CLAUDE.md §3.3 is
/// satisfied by the on-screen Step button (forward only); these
/// are the optional accelerators that also surface a backwards
/// step plus continuous scrub.
///
/// Both keys are paused-only — the underlying step helpers
/// hard-gate via destructure on `paused: true`. Pressing → during
/// running playback or ← at cursor 0 are silent no-ops; the
/// player learns "pause first, then arrow."
///
/// **Single press fires once immediately**
/// (`just_pressed`). **Holding** the key triggers continuous
/// scrub at [`SCRUB_REPEAT_INTERVAL_SECS`] cadence (10 steps/sec
/// at 100 ms): the per-key accumulator on
/// [`ReplayScrubKeyHold`] absorbs `time.delta_secs()` each frame
/// the key is held, fires + resets when the threshold is hit, and
/// resets to 0 on key release so the next fresh press fires
/// immediately. This matches the mockup's `[← →] scrub`
/// terminology while keeping single-press = single-step semantics.
#[allow(clippy::too_many_arguments)]
pub(crate) fn handle_arrow_keyboard(
    keys: Option<Res<ButtonInput<KeyCode>>>,
    time: Res<Time>,
    game: Option<Res<GameStateResource>>,
    mut hold: ResMut<ReplayScrubKeyHold>,
    mut state: ResMut<ReplayPlaybackState>,
    mut moves_writer: MessageWriter<MoveRequestEvent>,
    mut draws_writer: MessageWriter<DrawRequestEvent>,
    mut undo_writer: MessageWriter<UndoRequestEvent>,
    mut state_changed: MessageReader<StateChangedEvent>,
    // `true` while a backward step is in-flight: cursor was decremented and
    // `UndoRequestEvent` was written, but `handle_undo` hasn't applied it yet.
    // Cleared when `StateChangedEvent` confirms the game state has caught up.
    // Prevents rapid ← presses from accumulating multiple cursor decrements
    // before any undo is applied (Bug #16).
    mut back_pending: Local<bool>,
) {
    let Some(keys) = keys else { return };
    let dt = time.delta_secs();

    // Clear the in-flight flag once the game confirms the undo landed.
    if state_changed.read().count() > 0 {
        *back_pending = false;
    }

    // Right (forward step) — initial press fires immediately;
    // held repeats fire when the accumulator crosses the interval.
    if keys.just_pressed(KeyCode::ArrowRight) {
        step_replay_playback(
            &mut state,
            game.as_deref(),
            &mut moves_writer,
            &mut draws_writer,
        );
        hold.right_held_secs = 0.0;
    } else if keys.pressed(KeyCode::ArrowRight) {
        hold.right_held_secs += dt;
        if hold.right_held_secs >= SCRUB_REPEAT_INTERVAL_SECS {
            step_replay_playback(
                &mut state,
                game.as_deref(),
                &mut moves_writer,
                &mut draws_writer,
            );
            hold.right_held_secs = 0.0;
        }
    } else {
        hold.right_held_secs = 0.0;
    }

    // Left (backwards step) — gate on `back_pending` so at most one undo
    // is in-flight at a time. The cursor is only decremented inside
    // `step_backwards_replay_playback`, which also writes `UndoRequestEvent`.
    // `back_pending` is set after a successful step and cleared above when
    // `StateChangedEvent` confirms the undo was applied.
    if keys.just_pressed(KeyCode::ArrowLeft) {
        if !*back_pending {
            let fired = step_backwards_replay_playback(&mut state, &mut undo_writer);
            if fired {
                *back_pending = true;
            }
        }
        hold.left_held_secs = 0.0;
    } else if keys.pressed(KeyCode::ArrowLeft) {
        hold.left_held_secs += dt;
        if hold.left_held_secs >= SCRUB_REPEAT_INTERVAL_SECS {
            if !*back_pending {
                let fired = step_backwards_replay_playback(&mut state, &mut undo_writer);
                if fired {
                    *back_pending = true;
                }
            }
            hold.left_held_secs = 0.0;
        }
    } else {
        hold.left_held_secs = 0.0;
    }
}

/// Watches `Esc` for the keyboard stop accelerator. UI-first
/// contract from CLAUDE.md §3.3 is satisfied by the on-screen
/// Stop button; this is the optional accelerator.
///
/// Cross-plugin coordination: `pause_plugin::toggle_pause` also
/// listens for `Esc` and would otherwise open the pause modal on
/// the same press. The conflict is resolved by `toggle_pause`
/// gating itself on `ReplayPlaybackState::is_playing()` —
/// symmetrical to the existing `forfeit_screens` /
/// `other_modal_scrims` defer-if pattern in that system. So during
/// an active replay this handler owns the `Esc` press and the
/// pause modal stays closed.
///
/// No-op when the playback isn't `Playing` (the resource may still
/// exist as `Inactive` or `Completed`; only `Playing` means a
/// replay is on screen for the player to stop).
pub(crate) fn handle_stop_keyboard(
    mut commands: Commands,
    keys: Option<Res<ButtonInput<KeyCode>>>,
    mut state: ResMut<ReplayPlaybackState>,
) {
    let Some(keys) = keys else { return };
    if !keys.just_pressed(KeyCode::Escape) {
        return;
    }
    if !state.is_playing() {
        return;
    }
    stop_replay_playback(&mut commands, &mut state);
}
