use super::*;
use crate::ui_focus::{FocusRow, Focusable};
use crate::ui_modal::ModalButton;
use crate::ui_tooltip::Tooltip;

fn headless_app() -> App {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
        .add_plugins(SettingsPlugin::headless());
    app.init_resource::<ButtonInput<KeyCode>>();
    app.update();
    app
}

fn press(app: &mut App, key: KeyCode) {
    let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
    input.release(key);
    input.clear();
    input.press(key);
}

#[test]
fn defaults_are_loaded() {
    let app = headless_app();
    assert_eq!(
        app.world().resource::<SettingsResource>().0,
        Settings::default()
    );
}

#[test]
fn pressing_left_bracket_decreases_volume_and_emits_event() {
    let mut app = headless_app();
    let before = app.world().resource::<SettingsResource>().0.sfx_volume;

    press(&mut app, KeyCode::BracketLeft);
    app.update();

    let after = app.world().resource::<SettingsResource>().0.sfx_volume;
    assert!(after < before);

    let events = app.world().resource::<Messages<SettingsChangedEvent>>();
    let mut cursor = events.get_cursor();
    assert_eq!(cursor.read(events).count(), 1);
}

#[test]
fn pressing_right_bracket_increases_volume() {
    let mut app = headless_app();
    app.world_mut()
        .resource_mut::<SettingsResource>()
        .0
        .sfx_volume = 0.5;

    press(&mut app, KeyCode::BracketRight);
    app.update();

    let after = app.world().resource::<SettingsResource>().0.sfx_volume;
    assert!((after - 0.6).abs() < 1e-3);
}

#[test]
fn clamped_change_does_not_emit_event() {
    let mut app = headless_app();
    app.world_mut()
        .resource_mut::<SettingsResource>()
        .0
        .sfx_volume = 1.0;

    press(&mut app, KeyCode::BracketRight);
    app.update();

    let events = app.world().resource::<Messages<SettingsChangedEvent>>();
    let mut cursor = events.get_cursor();
    assert_eq!(cursor.read(events).count(), 0);
}

#[test]
fn volume_clamped_at_zero_does_not_emit_event() {
    let mut app = headless_app();
    app.world_mut()
        .resource_mut::<SettingsResource>()
        .0
        .sfx_volume = 0.0;

    press(&mut app, KeyCode::BracketLeft);
    app.update();

    let after = app.world().resource::<SettingsResource>().0.sfx_volume;
    assert!(after >= 0.0, "volume must not go below zero");

    let events = app.world().resource::<Messages<SettingsChangedEvent>>();
    let mut cursor = events.get_cursor();
    assert_eq!(
        cursor.read(events).count(),
        0,
        "no event when clamped at floor"
    );
}

#[test]
fn pressing_o_toggles_settings_screen_flag() {
    let mut app = headless_app();
    assert!(
        !app.world().resource::<SettingsScreen>().0,
        "screen is closed initially"
    );

    press(&mut app, KeyCode::KeyO);
    app.update();
    assert!(
        app.world().resource::<SettingsScreen>().0,
        "O opens settings"
    );

    press(&mut app, KeyCode::KeyO);
    app.update();
    assert!(
        !app.world().resource::<SettingsScreen>().0,
        "second O closes settings"
    );
}

// cycle_unlocked pure-function tests
#[test]
fn cycle_unlocked_wraps_at_end() {
    // [0, 1, 2] → cycling from 2 wraps to 0
    assert_eq!(cycle_unlocked(&[0, 1, 2], 2), 0);
}

#[test]
fn cycle_unlocked_advances_normally() {
    assert_eq!(cycle_unlocked(&[0, 1, 2], 0), 1);
    assert_eq!(cycle_unlocked(&[0, 1, 2], 1), 2);
}

#[test]
fn cycle_unlocked_single_element_stays() {
    // Only one unlockable — cycling always returns it.
    assert_eq!(cycle_unlocked(&[0], 0), 0);
}

#[test]
fn cycle_unlocked_current_not_in_list_falls_back_to_second() {
    // current=5 is not in [0,1,2]; falls back to pos=0, so next = unlocked[1] = 1
    assert_eq!(cycle_unlocked(&[0, 1, 2], 5), 1);
}

#[test]
fn cycle_unlocked_empty_returns_zero() {
    assert_eq!(cycle_unlocked(&[], 0), 0);
}

#[test]
fn scroll_is_noop_when_settings_panel_closed() {
    use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
    let mut app = headless_app();
    // Panel starts closed (SettingsScreen(false)); spawn a scrollable entity.
    let entity = app
        .world_mut()
        .spawn((SettingsPanelScrollable, ScrollPosition::default()))
        .id();
    // Send a downward scroll event while the panel is closed.
    app.world_mut().write_message(MouseWheel {
        unit: MouseScrollUnit::Line,
        x: 0.0,
        y: -3.0,
        window: Entity::PLACEHOLDER,
    });
    app.update();
    // ScrollPosition must remain at 0.0 — panel was closed.
    let offset = app
        .world()
        .entity(entity)
        .get::<ScrollPosition>()
        .unwrap()
        .0
        .y;
    assert_eq!(offset, 0.0, "scroll must not move when panel is closed");
}

#[test]
fn scroll_moves_offset_when_panel_open() {
    use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
    let mut app = headless_app();
    // Open the panel.
    app.world_mut().resource_mut::<SettingsScreen>().0 = true;
    // Spawn a scrollable entity with an existing offset so we can distinguish clamping.
    let entity = app
        .world_mut()
        .spawn((
            SettingsPanelScrollable,
            ScrollPosition(Vec2::new(0.0, 100.0)),
        ))
        .id();
    // Scroll down by 2 lines (50 px/line → +100 px added to offset_y).
    app.world_mut().write_message(MouseWheel {
        unit: MouseScrollUnit::Line,
        x: 0.0,
        y: -2.0,
        window: Entity::PLACEHOLDER,
    });
    app.update();
    let offset = app
        .world()
        .entity(entity)
        .get::<ScrollPosition>()
        .unwrap()
        .0
        .y;
    assert!(
        (offset - 200.0).abs() < 1e-3,
        "scrolling down should increase offset_y; got {offset}"
    );
}

// -----------------------------------------------------------------------
// Phase 3 — keyboard focus ring, Settings buttons + FocusRow
// -----------------------------------------------------------------------

/// Headless app that runs the *real* (UI-enabled) `SettingsPlugin`
/// alongside `UiModalPlugin` and `UiFocusPlugin`, so the spawn /
/// auto-tag systems fire end-to-end without writing to disk.
fn headless_app_with_focus() -> App {
    use crate::ui_focus::UiFocusPlugin;
    use crate::ui_modal::UiModalPlugin;

    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
        .add_plugins(UiModalPlugin)
        .add_plugins(UiFocusPlugin)
        .add_plugins(SettingsPlugin {
            // No persistence — keep the test isolated.
            storage_path: None,
            ui_enabled: true,
        });
    app.init_resource::<ButtonInput<KeyCode>>();
    app.update();
    app
}

#[test]
fn settings_buttons_get_focusable_marker() {
    let mut app = headless_app_with_focus();

    // Open the panel.
    app.world_mut().resource_mut::<SettingsScreen>().0 = true;
    app.update();
    // Two more ticks: the first runs `sync_settings_panel_visibility`
    // and queues the spawn commands; the second flushes them and
    // runs `attach_focusable_to_settings_buttons`.
    app.update();
    app.update();

    // Every bespoke `SettingsButton` (not `Done`, which is also a
    // `ModalButton`) must carry a `Focusable`.
    let untagged: Vec<&SettingsButton> = app
        .world_mut()
        .query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
        .iter(app.world())
        .collect();

    assert!(
        untagged.is_empty(),
        "every bespoke Settings button must carry Focusable; missing: {:?}",
        untagged
    );

    // And there must be at least one tagged `SettingsButton` so the
    // assertion above isn't vacuously true (the panel really did
    // spawn).
    let tagged_count = app
        .world_mut()
        .query_filtered::<&SettingsButton, With<Focusable>>()
        .iter(app.world())
        .count();
    assert!(
        tagged_count >= 6,
        "expected the panel to spawn many bespoke buttons (volume up/down ×2, toggles ×4, sync, swatches…); got {tagged_count}"
    );
}

/// Every bespoke `SettingsButton` (volume +/−, toggles, swatches,
/// Sync Now) must spawn with a `Tooltip` so the glyph-only icons and
/// indexed swatches carry hover-reveal context. Mirrors
/// `settings_buttons_get_focusable_marker` (Phase 3 focus test) so
/// the invariant — every interactive Settings element except the
/// `Done` modal button has a tooltip — is asserted consistently.
#[test]
fn settings_buttons_carry_tooltip() {
    let mut app = headless_app_with_focus();

    // Open the panel and let spawn + child-flush run.
    app.world_mut().resource_mut::<SettingsScreen>().0 = true;
    app.update();
    app.update();
    app.update();

    // No bespoke `SettingsButton` (i.e. excluding `Done`, which is
    // also a `ModalButton`) may be missing a `Tooltip`.
    let untipped: Vec<&SettingsButton> = app
        .world_mut()
        .query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
        .iter(app.world())
        .collect();
    assert!(
        untipped.is_empty(),
        "every bespoke Settings button must carry Tooltip; missing: {:?}",
        untipped
    );

    // And there must be at least 6 tipped buttons so the assertion
    // above isn't vacuously true: SFX +/−, Music +/−, Draw Mode,
    // Anim Speed, Theme, Color-blind, Sync Now, plus at least one
    // card-back and one background swatch — well over the floor.
    let tipped_count = app
        .world_mut()
        .query_filtered::<&SettingsButton, With<Tooltip>>()
        .iter(app.world())
        .count();
    assert!(
        tipped_count >= 6,
        "expected the panel to spawn many tooltipped buttons; got {tipped_count}"
    );

    // Spot-check: with default (Local) settings the Connect button
    // spawns. We verify its tooltip carries the canonical microcopy.
    let connect_tip = app
        .world_mut()
        .query::<(&SettingsButton, &Tooltip)>()
        .iter(app.world())
        .find_map(|(btn, tip)| matches!(btn, SettingsButton::ConnectSync).then(|| tip.0.clone()))
        .expect("Connect button should spawn with a Tooltip when backend is Local");
    assert_eq!(
        connect_tip.as_ref(),
        "Connect to a self-hosted Ferrous Solitaire sync server.",
        "ConnectSync tooltip must use the canonical microcopy"
    );
}

#[test]
fn settings_picker_rows_get_focus_row_marker() {
    let mut app = headless_app_with_focus();

    app.world_mut().resource_mut::<SettingsScreen>().0 = true;
    app.update();
    app.update();
    app.update();

    // Two picker rows are spawned (card-back + background); each
    // must carry the FocusRow marker.
    let row_count = app
        .world_mut()
        .query_filtered::<Entity, With<FocusRow>>()
        .iter(app.world())
        .count();
    assert!(
        row_count >= 2,
        "expected at least two FocusRow containers (card-back + background); got {row_count}"
    );
}

/// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has
/// at least one theme and the [`ThemeThumbnailCache`] holds a
/// fully-populated [`ThemeThumbnailPair`] for that theme's id, the
/// rendered chip carries a [`ThemeThumbnailMarker`]-tagged
/// `ImageNode` for each preview slot.
#[test]
fn theme_picker_chip_includes_thumbnail_sprite_when_thumbnails_loaded() {
    use crate::theme::{ThemeEntry, ThemeRegistry, ThemeThumbnailCache, ThemeThumbnailPair};

    let mut app = headless_app_with_focus();
    // Prime an Assets<Image> resource so we can mint stable handles
    // for the synthetic thumbnail pair.
    app.init_resource::<Assets<Image>>();
    let (ace_handle, back_handle) = {
        let mut images = app.world_mut().resource_mut::<Assets<Image>>();
        let ace = images.add(Image::default());
        let back = images.add(Image::default());
        (ace, back)
    };
    // Inject one theme entry + a matching thumbnail pair.
    app.insert_resource(ThemeRegistry {
        entries: vec![ThemeEntry {
            id: "test_theme".into(),
            display_name: "Test Theme".into(),
            manifest_url: "themes://test_theme/theme.ron".into(),
            meta: crate::theme::ThemeMeta {
                id: "test_theme".into(),
                name: "Test Theme".into(),
                author: "x".into(),
                version: "x".into(),
                card_aspect: (2, 3),
            },
        }],
    });
    let mut cache = ThemeThumbnailCache::default();
    cache.entries.insert(
        "test_theme".into(),
        ThemeThumbnailPair {
            ace: ace_handle.clone(),
            back: back_handle.clone(),
        },
    );
    app.insert_resource(cache);

    // Open the panel and let the spawn + child-flush systems run.
    app.world_mut().resource_mut::<SettingsScreen>().0 = true;
    app.update();
    app.update();
    app.update();

    // Find every ImageNode tagged with ThemeThumbnailMarker — the
    // theme picker chip for "test_theme" must contribute exactly
    // two of them (ace + back).
    let thumbnail_count = app
        .world_mut()
        .query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
        .iter(app.world())
        .count();
    assert!(
        thumbnail_count >= 2,
        "expected at least one ace + back thumbnail (2 sprites); got {thumbnail_count}"
    );

    // Spot-check: at least one thumbnail's image handle matches one
    // of the ones we inserted into the cache. This guards against a
    // future refactor that accidentally clones the wrong handle.
    let any_matches = app
        .world_mut()
        .query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
        .iter(app.world())
        .any(|node| node.image == ace_handle || node.image == back_handle);
    assert!(
        any_matches,
        "at least one rendered thumbnail must reuse the cached handle"
    );
}

// -----------------------------------------------------------------------
// Window geometry persistence
// -----------------------------------------------------------------------

#[test]
fn should_persist_geometry_respects_debounce_window() {
    // Within the debounce window: not yet.
    assert!(!should_persist_geometry(10.0, 9.7));
    assert!(!should_persist_geometry(
        10.0,
        10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS + 0.01
    ));
    // Exactly the debounce window: allowed (>= comparison).
    assert!(should_persist_geometry(
        10.0,
        10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS
    ));
    // Well past the debounce window: allowed.
    assert!(should_persist_geometry(20.0, 10.0));
}

#[test]
fn merge_geometry_uses_existing_when_event_components_missing() {
    let existing = WindowGeometry {
        width: 1280,
        height: 800,
        x: 100,
        y: 50,
    };
    // Position-only event keeps existing size.
    let merged = merge_geometry(Some(existing), None, Some((200, 75))).unwrap();
    assert_eq!(merged.width, 1280);
    assert_eq!(merged.height, 800);
    assert_eq!(merged.x, 200);
    assert_eq!(merged.y, 75);
    // Size-only event keeps existing position.
    let merged = merge_geometry(Some(existing), Some((1024, 768)), None).unwrap();
    assert_eq!(merged.width, 1024);
    assert_eq!(merged.height, 768);
    assert_eq!(merged.x, 100);
    assert_eq!(merged.y, 50);
}

#[test]
fn merge_geometry_returns_none_when_size_unknown() {
    // No existing geometry, no size in the event → can't fabricate one.
    assert!(merge_geometry(None, None, Some((10, 20))).is_none());
}

/// Drives `app.update()` past [`WINDOW_GEOMETRY_DEBOUNCE_SECS`] using
/// `TimeUpdateStrategy::ManualDuration`. `Time<Virtual>` clamps each
/// frame's delta to `max_delta` (default 250 ms), so we step in 150 ms
/// slices and run enough ticks to comfortably exceed the debounce
/// window after the first record tick has set `last_changed_secs`.
fn advance_past_geometry_debounce(app: &mut App) {
    use bevy::time::TimeUpdateStrategy;
    use std::time::Duration;
    app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
        0.15,
    )));
    // Tick 1 sets last_changed_secs from any pending events. Each
    // subsequent tick advances the clock by 150 ms; five ticks total
    // buys 0.75 s of elapsed time relative to the record tick — well
    // past the 0.5 s debounce window.
    for _ in 0..5 {
        app.update();
    }
}

fn fire_resize(app: &mut App, width: f32, height: f32) {
    app.world_mut().write_message(WindowResized {
        window: Entity::PLACEHOLDER,
        width,
        height,
    });
}

fn fire_move(app: &mut App, x: i32, y: i32) {
    app.world_mut().write_message(WindowMoved {
        window: Entity::PLACEHOLDER,
        position: IVec2::new(x, y),
    });
}

#[test]
fn resize_event_then_quiet_persists_window_geometry() {
    let mut app = headless_app();
    // Sanity: geometry starts unset (default).
    assert!(
        app.world()
            .resource::<SettingsResource>()
            .0
            .window_geometry
            .is_none()
    );

    // Fire a resize, then go quiet for past the debounce.
    fire_resize(&mut app, 1500.0, 950.0);
    advance_past_geometry_debounce(&mut app);

    let geom = app
        .world()
        .resource::<SettingsResource>()
        .0
        .window_geometry
        .expect("geometry should be persisted after debounce");
    assert_eq!(geom.width, 1500);
    assert_eq!(geom.height, 950);
    // Position not yet observed → defaults to 0, 0 since there was
    // no existing geometry to fall back on.
    assert_eq!(geom.x, 0);
    assert_eq!(geom.y, 0);
}

#[test]
fn move_event_after_resize_updates_position_only() {
    let mut app = headless_app();

    // First, establish a baseline geometry via a resize event.
    fire_resize(&mut app, 1280.0, 800.0);
    advance_past_geometry_debounce(&mut app);
    let baseline = app
        .world()
        .resource::<SettingsResource>()
        .0
        .window_geometry
        .unwrap();
    assert_eq!(baseline.width, 1280);

    // Now fire a move-only event — size must be preserved from the
    // existing geometry.
    fire_move(&mut app, 250, 175);
    advance_past_geometry_debounce(&mut app);

    let geom = app
        .world()
        .resource::<SettingsResource>()
        .0
        .window_geometry
        .unwrap();
    assert_eq!(
        geom.width, 1280,
        "size must be preserved across a move-only update"
    );
    assert_eq!(geom.height, 800);
    assert_eq!(geom.x, 250);
    assert_eq!(geom.y, 175);
}

#[test]
fn rapid_resize_storm_only_persists_final_size() {
    let mut app = headless_app();

    // Burst of resize events on a single frame — only the last one
    // should be the eventually-persisted size.
    fire_resize(&mut app, 900.0, 600.0);
    fire_resize(&mut app, 1100.0, 700.0);
    fire_resize(&mut app, 1400.0, 850.0);
    advance_past_geometry_debounce(&mut app);

    let geom = app
        .world()
        .resource::<SettingsResource>()
        .0
        .window_geometry
        .unwrap();
    assert_eq!((geom.width, geom.height), (1400, 850));
}

#[test]
fn no_window_events_no_geometry_change() {
    let mut app = headless_app();
    // Just advance time — without any events, settings must stay clean.
    advance_past_geometry_debounce(&mut app);
    assert!(
        app.world()
            .resource::<SettingsResource>()
            .0
            .window_geometry
            .is_none()
    );
}

#[test]
fn scroll_clamps_offset_to_zero_at_top() {
    use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
    let mut app = headless_app();
    app.world_mut().resource_mut::<SettingsScreen>().0 = true;
    // Entity starts at 10 px offset.
    let entity = app
        .world_mut()
        .spawn((
            SettingsPanelScrollable,
            ScrollPosition(Vec2::new(0.0, 10.0)),
        ))
        .id();
    // Scroll up by 5 lines → would subtract 250 px, but must clamp to 0.
    app.world_mut().write_message(MouseWheel {
        unit: MouseScrollUnit::Line,
        x: 0.0,
        y: 5.0,
        window: Entity::PLACEHOLDER,
    });
    app.update();
    let offset = app
        .world()
        .entity(entity)
        .get::<ScrollPosition>()
        .unwrap()
        .0
        .y;
    assert_eq!(
        offset, 0.0,
        "scrolling past top must clamp to 0, got {offset}"
    );
}
