//! Per-frame value-text updater systems and their label helpers.

use super::*;

use solitaire_core::DrawStockConfig;
use solitaire_data::{AnimSpeed, settings::Theme};

use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource;
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
use crate::theme::ThemeThumbnailCache;
use crate::ui_modal::ModalScrim;
use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBorder};

/// Spawns the Settings panel when `SettingsScreen` becomes `true`;
/// despawns it when it becomes `false`.
#[allow(clippy::too_many_arguments)]
pub(super) fn sync_settings_panel_visibility(
    screen: Res<SettingsScreen>,
    panels: Query<Entity, With<SettingsPanel>>,
    other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
    scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
    mut scroll_pos: ResMut<SettingsScrollPos>,
    mut commands: Commands,
    settings: Res<SettingsResource>,
    sync_status: Option<Res<SyncStatusResource>>,
    progress: Option<Res<ProgressResource>>,
    font_res: Option<Res<FontResource>>,
    theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
    theme_thumbs: Option<Res<ThemeThumbnailCache>>,
    card_images: Option<Res<crate::card_plugin::CardImageSet>>,
) {
    if !screen.is_changed() {
        return;
    }
    if screen.0 {
        if panels.is_empty() && other_modal_scrims.is_empty() {
            let status_label = sync_status.map_or_else(
                || "Status: local only".to_string(),
                |s| sync_status_label(&s.0),
            );
            let unlocked_backs = progress
                .as_ref()
                .map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice());
            let unlocked_bgs = progress
                .as_ref()
                .map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice());
            // Snapshot themes by id, display_name and (optional)
            // thumbnail pair so spawn_settings_panel doesn't have to
            // know about the registry / cache shapes. Empty when
            // ThemeRegistryPlugin isn't installed (tests under
            // MinimalPlugins) — the picker row simply won't render.
            // Missing thumbnails (cache not ready, or partial user
            // theme) leave `thumbnails: None` so the chip renders its
            // plain-text fallback instead of a broken sprite.
            let themes: Vec<ThemePickerEntry> = theme_registry
                .as_deref()
                .map(|r| {
                    r.iter()
                        .map(|e| ThemePickerEntry {
                            id: e.id.clone(),
                            display_name: e.display_name.clone(),
                            thumbnails: theme_thumbs
                                .as_deref()
                                .and_then(|c| c.get(&e.id))
                                .filter(|p| p.is_fully_populated())
                                .cloned(),
                        })
                        .collect()
                })
                .unwrap_or_default();
            // The active card-art theme can supply its own back image —
            // see `card_plugin::CardImageSet::theme_back`. When that is
            // populated the legacy "Card Back" picker has no visible
            // effect, so we render it muted with an explanatory caption
            // rather than letting the player click swatches that do
            // nothing. Absent under `MinimalPlugins`; treated as
            // "no override" in that case.
            let theme_overrides_back = card_images
                .as_ref()
                .is_some_and(|cs| cs.theme_back.is_some());
            spawn_settings_panel(
                &mut commands,
                &settings.0,
                &status_label,
                unlocked_backs,
                unlocked_bgs,
                &themes,
                scroll_pos.0,
                font_res.as_deref(),
                theme_overrides_back,
            );
        }
    } else {
        // Save the current scroll offset before despawning the panel.
        if let Ok(sp) = scroll_nodes.single() {
            scroll_pos.0 = sp.0.y;
        }
        for entity in &panels {
            commands.entity(entity).despawn();
        }
    }
}

/// Keeps the sync-status text node current while the panel is open.
pub(super) fn update_sync_status_text(
    sync_status: Option<Res<SyncStatusResource>>,
    mut text_nodes: Query<&mut Text, With<SyncStatusText>>,
) {
    let Some(status) = sync_status else {
        return;
    };
    if !status.is_changed() {
        return;
    }
    let label = sync_status_label(&status.0);
    for mut text in &mut text_nodes {
        **text = label.clone();
    }
}

pub(super) fn update_card_back_text(
    settings: Res<SettingsResource>,
    mut text_nodes: Query<&mut Text, With<CardBackText>>,
) {
    if !settings.is_changed() {
        return;
    }
    for mut text in &mut text_nodes {
        **text = card_back_label(settings.0.selected_card_back);
    }
}

pub(super) fn update_background_text(
    settings: Res<SettingsResource>,
    mut text_nodes: Query<&mut Text, With<BackgroundText>>,
) {
    if !settings.is_changed() {
        return;
    }
    for mut text in &mut text_nodes {
        **text = background_label(settings.0.selected_background);
    }
}

pub(super) fn update_anim_speed_text(
    settings: Res<SettingsResource>,
    mut text_nodes: Query<&mut Text, With<AnimSpeedText>>,
) {
    if !settings.is_changed() {
        return;
    }
    for mut text in &mut text_nodes {
        **text = anim_speed_label(&settings.0.animation_speed);
    }
}

pub(super) fn update_color_blind_text(
    settings: Res<SettingsResource>,
    mut text_nodes: Query<&mut Text, With<ColorBlindText>>,
) {
    if !settings.is_changed() {
        return;
    }
    for mut text in &mut text_nodes {
        **text = color_blind_label(settings.0.color_blind_mode);
    }
}

pub(super) fn update_high_contrast_text(
    settings: Res<SettingsResource>,
    mut text_nodes: Query<&mut Text, With<HighContrastText>>,
) {
    if !settings.is_changed() {
        return;
    }
    for mut text in &mut text_nodes {
        **text = on_off_label(settings.0.high_contrast_mode);
    }
}

/// Repaints `BorderColor` on every entity tagged with
/// [`HighContrastBorder`] based on `Settings::high_contrast_mode`.
/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC`
/// (`#a0a0a0`). Compares against the current border colour and
/// only mutates when different so Bevy's change-detection
/// doesn't trigger repaints every frame.
///
/// Spec at `design-system.md` §Accessibility (#2): under HC,
/// outlines boost from `#505050` (BORDER_STRONG) to `#a0a0a0` so
/// modal panels, popover edges, and focus-ring carriers stay
/// legible on low-quality displays / for low-vision users.
///
/// Tagged sites in v0.21.x: the modal scaffold's card border
/// (`ui_modal::spawn_modal`). More sites can be tagged in
/// follow-ups by adding `HighContrastBorder::with_default(...)`
/// to their spawn tuple.
pub(super) fn update_high_contrast_borders(
    settings: Res<SettingsResource>,
    mut borders: Query<(&HighContrastBorder, &mut BorderColor)>,
) {
    let high_contrast = settings.0.high_contrast_mode;
    for (marker, mut border) in borders.iter_mut() {
        let target = if high_contrast {
            BORDER_SUBTLE_HC
        } else {
            marker.default_color
        };
        // Only mutate when actually different — avoids per-frame
        // change-detection churn. `border.left` is representative
        // because every tagged site uses `BorderColor::all(...)`.
        if border.left != target {
            *border = BorderColor::all(target);
        }
    }
}

/// Repaints `BackgroundColor` on every entity tagged with
/// [`HighContrastBackground`] based on `Settings::high_contrast_mode`.
/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC`
/// (`#a0a0a0`). Compares against the current background and only
/// mutates when different so Bevy's change-detection doesn't trigger
/// repaints every frame.
///
/// Parallel to [`update_high_contrast_borders`]. Same on/off rule,
/// same change-suppression idiom, different colour channel —
/// `BackgroundColor` for tick marks, decorative strips, fine
/// separators that paint their shape directly rather than via a
/// `BorderColor` on a wider Node.
///
/// Tagged sites in v0.21.x: the replay overlay's 1 px scrub track
/// + 5 quarter-mark notch ticks (`replay_overlay::spawn_overlay`).
///
/// More sites can be tagged in follow-ups by adding
/// `HighContrastBackground::with_default(...)` to their spawn tuple.
pub(super) fn update_high_contrast_backgrounds(
    settings: Res<SettingsResource>,
    mut backgrounds: Query<(&HighContrastBackground, &mut BackgroundColor)>,
) {
    let high_contrast = settings.0.high_contrast_mode;
    for (marker, mut bg) in backgrounds.iter_mut() {
        let target = if high_contrast {
            marker.hc_color
        } else {
            marker.default_color
        };
        if bg.0 != target {
            *bg = BackgroundColor(target);
        }
    }
}

pub(super) fn update_reduce_motion_text(
    settings: Res<SettingsResource>,
    mut text_nodes: Query<&mut Text, With<ReduceMotionText>>,
) {
    if !settings.is_changed() {
        return;
    }
    for mut text in &mut text_nodes {
        **text = on_off_label(settings.0.reduce_motion_mode);
    }
}

pub(super) fn update_touch_input_mode_text(
    settings: Res<SettingsResource>,
    mut text_nodes: Query<&mut Text, With<TouchInputModeText>>,
) {
    if !settings.is_changed() {
        return;
    }
    for mut text in &mut text_nodes {
        **text = touch_input_mode_label(&settings.0.touch_input_mode);
    }
}

/// Refreshes the live "Winnable deals only" toggle value in the
/// Gameplay section whenever `SettingsResource` changes (button click,
/// hand-edited `settings.json` reload, etc.).
pub(super) fn update_winnable_deals_only_text(
    settings: Res<SettingsResource>,
    mut text_nodes: Query<&mut Text, With<WinnableDealsOnlyText>>,
) {
    if !settings.is_changed() {
        return;
    }
    for mut text in &mut text_nodes {
        **text = winnable_deals_only_label(settings.0.winnable_deals_only);
    }
}

/// Refreshes the live "Share usage data" toggle value in the Privacy section
/// whenever `SettingsResource` changes.
pub(super) fn update_analytics_enabled_text(
    settings: Res<SettingsResource>,
    mut text_nodes: Query<&mut Text, With<AnalyticsEnabledText>>,
) {
    if !settings.is_changed() {
        return;
    }
    for mut text in &mut text_nodes {
        **text = on_off_label(settings.0.analytics_enabled);
    }
}

/// Refreshes the live "Smart window size" toggle value whenever
/// `SettingsResource` changes. The flag is stored negatively as
/// `disable_smart_default_size`, so the label inverts.
pub(super) fn update_smart_default_size_text(
    settings: Res<SettingsResource>,
    mut text_nodes: Query<&mut Text, With<SmartDefaultSizeText>>,
) {
    if !settings.is_changed() {
        return;
    }
    for mut text in &mut text_nodes {
        **text = smart_default_size_label(!settings.0.disable_smart_default_size);
    }
}

/// Refreshes the live tooltip-delay value in the Gameplay section
/// whenever `SettingsResource` changes (slider buttons, hand-edited
/// settings.json reload, etc.).
pub(super) fn update_tooltip_delay_text(
    settings: Res<SettingsResource>,
    mut text_nodes: Query<&mut Text, With<TooltipDelayText>>,
) {
    if !settings.is_changed() {
        return;
    }
    for mut text in &mut text_nodes {
        **text = tooltip_delay_label(settings.0.tooltip_delay_secs);
    }
}

/// Refreshes the live time-bonus-multiplier value in the Gameplay
/// section whenever `SettingsResource` changes.
pub(super) fn update_time_bonus_multiplier_text(
    settings: Res<SettingsResource>,
    mut text_nodes: Query<&mut Text, With<TimeBonusMultiplierText>>,
) {
    if !settings.is_changed() {
        return;
    }
    for mut text in &mut text_nodes {
        **text = time_bonus_label(settings.0.time_bonus_multiplier);
    }
}

/// Refreshes the live replay-playback per-move-interval value in the
/// Gameplay section whenever `SettingsResource` changes (slider buttons,
/// hand-edited settings.json reload, etc.).
pub(super) fn update_replay_move_interval_text(
    settings: Res<SettingsResource>,
    mut text_nodes: Query<&mut Text, With<ReplayMoveIntervalText>>,
) {
    if !settings.is_changed() {
        return;
    }
    for mut text in &mut text_nodes {
        **text = replay_move_interval_label(settings.0.replay_move_interval_secs);
    }
}

pub(super) fn card_back_label(idx: usize) -> String {
    if idx == 0 {
        "Default".to_string()
    } else {
        format!("Style {idx}")
    }
}

pub(super) fn background_label(idx: usize) -> String {
    if idx == 0 {
        "Default".to_string()
    } else {
        format!("Style {idx}")
    }
}

pub(super) fn sync_status_label(status: &SyncStatus) -> String {
    match status {
        SyncStatus::Idle => "Status: idle".to_string(),
        SyncStatus::Syncing => "Status: syncing…".to_string(),
        SyncStatus::LastSynced(t) => {
            let secs = chrono::Utc::now()
                .signed_duration_since(*t)
                .num_seconds()
                .max(0);
            if secs < 60 {
                format!("Last synced: {secs}s ago")
            } else {
                format!("Last synced: {}m ago", secs / 60)
            }
        }
        SyncStatus::Error(e) => format!("Sync error: {e}"),
    }
}

pub(super) fn draw_mode_label(mode: &DrawStockConfig) -> String {
    match mode {
        DrawStockConfig::DrawOne => "Draw 1".into(),
        DrawStockConfig::DrawThree => "Draw 3".into(),
    }
}

pub(super) fn anim_speed_label(speed: &AnimSpeed) -> String {
    match speed {
        AnimSpeed::Normal => "Normal".into(),
        AnimSpeed::Fast => "Fast".into(),
        AnimSpeed::Instant => "Instant".into(),
    }
}

pub(super) fn theme_label(theme: &Theme) -> String {
    match theme {
        Theme::Green => "Green".into(),
        Theme::Blue => "Blue".into(),
        Theme::Dark => "Dark".into(),
    }
}

pub(super) fn color_blind_label(enabled: bool) -> String {
    if enabled { "ON".into() } else { "OFF".into() }
}

/// Generic ON/OFF label shared by the high-contrast and reduce-
/// motion accessibility toggles. Same format as
/// [`color_blind_label`] / [`winnable_deals_only_label`] —
/// keeping all simple boolean toggle rows visually uniform.
pub(super) fn on_off_label(enabled: bool) -> String {
    if enabled { "ON".into() } else { "OFF".into() }
}

/// Display string for the "Winnable deals only" toggle. Mirrors
/// [`color_blind_label`] — "ON" / "OFF" — so the layout is uniform
/// with the rest of the Gameplay-section toggles.
pub(super) fn winnable_deals_only_label(enabled: bool) -> String {
    if enabled { "ON".into() } else { "OFF".into() }
}

pub(super) fn touch_input_mode_label(mode: &solitaire_data::settings::TouchInputMode) -> String {
    use solitaire_data::settings::TouchInputMode;
    match mode {
        TouchInputMode::OneTap => "One-tap".into(),
        TouchInputMode::TapToSelect => "Tap to select".into(),
    }
}

/// Display string for the "Smart window size" toggle. The argument
/// is the *enabled* state (i.e. the inverse of the underlying
/// `disable_smart_default_size` field) so reading the label gives
/// the player intuitive ON/OFF semantics.
pub(super) fn smart_default_size_label(enabled: bool) -> String {
    if enabled { "ON".into() } else { "OFF".into() }
}

/// Formats the tooltip-hover delay for display in the Settings panel.
/// `0.0` reads as `"Instant"` so the zero-delay case has a name; any
/// other value prints as `"{n:.1} s"` (e.g. `"0.5 s"`, `"1.2 s"`).
pub(super) fn tooltip_delay_label(secs: f32) -> String {
    if secs <= 0.0 {
        "Instant".into()
    } else {
        format!("{secs:.1} s")
    }
}

/// Formats the cosmetic time-bonus multiplier for display in the
/// Settings panel. `0.0` reads as `"Off"` so the player understands the
/// time-bonus row will be hidden; any other value prints as
/// `"{n:.1}×"` (e.g. `"1.0×"`, `"1.5×"`).
pub(super) fn time_bonus_label(value: f32) -> String {
    if value <= 0.0 {
        "Off".into()
    } else {
        format!("{value:.1}×")
    }
}

/// Formats the replay-playback per-move interval for display in the
/// Settings panel. Mirrors [`tooltip_delay_label`] for parity — the
/// readout is `"{n:.2} s/move"` (e.g. `"0.45 s/move"`, `"0.10 s/move"`),
/// using two decimal places because the step is 0.05 s.
pub(super) fn replay_move_interval_label(secs: f32) -> String {
    format!("{secs:.2} s/move")
}
