//! Example: Overhead flat map with A5 grid overlay on slippy map tiles.
//!
//! Uses `bevy_slippy_tiles` to download OpenStreetMap tiles and renders
//! the A5 pentagonal grid on top using gizmos. The camera is a bird's-eye
//! orthographic view looking straight down.
//!
//! Mouse-over highlights the A5 cell beneath the cursor and shows its ID in
//! the top-right of the window. The top-left has a dropdown menu for picking
//! the A5 resolution; number-row keys (`1`..`9`, `0` = 10, `[` / `]` step)
//! also work as a power-user fallback. The grid recolours per resolution
//! (hue rotates with resolution) so changes are visually unmissable.
//!
//! The slippy-tile zoom adapts automatically to the camera viewport: as you
//! zoom in, finer-detail tiles are fetched; as you zoom out, the example
//! drops to coarser tiles. The world coordinate system stays anchored to a
//! fixed `REFERENCE_ZOOM` so panning a partially-loaded map stays stable.
//!
//! Controls:
//!   - WASD or click-and-drag the map to pan
//!   - Mouse wheel or Q/E to zoom
//!   - 1..9 jump directly to that resolution; 0 = resolution 10
//!   - `[` and `]` step the resolution down / up across the full supported range
//!
//! Run with:
//!   cargo run --example overhead_map

use bevy::asset::RenderAssetUsages;
use bevy::camera::ScalingMode;
use bevy::input::mouse::AccumulatedMouseScroll;
use bevy::mesh::{Indices, Mesh, PrimitiveTopology};
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use bevy_a5::prelude::*;
use bevy_a5::query::grid_disk;
use bevy_slippy_tiles::*;
use std::collections::HashSet;

/// Map center: Paris.
const CENTER_LAT: f64 = 48.8566;
const CENTER_LON: f64 = 2.3522;
/// World coordinate system reference. The world units / metre conversion is
/// anchored to this slippy zoom so that switching the *active* tile zoom
/// (which controls what gets fetched) doesn't shift the map.
const REFERENCE_ZOOM: ZoomLevel = ZoomLevel::L8;
const REFERENCE_ZOOM_INT: i32 = 8;
/// Initial active tile zoom (matches reference so the first frame is unscaled).
const DEFAULT_TILE_ZOOM: ZoomLevel = ZoomLevel::L8;
/// Adaptive-zoom clamp. Past 17 OSM rate-limits aggressively and tile counts
/// explode; below 4 the tile spans become huge and lose precision.
const MIN_TILE_ZOOM_INT: i32 = 4;
const MAX_TILE_ZOOM_INT: i32 = 17;
/// Initial A5 resolution.
const DEFAULT_RESOLUTION: i32 = 5;
/// World units per *reference-zoom* tile.
const TILE_SIZE: f32 = 256.0;
/// Min / max viewport heights (orthographic zoom limits).
const VIEWPORT_HEIGHT_MIN: f32 = 80.0;
const VIEWPORT_HEIGHT_MAX: f32 = 4096.0;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(BevyA5Plugin)
        .add_plugins(SlippyTilesPlugin)
        // `auto_render: false` — slippy_tiles' default `display` feature spawns
        // 2D sprite entities for every download, which we don't want (we
        // render our own 3D textured quads in `handle_tile_downloads`).
        .insert_resource(SlippyTilesSettings {
            auto_render: false,
            ..default()
        })
        .insert_resource(
            PlanetSettings::earth(),
        )
        .insert_resource(MapState::default())
        .insert_resource(HoveredCell::default())
        .insert_resource(GridResolution(DEFAULT_RESOLUTION))
        .insert_resource(GridMeshState::default())
        .insert_resource(TileTracker::default())
        .insert_resource(ActiveTileZoom(DEFAULT_TILE_ZOOM))
        .insert_resource(DropdownState::default())
        .add_systems(Startup, setup)
        .add_systems(
            Update,
            (
                pan_camera_keyboard,
                pan_camera_drag,
                zoom_camera,
                resolution_toggle_button,
                resolution_option_button,
                keyboard_resolution_shortcut,
                update_dropdown_visibility,
                update_resolution_dropdown_label,
                update_active_tile_zoom,
                request_visible_tiles,
                handle_tile_downloads,
                rebuild_grid_mesh,
                update_hovered_cell,
                draw_grid_overlay,
                update_cell_id_text,
            )
                .chain(),
        )
        .run();
}

/// Track map state.
#[derive(Resource, Default)]
struct MapState {
    center_tile: Option<SlippyTileCoordinates>,
}

/// Tracks the A5 cell currently under the mouse cursor.
#[derive(Resource, Default)]
struct HoveredCell(Option<GeoCell>);

/// Currently active A5 resolution.
#[derive(Resource)]
struct GridResolution(i32);

/// Currently-active slippy tile zoom. Updated automatically from camera
/// `viewport_height` by `update_active_tile_zoom`.
#[derive(Resource)]
struct ActiveTileZoom(ZoomLevel);

/// Open/closed state for the resolution dropdown.
#[derive(Resource, Default)]
struct DropdownState {
    open: bool,
}

/// Markers.
#[derive(Component)]
struct MapCamera;
#[derive(Component)]
struct CellIdText;
#[derive(Component)]
struct GridMesh;
#[derive(Component)]
struct TileQuad;
#[derive(Component)]
struct ResolutionToggleButton;
#[derive(Component)]
struct ResolutionToggleLabel;
#[derive(Component)]
struct ResolutionOptionsPanel;
#[derive(Component)]
struct ResolutionOption(i32);

/// Tracks what conditions the current grid mesh was built for, so we can
/// rebuild it when the resolution changes or the camera moves enough that
/// new cells should come into view.
#[derive(Resource, Default)]
struct GridMeshState {
    built_resolution: Option<i32>,
    built_center_world: Option<Vec2>,
    built_radius_m: f64,
}

/// Tracks which slippy tiles we have already requested / spawned so the
/// viewport-coverage system never duplicates work. Keyed by (zoom, x, y) so
/// tiles fetched at one active zoom don't conflict with tiles at another.
#[derive(Resource, Default)]
struct TileTracker {
    requested: HashSet<(u8, u32, u32)>,
    spawned: HashSet<(u8, u32, u32)>,
}

/// Tracks the previous mouse cursor position so we can compute drag deltas.
#[derive(Resource, Default)]
struct DragState {
    last_cursor: Option<Vec2>,
    dragging: bool,
}

fn setup(mut commands: Commands, mut map_state: ResMut<MapState>) {
    commands.insert_resource(DragState::default());

    // Orthographic camera looking straight down (bird's eye).
    commands.spawn((
        MapCamera,
        Camera3d::default(),
        Projection::from(OrthographicProjection {
            scaling_mode: ScalingMode::FixedVertical {
                // Start with ~5 reference-zoom tiles visible.
                viewport_height: TILE_SIZE * 5.0,
            },
            ..OrthographicProjection::default_3d()
        }),
        Transform::from_translation(Vec3::new(0.0, 500.0, 0.0))
            .looking_at(Vec3::ZERO, Vec3::NEG_Z),
    ));

    // Anchor map state on Paris. The visibility-driven tile fetcher will
    // request actual tiles starting on the first Update frame.
    let center_tile =
        SlippyTileCoordinates::from_latitude_longitude(CENTER_LAT, CENTER_LON, REFERENCE_ZOOM);
    map_state.center_tile = Some(center_tile);

    // Lighting (unlit tiles still need ambient gizmo visibility; cheap directional light).
    commands.spawn((
        DirectionalLight {
            illuminance: 20_000.0,
            shadows_enabled: false,
            ..default()
        },
        Transform::from_rotation(Quat::from_euler(
            EulerRot::XYZ,
            -std::f32::consts::FRAC_PI_2,
            0.0,
            0.0,
        )),
    ));

    // Floating origin for the A5 grid (at map center).
    let origin_cell = GeoCell::from_lon_lat(CENTER_LON, CENTER_LAT, DEFAULT_RESOLUTION)
        .expect("Center coordinates should be valid");
    commands.spawn((FloatingOrigin::default(), origin_cell, Transform::default()));

    // Top-right: cell-ID readout.
    commands.spawn((
        CellIdText,
        Text::new("Cell: —"),
        TextFont {
            font_size: 16.0,
            ..default()
        },
        TextColor(Color::WHITE),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(8.0),
            right: Val::Px(12.0),
            padding: UiRect::axes(Val::Px(8.0), Val::Px(4.0)),
            ..default()
        },
        BackgroundColor(Color::linear_rgba(0.0, 0.0, 0.0, 0.55)),
    ));

    // Top-left: dropdown menu for picking the A5 resolution.
    commands
        .spawn(Node {
            position_type: PositionType::Absolute,
            top: Val::Px(8.0),
            left: Val::Px(12.0),
            flex_direction: FlexDirection::Column,
            row_gap: Val::Px(2.0),
            ..default()
        })
        .with_children(|parent| {
            // Toggle button.
            parent
                .spawn((
                    ResolutionToggleButton,
                    Button,
                    Node {
                        padding: UiRect::axes(Val::Px(10.0), Val::Px(6.0)),
                        min_width: Val::Px(160.0),
                        ..default()
                    },
                    BackgroundColor(TOGGLE_BG_IDLE),
                ))
                .with_children(|btn| {
                    btn.spawn((
                        ResolutionToggleLabel,
                        Text::new(format!("Resolution: {}  ▾", DEFAULT_RESOLUTION)),
                        TextFont {
                            font_size: 16.0,
                            ..default()
                        },
                        TextColor(Color::WHITE),
                    ));
                });

            // Options panel — hidden until the toggle is opened.
            parent
                .spawn((
                    ResolutionOptionsPanel,
                    Node {
                        display: Display::None,
                        flex_direction: FlexDirection::Column,
                        min_width: Val::Px(160.0),
                        ..default()
                    },
                    BackgroundColor(PANEL_BG),
                ))
                .with_children(|panel| {
                    for res in MIN_RESOLUTION..=MAX_RESOLUTION {
                        panel
                            .spawn((
                                ResolutionOption(res),
                                Button,
                                Node {
                                    padding: UiRect::axes(Val::Px(10.0), Val::Px(4.0)),
                                    ..default()
                                },
                                BackgroundColor(option_bg(res, DEFAULT_RESOLUTION, false)),
                            ))
                            .with_children(|opt| {
                                opt.spawn((
                                    Text::new(format!("Resolution {}", res)),
                                    TextFont {
                                        font_size: 15.0,
                                        ..default()
                                    },
                                    TextColor(Color::WHITE),
                                ));
                            });
                    }
                });
        });

    info!("Overhead map example");
    info!("Slippy zoom adapts to camera viewport_height; tiles are re-fetched on zoom change.");
    info!("Controls: WASD or drag to pan, mouse-wheel or Q/E to zoom, dropdown or 1..0/[ ] for A5 resolution");
}

/// Background colour palette for the dropdown UI.
const TOGGLE_BG_IDLE: Color = Color::linear_rgba(0.10, 0.10, 0.10, 0.85);
const TOGGLE_BG_HOVER: Color = Color::linear_rgba(0.20, 0.20, 0.20, 0.90);
const TOGGLE_BG_PRESSED: Color = Color::linear_rgba(0.25, 0.25, 0.25, 0.95);
const PANEL_BG: Color = Color::linear_rgba(0.08, 0.08, 0.08, 0.95);

/// Per-option background colour: highlight the active resolution; mid-grey on hover.
fn option_bg(option_res: i32, active_res: i32, hovered: bool) -> Color {
    if option_res == active_res {
        if hovered {
            Color::linear_rgba(0.55, 0.40, 0.10, 0.95)
        } else {
            Color::linear_rgba(0.45, 0.30, 0.05, 0.85)
        }
    } else if hovered {
        Color::linear_rgba(0.30, 0.30, 0.30, 0.95)
    } else {
        Color::linear_rgba(0.0, 0.0, 0.0, 0.0)
    }
}

/// Walk the camera's visible bounding box, convert it to slippy-tile
/// coordinates *at the active tile zoom*, and request any tiles that haven't
/// been seen yet. Runs every frame so panning and zooming pull in fresh
/// tiles automatically; key includes zoom so tiles fetched at one zoom level
/// don't collide with those at another.
fn request_visible_tiles(
    map_state: Res<MapState>,
    active_zoom: Res<ActiveTileZoom>,
    cameras: Query<(&Projection, &GlobalTransform), With<MapCamera>>,
    windows: Query<&Window, With<PrimaryWindow>>,
    mut tracker: ResMut<TileTracker>,
    mut tile_requests: MessageWriter<DownloadSlippyTilesMessage>,
) {
    let Some(center_tile_ref) = &map_state.center_tile else {
        return;
    };
    let Ok((projection, gt)) = cameras.single() else {
        return;
    };
    let Ok(window) = windows.single() else {
        return;
    };

    let Projection::Orthographic(ortho) = projection else {
        return;
    };
    let ScalingMode::FixedVertical { viewport_height } = ortho.scaling_mode else {
        return;
    };

    let win_size = window.size();
    if win_size.x <= 0.0 || win_size.y <= 0.0 {
        return;
    }
    let aspect = win_size.x / win_size.y;
    let half_h = viewport_height * 0.5;
    let half_w = half_h * aspect;

    let cam = gt.translation();
    let center_ll = center_tile_ref.to_latitude_longitude(REFERENCE_ZOOM);

    // Visible-viewport bounding box → lon/lat → tile coords at the *active* zoom.
    let (lon_min, lat_max) =
        map_pos_to_lonlat(cam.x - half_w, cam.z - half_h, center_ll.longitude, center_ll.latitude);
    let (lon_max, lat_min) =
        map_pos_to_lonlat(cam.x + half_w, cam.z + half_h, center_ll.longitude, center_ll.latitude);

    let zoom = active_zoom.0;
    let zoom_byte = zoom.to_u8();
    let tl = SlippyTileCoordinates::from_latitude_longitude(lat_max, lon_min, zoom);
    let br = SlippyTileCoordinates::from_latitude_longitude(lat_min, lon_max, zoom);
    let min_tx = tl.x.min(br.x) as i64;
    let max_tx = tl.x.max(br.x) as i64;
    let min_ty = tl.y.min(br.y) as i64;
    let max_ty = tl.y.max(br.y) as i64;

    let max_idx = (1i64 << zoom_byte) - 1;
    // Hard cap so a wildly zoomed-out viewport can't queue thousands of tiles in one frame.
    const MAX_NEW_PER_FRAME: usize = 32;
    let mut queued = 0usize;

    for tx in min_tx..=max_tx {
        if tx < 0 || tx > max_idx {
            continue;
        }
        for ty in min_ty..=max_ty {
            if ty < 0 || ty > max_idx {
                continue;
            }
            let key = (zoom_byte, tx as u32, ty as u32);
            if tracker.requested.contains(&key) {
                continue;
            }
            tracker.requested.insert(key);
            tile_requests.write(DownloadSlippyTilesMessage {
                tile_size: TileSize::Normal,
                zoom_level: zoom,
                coordinates: Coordinates::SlippyTile(SlippyTileCoordinates {
                    x: key.1,
                    y: key.2,
                }),
                radius: Radius(0),
                use_cache: true,
            });
            queued += 1;
            if queued >= MAX_NEW_PER_FRAME {
                return;
            }
        }
    }
}

/// Spawn a flat textured quad for each tile as its download finishes.
/// `TileTracker::spawned` deduplicates repeated downloads of the same tile,
/// and any message whose `zoom_level` doesn't match the current
/// `ActiveTileZoom` is dropped on the floor — those are stale in-flight
/// fetches from before a zoom switch and would otherwise overlap the new-zoom
/// tiles and cause z-fighting.
fn handle_tile_downloads(
    mut commands: Commands,
    mut tile_messages: MessageReader<SlippyTileDownloadedMessage>,
    asset_server: Res<AssetServer>,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
    mut tracker: ResMut<TileTracker>,
    map_state: Res<MapState>,
    active_zoom: Res<ActiveTileZoom>,
) {
    let Some(center_tile_ref) = &map_state.center_tile else {
        return;
    };
    let center_ll = center_tile_ref.to_latitude_longitude(REFERENCE_ZOOM);
    let center_lon = center_ll.longitude;
    let center_lat = center_ll.latitude;
    let active_byte = active_zoom.0.to_u8();

    for message in tile_messages.read() {
        let tile_coords = message.get_slippy_tile_coordinates();
        let zoom_byte = message.zoom_level.to_u8();
        if zoom_byte != active_byte {
            // Stale: download was started under a previous active zoom.
            continue;
        }
        let key = (zoom_byte, tile_coords.x, tile_coords.y);
        if tracker.spawned.contains(&key) {
            continue;
        }
        tracker.spawned.insert(key);

        // Tile corners in lon/lat (slippy tiles use Mercator).
        let tl = tile_coords.to_latitude_longitude(message.zoom_level);
        let br = SlippyTileCoordinates {
            x: tile_coords.x + 1,
            y: tile_coords.y + 1,
        }
        .to_latitude_longitude(message.zoom_level);
        // Project both corners through our equirectangular `lonlat_to_map_pos`
        // and use the resulting world positions for both placement *and*
        // size. Mercator's latitude span per tile shrinks toward the poles,
        // so a uniform `TILE_SIZE` square would leave gaps between rows at
        // non-equator latitudes (this was the source of the persistent
        // horizontal black lines on map load).
        let world_tl = lonlat_to_map_pos(tl.longitude, tl.latitude, center_lon, center_lat);
        let world_br = lonlat_to_map_pos(br.longitude, br.latitude, center_lon, center_lat);
        let world_x = (world_tl.x + world_br.x) * 0.5;
        let world_z = (world_tl.z + world_br.z) * 0.5;
        let tile_size_x = (world_br.x - world_tl.x).abs();
        let tile_size_z = (world_br.z - world_tl.z).abs();

        let image_handle: Handle<Image> = asset_server.load(message.path.clone());

        commands.spawn((
            TileQuad,
            // Unit-sized plane scaled per-tile via Transform.scale — sizes
            // can differ between adjacent tile rows because of Mercator.
            Mesh3d(meshes.add(Plane3d::new(Vec3::Y, Vec2::splat(0.5)))),
            MeshMaterial3d(materials.add(StandardMaterial {
                base_color_texture: Some(image_handle),
                unlit: true,
                ..default()
            })),
            // y=0 keeps tiles below the y=1 grid lines.
            Transform {
                translation: Vec3::new(world_x, 0.0, world_z),
                scale: Vec3::new(tile_size_x, 1.0, tile_size_z),
                ..default()
            },
        ));
    }
}

/// Pick a slippy tile zoom that fits the camera's current viewport. Targets
/// roughly four tiles across the visible height so each tile renders at
/// somewhere near 1:1 with native pixels. Despawns existing tile quads and
/// clears the tracker on a zoom change so new-zoom tiles re-fetch.
///
/// Hysteresis: we only switch zoom if the target tile size has crossed the
/// current tile size by a 2× / 0.5× margin. That prevents a noisy
/// `viewport_height` (e.g. tiny mouse-wheel deltas) from flipping zoom on
/// successive frames and re-fetching the world.
fn update_active_tile_zoom(
    cameras: Query<&Projection, With<MapCamera>>,
    mut active_zoom: ResMut<ActiveTileZoom>,
    mut tracker: ResMut<TileTracker>,
    mut commands: Commands,
    tiles_q: Query<Entity, With<TileQuad>>,
) {
    let Ok(projection) = cameras.single() else {
        return;
    };
    let Projection::Orthographic(ortho) = projection else {
        return;
    };
    let ScalingMode::FixedVertical { viewport_height } = ortho.scaling_mode else {
        return;
    };

    let current_int = active_zoom.0.to_u8() as i32;
    let current_tile_world =
        TILE_SIZE / 2.0_f32.powi(current_int - REFERENCE_ZOOM_INT);
    let target_tile_world = (viewport_height / 4.0).max(1.0);

    // 2× deadband around the current zoom's tile size. The user has to zoom
    // far enough that the *target* tile world size moves out of
    // [current/2, current*2] before we react.
    let new_int = if target_tile_world > current_tile_world * 2.0 {
        // Viewport got bigger: drop to a coarser zoom.
        let steps = (target_tile_world / current_tile_world).log2().floor() as i32;
        (current_int - steps).max(MIN_TILE_ZOOM_INT)
    } else if target_tile_world < current_tile_world * 0.5 {
        // Viewport got smaller: switch to a finer zoom.
        let steps = (current_tile_world / target_tile_world).log2().floor() as i32;
        (current_int + steps).min(MAX_TILE_ZOOM_INT)
    } else {
        current_int
    };

    if new_int == current_int {
        return;
    }
    let Ok(new_zoom) = ZoomLevel::try_from(new_int as u8) else {
        return;
    };

    info!("Slippy tile zoom: {:?} -> {:?}", active_zoom.0, new_zoom);
    active_zoom.0 = new_zoom;
    tracker.requested.clear();
    tracker.spawned.clear();
    for entity in tiles_q.iter() {
        commands.entity(entity).despawn();
    }
}

/// Keyboard pan: WASD.
fn pan_camera_keyboard(
    time: Res<Time>,
    keys: Res<ButtonInput<KeyCode>>,
    mut query: Query<(&mut Transform, &Projection), With<MapCamera>>,
) {
    let Ok((mut transform, projection)) = query.single_mut() else {
        return;
    };
    let viewport_h = match projection {
        Projection::Orthographic(o) => match o.scaling_mode {
            ScalingMode::FixedVertical { viewport_height } => viewport_height,
            _ => 512.0,
        },
        _ => 512.0,
    };
    // Pan ~half a viewport per second.
    let speed = viewport_h * 0.6 * time.delta_secs();

    let mut delta = Vec3::ZERO;
    if keys.pressed(KeyCode::KeyW) {
        delta.z -= speed;
    }
    if keys.pressed(KeyCode::KeyS) {
        delta.z += speed;
    }
    if keys.pressed(KeyCode::KeyA) {
        delta.x -= speed;
    }
    if keys.pressed(KeyCode::KeyD) {
        delta.x += speed;
    }
    transform.translation += delta;
}

/// Click-and-drag pan with the left mouse button.
///
/// Skipped while the cursor is over a UI button so the dropdown remains usable.
fn pan_camera_drag(
    mouse_buttons: Res<ButtonInput<MouseButton>>,
    windows: Query<&Window, With<PrimaryWindow>>,
    cameras: Query<(&Camera, &Projection, &GlobalTransform), With<MapCamera>>,
    mut camera_transforms: Query<&mut Transform, With<MapCamera>>,
    button_interactions: Query<&Interaction, With<Button>>,
    mut drag: ResMut<DragState>,
) {
    let Ok(window) = windows.single() else {
        return;
    };
    let Ok((camera, projection, _camera_global)) = cameras.single() else {
        return;
    };
    let Ok(mut transform) = camera_transforms.single_mut() else {
        return;
    };

    let cursor = window.cursor_position();

    // If the user is interacting with a UI button, do not start a drag.
    let over_ui = button_interactions
        .iter()
        .any(|i| matches!(i, Interaction::Hovered | Interaction::Pressed));

    if mouse_buttons.just_pressed(MouseButton::Left) && !over_ui {
        drag.dragging = true;
        drag.last_cursor = cursor;
    }
    if mouse_buttons.just_released(MouseButton::Left) {
        drag.dragging = false;
        drag.last_cursor = None;
    }
    if !drag.dragging {
        drag.last_cursor = cursor;
        return;
    }

    let (Some(cur), Some(prev)) = (cursor, drag.last_cursor) else {
        drag.last_cursor = cursor;
        return;
    };
    if cur == prev {
        return;
    }

    // Convert pixel delta into world units using the orthographic viewport scale.
    let viewport_h = match projection {
        Projection::Orthographic(o) => match o.scaling_mode {
            ScalingMode::FixedVertical { viewport_height } => viewport_height,
            _ => 512.0,
        },
        _ => 512.0,
    };
    let Some(viewport_size) = camera.logical_viewport_size() else {
        return;
    };
    if viewport_size.y <= 0.0 {
        return;
    }
    let world_per_pixel = viewport_h / viewport_size.y;

    let pixel_delta = cur - prev;
    // Cursor pixel y grows downward; with the camera looking down -Y the world Z axis
    // grows in the same direction as cursor y (south = +z), so dragging down should
    // pull the world south, i.e. move the camera north (-z).
    transform.translation.x -= pixel_delta.x * world_per_pixel;
    transform.translation.z -= pixel_delta.y * world_per_pixel;

    drag.last_cursor = cursor;
}

/// Zoom with mouse wheel and Q/E.
fn zoom_camera(
    time: Res<Time>,
    keys: Res<ButtonInput<KeyCode>>,
    scroll: Res<AccumulatedMouseScroll>,
    mut query: Query<&mut Projection, With<MapCamera>>,
) {
    let Ok(mut projection) = query.single_mut() else {
        return;
    };
    let Projection::Orthographic(ref mut ortho) = *projection else {
        return;
    };
    let ScalingMode::FixedVertical {
        ref mut viewport_height,
    } = ortho.scaling_mode
    else {
        return;
    };

    // Multiplicative zoom: each scroll line / Q tap multiplies viewport height.
    let mut zoom_steps: f32 = 0.0;
    if scroll.delta.y != 0.0 {
        // Wheel up = zoom in = smaller viewport.
        zoom_steps -= scroll.delta.y;
    }
    if keys.pressed(KeyCode::KeyE) {
        zoom_steps -= 4.0 * time.delta_secs();
    }
    if keys.pressed(KeyCode::KeyQ) {
        zoom_steps += 4.0 * time.delta_secs();
    }
    if zoom_steps == 0.0 {
        return;
    }

    let factor = 1.15_f32.powf(zoom_steps);
    *viewport_height = (*viewport_height * factor).clamp(VIEWPORT_HEIGHT_MIN, VIEWPORT_HEIGHT_MAX);
}

/// Cast a ray from the cursor through the camera and intersect the y=0 plane,
/// then look up the A5 cell at that lon/lat and the active resolution.
fn update_hovered_cell(
    windows: Query<&Window, With<PrimaryWindow>>,
    cameras: Query<(&Camera, &GlobalTransform), With<MapCamera>>,
    map_state: Res<MapState>,
    resolution: Res<GridResolution>,
    mut hovered: ResMut<HoveredCell>,
) {
    let Some(center_tile) = &map_state.center_tile else {
        if hovered.0.is_some() {
            hovered.0 = None;
        }
        return;
    };
    let Ok(window) = windows.single() else {
        return;
    };
    let Ok((camera, camera_transform)) = cameras.single() else {
        return;
    };
    let Some(cursor) = window.cursor_position() else {
        if hovered.0.is_some() {
            hovered.0 = None;
        }
        return;
    };
    let Ok(ray) = camera.viewport_to_world(camera_transform, cursor) else {
        return;
    };

    let dir = ray.direction.as_vec3();
    if dir.y.abs() < 1e-6 {
        return;
    }
    let t = -ray.origin.y / dir.y;
    if t < 0.0 {
        if hovered.0.is_some() {
            hovered.0 = None;
        }
        return;
    }
    let hit = ray.origin + dir * t;

    let center_ll = center_tile.to_latitude_longitude(REFERENCE_ZOOM);
    let (lon, lat) = map_pos_to_lonlat(hit.x, hit.z, center_ll.longitude, center_ll.latitude);

    let new_cell = GeoCell::from_lon_lat(lon, lat, resolution.0);
    if hovered.0 != new_cell {
        hovered.0 = new_cell;
    }
}

/// Despawn any existing grid mesh entity and spawn a fresh one whenever the
/// resolution changes or the camera moves enough that new cells should come
/// into view.
fn rebuild_grid_mesh(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
    mut state: ResMut<GridMeshState>,
    resolution: Res<GridResolution>,
    map_state: Res<MapState>,
    camera_query: Query<(&Camera, &Projection, &GlobalTransform), With<MapCamera>>,
    grid_q: Query<Entity, With<GridMesh>>,
) {
    let Some(center_tile) = &map_state.center_tile else {
        return;
    };
    let Ok((_, _, camera_gt)) = camera_query.single() else {
        return;
    };

    let center_ll = center_tile.to_latitude_longitude(REFERENCE_ZOOM);
    let center_lon = center_ll.longitude;
    let center_lat = center_ll.latitude;

    let scale = lonlat_scale(center_lat);
    let visible_radius_m = visible_radius_meters(&camera_query, scale);
    let build_radius_m = visible_radius_m * 2.0;

    let cam_world = camera_gt.translation();
    let cam_xz = Vec2::new(cam_world.x, cam_world.z);

    let needs_resolution_rebuild =
        resolution.is_changed() || state.built_resolution != Some(resolution.0);
    let needs_pan_rebuild = match state.built_center_world {
        None => true,
        Some(prev) => {
            let drift_world = (cam_xz - prev).length() as f64;
            let metres_per_world = 110_574.0 / scale.1.max(1e-6);
            let drift_m = drift_world * metres_per_world;
            drift_m > (build_radius_m - visible_radius_m) * 0.5
                || visible_radius_m * 2.0 > state.built_radius_m
        }
    };

    if !needs_resolution_rebuild && !needs_pan_rebuild {
        return;
    }

    let (cam_lon, cam_lat) =
        map_pos_to_lonlat(cam_world.x, cam_world.z, center_lon, center_lat);
    let Some(cam_cell) = GeoCell::from_lon_lat(cam_lon, cam_lat, resolution.0) else {
        return;
    };
    // `bevy_a5::query` guarantees uniform-resolution results, so `grid_disk`,
    // `spherical_cap`, etc. all return cells at exactly `cam_cell.resolution()`.
    // We pick `grid_disk` here because it gives a predictable count budget:
    // pick a ring count `k` to cover `build_radius_m`, then clamp `k` so the
    // total never exceeds `MAX_CELLS`. The cells come back in concentric
    // rings around the centre, which makes the "we hit the budget" failure
    // mode visually clean — a centred disk of cells with empty corners,
    // never a randomly-truncated subset.
    const MAX_CELLS: usize = 200_000;
    // For pentagonal-ish tilings the disk count grows roughly as
    // `1 + 5 * k * (k+1) / 2 ≈ 2.5 * k²`. Solve for the largest k that fits.
    let max_k_by_budget =
        (((MAX_CELLS as f64 - 1.0) * 2.0 / 5.0).sqrt() as usize).max(1);

    // Cell area for this resolution → characteristic edge length in metres.
    let cell_area_m2 = cam_cell.area().max(1e-3);
    let cell_edge_m = cell_area_m2.sqrt();
    let k_for_coverage =
        ((build_radius_m / cell_edge_m).ceil() as usize).max(1) + 1;

    let k = k_for_coverage.min(max_k_by_budget);
    if k_for_coverage > max_k_by_budget {
        info!(
            "Grid disk capped at k={} (would need k={} for full coverage at resolution {}); centre region only",
            max_k_by_budget, k_for_coverage, resolution.0
        );
    }
    let cells = grid_disk(&cam_cell, k).unwrap_or_default();

    let mut positions: Vec<[f32; 3]> = Vec::new();
    let mut indices: Vec<u32> = Vec::new();
    let mut offset: u32 = 0;

    for cell in &cells {
        let Some(boundary) = cell.boundary() else {
            continue;
        };
        let verts: Vec<_> = if boundary.len() > 1 && boundary.first() == boundary.last() {
            boundary[..boundary.len() - 1].to_vec()
        } else {
            boundary
        };
        if verts.is_empty() {
            continue;
        }

        for ll in &verts {
            let p = lonlat_to_map_pos(ll.longitude(), ll.latitude(), center_lon, center_lat);
            positions.push([p.x, p.y, p.z]);
        }
        let n = verts.len() as u32;
        for i in 0..n {
            indices.push(offset + i);
            indices.push(offset + (i + 1) % n);
        }
        offset += n;
    }

    let mut new_mesh = Mesh::new(
        PrimitiveTopology::LineList,
        RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
    );
    new_mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
    new_mesh.insert_indices(Indices::U32(indices));

    // Despawn every existing grid-mesh entity, then spawn a fresh one.
    let mut despawned = 0;
    for e in grid_q.iter() {
        commands.entity(e).despawn();
        despawned += 1;
    }

    let (base_color, emissive) = grid_color_for_resolution(resolution.0);
    commands.spawn((
        GridMesh,
        Mesh3d(meshes.add(new_mesh)),
        MeshMaterial3d(materials.add(StandardMaterial {
            base_color,
            emissive,
            unlit: true,
            ..default()
        })),
        Transform::default(),
    ));

    info!(
        "Rebuilt A5 grid mesh: resolution={}, cells={}, despawned={}",
        resolution.0,
        cells.len(),
        despawned
    );

    state.built_resolution = Some(resolution.0);
    state.built_center_world = Some(cam_xz);
    state.built_radius_m = build_radius_m;
}

/// Bounds on resolution. A5 supports ~30 levels but past resolution 12 the
/// per-cell area shrinks below sub-metre, which is finer than this map's
/// projection accuracy.
const MIN_RESOLUTION: i32 = 1;
const MAX_RESOLUTION: i32 = 15;

/// Resolution input:
/// - Digits `1..9` jump to that exact resolution.
/// - `0` jumps to resolution 10.
/// - `[` / `]` step the current resolution down / up by one (full range).
fn keyboard_resolution_shortcut(
    keys: Res<ButtonInput<KeyCode>>,
    mut resolution: ResMut<GridResolution>,
    mut grid_state: ResMut<GridMeshState>,
    mut origin_q: Query<&mut GeoCell, With<FloatingOrigin>>,
) {
    let direct = [
        (KeyCode::Digit1, 1),
        (KeyCode::Digit2, 2),
        (KeyCode::Digit3, 3),
        (KeyCode::Digit4, 4),
        (KeyCode::Digit5, 5),
        (KeyCode::Digit6, 6),
        (KeyCode::Digit7, 7),
        (KeyCode::Digit8, 8),
        (KeyCode::Digit9, 9),
        (KeyCode::Digit0, 10),
    ];
    let mut target: Option<i32> = None;
    for (key, value) in direct {
        if keys.just_pressed(key) {
            target = Some(value);
            break;
        }
    }
    if target.is_none() {
        if keys.just_pressed(KeyCode::BracketRight) {
            target = Some((resolution.0 + 1).min(MAX_RESOLUTION));
        } else if keys.just_pressed(KeyCode::BracketLeft) {
            target = Some((resolution.0 - 1).max(MIN_RESOLUTION));
        }
    }
    let Some(new) = target else {
        return;
    };
    if resolution.0 == new {
        return;
    }
    info!("Resolution change requested: {}", new);
    resolution.0 = new;
    grid_state.built_resolution = None;
    if let Ok(mut cell) = origin_q.single_mut() {
        if let Some(new_cell) = GeoCell::from_lon_lat(CENTER_LON, CENTER_LAT, new) {
            *cell = new_cell;
        }
    }
}

/// Draw only the hovered-cell highlight using gizmos. The bulk grid is a
/// persistent mesh built by `rebuild_grid_mesh`.
fn draw_grid_overlay(mut gizmos: Gizmos, map_state: Res<MapState>, hovered: Res<HoveredCell>) {
    let Some(center_tile) = &map_state.center_tile else {
        return;
    };
    let Some(cell) = hovered.0 else {
        return;
    };
    let center_ll = center_tile.to_latitude_longitude(REFERENCE_ZOOM);
    let center_lon = center_ll.longitude;
    let center_lat = center_ll.latitude;

    let Some(boundary) = cell.boundary() else {
        return;
    };
    let verts: Vec<_> = if boundary.len() > 1 && boundary.first() == boundary.last() {
        boundary[..boundary.len() - 1].to_vec()
    } else {
        boundary
    };
    if verts.is_empty() {
        return;
    }

    let color = Color::linear_rgb(1.0, 1.0, 0.0);
    let projected: Vec<Vec3> = verts
        .iter()
        .map(|ll| lonlat_to_map_pos(ll.longitude(), ll.latitude(), center_lon, center_lat))
        .collect();

    // Two overlapping passes for a visibly thicker highlight outline.
    for offset in [Vec3::new(0.0, 0.5, 0.0), Vec3::new(0.0, 1.0, 0.0)] {
        for i in 0..projected.len() {
            let next = (i + 1) % projected.len();
            gizmos.line(projected[i] + offset, projected[next] + offset, color);
        }
    }

    if let Some(ll) = cell.center() {
        let pos = lonlat_to_map_pos(ll.longitude(), ll.latitude(), center_lon, center_lat);
        gizmos.sphere(Isometry3d::from_translation(pos), 2.0, color);
    }
}

/// Approximate the radius of the visible viewport in metres on the sphere.
fn visible_radius_meters(
    camera_query: &Query<(&Camera, &Projection, &GlobalTransform), With<MapCamera>>,
    scale: (f64, f64),
) -> f64 {
    let Ok((camera, projection, _)) = camera_query.single() else {
        return 200_000.0;
    };
    let viewport_h = match projection {
        Projection::Orthographic(o) => match o.scaling_mode {
            ScalingMode::FixedVertical { viewport_height } => viewport_height,
            _ => 512.0,
        },
        _ => 512.0,
    };
    let aspect = camera
        .logical_viewport_size()
        .map(|s| if s.y > 0.0 { s.x / s.y } else { 1.0 })
        .unwrap_or(1.0);
    let viewport_w = viewport_h * aspect;
    let half_diag_world = ((viewport_w * viewport_w + viewport_h * viewport_h).sqrt() * 0.5) as f64;

    // Use the smaller of x/y world-units-per-degree to stay conservative when converting back to metres.
    // scale is world-units-per-degree; metres-per-degree-lat is ~110_574, so metres-per-world-unit ≈ 110_574 / scale.1.
    let metres_per_world = 110_574.0 / scale.1.max(1e-6);
    half_diag_world * metres_per_world
}

/// Update the top-right text with the hovered cell ID.
fn update_cell_id_text(
    hovered: Res<HoveredCell>,
    mut text_query: Query<&mut Text, With<CellIdText>>,
) {
    if !hovered.is_changed() {
        return;
    }
    let Ok(mut text) = text_query.single_mut() else {
        return;
    };
    text.0 = match hovered.0 {
        Some(cell) => format!("Cell: 0x{:016x}  (res {})", cell.raw(), cell.resolution()),
        None => "Cell: —".to_string(),
    };
}

/// Pick a (base, emissive) colour pair for the grid at a given resolution so
/// resolution changes are visually unmissable. Hue rotates around the colour
/// wheel as resolution grows; saturation/brightness held constant.
fn grid_color_for_resolution(resolution: i32) -> (Color, LinearRgba) {
    // Spread one full hue revolution over the supported range.
    let span = (MAX_RESOLUTION - MIN_RESOLUTION).max(1) as f32;
    let t = ((resolution - MIN_RESOLUTION).clamp(0, MAX_RESOLUTION - MIN_RESOLUTION)) as f32
        / span;
    let hue = t * 360.0;
    let base = Color::hsl(hue, 0.95, 0.55);
    let emissive_color = Color::hsl(hue, 0.95, 0.65);
    let emissive = LinearRgba::from(emissive_color) * 1.5;
    (base, emissive)
}

/// Toggle the dropdown panel + repaint the toggle button per Interaction state.
fn resolution_toggle_button(
    mut q: Query<
        (&Interaction, &mut BackgroundColor),
        (Changed<Interaction>, With<ResolutionToggleButton>),
    >,
    mut state: ResMut<DropdownState>,
) {
    for (interaction, mut bg) in q.iter_mut() {
        match *interaction {
            Interaction::Pressed => {
                state.open = !state.open;
                bg.0 = TOGGLE_BG_PRESSED;
            }
            Interaction::Hovered => bg.0 = TOGGLE_BG_HOVER,
            Interaction::None => bg.0 = TOGGLE_BG_IDLE,
        }
    }
}

/// Pick a resolution when an option button is clicked. Updates `GridResolution`,
/// flags `GridMeshState::built_resolution = None` so `rebuild_grid_mesh` will
/// always rebuild on the next chained pass, re-anchors the floating origin,
/// and closes the dropdown.
fn resolution_option_button(
    interaction_q: Query<
        (&Interaction, &ResolutionOption),
        Changed<Interaction>,
    >,
    mut style_q: Query<(&ResolutionOption, &Interaction, &mut BackgroundColor)>,
    mut resolution: ResMut<GridResolution>,
    mut grid_state: ResMut<GridMeshState>,
    mut origin_q: Query<&mut GeoCell, With<FloatingOrigin>>,
    mut dropdown: ResMut<DropdownState>,
) {
    for (interaction, opt) in interaction_q.iter() {
        if matches!(*interaction, Interaction::Pressed) && resolution.0 != opt.0 {
            info!("Resolution selected: {}", opt.0);
            resolution.0 = opt.0;
            grid_state.built_resolution = None;
            if let Ok(mut cell) = origin_q.single_mut() {
                if let Some(new_cell) = GeoCell::from_lon_lat(CENTER_LON, CENTER_LAT, opt.0) {
                    *cell = new_cell;
                }
            }
            dropdown.open = false;
        }
    }

    // Repaint each option's background each frame (~15 buttons, negligible).
    for (opt, interaction, mut bg) in style_q.iter_mut() {
        let hovered = matches!(*interaction, Interaction::Hovered | Interaction::Pressed);
        bg.0 = option_bg(opt.0, resolution.0, hovered);
    }
}

/// Show / hide the options panel based on `DropdownState::open`.
fn update_dropdown_visibility(
    state: Res<DropdownState>,
    mut panel_q: Query<&mut Node, With<ResolutionOptionsPanel>>,
) {
    if !state.is_changed() {
        return;
    }
    let Ok(mut node) = panel_q.single_mut() else {
        return;
    };
    node.display = if state.open {
        Display::Flex
    } else {
        Display::None
    };
}

/// Keep the toggle-button label in sync with the active resolution.
fn update_resolution_dropdown_label(
    resolution: Res<GridResolution>,
    mut label_q: Query<&mut Text, With<ResolutionToggleLabel>>,
) {
    if !resolution.is_changed() {
        return;
    }
    let Ok(mut label) = label_q.single_mut() else {
        return;
    };
    label.0 = format!("Resolution: {}  ▾", resolution.0);
}

/// Convert lon/lat to flat map position relative to centre using a simple
/// equirectangular projection scaled to match slippy tile world units.
fn lonlat_to_map_pos(lon: f64, lat: f64, center_lon: f64, center_lat: f64) -> Vec3 {
    let scale = lonlat_scale(center_lat);
    let dx = (lon - center_lon) * scale.0;
    let dy = (lat - center_lat) * scale.1;
    Vec3::new(dx as f32, 1.0, -(dy) as f32)
}

/// Inverse of `lonlat_to_map_pos`.
fn map_pos_to_lonlat(x: f32, z: f32, center_lon: f64, center_lat: f64) -> (f64, f64) {
    let scale = lonlat_scale(center_lat);
    let lon = center_lon + (x as f64) / scale.0;
    let lat = center_lat + (-(z as f64)) / scale.1;
    (lon, lat)
}

/// Returns (world-units-per-degree-lon, world-units-per-degree-lat) at the given centre latitude.
fn lonlat_scale(center_lat: f64) -> (f64, f64) {
    let meters_per_deg_lon = 111_320.0 * center_lat.to_radians().cos();
    let meters_per_deg_lat = 110_574.0;

    let n = 2.0_f64.powi(REFERENCE_ZOOM.to_u8() as i32);
    let tile_deg_lon = 360.0 / n;
    let tile_meters = tile_deg_lon * meters_per_deg_lon;
    let meters_to_world = TILE_SIZE as f64 / tile_meters;

    (
        meters_per_deg_lon * meters_to_world,
        meters_per_deg_lat * meters_to_world,
    )
}
