//! In-game theme store: browse the sync server's theme catalog and
//! install themes without leaving the app.
//!
//! Settings → Cosmetic → "Browse theme store" fires
//! [`crate::events::ThemeStoreOpenRequestEvent`]; this plugin opens a
//! modal listing the catalog (fetched on [`AsyncComputeTaskPool`]) and
//! handles per-theme Install buttons. An install downloads the archive
//! (SHA-256-verified by [`ThemeStoreClient`]), writes it atomically
//! into `user_theme_dir()`, and runs it through the same hardened
//! [`import_theme`] pipeline as a manually dropped zip — the store
//! never bypasses the importer's validation.
//!
//! Native-only: downloads need `reqwest`/Tokio and the importer is
//! filesystem-based; the plugin is gated out on wasm32 alongside
//! `SyncPlugin`.

use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use thiserror::Error;

use solitaire_data::theme_store_client::{ThemeStoreClient, ThemeStoreError};
use solitaire_data::{Settings, SyncBackend};
use solitaire_sync::ThemeCatalogEntry;

use crate::assets::user_theme_dir;
use crate::events::{InfoToastEvent, ThemeStoreOpenRequestEvent, WarningToastEvent};
use crate::font_plugin::FontResource;
use crate::resources::TokioRuntimeResource;
use crate::settings_plugin::{SettingsPanel, SettingsResource};
use crate::theme::{ImportError, ThemeRegistry, import_theme, refresh_registry};
use crate::ui_modal::{
    ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
    spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
    BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3,
    Z_MODAL_PANEL,
};

// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------

/// Everything that can go wrong between clicking Install and the theme
/// appearing in the picker.
#[derive(Debug, Error)]
enum ThemeInstallError {
    /// Catalog fetch or archive download failed (network, HTTP status,
    /// size/checksum verification).
    #[error(transparent)]
    Download(#[from] ThemeStoreError),
    /// The verified archive could not be written into the themes dir.
    #[error("could not save archive: {0}")]
    Io(#[from] std::io::Error),
    /// The downloaded archive failed importer validation.
    #[error(transparent)]
    Import(#[from] ImportError),
}

// ---------------------------------------------------------------------------
// Components & resources
// ---------------------------------------------------------------------------

/// Marker on the theme-store modal's scrim root.
#[derive(Component)]
pub struct ThemeStoreScreen;

/// Marker on the modal's Close button.
#[derive(Component)]
struct ThemeStoreCloseButton;

/// Per-row Install button carrying its catalog entry.
#[derive(Component)]
struct ThemeStoreInstallButton(ThemeCatalogEntry);

/// Catalog fetch lifecycle. The modal renders directly from this state
/// and is rebuilt (despawn + respawn, mirroring the leaderboard panel)
/// whenever it changes.
#[derive(Resource, Default)]
enum CatalogState {
    /// No fetch has run since the modal was last opened.
    #[default]
    Idle,
    Loading,
    Loaded(Vec<ThemeCatalogEntry>),
    Error(String),
}

/// In-flight catalog fetch, polled without blocking.
#[derive(Resource, Default)]
struct CatalogTask(Option<Task<Result<Vec<ThemeCatalogEntry>, ThemeStoreError>>>);

/// Result of one download + import: `Ok(Some(id))` on a fresh install,
/// `Ok(None)` when the theme was already installed (id collision) —
/// informational, not an error.
type InstallResult = Result<Option<String>, ThemeInstallError>;

/// In-flight download + import. `String` is the theme id, for toasts.
#[derive(Resource, Default)]
struct InstallTask(Option<(String, Task<InstallResult>)>);

/// Server base URL captured when the modal opens. Catalog
/// `download_url`s are server-relative, so installs join them onto
/// this. `None` while the modal has never been opened.
#[derive(Resource, Default)]
struct StoreBaseUrl(Option<String>);

// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------

/// Bevy plugin for the in-game theme store. See the module docs.
pub struct ThemeStorePlugin;

impl Plugin for ThemeStorePlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<CatalogState>()
            .init_resource::<CatalogTask>()
            .init_resource::<InstallTask>()
            .init_resource::<StoreBaseUrl>()
            .add_message::<ThemeStoreOpenRequestEvent>()
            .add_message::<InfoToastEvent>()
            .add_message::<WarningToastEvent>()
            .add_systems(
                Update,
                (
                    handle_open_request,
                    poll_catalog_task,
                    handle_install_buttons,
                    poll_install_task,
                    handle_close_button,
                )
                    .chain(),
            );
    }
}

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

/// Resolves the store's server base URL from the player's sync
/// backend. The store rides the same self-hosted server as sync;
/// without one configured there is nothing to browse.
fn store_base_url(settings: &Settings) -> Option<String> {
    match &settings.sync_backend {
        SyncBackend::SolitaireServer { url, .. } => Some(url.clone()),
        _ => None,
    }
}

/// Opens the store modal and kicks off the catalog fetch.
///
/// Mirrors `open_sync_setup_modal`: the Settings button closes the
/// settings panel in the same frame it fires the event, and despawns
/// are deferred, so the settings scrim is excluded from the
/// another-modal-is-open guard.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn handle_open_request(
    mut events: MessageReader<ThemeStoreOpenRequestEvent>,
    existing: Query<(), With<ThemeStoreScreen>>,
    other_modal_scrims: Query<
        (),
        (
            With<ModalScrim>,
            Without<ThemeStoreScreen>,
            Without<SettingsPanel>,
        ),
    >,
    settings: Option<Res<SettingsResource>>,
    rt: Option<Res<TokioRuntimeResource>>,
    mut catalog_state: ResMut<CatalogState>,
    mut catalog_task: ResMut<CatalogTask>,
    mut store_base: ResMut<StoreBaseUrl>,
    mut warning_toast: MessageWriter<WarningToastEvent>,
    mut commands: Commands,
    font_res: Option<Res<FontResource>>,
) {
    if events.is_empty() {
        return;
    }
    events.clear();
    if !existing.is_empty() || !other_modal_scrims.is_empty() {
        return;
    }

    let Some(base_url) = settings.as_deref().and_then(|s| store_base_url(&s.0)) else {
        warning_toast.write(WarningToastEvent(
            "Theme store needs a server — connect one in Settings → Sync.".to_string(),
        ));
        return;
    };
    let Some(rt) = rt else {
        warning_toast.write(WarningToastEvent(
            "Theme store unavailable — no network runtime.".to_string(),
        ));
        return;
    };

    store_base.0 = Some(base_url.clone());
    *catalog_state = CatalogState::Loading;
    let rt = rt.0.clone();
    catalog_task.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
        rt.block_on(async { ThemeStoreClient::new(base_url).fetch_catalog().await })
    }));

    spawn_store_modal(&mut commands, &catalog_state, None, font_res.as_deref());
}

/// Polls the catalog fetch; on completion updates [`CatalogState`] and
/// rebuilds the modal if it is still open.
fn poll_catalog_task(
    mut catalog_task: ResMut<CatalogTask>,
    mut catalog_state: ResMut<CatalogState>,
    screens: Query<Entity, With<ThemeStoreScreen>>,
    registry: Option<Res<ThemeRegistry>>,
    mut commands: Commands,
    font_res: Option<Res<FontResource>>,
) {
    let Some(task) = catalog_task.0.as_mut() else {
        return;
    };
    let Some(result) = future::block_on(future::poll_once(task)) else {
        return;
    };
    catalog_task.0 = None;

    *catalog_state = match result {
        Ok(entries) => CatalogState::Loaded(entries),
        Err(e) => {
            warn!("theme store: catalog fetch failed: {e}");
            CatalogState::Error(e.to_string())
        }
    };

    rebuild_open_modal(
        &screens,
        &mut commands,
        &catalog_state,
        registry.as_deref(),
        font_res.as_deref(),
    );
}

/// Starts a download + import task when an Install button is pressed.
/// One install at a time; repeat clicks while busy are ignored.
fn handle_install_buttons(
    interactions: Query<(&Interaction, &ThemeStoreInstallButton), Changed<Interaction>>,
    rt: Option<Res<TokioRuntimeResource>>,
    store_base: Res<StoreBaseUrl>,
    mut install_task: ResMut<InstallTask>,
    mut info_toast: MessageWriter<InfoToastEvent>,
) {
    for (interaction, button) in &interactions {
        if *interaction != Interaction::Pressed || install_task.0.is_some() {
            continue;
        }
        let (Some(rt), Some(base_url)) = (rt.as_ref(), store_base.0.clone()) else {
            continue;
        };
        let entry = button.0.clone();
        info_toast.write(InfoToastEvent(format!("Downloading '{}'…", entry.name)));
        let rt = rt.0.clone();
        let task = AsyncComputeTaskPool::get().spawn(async move {
            let bytes = rt
                .block_on(async { ThemeStoreClient::new(base_url).download_theme(&entry).await })?;
            install_verified_archive(&entry.id, &bytes)
        });
        install_task.0 = Some((button.0.id.clone(), task));
    }
}

/// Downloads land here (already SHA-256-verified): write the archive
/// atomically into the user themes dir (`.tmp` + rename, §2.5), then
/// run the standard importer. Returns `Ok(None)` when the theme was
/// already installed.
fn install_verified_archive(id: &str, bytes: &[u8]) -> Result<Option<String>, ThemeInstallError> {
    let themes_dir = user_theme_dir();
    std::fs::create_dir_all(&themes_dir)?;
    let final_path = themes_dir.join(format!("{id}.zip"));
    let tmp_path = themes_dir.join(format!("{id}.zip.tmp"));
    std::fs::write(&tmp_path, bytes)?;
    std::fs::rename(&tmp_path, &final_path)?;

    match import_theme(&final_path) {
        Ok(theme_id) => Ok(Some(theme_id.as_str().to_string())),
        Err(ImportError::IdCollision { .. }) => Ok(None),
        Err(e) => {
            // Don't leave a zip that fails validation lying around for
            // the folder-scan flow to re-try on every scan.
            let _ = std::fs::remove_file(&final_path);
            Err(e.into())
        }
    }
}

/// Polls the install task; on completion refreshes the theme registry,
/// toasts the outcome, and rebuilds the modal so the row flips to
/// "Installed".
#[allow(clippy::too_many_arguments)]
fn poll_install_task(
    mut install_task: ResMut<InstallTask>,
    mut registry: Option<ResMut<ThemeRegistry>>,
    catalog_state: Res<CatalogState>,
    screens: Query<Entity, With<ThemeStoreScreen>>,
    mut info_toast: MessageWriter<InfoToastEvent>,
    mut warning_toast: MessageWriter<WarningToastEvent>,
    mut commands: Commands,
    font_res: Option<Res<FontResource>>,
) {
    let Some((id, task)) = install_task.0.as_mut() else {
        return;
    };
    let Some(result) = future::block_on(future::poll_once(task)) else {
        return;
    };
    let id = id.clone();
    install_task.0 = None;

    match result {
        Ok(Some(theme_id)) => {
            if let Some(registry) = registry.as_deref_mut() {
                refresh_registry(registry, &user_theme_dir());
            }
            info_toast.write(InfoToastEvent(format!(
                "Theme '{theme_id}' installed — pick it in Settings → Cosmetic."
            )));
        }
        Ok(None) => {
            info_toast.write(InfoToastEvent(format!("Theme '{id}' already installed.")));
        }
        Err(e) => {
            warn!("theme store: install of '{id}' failed: {e}");
            warning_toast.write(WarningToastEvent(format!("Install failed: {e}")));
        }
    }

    rebuild_open_modal(
        &screens,
        &mut commands,
        &catalog_state,
        registry.as_deref(),
        font_res.as_deref(),
    );
}

/// Despawns the store modal when Close is pressed.
fn handle_close_button(
    interactions: Query<&Interaction, (Changed<Interaction>, With<ThemeStoreCloseButton>)>,
    screens: Query<Entity, With<ThemeStoreScreen>>,
    mut commands: Commands,
) {
    for interaction in &interactions {
        if *interaction != Interaction::Pressed {
            continue;
        }
        for entity in &screens {
            commands.entity(entity).despawn();
        }
    }
}

// ---------------------------------------------------------------------------
// Modal rendering
// ---------------------------------------------------------------------------

/// Despawn + respawn the modal for every open screen (Bevy despawns
/// are deferred, so this is safe within one frame).
fn rebuild_open_modal(
    screens: &Query<Entity, With<ThemeStoreScreen>>,
    commands: &mut Commands,
    catalog_state: &CatalogState,
    registry: Option<&ThemeRegistry>,
    font_res: Option<&FontResource>,
) {
    for entity in screens {
        commands.entity(entity).despawn();
        spawn_store_modal(commands, catalog_state, registry, font_res);
    }
}

/// Spawns the store modal for the current catalog state.
fn spawn_store_modal(
    commands: &mut Commands,
    catalog_state: &CatalogState,
    registry: Option<&ThemeRegistry>,
    font_res: Option<&FontResource>,
) {
    let body_font = TextFont {
        font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
        font_size: TYPE_BODY,
        ..default()
    };
    let caption_font = TextFont {
        font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
        font_size: TYPE_CAPTION,
        ..default()
    };

    let scrim = spawn_modal(commands, ThemeStoreScreen, Z_MODAL_PANEL + 1, |card| {
        spawn_modal_header(card, "Theme Store", font_res);

        match catalog_state {
            CatalogState::Idle | CatalogState::Loading => {
                card.spawn((
                    Text::new("Loading catalog…"),
                    body_font.clone(),
                    TextColor(TEXT_SECONDARY),
                ));
            }
            CatalogState::Error(msg) => {
                card.spawn((
                    Text::new(format!("Could not load the catalog: {msg}")),
                    body_font.clone(),
                    TextColor(TEXT_SECONDARY),
                ));
            }
            CatalogState::Loaded(entries) if entries.is_empty() => {
                card.spawn((
                    Text::new("The server has no themes yet."),
                    body_font.clone(),
                    TextColor(TEXT_SECONDARY),
                ));
            }
            CatalogState::Loaded(entries) => {
                for entry in entries {
                    let installed =
                        registry.is_some_and(|registry| registry.find(&entry.id).is_some());
                    spawn_store_row(card, entry, installed, &body_font, &caption_font, font_res);
                }
            }
        }

        spawn_modal_actions(card, |actions| {
            spawn_modal_button(
                actions,
                ThemeStoreCloseButton,
                "Close",
                None,
                ButtonVariant::Primary,
                font_res,
            );
        });
    });
    commands.entity(scrim).insert(ScrimDismissible);
}

/// One catalog row: name + author/size caption on the left, Install
/// button (or "Installed" caption) on the right.
fn spawn_store_row(
    parent: &mut ChildSpawnerCommands,
    entry: &ThemeCatalogEntry,
    installed: bool,
    body_font: &TextFont,
    caption_font: &TextFont,
    font_res: Option<&FontResource>,
) {
    parent
        .spawn((
            Node {
                flex_direction: FlexDirection::Row,
                justify_content: JustifyContent::SpaceBetween,
                align_items: AlignItems::Center,
                column_gap: VAL_SPACE_3,
                padding: UiRect::vertical(VAL_SPACE_2),
                border: UiRect::bottom(Val::Px(1.0)),
                ..default()
            },
            BorderColor::all(BORDER_SUBTLE),
        ))
        .with_children(|row| {
            row.spawn(Node {
                flex_direction: FlexDirection::Column,
                row_gap: VAL_SPACE_2,
                ..default()
            })
            .with_children(|col| {
                col.spawn((
                    Text::new(entry.name.clone()),
                    body_font.clone(),
                    TextColor(TEXT_PRIMARY),
                ));
                col.spawn((
                    Text::new(format!(
                        "by {} — {} KB",
                        entry.author,
                        entry.size_bytes.div_ceil(1024)
                    )),
                    caption_font.clone(),
                    TextColor(TEXT_SECONDARY),
                ));
            });
            if installed {
                row.spawn((
                    Text::new("Installed"),
                    caption_font.clone(),
                    TextColor(TEXT_SECONDARY),
                ));
            } else {
                spawn_modal_button(
                    row,
                    ThemeStoreInstallButton(entry.clone()),
                    "Install",
                    None,
                    ButtonVariant::Secondary,
                    font_res,
                );
            }
        });
}

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

#[cfg(test)]
mod tests {
    use super::*;
    use bevy::ecs::message::Messages;

    fn headless_app() -> App {
        let mut app = App::new();
        app.add_plugins(MinimalPlugins)
            .add_plugins(ThemeStorePlugin);
        app.update();
        app
    }

    fn settings_with_server() -> SettingsResource {
        SettingsResource(Settings {
            sync_backend: SyncBackend::SolitaireServer {
                url: "http://127.0.0.1:1".to_string(),
                username: "tester".to_string(),
                avatar_url: None,
            },
            ..Settings::default()
        })
    }

    fn screen_count(app: &mut App) -> usize {
        app.world_mut()
            .query::<&ThemeStoreScreen>()
            .iter(app.world())
            .count()
    }

    #[test]
    fn store_base_url_requires_server_backend() {
        assert_eq!(store_base_url(&Settings::default()), None);
        assert_eq!(
            store_base_url(&settings_with_server().0).as_deref(),
            Some("http://127.0.0.1:1")
        );
    }

    #[test]
    fn open_request_without_server_warns_and_spawns_nothing() {
        let mut app = headless_app();
        // Default settings → SyncBackend::Local → no store.
        app.insert_resource(SettingsResource(Settings::default()));
        app.world_mut()
            .resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
            .write(ThemeStoreOpenRequestEvent);
        app.update();

        assert_eq!(screen_count(&mut app), 0);
        assert!(
            !app.world()
                .resource::<Messages<WarningToastEvent>>()
                .is_empty(),
            "player must be told why the store did not open"
        );
    }

    #[test]
    fn open_request_without_runtime_warns_and_spawns_nothing() {
        let mut app = headless_app();
        app.insert_resource(settings_with_server());
        // No TokioRuntimeResource inserted.
        app.world_mut()
            .resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
            .write(ThemeStoreOpenRequestEvent);
        app.update();

        assert_eq!(screen_count(&mut app), 0);
        assert!(
            !app.world()
                .resource::<Messages<WarningToastEvent>>()
                .is_empty()
        );
    }

    #[test]
    fn open_request_with_server_spawns_modal_in_loading_state() {
        let mut app = headless_app();
        app.insert_resource(settings_with_server());
        app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
        app.world_mut()
            .resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
            .write(ThemeStoreOpenRequestEvent);
        app.update();

        assert_eq!(screen_count(&mut app), 1);
        assert!(matches!(
            *app.world().resource::<CatalogState>(),
            CatalogState::Loading | CatalogState::Error(_)
        ));
        assert_eq!(
            app.world().resource::<StoreBaseUrl>().0.as_deref(),
            Some("http://127.0.0.1:1")
        );
    }

    #[test]
    fn second_open_request_does_not_stack_a_second_modal() {
        let mut app = headless_app();
        app.insert_resource(settings_with_server());
        app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
        for _ in 0..2 {
            app.world_mut()
                .resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
                .write(ThemeStoreOpenRequestEvent);
            app.update();
        }
        assert_eq!(screen_count(&mut app), 1);
    }

    #[test]
    fn failed_catalog_fetch_rebuilds_modal_with_error_state() {
        let mut app = headless_app();
        app.insert_resource(settings_with_server());
        app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
        app.world_mut()
            .resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
            .write(ThemeStoreOpenRequestEvent);
        app.update();

        // 127.0.0.1:1 refuses connections; the fetch task must resolve
        // to an error within a bounded number of frames and the modal
        // must survive the rebuild.
        for _ in 0..200 {
            app.update();
            if matches!(
                *app.world().resource::<CatalogState>(),
                CatalogState::Error(_)
            ) {
                break;
            }
            std::thread::sleep(std::time::Duration::from_millis(10));
        }
        assert!(matches!(
            *app.world().resource::<CatalogState>(),
            CatalogState::Error(_)
        ));
        assert_eq!(screen_count(&mut app), 1);
    }
}
