//! Safe-area insets.
//!
//! Reports the OS-reserved regions around the playable surface (status
//! bar at the top, gesture / navigation bar at the bottom on Android,
//! display cutouts, etc.) so UI anchored to a screen edge can avoid
//! collisions.
//!
//! On non-Android targets all four edges report `0.0`. On Android the
//! values come from `WindowInsets.getInsets(WindowInsets.Type.systemBars())`
//! via JNI; the call is retried for the first few frames because
//! `getRootWindowInsets()` only returns useful values after the decor
//! view has been laid out at least once.
//!
//! UI that wants to respect the top inset should tag itself with the
//! [`SafeAreaAnchoredTop`] marker carrying the layout's original top
//! offset; [`apply_safe_area_anchors`] re-applies `base_top + insets.top`
//! whenever the resource changes, so late inset arrival or orientation
//! changes flow through automatically.

use bevy::prelude::*;
use bevy::window::{AppLifecycle, WindowResized};

use crate::ui_modal::ModalScrim;

/// Pixel sizes of the system-reserved regions on each edge of the
/// surface. Zero on desktop.
#[derive(Resource, Debug, Clone, Copy, Default, PartialEq)]
pub struct SafeAreaInsets {
    pub top: f32,
    pub bottom: f32,
    pub left: f32,
    pub right: f32,
}

impl SafeAreaInsets {
    /// `true` when any edge has a non-zero reservation. Used by the
    /// Android polling system to know it can stop querying.
    pub fn is_populated(&self) -> bool {
        self.top > 0.0 || self.bottom > 0.0 || self.left > 0.0 || self.right > 0.0
    }
}

/// Marker for `Node` entities whose `top` offset should be re-applied
/// as `base_top + SafeAreaInsets::top`.
///
/// `base_top` is the offset the layout would have used on a surface
/// with no system reservation (i.e. on desktop). The fix-up system
/// adds the current top inset on top of it whenever the resource
/// changes.
#[derive(Component, Debug, Clone, Copy)]
pub struct SafeAreaAnchoredTop {
    pub base_top: f32,
}

/// Marker for `Node` entities whose `bottom` offset should be re-applied
/// as `base_bottom + SafeAreaInsets::bottom / scale`.
///
/// Use this for elements anchored to the bottom edge (e.g. a bottom action
/// bar) so they clear the Android gesture-navigation zone automatically.
#[derive(Component, Debug, Clone, Copy)]
pub struct SafeAreaAnchoredBottom {
    pub base_bottom: f32,
}

pub struct SafeAreaInsetsPlugin;

impl Plugin for SafeAreaInsetsPlugin {
    fn build(&self, app: &mut App) {
        // Both message types may already be registered by GamePlugin / TablePlugin;
        // add_message is idempotent.
        app.add_message::<AppLifecycle>()
            .add_message::<WindowResized>()
            .init_resource::<SafeAreaInsets>()
            .add_systems(
                Update,
                (
                    apply_safe_area_anchors,
                    apply_safe_area_bottom_anchors,
                    apply_safe_area_to_modal_scrims,
                    on_app_resumed,
                ),
            );

        #[cfg(target_os = "android")]
        app.init_resource::<android::SafeAreaPollTries>()
            .add_systems(Update, android::refresh_insets)
            .add_systems(Update, android::rearm_on_resumed)
            .add_systems(Update, android::refresh_surface_size);
    }
}

/// Re-applies `base_top + insets.top` to every entity carrying the
/// [`SafeAreaAnchoredTop`] marker whenever [`SafeAreaInsets`] changes.
///
/// Bevy resource change detection (`Res::is_changed`) is `true` on the
/// frame the resource is inserted and every frame a `ResMut` borrow
/// occurs. Combined with the Android polling loop short-circuiting
/// once insets are populated, this runs at most a handful of times in
/// a session.
fn apply_safe_area_anchors(
    insets: Res<SafeAreaInsets>,
    windows: Query<&Window>,
    mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>,
) {
    if !insets.is_changed() {
        return;
    }
    // Android's WindowInsets API returns physical pixels; Bevy UI's Val::Px
    // expects logical pixels (≈ dp). Divide by the window scale factor so
    // the HUD band shifts by the correct number of dp on high-DPI devices.
    let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
    let window_height = windows.iter().next().map_or(800.0, |w| w.height());
    let max_inset = window_height * 0.25;
    let raw_top = insets.top / scale;
    if raw_top > max_inset {
        warn!(
            "safe_area: top inset {raw_top:.0}px exceeds 25% of window height ({max_inset:.0}px); clamping"
        );
    }
    let top_logical = raw_top.min(max_inset);
    for (anchor, mut node) in &mut q {
        node.top = Val::Px(anchor.base_top + top_logical);
    }
}

/// Re-applies `base_bottom + insets.bottom / scale` to every entity carrying
/// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes.
fn apply_safe_area_bottom_anchors(
    insets: Res<SafeAreaInsets>,
    windows: Query<&Window>,
    mut q: Query<(&SafeAreaAnchoredBottom, &mut Node)>,
) {
    if !insets.is_changed() {
        return;
    }
    let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
    let window_height = windows.iter().next().map_or(800.0, |w| w.height());
    let max_inset = window_height * 0.25;
    let raw_bottom = insets.bottom / scale;
    if raw_bottom > max_inset {
        warn!(
            "safe_area: bottom inset {raw_bottom:.0}px exceeds 25% of window height ({max_inset:.0}px); clamping"
        );
    }
    let bottom_logical = raw_bottom.min(max_inset);
    for (anchor, mut node) in &mut q {
        node.bottom = Val::Px(anchor.base_bottom + bottom_logical);
    }
}

/// Pads both edges of every [`ModalScrim`] by the logical system-bar insets so
/// modal cards are centred within the usable area (between the status bar at
/// the top and the gesture-navigation bar at the bottom).
///
/// `padding.top` = status-bar inset; `padding.bottom` = gesture-bar inset.
/// With `align_items: Center` / `justify_content: Center` on the scrim the
/// `ModalCard` lands at the visual midpoint of the visible content area.
///
/// Fires when [`SafeAreaInsets`] changes (covers the common case of insets
/// arriving a few frames after app start) AND when a new `ModalScrim` is
/// spawned (covers modals opened after insets have already settled).
fn apply_safe_area_to_modal_scrims(
    insets: Res<SafeAreaInsets>,
    windows: Query<&Window>,
    mut scrims: Query<&mut Node, With<ModalScrim>>,
    new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
) {
    let has_new = !new_scrims.is_empty();
    if !insets.is_changed() && !has_new {
        return;
    }
    let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
    let window_height = windows.iter().next().map_or(800.0, |w| w.height());
    // Clamp each inset to 25% of screen height so an unexpectedly large OS
    // value can't push the modal card off the visible area entirely.
    let top_logical = (insets.top / scale).min(window_height * 0.25);
    let bottom_logical = (insets.bottom / scale).min(window_height * 0.25);
    for mut node in &mut scrims {
        // Set both edges so the scrim's content box equals the usable area
        // between the status bar and the gesture/navigation bar. With
        // `align_items: Center` / `justify_content: Center` on the scrim,
        // the modal card is centred within that usable region rather than
        // the full viewport, correcting the slight upward shift seen when
        // only the bottom inset was applied.
        node.padding.top = Val::Px(top_logical);
        node.padding.bottom = Val::Px(bottom_logical);
    }
}

/// Emits a synthetic `WindowResized` on `AppLifecycle::WillResume` so that
/// `on_window_resized` (in `table_plugin`) recomputes the board layout with
/// whatever `SafeAreaInsets` are current at that moment.
///
/// On Android the `android::rearm_on_resumed` system runs in the same frame
/// and resets `SafeAreaPollTries` (the cached `SafeAreaInsets` keep their
/// last-known values), causing `refresh_insets` to re-poll JNI over the next
/// few frames.  If the insets changed while backgrounded, `on_safe_area_changed`
/// in `table_plugin` emits a second synthetic `WindowResized` and the layout
/// converges to the right position; if they didn't, nothing is rewritten and
/// the layout stays put.
///
/// On non-Android targets this handler still fires — it ensures that a resume
/// event always refreshes the layout (e.g., after a minimise/restore on
/// desktop) even though insets are always zero.
fn on_app_resumed(
    mut lifecycle: MessageReader<AppLifecycle>,
    windows: Query<(Entity, &Window)>,
    mut resize_events: MessageWriter<WindowResized>,
) {
    for event in lifecycle.read() {
        if !matches!(event, AppLifecycle::WillResume) {
            continue;
        }
        let Some((entity, window)) = windows.iter().next() else {
            return;
        };
        resize_events.write(WindowResized {
            window: entity,
            width: window.resolution.width(),
            height: window.resolution.height(),
        });
    }
}

#[cfg(target_os = "android")]
mod android {
    use super::{AppLifecycle, SafeAreaInsets};
    use bevy::prelude::*;
    use bevy::window::WindowResized;

    /// Tracks how many frames `refresh_insets` has polled.  Stored as a
    /// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
    /// when `AppLifecycle::WillResume` fires, causing the poller to re-query JNI
    /// after a background/foreground cycle.
    #[derive(Resource, Default)]
    pub(super) struct SafeAreaPollTries(pub u32);

    /// Polls Android for safe-area insets until we get a non-zero
    /// reading, then settles until [`rearm_on_resumed`] re-arms it on the
    /// next foreground resume — insets can change while backgrounded
    /// (rotation, fold/unfold, gesture ↔ 3-button nav). The poll counter
    /// (not `insets.is_populated()`) gates the loop, so a re-armed cycle
    /// re-queries JNI even though cached values are already populated.
    /// `getRootWindowInsets()` returns `null` (or all-zero `Insets`)
    /// until the decor view has been laid out, which is typically frame
    /// 1–3 of a fresh launch.
    pub(super) fn refresh_insets(
        mut insets: ResMut<SafeAreaInsets>,
        mut poll: ResMut<SafeAreaPollTries>,
    ) {
        // Cap retries so we don't burn CPU forever on edge-to-edge
        // devices that genuinely report zero insets.
        const MAX_TRIES: u32 = 120; // ~2 seconds @ 60 fps

        if poll.0 >= MAX_TRIES {
            return;
        }
        poll.0 += 1;

        match query_insets() {
            Ok(v) if v.is_populated() => {
                if *insets != v {
                    info!(
                        "safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
                        v.top, v.bottom, v.left, v.right, poll.0
                    );
                    *insets = v;
                }
                // Settled for this poll cycle; `rearm_on_resumed` re-arms on
                // the next resume. Writing `insets` only on an actual change
                // keeps resource change detection (and the relayout it
                // triggers) quiet on resumes where nothing moved.
                poll.0 = MAX_TRIES;
            }
            Ok(_) => {
                // Layout not ready yet; try again next frame.
            }
            Err(e) => {
                // Don't spam — log once and let polling continue silently.
                if poll.0 == 1 {
                    warn!("safe_area: JNI query failed (will retry): {e}");
                }
            }
        }
    }

    /// Resets the inset poller on `AppLifecycle::WillResume` so that
    /// `refresh_insets` re-queries JNI in the frames immediately after the app
    /// returns to the foreground.
    ///
    /// The cached `SafeAreaInsets` are intentionally **not** zeroed here.
    /// Zeroing them would cause two layout recomputes on every resume:
    /// once with zero insets (wrong position) and again when JNI resolves the
    /// real values — visible as a flash.  By preserving the last-known values
    /// the layout remains stable; if JNI returns a different value (e.g. after
    /// a rotation) the single update that fires when `SafeAreaInsets` actually
    /// changes is enough.
    pub(super) fn rearm_on_resumed(
        mut lifecycle: MessageReader<AppLifecycle>,
        mut poll: ResMut<SafeAreaPollTries>,
    ) {
        for event in lifecycle.read() {
            if matches!(event, AppLifecycle::WillResume) {
                // Evidence line for #130: winit's Android backend has open
                // TODOs around forwarding resume notifications, so whether
                // this ever fires on a given device is an open question.
                info!("safe_area: AppLifecycle::WillResume received; re-arming inset poll");
                poll.0 = 0;
            }
        }
    }

    /// Polls the decor view's size via JNI and forces a relayout when it
    /// disagrees with Bevy's cached `Window` resolution (#130).
    ///
    /// winit's Android backend does not forward content-rect changes that
    /// happen while the app is backgrounded (fold/unfold on foldables), so
    /// after a fold cycle Bevy can keep rendering and laying out for the
    /// previous screen's dimensions. Unlike `refresh_insets` this poller
    /// never settles: it cannot rely on `AppLifecycle::WillResume` to re-arm
    /// it, because that event is itself delivered through the same unreliable
    /// lifecycle plumbing. A JNI round-trip every `POLL_INTERVAL_FRAMES`
    /// frames is cheap.
    ///
    /// On a mismatch it:
    /// 1. writes the real size into `window.resolution` so the renderer
    ///    reconfigures the surface and systems reading `window.width()` see
    ///    the truth,
    /// 2. emits a synthetic `WindowResized` (logical pixels) so
    ///    `on_window_resized` in `table_plugin` recomputes the board layout,
    /// 3. re-arms the inset poller, because a screen change almost always
    ///    moves the system bars too — covering the "re-poll never fires"
    ///    hole left open in #116.
    pub(super) fn refresh_surface_size(
        mut frame: Local<u32>,
        mut windows: Query<(Entity, &mut Window)>,
        mut resize_events: MessageWriter<WindowResized>,
        mut poll: ResMut<SafeAreaPollTries>,
    ) {
        const POLL_INTERVAL_FRAMES: u32 = 30; // ~0.5 s @ 60 fps

        *frame += 1;
        if !frame.is_multiple_of(POLL_INTERVAL_FRAMES) {
            return;
        }
        let Some((entity, mut window)) = windows.iter_mut().next() else {
            return;
        };

        let (decor_w, decor_h) = match query_decor_size() {
            Ok(size) => size,
            Err(e) => {
                // One-time note; the bridge simply isn't up yet during the
                // first frames of a launch.
                if *frame == POLL_INTERVAL_FRAMES {
                    warn!("safe_area: decor size query failed (will retry): {e}");
                }
                return;
            }
        };
        if decor_w == 0 || decor_h == 0 {
            return; // decor view not laid out yet
        }

        // Reads go through `Deref` and do not trip change detection; only
        // mutate `window` once a mismatch is confirmed.
        let cached_w = window.resolution.physical_width();
        let cached_h = window.resolution.physical_height();
        if decor_w == cached_w && decor_h == cached_h {
            return;
        }

        info!(
            "safe_area: decor view is {decor_w}x{decor_h} but cached resolution is \
             {cached_w}x{cached_h}; forcing relayout (fold/unfold missed by winit?)"
        );
        window.resolution.set_physical_resolution(decor_w, decor_h);
        let scale = window.scale_factor();
        resize_events.write(WindowResized {
            window: entity,
            width: decor_w as f32 / scale,
            height: decor_h as f32 / scale,
        });
        poll.0 = 0;
    }

    /// Physical pixel size of the activity's decor view — the ground truth
    /// for the surface we are actually being displayed on, independent of
    /// whatever winit last told Bevy.
    fn query_decor_size() -> Result<(u32, u32), String> {
        use solitaire_data::android_jni;

        android_jni::with_activity_env(|env, activity| {
            let window = env
                .call_method(activity, "getWindow", "()Landroid/view/Window;", &[])?
                .l()?;
            let decor = env
                .call_method(&window, "getDecorView", "()Landroid/view/View;", &[])?
                .l()?;
            let w = env.call_method(&decor, "getWidth", "()I", &[])?.i()?;
            let h = env.call_method(&decor, "getHeight", "()I", &[])?.i()?;
            Ok((w.max(0) as u32, h.max(0) as u32))
        })
    }

    fn query_insets() -> Result<SafeAreaInsets, String> {
        use solitaire_data::android_jni;

        android_jni::with_activity_env(|env, activity| {
            // Window window = activity.getWindow();
            let window = env
                .call_method(activity, "getWindow", "()Landroid/view/Window;", &[])?
                .l()?;

            // View decor = window.getDecorView();
            let decor = env
                .call_method(&window, "getDecorView", "()Landroid/view/View;", &[])?
                .l()?;

            // WindowInsets insets = decor.getRootWindowInsets();
            let raw_insets = env
                .call_method(
                    &decor,
                    "getRootWindowInsets",
                    "()Landroid/view/WindowInsets;",
                    &[],
                )?
                .l()?;
            if raw_insets.is_null() {
                return Ok(SafeAreaInsets::default());
            }

            // int types = WindowInsets.Type.systemBars();
            // (Static method on the WindowInsets$Type inner class.
            // Available since API 30 / Android 11.)
            let type_class = env.find_class("android/view/WindowInsets$Type")?;
            let bars_type = env
                .call_static_method(&type_class, "systemBars", "()I", &[])?
                .i()?;

            // Insets bars = insets.getInsets(types);
            let bars = env
                .call_method(
                    &raw_insets,
                    "getInsets",
                    "(I)Landroid/graphics/Insets;",
                    &[bars_type.into()],
                )?
                .l()?;

            // `Insets` exposes `top`, `bottom`, `left`, `right` as public
            // `int` fields (pixel values, not dp).
            let top = env.get_field(&bars, "top", "I")?.i()? as f32;
            let bottom = env.get_field(&bars, "bottom", "I")?.i()? as f32;
            let left = env.get_field(&bars, "left", "I")?.i()? as f32;
            let right = env.get_field(&bars, "right", "I")?.i()? as f32;

            Ok(SafeAreaInsets {
                top,
                bottom,
                left,
                right,
            })
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_is_zero_and_not_populated() {
        let i = SafeAreaInsets::default();
        assert_eq!(i.top, 0.0);
        assert_eq!(i.bottom, 0.0);
        assert!(!i.is_populated());
    }

    #[test]
    fn is_populated_returns_true_for_any_nonzero_edge() {
        assert!(
            SafeAreaInsets {
                top: 24.0,
                ..Default::default()
            }
            .is_populated()
        );
        assert!(
            SafeAreaInsets {
                bottom: 16.0,
                ..Default::default()
            }
            .is_populated()
        );
        assert!(
            SafeAreaInsets {
                left: 8.0,
                ..Default::default()
            }
            .is_populated()
        );
        assert!(
            SafeAreaInsets {
                right: 8.0,
                ..Default::default()
            }
            .is_populated()
        );
    }
}
