//! Persists `solitaire_data::Settings`, exposes hotkeys for live tuning,
//! and renders a Bevy UI Settings panel.
//!
//! Hotkeys (always active, no overlay required):
//! - `[` — decrease SFX volume by `SFX_STEP`
//! - `]` — increase SFX volume by `SFX_STEP`
//! - `O` — open / close the Settings panel
//!
//! On change, the plugin persists `settings.json` and fires
//! `SettingsChangedEvent` so dependents (e.g. `AudioPlugin`) can react.

use std::path::PathBuf;

use bevy::input::mouse::MouseWheel;
use bevy::prelude::*;
use bevy::window::{WindowMoved, WindowResized};
use solitaire_data::{
    Settings, WindowGeometry, load_settings_from, save_settings_to, settings_file_path,
};

use crate::events::{
    DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
    SyncLogoutRequestEvent, ToggleSettingsRequestEvent,
};
use crate::resources::SettingsScrollPos;
use crate::theme::ThemeThumbnailPair;

mod input;
mod ui;
mod updates;

use input::*;
use ui::*;
use updates::*;

/// Side length of a swatch button in the card-back / background pickers.
/// Smaller than the smallest spacing rung so it stays a literal.
const SWATCH_PX: f32 = 40.0;

/// Side length of a small toggle / cycle button (e.g. the "⇄" affordances).
/// Sub-rung sizing — kept as a literal, see SWATCH_PX. 32 px meets the
/// minimum desktop hit-target threshold while staying smaller than `SWATCH_PX`.
const ICON_BUTTON_PX: f32 = 32.0;

/// Volume adjustment step applied by the `[` / `]` hotkeys.
pub const SFX_STEP: f32 = 0.1;

/// Bevy resource wrapping the current `Settings`.
#[derive(Resource, Debug, Clone)]
pub struct SettingsResource(pub Settings);

/// Persistence path for `SettingsResource`. `None` disables I/O (used in tests).
#[derive(Resource, Debug, Clone)]
pub struct SettingsStoragePath(pub Option<PathBuf>);

/// Whether the Settings panel is currently visible. Toggle with `O`.
#[derive(Resource, Debug, Clone, Default)]
pub struct SettingsScreen(pub bool);

/// Debounce window for persisting window-geometry changes, in seconds.
///
/// `WindowResized` and `WindowMoved` fire continuously during a resize/
/// move drag, so writing to disk on every event would thrash the file
/// system. Instead the geometry-watch system records the pending value
/// and waits this long after the *last* event before saving.
pub const WINDOW_GEOMETRY_DEBOUNCE_SECS: f32 = 0.5;

/// Tracks a pending window-geometry change so the saver can debounce
/// `WindowResized` / `WindowMoved` storms during a resize / move drag.
#[derive(Resource, Debug, Default, Clone, Copy)]
pub struct PendingWindowGeometry {
    /// Most recent observed geometry. `None` when nothing is pending.
    pub geometry: Option<WindowGeometry>,
    /// `Time::elapsed_secs()` value at which `geometry` was last updated.
    pub last_changed_secs: f32,
}

/// Fired whenever settings change so consumers (audio, UI) can react.
#[derive(Message, Debug, Clone)]
pub struct SettingsChangedEvent(pub Settings);

/// System set for the systems that mutate [`SettingsResource`] every frame
/// (hotkeys and window-geometry persistence). Ordered before
/// [`crate::game_plugin::GameMutation`]; readers of settings should sit
/// after this set (directly, or transitively via `.after(GameMutation)`)
/// so they observe the current frame's settings deterministically (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct SettingsMutation;

/// Marker on the root Settings panel entity.
#[derive(Component, Debug)]
pub struct SettingsPanel;

/// Marks the `Text` node showing the live SFX volume value.
#[derive(Component, Debug)]
struct SfxVolumeText;

/// Marks the `Text` node showing the live music volume value.
#[derive(Component, Debug)]
struct MusicVolumeText;

/// Marks the `Text` node showing the current draw mode.
#[derive(Component, Debug)]
struct DrawModeText;

/// Marks the `Text` node showing the current theme.
#[derive(Component, Debug)]
struct ThemeText;

/// Marks the `Text` node showing the live sync status.
#[derive(Component, Debug)]
struct SyncStatusText;

/// Marks the `Text` node showing the active card-back index.
#[derive(Component, Debug)]
struct CardBackText;

/// Marks the `Text` node showing the current animation speed.
#[derive(Component, Debug)]
struct AnimSpeedText;

/// Marks the `Text` node showing the active background index.
#[derive(Component, Debug)]
struct BackgroundText;

/// Marks the `Text` node showing the current color-blind mode state.
#[derive(Component, Debug)]
struct ColorBlindText;

/// Marks the `Text` node showing the current high-contrast mode state.
#[derive(Component, Debug)]
struct HighContrastText;

/// Marks the `Text` node showing the current reduce-motion mode state.
#[derive(Component, Debug)]
struct ReduceMotionText;

/// Marks the `Text` node showing the current touch input mode state.
#[derive(Component, Debug)]
struct TouchInputModeText;

/// Marks the `Text` node showing the live tooltip-delay value.
#[derive(Component, Debug)]
struct TooltipDelayText;

/// Marks the `Text` node showing the live time-bonus-multiplier value.
#[derive(Component, Debug)]
struct TimeBonusMultiplierText;

/// Marks the `Text` node showing the live replay-playback per-move
/// interval value. The Gameplay-section row beside this label lets the
/// player tune `Settings::replay_move_interval_secs`.
#[derive(Component, Debug)]
struct ReplayMoveIntervalText;

/// Marks the `Text` node showing the current "Winnable deals only"
/// state ("ON" / "OFF") in the Gameplay section.
#[derive(Component, Debug)]
struct WinnableDealsOnlyText;

/// Marks the `Text` node showing the current "Smart window size"
/// state ("ON" / "OFF") in the Gameplay section. The flag is stored
/// negatively in `Settings::disable_smart_default_size`, so the
/// label inverts: "ON" = smart sizing enabled (the default).
#[derive(Component, Debug)]
struct SmartDefaultSizeText;

/// Marks the `Text` node showing the current "Share usage data" (analytics)
/// state ("ON" / "OFF") in the Privacy section.
#[derive(Component, Debug)]
struct AnalyticsEnabledText;

/// Marks the scrollable inner card so the mouse-wheel system can target it.
#[derive(Component, Debug)]
struct SettingsPanelScrollable;

/// Marks the scrollable inner card so its `ScrollPosition` can be read before despawn.
#[derive(Component, Debug)]
struct SettingsScrollNode;

/// Snapshot row used by [`spawn_settings_panel`] to render the card-art
/// theme picker. Carries the `ThemeRegistry` entry's display fields plus
/// the (optional) thumbnail pair from [`ThemeThumbnailCache`]. A `None`
/// thumbnail means the picker should render a placeholder swatch — used
/// when the cache hasn't generated handles yet, or when a user theme
/// is missing one of the required preview SVGs.
#[derive(Debug, Clone)]
struct ThemePickerEntry {
    /// Stable theme id (matches `ThemeMeta::id`).
    id: String,
    /// Player-facing label.
    display_name: String,
    /// Pre-generated picker preview pair, when ready. `None` collapses
    /// the chip to its plain-text fallback.
    thumbnails: Option<ThemeThumbnailPair>,
}

/// Tags interactive buttons inside the Settings panel.
#[derive(Component, Debug)]
enum SettingsButton {
    SfxDown,
    SfxUp,
    MusicDown,
    MusicUp,
    ToggleDrawMode,
    CycleAnimSpeed,
    /// Decrement the tooltip-hover dwell delay by one step.
    TooltipDelayDown,
    /// Increment the tooltip-hover dwell delay by one step.
    TooltipDelayUp,
    /// Decrement the cosmetic time-bonus multiplier by one step.
    TimeBonusDown,
    /// Increment the cosmetic time-bonus multiplier by one step.
    TimeBonusUp,
    /// Decrement the replay-playback per-move interval by one step
    /// (i.e. speed playback up).
    ReplayMoveIntervalDown,
    /// Increment the replay-playback per-move interval by one step
    /// (i.e. slow playback down).
    ReplayMoveIntervalUp,
    ToggleTheme,
    ToggleColorBlind,
    /// Toggle the [`Settings::high_contrast_mode`] flag — boosts
    /// foreground / suit-red glyphs to higher-luminance variants per
    /// `design-system.md` §Accessibility (#2).
    ToggleHighContrast,
    /// Toggle the [`Settings::reduce_motion_mode`] flag — suppresses
    /// non-essential motion (card-slide animations become instant
    /// snaps) per `design-system.md` §Accessibility (#3).
    ToggleReduceMotion,
    /// Toggle [`Settings::touch_input_mode`] between `OneTap`
    /// (auto-move on tap, default) and `TapToSelect` (first tap selects
    /// a card/stack, second tap on a target pile moves it).
    ToggleTouchInputMode,
    /// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
    /// random Classic-mode deals are filtered through
    /// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably
    /// winnable (or the retry cap is hit). Off by default.
    ToggleWinnableDealsOnly,
    /// Toggle the inverse of [`Settings::disable_smart_default_size`].
    /// When the visible label reads "ON", the launch-time window
    /// sizer scales the window to ~70 % of the primary monitor on a
    /// fresh install; "OFF" pins the literal 1280×800 baseline. The
    /// flag only affects launches without saved geometry — the
    /// player's last window size always wins.
    ToggleSmartDefaultSize,
    /// Toggle [`Settings::analytics_enabled`]. Only rendered when a
    /// Matomo URL is configured.
    ToggleAnalytics,
    /// Scan `user_theme_dir()` for new `.zip` files and import each one.
    #[cfg(not(target_arch = "wasm32"))]
    ScanThemes,
    SyncNow,
    /// Open the sync-server Connect modal (shown when backend = Local).
    ConnectSync,
    /// Disconnect from the sync server (shown when backend = SolitaireServer).
    DisconnectSync,
    /// Open the account-deletion confirmation modal.
    DeleteAccount,
    Done,
    /// Select a specific card-back by index from the picker row.
    SelectCardBack(usize),
    /// Select a specific background by index from the picker row.
    SelectBackground(usize),
    /// Select a specific card-art theme by `meta.id` from the
    /// `ThemeRegistry`. The string is owned so the click handler can
    /// hand it directly to `Settings::selected_theme_id`.
    SelectTheme(String),
}

impl SettingsButton {
    /// Tab-walk priority — lower numbers visited first. Visual reading
    /// order is top-to-bottom by section, left-to-right inside each row.
    /// Two buttons in the same picker row receive the same `order`;
    /// `handle_focus_keys` then breaks ties by entity index, which
    /// matches `Children` spawn order inside each row.
    fn focus_order(&self) -> i32 {
        match self {
            // Audio section
            SettingsButton::SfxDown => 10,
            SettingsButton::SfxUp => 11,
            SettingsButton::MusicDown => 20,
            SettingsButton::MusicUp => 21,
            // Gameplay section
            SettingsButton::ToggleDrawMode => 30,
            SettingsButton::ToggleWinnableDealsOnly => 35,
            SettingsButton::CycleAnimSpeed => 40,
            SettingsButton::TooltipDelayDown => 45,
            SettingsButton::TooltipDelayUp => 46,
            SettingsButton::TimeBonusDown => 47,
            SettingsButton::TimeBonusUp => 48,
            // Replay-speed slider — last Gameplay-section row, so it
            // sits between TimeBonusUp (48) and the Cosmetic section.
            SettingsButton::ReplayMoveIntervalDown => 49,
            SettingsButton::ReplayMoveIntervalUp => 49,
            // Smart-default-size toggle — sits at the end of Gameplay.
            SettingsButton::ToggleSmartDefaultSize => 50,
            // Privacy section — just before Sync.
            SettingsButton::ToggleAnalytics => 89,
            // Cosmetic section
            SettingsButton::ToggleTheme => 55,
            SettingsButton::ToggleColorBlind => 60,
            // Accessibility-section toggles sit alongside Color-blind so
            // tab-walk visits all three a11y flags in the same vertical
            // run before continuing to the picker rows.
            SettingsButton::ToggleHighContrast => 61,
            SettingsButton::ToggleReduceMotion => 62,
            SettingsButton::ToggleTouchInputMode => 63,
            // Picker rows — every swatch in a row shares the row's
            // priority so entity-index tiebreaking yields left → right.
            SettingsButton::SelectCardBack(_) => 70,
            SettingsButton::SelectBackground(_) => 80,
            SettingsButton::SelectTheme(_) => 85,
            #[cfg(not(target_arch = "wasm32"))]
            SettingsButton::ScanThemes => 86,
            // Sync section
            SettingsButton::SyncNow => 90,
            SettingsButton::ConnectSync => 91,
            SettingsButton::DisconnectSync => 92,
            SettingsButton::DeleteAccount => 93,
            // Done is tagged by `attach_focusable_to_modal_buttons` and
            // never reaches `attach_focusable_to_settings_buttons`; the
            // value here is only a fallback for completeness.
            SettingsButton::Done => 100,
        }
    }
}

/// Plugin that owns the settings lifecycle.
pub struct SettingsPlugin {
    /// Path to `settings.json`. `None` in headless/test mode.
    pub storage_path: Option<PathBuf>,
    /// When `false`, panel spawn/despawn systems are not registered.
    /// Use [`SettingsPlugin::headless`] for tests running under `MinimalPlugins`.
    pub ui_enabled: bool,
}

impl Default for SettingsPlugin {
    fn default() -> Self {
        Self {
            storage_path: settings_file_path(),
            ui_enabled: true,
        }
    }
}

impl SettingsPlugin {
    /// No persistence, no UI — safe to use under `MinimalPlugins` in tests.
    pub fn headless() -> Self {
        Self {
            storage_path: None,
            ui_enabled: false,
        }
    }
}

impl Plugin for SettingsPlugin {
    fn build(&self, app: &mut App) {
        let loaded = match &self.storage_path {
            Some(path) => load_settings_from(path),
            None => Settings::default(),
        };
        app.insert_resource(SettingsResource(loaded))
            .insert_resource(SettingsStoragePath(self.storage_path.clone()))
            .init_resource::<SettingsScreen>()
            .init_resource::<SettingsScrollPos>()
            .init_resource::<PendingWindowGeometry>()
            .add_message::<SettingsChangedEvent>()
            .add_message::<ManualSyncRequestEvent>()
            .add_message::<SyncConfigureRequestEvent>()
            .add_message::<SyncLogoutRequestEvent>()
            .add_message::<DeleteAccountRequestEvent>()
            .add_message::<ToggleSettingsRequestEvent>()
            .add_message::<InfoToastEvent>()
            .add_message::<MouseWheel>()
            .add_message::<TouchInput>()
            // `WindowResized` / `WindowMoved` are real Bevy window events
            // and emitted by the windowing backend under `DefaultPlugins`,
            // but we register them explicitly here so the geometry watcher
            // also runs cleanly under `MinimalPlugins` (tests).
            .add_message::<WindowResized>()
            .add_message::<WindowMoved>()
            // Settings changes land before game logic runs: the mutator
            // chain (volume keys → geometry record → geometry persist) is a
            // deterministic spine, and the whole set precedes GameMutation so
            // every reader already ordered after GameMutation sees this
            // frame's settings transitively (ambiguity burn-down, #143).
            .configure_sets(
                Update,
                SettingsMutation
                    .after(crate::layout::LayoutSystem::UpdateOnResize)
                    .before(crate::game_plugin::GameMutation),
            )
            .add_systems(
                Update,
                (
                    handle_volume_keys,
                    record_window_geometry_changes,
                    persist_window_geometry_after_debounce,
                )
                    .chain()
                    .in_set(SettingsMutation),
            )
            .add_systems(
                Update,
                (
                    toggle_settings_screen
                        .before(crate::ui_focus::FocusKeys)
                        .ambiguous_with(crate::hud_plugin::HudButtons),
                    scroll_settings_panel,
                    crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>,
                )
                    .chain(),
            );

        if self.ui_enabled {
            app.add_systems(
                Update,
                (
                    sync_settings_panel_visibility,
                    handle_settings_buttons,
                    handle_sync_buttons,
                    #[cfg(not(target_arch = "wasm32"))]
                    handle_scan_themes,
                    update_sync_status_text,
                    update_card_back_text,
                    update_background_text,
                    update_anim_speed_text,
                    update_color_blind_text,
                    update_high_contrast_text,
                    update_high_contrast_borders.run_if(resource_changed::<SettingsResource>),
                    update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>),
                    update_reduce_motion_text,
                    update_touch_input_mode_text,
                    update_tooltip_delay_text,
                    update_time_bonus_multiplier_text,
                    update_replay_move_interval_text,
                    update_winnable_deals_only_text,
                    update_smart_default_size_text,
                ),
            );
            app.add_systems(
                Update,
                (
                    update_analytics_enabled_text,
                    attach_focusable_to_settings_buttons,
                ),
            );
            app.add_systems(Update, scroll_focus_into_view);
        }
    }
}

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

fn persist(path: &SettingsStoragePath, settings: &Settings) {
    let Some(target) = &path.0 else { return };
    if let Err(e) = save_settings_to(target, settings) {
        warn!("failed to save settings: {e}");
    }
}

/// Pure helper: returns `true` when a pending geometry change has sat
/// quietly long enough to flush to disk.
///
/// Extracted so the debounce condition can be unit-tested without
/// spinning up a Bevy app.
fn should_persist_geometry(now_secs: f32, last_changed_secs: f32) -> bool {
    (now_secs - last_changed_secs) >= WINDOW_GEOMETRY_DEBOUNCE_SECS
}

/// Returns the geometry implied by an event pair `(width, height, x, y)`,
/// using each component from `existing` when the corresponding event-derived
/// value is `None`. Returns `None` when neither side supplies width/height.
///
/// Pure helper so the merge logic can be unit-tested without an `App`.
fn merge_geometry(
    existing: Option<WindowGeometry>,
    new_size: Option<(u32, u32)>,
    new_pos: Option<(i32, i32)>,
) -> Option<WindowGeometry> {
    let (width, height) = new_size.or_else(|| existing.map(|g| (g.width, g.height)))?;
    let (x, y) = new_pos
        .or_else(|| existing.map(|g| (g.x, g.y)))
        .unwrap_or((0, 0));
    Some(WindowGeometry {
        width,
        height,
        x,
        y,
    })
}

// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------

/// Returns the next unlocked index after `current` in the sorted `unlocked` list.
/// Wraps around. Falls back to `unlocked[0]` if `current` is not found.
#[cfg(test)]
fn cycle_unlocked(unlocked: &[usize], current: usize) -> usize {
    if unlocked.is_empty() {
        return 0;
    }
    let pos = unlocked.iter().position(|&i| i == current).unwrap_or(0);
    unlocked[(pos + 1) % unlocked.len()]
}

/// Records `WindowResized` and `WindowMoved` events into
/// [`PendingWindowGeometry`], coalescing every event arriving this frame
/// into the latest pending geometry.
///
/// The actual disk write is debounced — see
/// [`persist_window_geometry_after_debounce`] — so the file system isn't
/// hit on every pixel of a resize / move drag.
fn record_window_geometry_changes(
    time: Res<Time>,
    mut resized: MessageReader<WindowResized>,
    mut moved: MessageReader<WindowMoved>,
    settings: Res<SettingsResource>,
    mut pending: ResMut<PendingWindowGeometry>,
) {
    // Read .last() — only the final event matters for persistence; the
    // intermediate sizes/positions are noise during a drag.
    let new_size = resized.read().last().map(|ev| {
        (
            ev.width.round().max(0.0) as u32,
            ev.height.round().max(0.0) as u32,
        )
    });
    let new_pos = moved.read().last().map(|ev| (ev.position.x, ev.position.y));

    if new_size.is_none() && new_pos.is_none() {
        return;
    }

    // Fold the new components into the existing pending value (if any),
    // otherwise into the persisted geometry from settings.
    let baseline = pending.geometry.or(settings.0.window_geometry);
    let Some(geometry) = merge_geometry(baseline, new_size, new_pos) else {
        return;
    };

    pending.geometry = Some(geometry);
    pending.last_changed_secs = time.elapsed_secs();
}

/// After [`WINDOW_GEOMETRY_DEBOUNCE_SECS`] of quiet (no `WindowResized` or
/// `WindowMoved` events arriving), commits the pending geometry to
/// `SettingsResource` and writes `settings.json`. Skips the write when the
/// pending value already matches the settings (e.g. a resize that was
/// reverted, or a synthetic event with no geometry change).
fn persist_window_geometry_after_debounce(
    time: Res<Time>,
    mut pending: ResMut<PendingWindowGeometry>,
    mut settings: ResMut<SettingsResource>,
    path: Res<SettingsStoragePath>,
    mut changed: MessageWriter<SettingsChangedEvent>,
) {
    let Some(new_geom) = pending.geometry else {
        return;
    };
    if !should_persist_geometry(time.elapsed_secs(), pending.last_changed_secs) {
        return;
    }

    // Always clear the pending slot regardless of whether we end up
    // writing — otherwise an idempotent change would re-trigger this
    // system every tick.
    pending.geometry = None;

    if settings.0.window_geometry == Some(new_geom) {
        return;
    }
    settings.0.window_geometry = Some(new_geom);
    persist(&path, &settings.0);
    changed.write(SettingsChangedEvent(settings.0.clone()));
}

// ---------------------------------------------------------------------------
// UI construction
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests;
