//! Settings panel construction: the spawn function and row builders.

use super::*;

use std::path::PathBuf;

use solitaire_data::Settings;

use solitaire_data::settings::SyncBackend;

#[cfg(not(target_arch = "wasm32"))]
use crate::assets::user_theme_dir;
use crate::events::InfoToastEvent;
use crate::font_plugin::FontResource;
use crate::theme::ThemeThumbnailPair;
#[cfg(not(target_arch = "wasm32"))]
use crate::theme::{ImportError, import_theme, refresh_registry};
use crate::ui_focus::FocusRow;
use crate::ui_modal::{
    ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
    BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM,
    STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION,
    VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
};
use crate::ui_tooltip::Tooltip;

/// Spawns the Settings modal.
///
/// `theme_overrides_back` is `true` when the active card-art theme
/// supplies its own back (`CardImageSet::theme_back == Some(_)`). The
/// "Card Back" picker is rendered with a small caption and the
/// swatches are hidden in this state — the theme's back wins
/// regardless of which legacy back is selected, so the picker would
/// be inert otherwise.
#[allow(clippy::too_many_arguments)]
pub(super) fn spawn_settings_panel(
    commands: &mut Commands,
    settings: &Settings,
    sync_status: &str,
    unlocked_card_backs: &[usize],
    unlocked_backgrounds: &[usize],
    themes: &[ThemePickerEntry],
    scroll_offset: f32,
    font_res: Option<&FontResource>,
    theme_overrides_back: bool,
) {
    spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
        spawn_modal_header(card, "Settings", font_res);

        // Scrollable body — contains every section so tall content stays
        // reachable on short windows. The Done button below stays fixed
        // outside the scroll so it's always one click away.
        card.spawn((
            SettingsPanelScrollable,
            SettingsScrollNode,
            ScrollPosition(Vec2::new(0.0, scroll_offset)),
            Node {
                flex_direction: FlexDirection::Column,
                row_gap: VAL_SPACE_3,
                max_height: Val::Vh(60.0),
                overflow: Overflow::scroll_y(),
                padding: UiRect::bottom(Val::Px(96.0)),
                ..default()
            },
        ))
        .with_children(|body| {
            // --- Audio ---
            section_label(body, "Audio", font_res);
            volume_row(
                body,
                "SFX Volume",
                settings.sfx_volume,
                SfxVolumeText,
                SettingsButton::SfxDown,
                SettingsButton::SfxUp,
                "Lower sound effects volume.",
                "Raise sound effects volume.",
                font_res,
            );
            volume_row(
                body,
                "Music Volume",
                settings.music_volume,
                MusicVolumeText,
                SettingsButton::MusicDown,
                SettingsButton::MusicUp,
                "Lower music and ambience volume.",
                "Raise music and ambience volume.",
                font_res,
            );

            // --- Gameplay ---
            section_label(body, "Gameplay", font_res);
            toggle_row(
                body,
                "Draw Mode",
                DrawModeText,
                draw_mode_label(&settings.draw_mode),
                SettingsButton::ToggleDrawMode,
                "Switch between Draw 1 and Draw 3. Takes effect next deal.",
                font_res,
            );
            toggle_row(
                body,
                "Winnable deals only",
                WinnableDealsOnlyText,
                winnable_deals_only_label(settings.winnable_deals_only),
                SettingsButton::ToggleWinnableDealsOnly,
                "When on, fresh Classic deals are filtered through a solver \
                 (may take a moment when on).",
                font_res,
            );
            toggle_row(
                body,
                "Anim Speed",
                AnimSpeedText,
                anim_speed_label(&settings.animation_speed),
                SettingsButton::CycleAnimSpeed,
                "Cycle animation speed: Normal, Fast, Instant.",
                font_res,
            );
            tooltip_delay_row(
                body,
                settings.tooltip_delay_secs,
                font_res,
            );
            time_bonus_multiplier_row(
                body,
                settings.time_bonus_multiplier,
                font_res,
            );
            replay_move_interval_row(
                body,
                settings.replay_move_interval_secs,
                font_res,
            );
            toggle_row(
                body,
                "Smart window size",
                SmartDefaultSizeText,
                smart_default_size_label(!settings.disable_smart_default_size),
                SettingsButton::ToggleSmartDefaultSize,
                "When ON, fresh launches resize the window to ~70 % of the \
                 monitor. OFF pins the 1280\u{00D7}800 baseline. Saved \
                 window size always wins.",
                font_res,
            );

            // --- Cosmetic ---
            section_label(body, "Cosmetic", font_res);
            toggle_row(
                body,
                "Theme",
                ThemeText,
                theme_label(&settings.theme),
                SettingsButton::ToggleTheme,
                "Cycle felt color: Green, Blue, Dark.",
                font_res,
            );
            toggle_row(
                body,
                "Color-blind Mode",
                ColorBlindText,
                color_blind_label(settings.color_blind_mode),
                SettingsButton::ToggleColorBlind,
                "Show shape glyphs alongside suit colors. Suit-blind friendly.",
                font_res,
            );
            toggle_row(
                body,
                "High Contrast",
                HighContrastText,
                on_off_label(settings.high_contrast_mode),
                SettingsButton::ToggleHighContrast,
                "Boosts foreground + suit-red glyphs to higher-luminance variants for low-vision readers and low-quality displays.",
                font_res,
            );
            toggle_row(
                body,
                "Reduce Motion",
                ReduceMotionText,
                on_off_label(settings.reduce_motion_mode),
                SettingsButton::ToggleReduceMotion,
                "Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
                font_res,
            );
            toggle_row(
                body,
                "Touch Input Mode",
                TouchInputModeText,
                touch_input_mode_label(&settings.touch_input_mode),
                SettingsButton::ToggleTouchInputMode,
                "One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
                font_res,
            );
            if theme_overrides_back {
                // The active theme provides its own back; the legacy
                // picker has no visible effect, so we replace its
                // swatch row with an informational caption. The
                // player's `selected_card_back` value still
                // round-trips through `settings.json` — the moment
                // they switch to a theme without a back, the picker
                // re-appears with their previous choice intact.
                picker_row_overridden_by_theme(body, "Card Back", font_res);
            } else {
                picker_row(
                    body,
                    "Card Back",
                    unlocked_card_backs,
                    settings.selected_card_back,
                    SettingsButton::SelectCardBack,
                    "Choose your deck art. New backs unlock at higher levels.",
                    font_res,
                );
            }
            picker_row(
                body,
                "Background",
                unlocked_backgrounds,
                settings.selected_background,
                SettingsButton::SelectBackground,
                "Choose your felt art. New felts unlock at higher levels.",
                font_res,
            );
            // Card-art theme picker — only renders when the registry has
            // entries (production: always; tests: only when
            // ThemeRegistryPlugin is installed).
            if !themes.is_empty() {
                theme_picker_row(
                    body,
                    "Card Theme",
                    themes,
                    &settings.selected_theme_id,
                    "Choose card-face artwork. Imported themes appear here.",
                    font_res,
                );
            }
            #[cfg(not(target_arch = "wasm32"))]
            import_themes_row(body, font_res);

            // --- Privacy (only shown when a Matomo URL is configured) ---
            if settings.matomo_url.is_some() {
                section_label(body, "Privacy", font_res);
                toggle_row(
                    body,
                    "Share usage data",
                    AnalyticsEnabledText,
                    on_off_label(settings.analytics_enabled),
                    SettingsButton::ToggleAnalytics,
                    "Sends anonymous game events to Matomo for aggregate analytics.",
                    font_res,
                );
            }

            // --- Sync ---
            section_label(body, "Sync", font_res);
            sync_row(body, sync_status, &settings.sync_backend, font_res);
        });

        // Done is the only action — primary so the player always knows
        // how to leave the modal. `O` toggles it the same way.
        spawn_modal_actions(card, |actions| {
            spawn_modal_button(
                actions,
                SettingsButton::Done,
                "Done",
                Some("O"),
                ButtonVariant::Primary,
                font_res,
            );
        });
    });
}

/// Section divider — small lavender label inside the scrollable body.
pub(super) fn section_label(
    parent: &mut ChildSpawnerCommands,
    title: &str,
    font_res: Option<&FontResource>,
) {
    let font = TextFont {
        font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
        font_size: TYPE_BODY,
        ..default()
    };
    parent.spawn((Text::new(title), font, TextColor(TEXT_SECONDARY)));
}

/// `Label  0.80  [−]  [+]` — used for SFX and Music volume rows.
///
/// `tooltip_down` / `tooltip_up` are attached to the `−` / `+` buttons
/// respectively so each glyph carries a one-line reminder of which channel
/// it adjusts.
#[allow(clippy::too_many_arguments)]
pub(super) fn volume_row<Marker: Component>(
    parent: &mut ChildSpawnerCommands,
    label: &str,
    value: f32,
    marker: Marker,
    btn_down: SettingsButton,
    btn_up: SettingsButton,
    tooltip_down: &'static str,
    tooltip_up: &'static str,
    font_res: Option<&FontResource>,
) {
    let label_font = label_text_font(font_res);
    let value_font = value_text_font(font_res);
    // Row spans the full body width with a flex-grow spacer between
    // the left-aligned label and the right-aligned controls cluster.
    // Without `width: 100%` + the spacer, the label / value / buttons
    // bunch against the left edge and a varying-length value (e.g.
    // "0.80" → "1.00") shifts the +/− buttons sideways frame to
    // frame, visually overlapping with adjacent UI on small windows.
    parent
        .spawn(Node {
            flex_direction: FlexDirection::Row,
            align_items: AlignItems::Center,
            column_gap: VAL_SPACE_2,
            width: Val::Percent(100.0),
            ..default()
        })
        .with_children(|row| {
            row.spawn((
                Text::new(label.to_string()),
                label_font,
                TextColor(TEXT_SECONDARY),
            ));
            // Spacer: takes up all remaining horizontal space so the
            // controls cluster sits flush against the right edge.
            row.spawn(Node {
                flex_grow: 1.0,
                ..default()
            });
            // Controls cluster — value + decrement + increment held
            // together so the buttons stay in fixed positions even
            // as the value text width varies.
            row.spawn(Node {
                flex_direction: FlexDirection::Row,
                align_items: AlignItems::Center,
                column_gap: VAL_SPACE_2,
                ..default()
            })
            .with_children(|cluster| {
                cluster.spawn((
                    marker,
                    Text::new(format!("{value:.2}")),
                    value_font,
                    TextColor(TEXT_PRIMARY),
                ));
                icon_button(cluster, "−", btn_down, tooltip_down, font_res);
                icon_button(cluster, "+", btn_up, tooltip_up, font_res);
            });
        });
}

/// `Tooltip Delay  0.5 s  [−]  [+]` — slider row for the player-tunable
/// tooltip-hover dwell. Mirrors [`volume_row`] (label, current value,
/// decrement, increment) but formats the value via [`tooltip_delay_label`]
/// so `0.0` reads as `"Instant"` and other values as `"{n:.1} s"`.
pub(super) fn tooltip_delay_row(
    parent: &mut ChildSpawnerCommands,
    value_secs: f32,
    font_res: Option<&FontResource>,
) {
    let label_font = label_text_font(font_res);
    let value_font = value_text_font(font_res);
    parent
        .spawn(Node {
            flex_direction: FlexDirection::Row,
            align_items: AlignItems::Center,
            column_gap: VAL_SPACE_2,
            width: Val::Percent(100.0),
            ..default()
        })
        .with_children(|row| {
            row.spawn((
                Text::new("Tooltip Delay".to_string()),
                label_font,
                TextColor(TEXT_SECONDARY),
            ));
            row.spawn(Node {
                flex_grow: 1.0,
                ..default()
            });
            row.spawn(Node {
                flex_direction: FlexDirection::Row,
                align_items: AlignItems::Center,
                column_gap: VAL_SPACE_2,
                ..default()
            })
            .with_children(|cluster| {
                cluster.spawn((
                    TooltipDelayText,
                    Text::new(tooltip_delay_label(value_secs)),
                    value_font,
                    TextColor(TEXT_PRIMARY),
                ));
                icon_button(
                    cluster,
                    "−",
                    SettingsButton::TooltipDelayDown,
                    "Shorten the hover delay before tooltips appear.",
                    font_res,
                );
                icon_button(
                    cluster,
                    "+",
                    SettingsButton::TooltipDelayUp,
                    "Lengthen the hover delay before tooltips appear.",
                    font_res,
                );
            });
        });
}

/// `Time bonus  1.0×  [−]  [+]` — slider row for the cosmetic
/// `Settings::time_bonus_multiplier`. Mirrors [`tooltip_delay_row`]
/// (label, current value, decrement, increment) but formats the value
/// via [`time_bonus_label`] so `0.0` reads as `"Off"` and other values
/// as `"{n:.1}×"`. The multiplier is **cosmetic** — adjusting it
/// changes only the win-modal score breakdown, not the canonical
/// scores recorded in stats / achievements / leaderboards.
pub(super) fn time_bonus_multiplier_row(
    parent: &mut ChildSpawnerCommands,
    value: f32,
    font_res: Option<&FontResource>,
) {
    let label_font = label_text_font(font_res);
    let value_font = value_text_font(font_res);
    parent
        .spawn(Node {
            flex_direction: FlexDirection::Row,
            align_items: AlignItems::Center,
            column_gap: VAL_SPACE_2,
            width: Val::Percent(100.0),
            ..default()
        })
        .with_children(|row| {
            row.spawn((
                Text::new("Time bonus".to_string()),
                label_font,
                TextColor(TEXT_SECONDARY),
            ));
            row.spawn(Node {
                flex_grow: 1.0,
                ..default()
            });
            row.spawn(Node {
                flex_direction: FlexDirection::Row,
                align_items: AlignItems::Center,
                column_gap: VAL_SPACE_2,
                ..default()
            })
            .with_children(|cluster| {
                cluster.spawn((
                    TimeBonusMultiplierText,
                    Text::new(time_bonus_label(value)),
                    value_font,
                    TextColor(TEXT_PRIMARY),
                ));
                icon_button(
                    cluster,
                    "−",
                    SettingsButton::TimeBonusDown,
                    "Shrink the time-bonus shown in the win modal. Cosmetic only.",
                    font_res,
                );
                icon_button(
                    cluster,
                    "+",
                    SettingsButton::TimeBonusUp,
                    "Boost the time-bonus shown in the win modal. Cosmetic only.",
                    font_res,
                );
            });
        });
}

/// `Replay speed  0.45 s/move  [−]  [+]` — slider row for the
/// player-tunable replay-playback per-move interval. Mirrors
/// [`tooltip_delay_row`] (label, current value, decrement, increment)
/// but formats the value via [`replay_move_interval_label`] as
/// `"{n:.2} s/move"`. The decrement button speeds playback up
/// (smaller interval); the increment slows it down — same direction
/// convention as the tooltip-delay slider.
pub(super) fn replay_move_interval_row(
    parent: &mut ChildSpawnerCommands,
    value_secs: f32,
    font_res: Option<&FontResource>,
) {
    let label_font = label_text_font(font_res);
    let value_font = value_text_font(font_res);
    parent
        .spawn(Node {
            flex_direction: FlexDirection::Row,
            align_items: AlignItems::Center,
            column_gap: VAL_SPACE_2,
            width: Val::Percent(100.0),
            ..default()
        })
        .with_children(|row| {
            row.spawn((
                Text::new("Replay speed".to_string()),
                label_font,
                TextColor(TEXT_SECONDARY),
            ));
            row.spawn(Node {
                flex_grow: 1.0,
                ..default()
            });
            row.spawn(Node {
                flex_direction: FlexDirection::Row,
                align_items: AlignItems::Center,
                column_gap: VAL_SPACE_2,
                ..default()
            })
            .with_children(|cluster| {
                cluster.spawn((
                    ReplayMoveIntervalText,
                    Text::new(replay_move_interval_label(value_secs)),
                    value_font,
                    TextColor(TEXT_PRIMARY),
                ));
                icon_button(
                    cluster,
                    "−",
                    SettingsButton::ReplayMoveIntervalDown,
                    "Speed up replay playback (shorter per-move interval).",
                    font_res,
                );
                icon_button(
                    cluster,
                    "+",
                    SettingsButton::ReplayMoveIntervalUp,
                    "Slow down replay playback (longer per-move interval).",
                    font_res,
                );
            });
        });
}

/// `Label  Value  [⇄]` — used for cycle/toggle rows (draw mode, theme,
/// anim speed, colour-blind).
///
/// `tooltip` is attached to the `⇄` button so the cycle glyph carries a
/// one-line reminder of what it iterates through.
#[allow(clippy::too_many_arguments)]
pub(super) fn toggle_row<Marker: Component>(
    parent: &mut ChildSpawnerCommands,
    label: &str,
    marker: Marker,
    value: String,
    action: SettingsButton,
    tooltip: &'static str,
    font_res: Option<&FontResource>,
) {
    let label_font = label_text_font(font_res);
    let value_font = value_text_font(font_res);
    parent
        .spawn(Node {
            flex_direction: FlexDirection::Row,
            align_items: AlignItems::Center,
            column_gap: VAL_SPACE_2,
            width: Val::Percent(100.0),
            ..default()
        })
        .with_children(|row| {
            row.spawn((
                Text::new(label.to_string()),
                label_font,
                TextColor(TEXT_SECONDARY),
            ));
            row.spawn(Node {
                flex_grow: 1.0,
                ..default()
            });
            row.spawn(Node {
                flex_direction: FlexDirection::Row,
                align_items: AlignItems::Center,
                column_gap: VAL_SPACE_2,
                ..default()
            })
            .with_children(|cluster| {
                cluster.spawn((
                    marker,
                    Text::new(value),
                    value_font,
                    TextColor(TEXT_PRIMARY),
                ));
                icon_button(cluster, "⇄", action, tooltip, font_res);
            });
        });
}

/// Wrapping row of indexed swatch buttons — used for card-back and
/// background pickers. The currently-selected swatch is tinted with
/// `STATE_SUCCESS` so the user can see it without reading a label.
///
/// `tooltip` is attached to every swatch in the row so hovering any chip
/// reveals what the picker controls and how new entries unlock.
#[allow(clippy::too_many_arguments)]
pub(super) fn picker_row(
    parent: &mut ChildSpawnerCommands,
    label: &str,
    unlocked: &[usize],
    selected: usize,
    make_button: impl Fn(usize) -> SettingsButton,
    tooltip: &'static str,
    font_res: Option<&FontResource>,
) {
    let label_font = label_text_font(font_res);
    let chip_font = TextFont {
        font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
        font_size: TYPE_BODY,
        ..default()
    };
    parent
        .spawn((
            // The row container is a `FocusRow` so Left / Right arrow
            // keys cycle within its swatch children. Tab still escapes
            // the row to the next focusable in the modal.
            FocusRow,
            Node {
                flex_direction: FlexDirection::Row,
                align_items: AlignItems::Center,
                column_gap: VAL_SPACE_2,
                flex_wrap: FlexWrap::Wrap,
                ..default()
            },
        ))
        .with_children(|row| {
            row.spawn((
                Text::new(label.to_string()),
                label_font,
                TextColor(TEXT_SECONDARY),
            ));
            // Always show at least swatch 0 (default).
            let entries: &[usize] = if unlocked.is_empty() { &[0] } else { unlocked };
            for &idx in entries {
                let is_selected = idx == selected;
                let bg = if is_selected {
                    STATE_SUCCESS
                } else {
                    BG_ELEVATED_HI
                };
                row.spawn((
                    make_button(idx),
                    Button,
                    Tooltip::new(tooltip),
                    Node {
                        width: Val::Px(SWATCH_PX),
                        height: Val::Px(SWATCH_PX),
                        justify_content: JustifyContent::Center,
                        align_items: AlignItems::Center,
                        border: UiRect::all(Val::Px(1.0)),
                        border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
                        ..default()
                    },
                    BackgroundColor(bg),
                    BorderColor::all(BORDER_SUBTLE),
                    HighContrastBorder::with_default(BORDER_SUBTLE),
                ))
                .with_children(|b| {
                    let text_color = if is_selected { BG_BASE } else { TEXT_PRIMARY };
                    b.spawn((
                        Text::new(format!("{}", idx + 1)),
                        chip_font.clone(),
                        TextColor(text_color),
                    ));
                });
            }
        });
}

/// Marker on the row spawned by [`picker_row_overridden_by_theme`] so
/// tests can find the caption without depending on text-content
/// matching.
#[derive(Component, Debug)]
pub(crate) struct CardBackPickerOverriddenByTheme;

/// Marker placed on every preview-thumbnail [`ImageNode`] inside a
/// theme picker chip. Lets tests assert that a chip's children include
/// the rasterised preview pair, and lets a future system update or
/// hot-swap thumbnails without scanning the whole UI tree.
#[derive(Component, Debug)]
pub(crate) struct ThemeThumbnailMarker;

/// Renders the "Card Back" row in its overridden-by-theme state: a
/// labelled caption explaining why the swatches are hidden, with no
/// interactive children. This is what the player sees when the active
/// card-art theme supplies its own `back.svg` — the theme's back wins
/// over the legacy `selected_card_back` choice, so showing the
/// swatches would only confuse the player into thinking they were
/// changing something when they weren't.
pub(super) fn picker_row_overridden_by_theme(
    parent: &mut ChildSpawnerCommands,
    label: &str,
    font_res: Option<&FontResource>,
) {
    let label_font = label_text_font(font_res);
    let caption_font = TextFont {
        font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
        font_size: TYPE_CAPTION,
        ..default()
    };
    parent
        .spawn((
            CardBackPickerOverriddenByTheme,
            Node {
                flex_direction: FlexDirection::Row,
                align_items: AlignItems::Center,
                column_gap: VAL_SPACE_2,
                ..default()
            },
        ))
        .with_children(|row| {
            row.spawn((
                Text::new(label.to_string()),
                label_font,
                TextColor(TEXT_SECONDARY),
            ));
            row.spawn((
                Text::new("Active theme provides its own back"),
                caption_font,
                TextColor(TEXT_SECONDARY),
            ));
        });
}

/// Logical width (px) of one preview thumbnail inside a picker chip.
/// Mirrors [`crate::theme::THEME_THUMBNAIL_WIDTH_PX`] but at the UI
/// scale used by Bevy's flex layout. The rasterised image itself is
/// 100×140 px; the chip displays it at the same logical size so
/// scaling artifacts stay minimal.
const THUMBNAIL_LOGICAL_WIDTH_PX: f32 = 50.0;
/// Logical height counterpart to [`THUMBNAIL_LOGICAL_WIDTH_PX`] —
/// preserves the 2:3 card aspect.
const THUMBNAIL_LOGICAL_HEIGHT_PX: f32 = 70.0;

/// Picker row for card-art themes. Distinct from [`picker_row`]
/// because themes are identified by `String` ids (matching
/// `ThemeMeta::id`) instead of dense indices, and each chip carries
/// the theme's display name plus a small Ace + back preview pair
/// (when available in [`ThemeThumbnailCache`]).
pub(super) fn theme_picker_row(
    parent: &mut ChildSpawnerCommands,
    label: &str,
    themes: &[ThemePickerEntry],
    selected_id: &str,
    tooltip: &'static str,
    font_res: Option<&FontResource>,
) {
    let label_font = label_text_font(font_res);
    let chip_font = TextFont {
        font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
        font_size: TYPE_BODY,
        ..default()
    };
    parent
        .spawn((
            FocusRow,
            Node {
                flex_direction: FlexDirection::Row,
                align_items: AlignItems::Center,
                column_gap: VAL_SPACE_2,
                flex_wrap: FlexWrap::Wrap,
                ..default()
            },
        ))
        .with_children(|row| {
            row.spawn((
                Text::new(label.to_string()),
                label_font,
                TextColor(TEXT_SECONDARY),
            ));
            for entry in themes {
                let is_selected = entry.id == selected_id;
                let bg = if is_selected {
                    STATE_SUCCESS
                } else {
                    BG_ELEVATED_HI
                };
                row.spawn((
                    SettingsButton::SelectTheme(entry.id.clone()),
                    Button,
                    Tooltip::new(tooltip),
                    Node {
                        // Chips with thumbnails stack the preview pair
                        // above the label so a glance reveals the
                        // theme's art without hovering for the
                        // tooltip.
                        flex_direction: FlexDirection::Column,
                        // Theme names are wider than numeric chips —
                        // pad horizontally instead of using a fixed
                        // square swatch.
                        padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
                        min_height: Val::Px(SWATCH_PX),
                        row_gap: VAL_SPACE_2,
                        justify_content: JustifyContent::Center,
                        align_items: AlignItems::Center,
                        border: UiRect::all(Val::Px(1.0)),
                        border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
                        ..default()
                    },
                    BackgroundColor(bg),
                    BorderColor::all(BORDER_SUBTLE),
                    HighContrastBorder::with_default(BORDER_SUBTLE),
                ))
                .with_children(|b| {
                    spawn_thumbnail_pair(b, entry.thumbnails.as_ref());
                    let text_color = if is_selected { BG_BASE } else { TEXT_PRIMARY };
                    b.spawn((
                        Text::new(entry.display_name.clone()),
                        chip_font.clone(),
                        TextColor(text_color),
                    ));
                });
            }
        });
}

/// Spawns the Ace + back preview pair for a theme picker chip.
///
/// When `thumbnails` is `Some(_)` and both handles are non-default,
/// renders two `ImageNode` siblings (Ace on the left, back on the
/// right). When the thumbnails are missing or only partially loaded,
/// renders two muted `BG_ELEVATED` placeholder rectangles at the same
/// logical size — keeping the chip's overall footprint stable so the
/// picker row layout doesn't reflow as the cache fills in.
pub(super) fn spawn_thumbnail_pair(
    parent: &mut ChildSpawnerCommands,
    thumbnails: Option<&ThemeThumbnailPair>,
) {
    parent
        .spawn(Node {
            flex_direction: FlexDirection::Row,
            column_gap: VAL_SPACE_2,
            align_items: AlignItems::Center,
            ..default()
        })
        .with_children(|pair| match thumbnails {
            Some(t) if t.is_fully_populated() => {
                spawn_thumbnail_image(pair, t.ace.clone());
                spawn_thumbnail_image(pair, t.back.clone());
            }
            _ => {
                spawn_thumbnail_placeholder(pair);
                spawn_thumbnail_placeholder(pair);
            }
        });
}

/// Spawns one `ImageNode` thumbnail at the canonical preview size.
/// Tagged with [`ThemeThumbnailMarker`] so tests can scan a chip's
/// children for the rendered preview without crawling the whole UI.
pub(super) fn spawn_thumbnail_image(parent: &mut ChildSpawnerCommands, image: Handle<Image>) {
    parent.spawn((
        ThemeThumbnailMarker,
        ImageNode::new(image),
        Node {
            width: Val::Px(THUMBNAIL_LOGICAL_WIDTH_PX),
            height: Val::Px(THUMBNAIL_LOGICAL_HEIGHT_PX),
            ..default()
        },
    ));
}

/// Spawns a muted placeholder rectangle for the case where the cache
/// has not yet generated thumbnails for a theme — or when a user theme
/// is missing one of its preview SVGs. Same logical size as
/// [`spawn_thumbnail_image`] so chip layout stays stable.
pub(super) fn spawn_thumbnail_placeholder(parent: &mut ChildSpawnerCommands) {
    parent.spawn((
        Node {
            width: Val::Px(THUMBNAIL_LOGICAL_WIDTH_PX),
            height: Val::Px(THUMBNAIL_LOGICAL_HEIGHT_PX),
            border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
            ..default()
        },
        BackgroundColor(BG_ELEVATED),
    ));
}

/// Sync section row — shows different controls depending on whether a server
/// backend is configured.
pub(super) fn sync_row(
    parent: &mut ChildSpawnerCommands,
    status_text: &str,
    backend: &SyncBackend,
    font_res: Option<&FontResource>,
) {
    let status_font = TextFont {
        font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
        font_size: TYPE_BODY,
        ..default()
    };
    let button_font = TextFont {
        font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
        font_size: TYPE_CAPTION,
        ..default()
    };

    // Helper closure to spawn a small settings-style pill button.
    let small_button = |row: &mut ChildSpawnerCommands,
                        marker: SettingsButton,
                        label: &str,
                        tooltip: String,
                        font: TextFont| {
        row.spawn((
            marker,
            Button,
            Tooltip::new(tooltip),
            Node {
                padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
                justify_content: JustifyContent::Center,
                border: UiRect::all(Val::Px(1.0)),
                border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
                ..default()
            },
            BackgroundColor(BG_ELEVATED_HI),
            BorderColor::all(BORDER_SUBTLE),
            HighContrastBorder::with_default(BORDER_SUBTLE),
        ))
        .with_children(|b| {
            b.spawn((Text::new(label.to_string()), font, TextColor(TEXT_PRIMARY)));
        });
    };

    parent
        .spawn(Node {
            flex_direction: FlexDirection::Column,
            row_gap: VAL_SPACE_2,
            ..default()
        })
        .with_children(|col| {
            // Status line + inline action buttons.
            col.spawn(Node {
                flex_direction: FlexDirection::Row,
                align_items: AlignItems::Center,
                column_gap: VAL_SPACE_3,
                flex_wrap: FlexWrap::Wrap,
                row_gap: VAL_SPACE_2,
                ..default()
            })
            .with_children(|row| {
                row.spawn((
                    SyncStatusText,
                    Text::new(status_text.to_string()),
                    status_font,
                    TextColor(TEXT_SECONDARY),
                ));

                match backend {
                    SyncBackend::Local => {
                        small_button(
                            row,
                            SettingsButton::ConnectSync,
                            "Connect",
                            "Connect to a self-hosted Ferrous Solitaire sync server.".to_string(),
                            button_font,
                        );
                    }
                    SyncBackend::SolitaireServer { username, .. } => {
                        // Show the logged-in username as a secondary label.
                        row.spawn((
                            Text::new(format!("({username})")),
                            TextFont {
                                font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
                                font_size: TYPE_CAPTION,
                                ..default()
                            },
                            TextColor(TEXT_SECONDARY),
                        ));
                        small_button(
                            row,
                            SettingsButton::SyncNow,
                            "Sync Now",
                            "Push and pull stats now. Runs automatically on launch and exit.".to_string(),
                            button_font.clone(),
                        );
                        small_button(
                            row,
                            SettingsButton::DisconnectSync,
                            "Disconnect",
                            "Unlink this device from the sync server.".to_string(),
                            button_font.clone(),
                        );
                        small_button(
                            row,
                            SettingsButton::DeleteAccount,
                            "Delete Account",
                            "Permanently delete your account and all server data. Cannot be undone.".to_string(),
                            button_font,
                        );
                    }
                }
            });
        });
}

pub(super) fn label_text_font(font_res: Option<&FontResource>) -> TextFont {
    TextFont {
        font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
        font_size: TYPE_BODY_LG,
        ..default()
    }
}

pub(super) fn value_text_font(font_res: Option<&FontResource>) -> TextFont {
    TextFont {
        font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
        font_size: TYPE_BODY_LG,
        ..default()
    }
}

/// Spawns a small square icon button (volume +/−, toggle, cycle).
///
/// `tooltip` is the hover-reveal caption attached via [`Tooltip`]. Every
/// Settings icon button ships with one because the glyph alone (`+`, `−`,
/// `⇄`) does not name what it adjusts; the tooltip carries that meaning.
/// Scans `user_theme_dir()` for `.zip` files and calls [`import_theme`] on
/// each one. On success, [`ThemeRegistry`] is refreshed in place and an
/// [`InfoToastEvent`] is fired per imported theme. `IdCollision` errors (theme
/// already installed) are silently skipped; all other errors produce a warning
/// toast. A final toast tells the player to reopen Settings to see new themes.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn handle_scan_themes(
    interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
    mut toast: MessageWriter<InfoToastEvent>,
    mut registry: Option<ResMut<crate::theme::ThemeRegistry>>,
) {
    for (interaction, button) in &interaction_query {
        if *interaction != Interaction::Pressed {
            continue;
        }
        if !matches!(button, SettingsButton::ScanThemes) {
            continue;
        }

        let themes_dir = user_theme_dir();

        let zips: Vec<PathBuf> = match std::fs::read_dir(&themes_dir) {
            Ok(entries) => entries
                .flatten()
                .map(|e| e.path())
                .filter(|p| p.extension().is_some_and(|ext| ext == "zip"))
                .collect(),
            Err(_) => {
                toast.write(InfoToastEvent(
                    "Themes folder not found — drop .zip files there first.".to_string(),
                ));
                return;
            }
        };

        if zips.is_empty() {
            toast.write(InfoToastEvent(
                "No .zip files found in themes folder.".to_string(),
            ));
            return;
        }

        let mut imported = 0u32;
        let mut errors = 0u32;

        for zip_path in &zips {
            match import_theme(zip_path) {
                Ok(theme_id) => {
                    toast.write(InfoToastEvent(format!(
                        "Imported theme '{}'.",
                        theme_id.as_str()
                    )));
                    imported += 1;
                }
                Err(ImportError::IdCollision { .. }) => {
                    // Already installed — silent skip.
                }
                Err(e) => {
                    let name = zip_path
                        .file_name()
                        .map(|n| n.to_string_lossy().into_owned())
                        .unwrap_or_default();
                    toast.write(InfoToastEvent(format!("Import failed ({name}): {e}")));
                    errors += 1;
                }
            }
        }

        if imported == 0 && errors == 0 {
            toast.write(InfoToastEvent("All themes already installed.".to_string()));
            return;
        }

        if imported > 0 {
            if let Some(reg) = &mut registry {
                refresh_registry(reg, &themes_dir);
            }
            toast.write(InfoToastEvent(
                "Reopen Settings to see new themes in the picker.".to_string(),
            ));
        }
    }
}

#[cfg(not(target_arch = "wasm32"))]
/// A small pill-shaped settings button, matching the style used in `sync_row`.
pub(super) fn pill_button(
    parent: &mut ChildSpawnerCommands,
    marker: SettingsButton,
    label: &str,
    tooltip: &'static str,
    font_res: Option<&FontResource>,
) {
    let font = TextFont {
        font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
        font_size: TYPE_CAPTION,
        ..default()
    };
    parent
        .spawn((
            marker,
            Button,
            Tooltip::new(tooltip),
            Node {
                padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
                justify_content: JustifyContent::Center,
                border: UiRect::all(Val::Px(1.0)),
                border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
                ..default()
            },
            BackgroundColor(BG_ELEVATED_HI),
            BorderColor::all(BORDER_SUBTLE),
            HighContrastBorder::with_default(BORDER_SUBTLE),
        ))
        .with_children(|b| {
            b.spawn((Text::new(label.to_string()), font, TextColor(TEXT_PRIMARY)));
        });
}

/// "Import Theme" row: folder-path label + "Scan for new themes" button.
///
/// The player drops `.zip` theme archives into the themes folder shown here,
/// then presses the button. [`handle_scan_themes`] picks them up, validates,
/// and installs them. Reopen Settings to see newly imported themes in the
/// card-theme picker.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn import_themes_row(
    parent: &mut ChildSpawnerCommands,
    font_res: Option<&FontResource>,
) {
    let caption_font = TextFont {
        font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
        font_size: TYPE_CAPTION,
        ..default()
    };

    parent
        .spawn((
            FocusRow,
            Node {
                flex_direction: FlexDirection::Column,
                row_gap: VAL_SPACE_2,
                ..default()
            },
        ))
        .with_children(|col| {
            // Folder path hint.
            let path_str = user_theme_dir().to_string_lossy().into_owned();
            col.spawn((
                Text::new(format!("Drop .zip files into: {path_str}")),
                caption_font,
                TextColor(TEXT_SECONDARY),
            ));

            // Scan button.
            col.spawn(Node {
                flex_direction: FlexDirection::Row,
                align_items: AlignItems::Center,
                ..default()
            })
            .with_children(|row| {
                pill_button(
                    row,
                    SettingsButton::ScanThemes,
                    "Scan for new themes",
                    "Scan the themes folder for .zip archives and install any that are new.",
                    font_res,
                );
            });
        });
}

pub(super) fn icon_button(
    parent: &mut ChildSpawnerCommands,
    label: &str,
    action: SettingsButton,
    tooltip: &'static str,
    font_res: Option<&FontResource>,
) {
    let glyph_font = TextFont {
        font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
        font_size: TYPE_BODY_LG,
        ..default()
    };
    parent
        .spawn((
            action,
            Button,
            Tooltip::new(tooltip),
            Node {
                width: Val::Px(ICON_BUTTON_PX),
                height: Val::Px(ICON_BUTTON_PX),
                justify_content: JustifyContent::Center,
                align_items: AlignItems::Center,
                border: UiRect::all(Val::Px(1.0)),
                border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
                ..default()
            },
            BackgroundColor(BG_ELEVATED_HI),
            BorderColor::all(BORDER_SUBTLE),
            HighContrastBorder::with_default(BORDER_SUBTLE),
        ))
        .with_children(|b| {
            b.spawn((
                Text::new(label.to_string()),
                glyph_font,
                TextColor(TEXT_PRIMARY),
            ));
        });
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
