//! Library entry point for `solitaire_app`.
//!
//! The app is a `cdylib + bin` hybrid: desktop builds run through the
//! `bin` target's [`main`](crate::main_desktop) shim; Android builds
//! load this `cdylib` via NativeActivity / GameActivity, which calls
//! into the platform's own `main` glue. Both paths converge on
//! [`run`], so the ECS bootstrap is single-sourced.
//!
//! Why split this out: cargo-apk requires the package to expose a
//! `cdylib` library target — the Android activity dlopens
//! `libsolitaire_app.so` and calls into it. A bin-only crate panics
//! at build time with `Bin is not compatible with Cdylib`. The split
//! keeps the desktop `cargo run -p solitaire_app` flow unchanged
//! while making `cargo apk build -p solitaire_app` viable.

use std::fs::OpenOptions;
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};

use bevy::prelude::*;
#[cfg(not(target_os = "android"))]
use bevy::window::{Monitor, PrimaryMonitor, PrimaryWindow};
use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
#[cfg(not(target_os = "android"))]
use bevy::winit::WinitWindows;
#[cfg(target_os = "android")]
use bevy::winit::{UpdateMode, WinitSettings};
use solitaire_data::{
    Settings, cleanup_orphaned_tmp_files, load_settings_from, provider_for_backend,
    settings_file_path,
};
use solitaire_engine::{CoreGamePlugin, SyncProvider, register_theme_asset_sources};

fn load_settings() -> Settings {
    settings_file_path()
        .map(|p| load_settings_from(&p))
        .unwrap_or_default()
}

/// Build the Bevy app without entering the event loop.
pub fn build_app(sync_provider: Box<dyn SyncProvider + Send + Sync>) -> App {
    build_app_with_settings(load_settings(), sync_provider)
}

/// App entry point — configures runtime services, builds, and runs the app.
///
/// Called from both the desktop `bin` target's `main` shim and (on
/// Android) the platform's NativeActivity / GameActivity glue.
pub fn run() {
    // Install a panic hook that writes a crash log next to the save files
    // before re-running the default hook (so stderr still gets the message
    // and any debugger attached still sees the panic).
    install_crash_log_hook();

    // Remove any *.tmp files left behind by a crash between an atomic write
    // and its rename.  Safe to call unconditionally — missing data dir is a
    // no-op.  Must run before GamePlugin loads saved state so orphaned files
    // don't accumulate across launches.
    let _ = cleanup_orphaned_tmp_files();

    // Initialise the platform keyring store before any token operations.
    // On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
    // macOS it uses the Keychain; on Windows it uses the Credential store.
    // If the platform has no OS keyring (e.g. a headless CI box), keyring
    // operations will fail gracefully with TokenError::KeychainUnavailable.
    //
    // Android: `keyring` isn't compiled in (its `rpassword` transitive
    // pulls a libc symbol Android's bionic doesn't expose). The Android
    // auth-token path uses Android Keystore via JNI; `android_main` passes
    // the process JavaVM pointer into `solitaire_data` before `run()`.
    #[cfg(not(target_os = "android"))]
    if let Err(e) = keyring::use_native_store(true) {
        eprintln!(
            "warn: could not initialise OS keyring ({e}); \
             server sync login will be unavailable"
        );
    }

    let settings = load_settings();
    let sync_provider = provider_for_backend(&settings.sync_backend);
    build_app_with_settings(settings, sync_provider).run();
}

fn build_app_with_settings(
    settings: Settings,
    sync_provider: Box<dyn SyncProvider + Send + Sync>,
) -> App {
    // Restore the previous window geometry if the player has one saved.
    // Otherwise open at the platform default (1280×800, centred on the
    // primary monitor) — `apply_smart_default_window_size` will resize
    // up to a monitor-relative target on the first frame so HiDPI / 4K
    // sessions don't end up with a comparatively tiny window.
    #[cfg(not(target_os = "android"))]
    let had_saved_geometry = settings.window_geometry.is_some();
    let (window_resolution, window_position) = match settings.window_geometry.as_ref() {
        Some(geom) => (
            (geom.width, geom.height).into(),
            WindowPosition::At(IVec2::new(geom.x, geom.y)),
        ),
        None => (
            (1280u32, 800u32).into(),
            WindowPosition::Centered(MonitorSelection::Primary),
        ),
    };

    let mut app = App::new();

    // The card-theme system's `themes://` asset source must be
    // registered *before* `DefaultPlugins` builds `AssetPlugin`,
    // because that plugin freezes the asset-source list at build
    // time. The matching `AssetSourcesPlugin` (registered by
    // `CoreGamePlugin`) finishes the wiring after `DefaultPlugins`
    // by populating the embedded default theme into Bevy's
    // `EmbeddedAssetRegistry`.
    register_theme_asset_sources(&mut app);

    app.add_plugins(
        DefaultPlugins
            .set(WindowPlugin {
                primary_window: Some(Window {
                    title: "Ferrous Solitaire".into(),
                    // X11/Wayland WM_CLASS so taskbar managers group
                    // multiple windows of this app correctly.
                    name: Some("ferrous-solitaire".into()),
                    resolution: window_resolution,
                    position: window_position,
                    // On Android, AutoVsync caps the GPU at the display
                    // refresh rate (~60-90 fps). Without it the renderer
                    // spins as fast as the hardware allows, keeping the
                    // GPU fully loaded and draining the battery even when
                    // the game is completely idle.
                    //
                    // On desktop (X11 / Wayland) AutoNoVsync prefers
                    // Mailbox (triple-buffered) and falls back to
                    // Immediate, eliminating the vsync stall that
                    // AutoVsync produces during continuous window resize.
                    // The game's frame budget is small enough that a few
                    // stray dropped frames from disabling vsync are
                    // imperceptible on desktop.
                    #[cfg(target_os = "android")]
                    present_mode: PresentMode::AutoVsync,
                    #[cfg(not(target_os = "android"))]
                    present_mode: PresentMode::AutoNoVsync,
                    // Android windows always fill the screen; max_width/max_height
                    // default to 0.0, which panics Bevy's clamp when min > max.
                    #[cfg(not(target_os = "android"))]
                    resize_constraints: WindowResizeConstraints {
                        min_width: 800.0,
                        min_height: 600.0,
                        ..default()
                    },
                    ..default()
                }),
                ..default()
            })
            // The `assets/` directory lives at the workspace root, but
            // on desktop Bevy resolves `AssetPlugin::file_path` relative
            // to the binary package's `CARGO_MANIFEST_DIR`
            // (`solitaire_app/`), so `cargo run -p solitaire_app` would
            // miss the workspace-root `assets/` without a `../` prefix.
            //
            // On Android cargo-apk packages the same directory into the
            // APK at `assets/` (via `[package.metadata.android].assets`
            // in solitaire_app/Cargo.toml). Bevy's `AndroidAssetReader`
            // is already rooted there, so any `file_path` other than the
            // default makes it walk *out* of the APK's assets root and
            // all loads fail silently — which is what produced the
            // solid-red card-back fallback in the v0.22.3 screenshot.
            .set(AssetPlugin {
                #[cfg(not(target_os = "android"))]
                file_path: "../assets".to_string(),
                ..default()
            }),
    )
    .add_plugins(CoreGamePlugin::new(sync_provider));

    // On Android the default WinitSettings use UpdateMode::Continuous for
    // the focused window, which means Bevy renders as fast as possible even
    // when the game is completely idle. Switching to reactive_low_power with
    // a 1-second ceiling when the app is backgrounded cuts wake-up frequency
    // from ~60 Hz to ≤1 Hz, dramatically reducing background battery drain.
    //
    // focused_mode uses reactive_low_power(100 ms) so the CPU only wakes when
    // an event arrives (touch, resize, etc.) or an animation system writes
    // RequestRedraw. The 100 ms ceiling is a fallback that ensures the game
    // timer ticks at least 10×/s even with no input, while keeping the GPU
    // completely idle between frames when the board is static.
    // PresentMode::AutoVsync (set above) still caps the GPU at the display
    // refresh rate when frames do render.
    #[cfg(target_os = "android")]
    app.insert_resource(WinitSettings {
        focused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_millis(100)),
        unfocused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_secs(1)),
    });

    // Wire the runtime window icon. Bevy 0.18 has no first-class
    // `Window::icon` field; the icon is set through the underlying
    // `winit::window::Window` via `WinitWindows`. Android draws its
    // launcher icon from the APK manifest, so the system is desktop-
    // only — same target-gate as the `winit` dep itself.
    #[cfg(not(target_os = "android"))]
    app.add_systems(Update, set_window_icon);

    // Smart default window sizing: when no saved geometry was loaded,
    // resize the freshly-opened 1280×800 window to ~70 % of the primary
    // monitor's logical size on the first frame. Without this, a 4K
    // monitor opens the same 1280×800 window that a 1080p monitor
    // does — visually tiny relative to screen. Skipped entirely when
    // saved geometry was applied; the player's preference always wins.
    //
    // Players who specifically want the literal 1280×800 baseline on
    // every fresh launch can flip `disable_smart_default_size` in
    // Settings to opt out. The flag is checked once at startup; a
    // mid-session change applies on the next launch.
    // Android windows are always full-screen; the OS controls sizing.
    #[cfg(not(target_os = "android"))]
    if !had_saved_geometry && !settings.disable_smart_default_size {
        app.add_systems(Update, apply_smart_default_window_size);
    }

    app
}

/// One-shot Update system that runs only on launches without saved
/// window geometry. Resizes the primary window to a fraction of the
/// primary monitor's *logical* size — bigger monitors get bigger
/// windows automatically. Logical size already accounts for the OS's
/// HiDPI scale factor, so a 2880×1800 Retina display reporting
/// scale_factor 2.0 yields a 1440×900 logical size and a 1008×630
/// target window — same physical inches as a 1920×1080 monitor with
/// scale_factor 1.0 yielding 1344×756.
///
/// Uses `Local<bool>` to make itself one-shot rather than introducing
/// a dedicated resource. The Update tick is necessary because Bevy
/// populates the `Monitor` entities asynchronously after winit's
/// Resumed event fires; they may not exist on the first Startup pass.
#[cfg(not(target_os = "android"))]
fn apply_smart_default_window_size(
    mut applied: Local<bool>,
    monitors: Query<&Monitor, With<PrimaryMonitor>>,
    mut windows: Query<&mut Window, With<PrimaryWindow>>,
) {
    if *applied {
        return;
    }
    let Ok(monitor) = monitors.single() else {
        // Primary monitor not yet spawned by bevy_winit. Try again
        // next frame; the cost is one early-exit per tick until
        // monitors arrive (typically frame 1 or 2).
        return;
    };
    let Ok(mut window) = windows.single_mut() else {
        return;
    };

    let scale = monitor.scale_factor as f32;
    if scale <= 0.0 {
        // Defensive: a zero or negative scale factor would NaN the
        // arithmetic below. Bail and accept the default size.
        *applied = true;
        return;
    }
    let logical_w = monitor.physical_width as f32 / scale;
    let logical_h = monitor.physical_height as f32 / scale;

    // Target 70 % of monitor in each dimension, clamped to the
    // existing 800×600 minimum and the monitor's own logical size
    // (so we never request a window larger than the screen).
    let target_w = (logical_w * 0.7).clamp(800.0, logical_w);
    let target_h = (logical_h * 0.7).clamp(600.0, logical_h);

    // Resize only when the change is meaningful — at exactly 1280×800
    // on a 1920×1080 monitor the new target is 1344×756 (only ~5 %
    // wider), worth the resize; at the same default on an 800×600
    // monitor the clamp pins us at 800×600 and we shouldn't resize.
    let curr_w = window.resolution.width();
    let curr_h = window.resolution.height();
    if (curr_w - target_w).abs() > 8.0 || (curr_h - target_h).abs() > 8.0 {
        window.resolution.set(target_w, target_h);
    }
    *applied = true;
}

/// One-shot Update system that sets the primary window's taskbar /
/// title-bar icon to the embedded 256 px Terminal-aesthetic mark
/// generated by `solitaire_engine/examples/icon_generator.rs`.
///
/// Bevy 0.18 has no `Window::icon` field — the icon is set through
/// the underlying `winit::window::Window` via the `WinitWindows`
/// resource. The system is desktop-only (Android draws its launcher
/// icon from the APK manifest, not from any runtime call). Returns
/// silently and tries again next frame until both the primary
/// window and `WinitWindows` are populated, then sets the icon
/// once and self-disables via `Local<bool>`.
///
/// Icon bytes are `include_bytes!()`-embedded at compile time, same
/// shape as the audio assets and default-theme SVGs — no runtime
/// asset-path resolution, no `cargo run` working-directory
/// assumptions. PNG → RGBA decode runs through `tiny_skia` (already
/// in the build for SVG rasterisation), so this system adds zero
/// new dependencies on top of the direct `winit` dep that's
/// already required for `Icon` construction.
#[cfg(not(target_os = "android"))]
fn set_window_icon(
    mut applied: Local<bool>,
    primary_window: Query<Entity, With<PrimaryWindow>>,
    // `Option<NonSend<...>>` rather than `NonSend<...>` because Bevy
    // 0.18's stricter system-param validation panics on the first
    // few frames before `WinitWindows` is inserted (the resource is
    // populated after winit's `Resumed` event, which fires after
    // the first system-tick batch). The early-return below handles
    // the `None` window-wrapper case for the same lifecycle reason.
    winit_windows: Option<NonSend<WinitWindows>>,
) {
    if *applied {
        return;
    }
    let Some(winit_windows) = winit_windows else {
        return;
    };
    let Ok(primary_entity) = primary_window.single() else {
        return;
    };
    let Some(window_wrapper) = winit_windows.get_window(primary_entity) else {
        // Primary window's underlying winit handle not yet
        // populated — `WinitWindows` fills in after the first
        // `Resumed` event. Try again next frame.
        return;
    };

    // The 256 × 256 PNG is sufficient for `set_window_icon`; winit
    // scales it for the actual rendered size. Smaller PNGs in
    // `assets/icon/` exist for downstream Linux hicolor / Windows
    // `.ico` / macOS `.icns` packaging — they're not used here.
    const ICON_BYTES: &[u8] = include_bytes!("../../assets/icon/icon_256.png");

    let pixmap = match tiny_skia::Pixmap::decode_png(ICON_BYTES) {
        Ok(p) => p,
        Err(e) => {
            eprintln!("warn: could not decode embedded window icon PNG: {e}");
            *applied = true; // don't retry every frame
            return;
        }
    };
    let rgba = pixmap.data().to_vec();
    let icon = match winit::window::Icon::from_rgba(rgba, pixmap.width(), pixmap.height()) {
        Ok(i) => i,
        Err(e) => {
            eprintln!("warn: could not construct window icon: {e}");
            *applied = true;
            return;
        }
    };
    window_wrapper.set_window_icon(Some(icon));
    *applied = true;
}

/// Android entry point called by NativeActivity after dlopen-ing the `.so`.
/// Sets the `AndroidApp` handle that Bevy's winit backend reads before
/// constructing the event loop, then delegates to [`run`].
///
/// The `#[bevy_main]` proc-macro would generate the same code but only
/// works on a function named `main`; our shared entry point is `run`, so
/// we emit the equivalent expansion manually.
#[cfg(target_os = "android")]
#[allow(unsafe_code)]
#[unsafe(no_mangle)]
fn android_main(android_app: bevy::android::android_activity::AndroidApp) {
    if let Err(e) = init_android_jni(&android_app) {
        eprintln!("warn: could not initialise Android JNI bridge ({e})");
    }
    let _ = bevy::android::ANDROID_APP.set(android_app);
    run();
}

/// Reconstructs the raw `JavaVM` / `NativeActivity` handles handed over by the
/// Android runtime and registers safe wrappers with `solitaire_data`.
///
/// This is the *only* place in the workspace that performs `unsafe` FFI handle
/// reconstruction. Every other crate consumes the safe
/// [`solitaire_data::android_jni`] bridge and stays `forbid(unsafe_code)`;
/// `solitaire_app` opts down to `deny` with a narrowly scoped allow on this
/// function and the `#[unsafe(no_mangle)]` entry point above.
#[cfg(target_os = "android")]
#[allow(unsafe_code)]
fn init_android_jni(
    android_app: &bevy::android::android_activity::AndroidApp,
) -> Result<(), String> {
    use jni::JavaVM;
    use jni::objects::JObject;

    let vm_ptr = android_app.vm_as_ptr();
    if vm_ptr.is_null() {
        return Err("JavaVM pointer is null".into());
    }
    // SAFETY: `vm_as_ptr()` returns the process-wide JavaVM* established by the
    // Android runtime; it is valid for the lifetime of the process.
    let vm = unsafe { JavaVM::from_raw(vm_ptr.cast()) }.map_err(|e| format!("JavaVM: {e}"))?;

    let env = vm
        .attach_current_thread_permanently()
        .map_err(|e| format!("attach_current_thread: {e}"))?;

    // SAFETY: `activity_as_ptr()` returns the NativeActivity jobject pointer,
    // valid for the lifetime of the process. Promote it to a global reference
    // so the safe bridge can hand it to any thread.
    let activity = unsafe { JObject::from_raw(android_app.activity_as_ptr().cast()) };
    let activity_ref = env
        .new_global_ref(&activity)
        .map_err(|e| format!("activity global ref: {e}"))?;

    solitaire_data::android_jni::set_jvm(vm);
    solitaire_data::android_jni::set_activity(activity_ref);
    Ok(())
}

/// Wraps the default panic hook with one that also appends a crash log
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
/// still runs afterwards, so stderr output and debugger integration are
/// unchanged. If the data directory is unavailable, the wrapper silently
/// falls through — the default hook handles output either way.
fn install_crash_log_hook() {
    let crash_log_path =
        settings_file_path().and_then(|p| p.parent().map(|parent| parent.join("crash.log")));
    let default_hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        if let Some(path) = crash_log_path.as_ref()
            && let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path)
        {
            // Plain unix-seconds timestamp keeps the format trivially
            // parseable and avoids pulling in chrono just for this.
            let secs = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .map_or(0, |d| d.as_secs());
            let _ = writeln!(file, "----- t={secs} -----\n{info}\n");
        }
        default_hook(info);
    }));
}
