//! Per-frame HUD text/typography/visibility updater systems.

use super::*;

use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::Suit;
use solitaire_core::{DrawStockConfig, game_state::GameMode};

use crate::auto_complete_plugin::AutoCompleteState;

/// Formats a time-limit value in seconds as `"mm:ss"` for HUD display.
///
/// For example `format_time_limit(300)` returns `"5:00"`.
pub fn format_time_limit(secs: u64) -> String {
    let m = secs / 60;
    let s = secs % 60;
    format!("{m}:{s:02}")
}

// ---------------------------------------------------------------------------
// Score-change feedback (G2)
//
// The flow for each Update tick:
//   1. `detect_score_change` diffs `GameStateResource.score` against
//      `PreviousScore`. On any positive delta it inserts/refreshes
//      `ScorePulse` on the score readout; on a delta ≥
//      `SCORE_FLOATER_THRESHOLD` it also spawns a floating "+N" UI text
//      anchored just below the score.
//   2. `advance_score_pulse` ticks the pulse component, applies the
//      triangular 1.0 → 1.1 → 1.0 scale curve, and removes the
//      component on completion.
//   3. `advance_score_floater` drifts each floater upward, fades it to
//      transparent, and despawns it when its lifetime expires.
//
// The threshold of 50 (a foundation promotion's typical bonus) keeps
// floaters rare and meaningful — see `SCORE_FLOATER_THRESHOLD`.
// ---------------------------------------------------------------------------

/// Sets the [`HudWonPreviously`] text to "✓ Won before" whenever the
/// current deal's seed + draw_mode + mode triple matches an entry in
/// the rolling [`ReplayHistory`]. Cleared while the active game is won
/// (the on-screen "Game won!" cue already conveys victory) and on
/// fresh deals the player hasn't won before.
///
/// Lives in its own system rather than `update_hud` to keep this
/// orthogonal: `update_hud`'s query disambiguation is already busy
/// enough; threading another marker through every Without filter
/// would touch ~10 unrelated queries for no benefit.
pub(super) fn update_won_previously(
    game: Res<GameStateResource>,
    // Optional because the HUD plugin's headless tests run without
    // `StatsPlugin` and therefore without this resource. With the
    // resource absent there's no history to compare against; the
    // indicator just stays empty.
    history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
    mut q: Query<&mut Text, With<HudWonPreviously>>,
) {
    let Ok(mut text) = q.single_mut() else {
        return;
    };
    let won_before = !game.0.is_won()
        && history.as_ref().is_some_and(|h| {
            h.0.replays.iter().any(|r| {
                r.seed == game.0.seed && r.draw_mode == game.0.draw_mode() && r.mode == game.0.mode
            })
        });
    let next = if won_before {
        "\u{2713} Won before"
    } else {
        ""
    };
    if text.0 != next {
        text.0 = next.to_string();
    }
}

#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub(super) fn update_hud(
    game: Res<GameStateResource>,
    time_attack: Option<Res<TimeAttackResource>>,
    daily: Option<Res<DailyChallengeResource>>,
    auto_complete: Option<Res<AutoCompleteState>>,
    mut score_q: Query<
        &mut Text,
        (
            With<HudScore>,
            Without<HudMoves>,
            Without<HudTime>,
            Without<HudMode>,
            Without<HudChallenge>,
            Without<HudUndos>,
            Without<HudAutoComplete>,
            Without<HudRecycles>,
            Without<HudDrawCycle>,
            Without<HudSelection>,
        ),
    >,
    mut moves_q: Query<
        &mut Text,
        (
            With<HudMoves>,
            Without<HudScore>,
            Without<HudTime>,
            Without<HudMode>,
            Without<HudChallenge>,
            Without<HudUndos>,
            Without<HudAutoComplete>,
            Without<HudRecycles>,
            Without<HudDrawCycle>,
            Without<HudSelection>,
        ),
    >,
    mut time_q: Query<
        &mut Text,
        (
            With<HudTime>,
            Without<HudScore>,
            Without<HudMoves>,
            Without<HudMode>,
            Without<HudChallenge>,
            Without<HudUndos>,
            Without<HudAutoComplete>,
            Without<HudRecycles>,
            Without<HudDrawCycle>,
            Without<HudSelection>,
        ),
    >,
    mut mode_q: Query<
        &mut Text,
        (
            With<HudMode>,
            Without<HudScore>,
            Without<HudMoves>,
            Without<HudTime>,
            Without<HudChallenge>,
            Without<HudUndos>,
            Without<HudAutoComplete>,
            Without<HudRecycles>,
            Without<HudDrawCycle>,
            Without<HudSelection>,
        ),
    >,
    mut challenge_q: Query<
        (&mut Text, &mut TextColor),
        (
            With<HudChallenge>,
            Without<HudScore>,
            Without<HudMoves>,
            Without<HudTime>,
            Without<HudMode>,
            Without<HudUndos>,
            Without<HudAutoComplete>,
            Without<HudRecycles>,
            Without<HudDrawCycle>,
            Without<HudSelection>,
        ),
    >,
    mut undos_q: Query<
        (&mut Text, &mut TextColor),
        (
            With<HudUndos>,
            Without<HudScore>,
            Without<HudMoves>,
            Without<HudTime>,
            Without<HudMode>,
            Without<HudChallenge>,
            Without<HudAutoComplete>,
            Without<HudRecycles>,
            Without<HudDrawCycle>,
            Without<HudSelection>,
        ),
    >,
    mut auto_q: Query<
        &mut Text,
        (
            With<HudAutoComplete>,
            Without<HudScore>,
            Without<HudMoves>,
            Without<HudTime>,
            Without<HudMode>,
            Without<HudChallenge>,
            Without<HudUndos>,
            Without<HudRecycles>,
            Without<HudDrawCycle>,
            Without<HudSelection>,
        ),
    >,
    mut recycles_q: Query<
        &mut Text,
        (
            With<HudRecycles>,
            Without<HudScore>,
            Without<HudMoves>,
            Without<HudTime>,
            Without<HudMode>,
            Without<HudChallenge>,
            Without<HudUndos>,
            Without<HudAutoComplete>,
            Without<HudDrawCycle>,
            Without<HudSelection>,
        ),
    >,
    mut draw_cycle_q: Query<
        &mut Text,
        (
            With<HudDrawCycle>,
            Without<HudScore>,
            Without<HudMoves>,
            Without<HudTime>,
            Without<HudMode>,
            Without<HudChallenge>,
            Without<HudUndos>,
            Without<HudAutoComplete>,
            Without<HudRecycles>,
            Without<HudSelection>,
        ),
    >,
) {
    let ta_active = time_attack.as_ref().is_some_and(|ta| ta.active);

    // Score, moves, mode, challenge, and undos only need updating when game state changes.
    if game.is_changed() {
        let g = &game.0;
        let is_zen = g.mode == GameMode::Zen;
        if let Ok(mut t) = score_q.single_mut() {
            // Zen mode suppresses score display per spec ("No score display").
            **t = if is_zen {
                String::new()
            } else {
                format!("Score: {}", g.score())
            };
        }
        if let Ok(mut t) = moves_q.single_mut() {
            **t = format!("Moves: {}", g.move_count());
        }
        if let Ok(mut t) = mode_q.single_mut() {
            **t = match g.mode {
                GameMode::Classic => match g.draw_mode() {
                    DrawStockConfig::DrawOne => String::new(),
                    DrawStockConfig::DrawThree => "Draw 3".to_string(),
                },
                GameMode::Zen => "ZEN".to_string(),
                GameMode::Challenge => "CHALLENGE".to_string(),
                GameMode::TimeAttack => "TIME ATTACK".to_string(),
                GameMode::Difficulty(level) => level.label().to_uppercase(),
            };
        }

        // --- Daily challenge constraint (with time-low colour warning) ---
        if let Ok((mut t, mut color)) = challenge_q.single_mut() {
            if g.is_won() {
                **t = String::new();
            } else if let Some(dc) = daily.as_deref() {
                **t = challenge_hud_text(dc);
                if let Some(max_secs) = dc.max_time_secs {
                    let remaining = max_secs.saturating_sub(g.elapsed_seconds);
                    *color = TextColor(challenge_time_color(remaining));
                }
            } else {
                **t = String::new();
            }
        }

        // --- Undo count ---
        if let Ok((mut t, mut color)) = undos_q.single_mut() {
            let count = g.undo_count();
            if count == 0 {
                **t = String::new();
                *color = TextColor(TEXT_PRIMARY);
            } else {
                **t = format!("Undos: {count}");
                // STATE_WARNING signals "you took a penalty" — same hue
                // as the Recycles counter so they read as one category.
                *color = TextColor(STATE_WARNING);
            }
        }

        // --- Recycle counter (both modes, hidden until first recycle) ---
        if let Ok(mut t) = recycles_q.single_mut() {
            **t = if g.recycle_count() > 0 {
                format!("Recycles: {}", g.recycle_count())
            } else {
                String::new()
            };
        }

        // --- Draw-cycle indicator (Draw-Three mode only) ---
        if let Ok(mut t) = draw_cycle_q.single_mut() {
            **t = if g.is_won() || g.draw_mode() != DrawStockConfig::DrawThree {
                // Hide when not in Draw-Three or after the game is won.
                String::new()
            } else {
                let stock_len = g.stock_cards().len();
                let next_draw = stock_len.min(3);
                format!("Cycle: {next_draw}/3")
            };
        }
    }

    // Time display: show Time Attack countdown every frame when active;
    // Zen mode suppresses the timer per spec ("No timer") — cleared unconditionally
    // every frame so it disappears immediately on the frame Z is pressed.
    // Otherwise show game elapsed time (updates once per second via game.is_changed()).
    let is_zen = game.0.mode == GameMode::Zen;
    let update_time = (ta_active || game.is_changed()) && !is_zen;
    if update_time {
        if let Ok(mut t) = time_q.single_mut() {
            if let Some(ta) = time_attack.as_ref().filter(|ta| ta.active) {
                let remaining = ta.remaining_secs.max(0.0) as u64;
                let m = remaining / 60;
                let s = remaining % 60;
                **t = format!("{m}:{s:02}");
            } else {
                let secs = game.0.elapsed_seconds;
                let m = secs / 60;
                let s = secs % 60;
                **t = format!("{m}:{s:02}");
            }
        }
    } else if is_zen {
        // Clear the time display immediately whenever Zen mode is active —
        // do not guard on game.is_changed() so it clears on the same frame
        // the player presses Z, before any move is made.
        if let Ok(mut t) = time_q.single_mut() {
            **t = String::new();
        }
    }

    // --- Auto-complete badge ---
    // Reflects the AutoCompleteState resource; update whenever it changes or game changes.
    let ac_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
    let ac_changed = auto_complete.as_ref().is_some_and(|ac| ac.is_changed());
    if (ac_changed || game.is_changed())
        && let Ok(mut t) = auto_q.single_mut()
    {
        **t = if ac_active {
            "AUTO".to_string()
        } else {
            String::new()
        };
    }
}

/// Updates the `HudSelection` text node to show which pile is Tab-selected.
///
/// Displays `"▶ {pile_name}"` while `SelectionState::selected_pile` is `Some`,
/// or an empty string when no pile is selected. Runs every frame so the
/// indicator stays in sync with the selection resource.
pub(super) fn update_selection_hud(
    selection: Option<Res<SelectionState>>,
    game: Option<Res<GameStateResource>>,
    mut q: Query<&mut Text, With<HudSelection>>,
) {
    let Ok(mut t) = q.single_mut() else { return };
    let label = match selection.as_deref().and_then(|s| s.selected_pile.as_ref()) {
        None => String::new(),
        Some(KlondikePile::Stock) => "▶ Waste".to_string(),
        Some(KlondikePile::Foundation(slot)) => match game.as_deref() {
            Some(g) => foundation_selection_label(*slot, &g.0),
            // No game resource means we can't probe claimed_suit; show the
            // slot-based placeholder so the HUD still surfaces the selection.
            None => format!("▶ Foundation {}", foundation_number(*slot)),
        },
        Some(KlondikePile::Tableau(idx)) => format!("▶ Column {}", tableau_number(*idx)),
    };
    **t = label;
}

/// Returns the HUD selection label for a foundation slot.
///
/// When the slot has a claimed suit (any card has landed) the announcement is
/// "▶ {Suit} Foundation"; while the slot is empty it falls back to a
/// "▶ Foundation N" placeholder labelled by the 1-based slot index.
pub(super) fn foundation_selection_label(
    slot: Foundation,
    game: &solitaire_core::game_state::GameState,
) -> String {
    let claimed = game
        .pile(KlondikePile::Foundation(slot))
        .first()
        .map(|c| c.0.suit());
    match claimed {
        Some(suit) => {
            let s = match suit {
                Suit::Clubs => "Clubs",
                Suit::Diamonds => "Diamonds",
                Suit::Hearts => "Hearts",
                Suit::Spades => "Spades",
            };
            format!("▶ {s} Foundation")
        }
        None => format!("▶ Foundation {}", foundation_number(slot)),
    }
}

const fn foundation_number(foundation: Foundation) -> u8 {
    match foundation {
        Foundation::Foundation1 => 1,
        Foundation::Foundation2 => 2,
        Foundation::Foundation3 => 3,
        Foundation::Foundation4 => 4,
    }
}

const fn tableau_number(tableau: Tableau) -> u8 {
    match tableau {
        Tableau::Tableau1 => 1,
        Tableau::Tableau2 => 2,
        Tableau::Tableau3 => 3,
        Tableau::Tableau4 => 4,
        Tableau::Tableau5 => 5,
        Tableau::Tableau6 => 6,
        Tableau::Tableau7 => 7,
    }
}

/// Fires `InfoToastEvent("Auto-completing...")` exactly once each time
/// `AutoCompleteState` transitions from inactive to active. Uses a `Local<bool>`
/// to debounce so the toast only appears on the leading edge.
pub(super) fn announce_auto_complete(
    auto_complete: Option<Res<AutoCompleteState>>,
    mut toast: MessageWriter<InfoToastEvent>,
    mut was_active: Local<bool>,
) {
    let now_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
    if now_active && !*was_active {
        toast.write(InfoToastEvent("Auto-completing...".to_string()));
    }
    *was_active = now_active;
}

/// Builds the HUD text for the active daily challenge constraints.
///
/// Returns `"Limit: mm:ss"` when a time limit is set, `"Goal: N pts"` when a
/// score target is set, or an empty string when the challenge has no extra
/// constraints.
pub(super) fn challenge_hud_text(dc: &DailyChallengeResource) -> String {
    if let Some(secs) = dc.max_time_secs {
        format!("Limit: {}", format_time_limit(secs))
    } else if let Some(score) = dc.target_score {
        format!("Goal: {score} pts")
    } else {
        String::new()
    }
}

/// Returns the colour for the challenge time-limit HUD label based on
/// seconds remaining. Uses theme tokens so the urgency ramp picks up
/// palette changes for free.
///
/// | Remaining   | Token            |
/// |-------------|------------------|
/// | ≥ 60 s      | `STATE_INFO`     |
/// | 30 – 59 s   | `STATE_WARNING`  |
/// | < 30 s      | `STATE_DANGER`   |
pub fn challenge_time_color(remaining: u64) -> Color {
    if remaining < 30 {
        STATE_DANGER
    } else if remaining < 60 {
        STATE_WARNING
    } else {
        STATE_INFO
    }
}

/// Scales HUD Tier-1 font sizes to fit a narrow viewport.
///
/// Fires on every `WindowResized` event. Below 480 logical pixels wide the
/// score drops from `TYPE_HEADLINE` (26 px) to `TYPE_BODY_LG` (18 px) and the
/// Moves/Timer labels drop from `TYPE_BODY_LG` to `TYPE_CAPTION` (11 px), so
/// all three items remain on one row inside the 50 %-wide HUD column
/// (≈ 180 dp on a 360 dp phone).  At ≥ 480 px the original sizes are
/// restored so desktop/tablet layouts are unaffected.
type HudScoreFont<'w, 's> =
    Query<'w, 's, &'static mut TextFont, (With<HudScore>, Without<HudMoves>, Without<HudTime>)>;
type HudMovesFont<'w, 's> =
    Query<'w, 's, &'static mut TextFont, (With<HudMoves>, Without<HudScore>, Without<HudTime>)>;
type HudTimeFont<'w, 's> =
    Query<'w, 's, &'static mut TextFont, (With<HudTime>, Without<HudScore>, Without<HudMoves>)>;

pub(super) fn update_hud_typography(
    mut events: MessageReader<WindowResized>,
    mut score_q: HudScoreFont,
    mut moves_q: HudMovesFont,
    mut time_q: HudTimeFont,
) {
    let Some(ev) = events.read().last() else {
        return;
    };
    let (score_size, secondary_size) = if ev.width < 480.0 {
        (TYPE_BODY_LG, TYPE_CAPTION)
    } else {
        (TYPE_HEADLINE, TYPE_BODY_LG)
    };
    for mut font in &mut score_q {
        font.font_size = score_size;
    }
    for mut font in &mut moves_q {
        font.font_size = secondary_size;
    }
    for mut font in &mut time_q {
        font.font_size = secondary_size;
    }
}

pub(super) fn apply_hud_visibility(
    hud_vis: Res<HudVisibility>,
    mut action_bar: Query<&mut Visibility, With<HudActionBar>>,
) {
    if !hud_vis.is_changed() {
        return;
    }
    let v = if *hud_vis == HudVisibility::Visible {
        Visibility::Visible
    } else {
        Visibility::Hidden
    };
    for mut vis in &mut action_bar {
        *vis = v;
    }
    // The bottom action bar is a pure overlay — it does not claim any
    // space in the card layout, so no WindowResized event is needed.
}

pub(super) fn restore_hud_on_modal(
    new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
    mut hud_vis: ResMut<HudVisibility>,
) {
    if !new_scrims.is_empty() {
        *hud_vis = HudVisibility::Visible;
    }
}

/// Returns the action-bar label font size for a given logical window width.
pub(super) fn action_bar_font_size(window_width: f32) -> f32 {
    if USE_TOUCH_UI_LAYOUT {
        // Seven word-labels ("Menu","Undo","Pause","Help","Hint","Mode","New")
        // must share one row. The widest characters are in FiraMono (a
        // monospace whose advance is ~0.62 of the font size). On a 900
        // logical-px phone the row budget after bar padding (2*12) and six
        // 4 px column gaps is ~852 px for ~28 label chars + 7*2*3 px button
        // padding. Solving 28*0.62*size + 42 <= 852 gives size <= ~46, so the
        // labels are advance-bound only on very narrow viewports; the real
        // constraint is legibility, not fit. ~1/60 of the width yields ~15 px
        // at 900 px — comfortably one row with margin to spare — clamped so it
        // never drops below the 12 px legibility floor or grows past 18 px on
        // landscape tablets where it would crowd the row again.
        (window_width / 60.0).clamp(12.0, 18.0)
    } else {
        TYPE_BODY
    }
}

pub(super) fn action_button_metrics() -> (UiRect, Val, Val) {
    if USE_TOUCH_UI_LAYOUT {
        // Tight 3 px horizontal padding (down from 4) trims 14 px off the row
        // total across 7 buttons, and a 44 px min_width (down from 52) lets the
        // shortest labels ("New", "Help") shrink to their text rather than
        // padding the row out past the 900 logical-px viewport. min_height
        // stays at 44 px to preserve the comfortable touch target.
        (
            UiRect::axes(Val::Px(3.0), Val::Px(4.0)),
            Val::Px(44.0),
            Val::Px(44.0),
        )
    } else {
        (
            UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
            Val::Px(48.0),
            Val::Px(48.0),
        )
    }
}

pub(super) fn spawn_action_button_label(
    parent: &mut ChildSpawnerCommands,
    label: &str,
    font: &TextFont,
    text_color: Color,
) {
    if USE_TOUCH_UI_LAYOUT {
        parent.spawn((
            ActionButtonLabel,
            Text::new(label),
            font.clone(),
            TextColor(text_color),
        ));
    } else {
        parent.spawn((Text::new(label), font.clone(), TextColor(text_color)));
    }
}

/// Resizes the glyph text inside every [`ActionButtonLabel`] to match the
/// current viewport width whenever [`LayoutResource`] changes (orientation
/// change or window resize).
#[cfg(target_os = "android")]
pub(super) fn resize_action_bar_labels(
    layout: Res<crate::layout::LayoutResource>,
    windows: Query<&Window>,
    mut labels: Query<&mut TextFont, With<ActionButtonLabel>>,
) {
    let w = windows
        .iter()
        .next()
        .map_or(layout.0.card_size.x * 7.25, |win| win.width());
    let new_size = action_bar_font_size(w);
    for mut font in &mut labels {
        font.font_size = new_size;
    }
}

