#![warn(missing_docs)]

//! A simple map view widget for `egui`.
//!
//! This crate provides a `Map` widget that can be used to display a map from a tile server.
//! It supports panning, zooming, and displaying the current mouse position in geographical coordinates.
//!
//! # Example
//!
//! ```no_run
//! use eframe::egui;
//! use egui_map_view::{Map, config::OpenStreetMapConfig};
//!
//! struct MyApp {
//!     map: Map,
//! }
//!
//! impl Default for MyApp {
//!     fn default() -> Self {
//!         Self {
//!             map: Map::new(OpenStreetMapConfig::default()),
//!         }
//!     }
//! }
//!
//! impl eframe::App for MyApp {
//!     fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
//!         egui::CentralPanel::default()
//!             .frame(egui::Frame::NONE)
//!             .show_inside(ui, |ui| {
//!                if ui.add(&mut self.map).clicked() {
//!                    if let Some(pos) = self.map.mouse_pos {
//!                        println!("Map clicked at {} x {}", pos.lon, pos.lat);
//!                    }
//!                };
//!             });
//!     }
//! }
//! ```

/// Configuration traits and types for the map widget.
pub mod config;

/// Map layers.
#[cfg(feature = "layers")]
pub mod layers;

/// Map projection.
pub mod projection;

use eframe::egui;
use egui::{Color32, NumExt, Rect, Response, Sense, Ui, Vec2, Widget, pos2};
use eyre::{Context, Result};
use log::{debug, error};
use poll_promise::Promise;
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use thiserror::Error;

use crate::config::MapConfig;
use crate::layers::Layer;
use crate::projection::{GeoPos, MapProjection};

// The size of a map tile in pixels.
const TILE_SIZE: u32 = 256;
/// The minimum zoom level.
pub const MIN_ZOOM: u8 = 0;
/// The maximum zoom level.
pub const MAX_ZOOM: u8 = 19;

// Reuse the reqwest client for all tile downloads by making it a static variable.
static CLIENT: std::sync::LazyLock<reqwest::blocking::Client> = std::sync::LazyLock::new(|| {
    reqwest::blocking::Client::builder()
        .user_agent(format!(
            "{}/{}",
            env!("CARGO_PKG_NAME"),
            env!("CARGO_PKG_VERSION")
        ))
        .build()
        .expect("Failed to build reqwest client")
});

/// Errors that can occur while using the map widget.
#[derive(Error, Debug)]
pub enum MapError {
    /// An error occurred while making a web request.
    #[error("Connection error")]
    ConnectionError(#[from] reqwest::Error),

    /// A map tile failed to download.
    #[error("A map tile failed to download. HTTP Status: `{0}`")]
    TileDownloadError(String),

    /// The downloaded tile bytes could not be converted to an image.
    #[error("Unable to convert downloaded map tile bytes as image")]
    TileBytesConversionError(#[from] image::ImageError),
}

/// A unique identifier for a map tile.
#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq)]
pub struct TileId {
    /// The zoom level.
    pub z: u8,

    /// The x-coordinate of the tile.
    pub x: u32,

    /// The y-coordinate of the tile.
    pub y: u32,
}

impl TileId {
    fn to_url(self, config: &dyn MapConfig) -> String {
        config.tile_url(&self)
    }
}

/// The state of a tile in the cache.
enum Tile {
    /// The tile is being downloaded.
    Loading(Promise<Result<egui::ColorImage, Arc<eyre::Report>>>),

    /// The tile is in memory.
    Loaded(egui::TextureHandle),

    /// The tile failed to download.
    Failed(Arc<eyre::Report>),

    /// The tile state is unknown.
    Unknown,
}

/// The map widget.
pub struct Map {
    /// The geographical center of the map. (longitude, latitude)
    pub center: GeoPos,

    /// The zoom level of the map.
    pub zoom: u8,

    tiles: HashMap<TileId, Tile>,

    /// The geographical position under the mouse pointer, if any. (longitude, latitude)
    pub mouse_pos: Option<GeoPos>,

    /// Configuration for the map, such as the tile server URL.
    config: Box<dyn MapConfig>,

    /// Layers to be drawn on top of the base map.
    layers: BTreeMap<String, Box<dyn Layer>>,
}

impl Map {
    /// Creates a new `Map` widget.
    ///
    /// # Arguments
    ///
    /// * `config` - A type that implements `MapConfig`, which provides configuration for the map.
    pub fn new<C: MapConfig + 'static>(config: C) -> Self {
        let center = GeoPos::from(config.default_center());
        let min_zoom = config.min_zoom();
        let max_zoom = config.max_zoom().max(min_zoom);
        let zoom = config.default_zoom().clamp(min_zoom, max_zoom);
        Self {
            tiles: HashMap::new(),
            mouse_pos: None,
            config: Box::new(config),
            center,
            zoom,
            layers: BTreeMap::new(),
        }
    }

    /// Adds a layer to the map.
    pub fn add_layer(&mut self, key: impl Into<String>, layer: impl Layer + 'static) {
        self.layers.insert(key.into(), Box::new(layer));
    }

    /// Remove a layer from the map
    pub fn remove_layer(&mut self, key: &str) -> bool {
        self.layers.remove(key).is_some()
    }

    /// Get a reference to the layers.
    #[must_use]
    pub fn layers(&self) -> &BTreeMap<String, Box<dyn Layer>> {
        &self.layers
    }

    /// Get a mutable reference to the layers.
    pub fn layers_mut(&mut self) -> &mut BTreeMap<String, Box<dyn Layer>> {
        &mut self.layers
    }

    /// Get a reference to a specific layer.
    #[must_use]
    pub fn layer<T: Layer>(&self, key: &str) -> Option<&T> {
        self.layers
            .get(key)
            .and_then(|layer| layer.as_any().downcast_ref::<T>())
    }

    /// Get a mutable reference to a specific layer.
    pub fn layer_mut<T: Layer>(&mut self, key: &str) -> Option<&mut T> {
        self.layers
            .get_mut(key)
            .and_then(|layer| layer.as_any_mut().downcast_mut::<T>())
    }

    /// Handles user input for panning and zooming.
    fn handle_input(&mut self, ui: &Ui, rect: &Rect, response: &Response) {
        // Handle panning
        if response.dragged() {
            let delta = response.drag_delta();
            let center_in_tiles_x = lon_to_x(self.center.lon, self.zoom);
            let center_in_tiles_y = lat_to_y(self.center.lat, self.zoom);

            let mut new_center_x = center_in_tiles_x - (f64::from(delta.x) / f64::from(TILE_SIZE));
            let mut new_center_y = center_in_tiles_y - (f64::from(delta.y) / f64::from(TILE_SIZE));

            // Clamp the new center to the map boundaries.
            let world_size_in_tiles = 2.0_f64.powi(i32::from(self.zoom));
            let view_size_in_tiles_x = f64::from(rect.width()) / f64::from(TILE_SIZE);
            let view_size_in_tiles_y = f64::from(rect.height()) / f64::from(TILE_SIZE);

            let min_center_x = view_size_in_tiles_x / 2.0;
            let max_center_x = world_size_in_tiles - view_size_in_tiles_x / 2.0;
            let min_center_y = view_size_in_tiles_y / 2.0;
            let max_center_y = world_size_in_tiles - view_size_in_tiles_y / 2.0;

            // If the map is smaller than the viewport, center it. Otherwise, clamp the center.
            new_center_x = if min_center_x > max_center_x {
                world_size_in_tiles / 2.0
            } else {
                new_center_x.clamp(min_center_x, max_center_x)
            };
            new_center_y = if min_center_y > max_center_y {
                world_size_in_tiles / 2.0
            } else {
                new_center_y.clamp(min_center_y, max_center_y)
            };

            self.center = (
                x_to_lon(new_center_x, self.zoom),
                y_to_lat(new_center_y, self.zoom),
            )
                .into();
        }

        // Handle double-click to zoom and center
        if response.double_clicked()
            && let Some(pointer_pos) = response.interact_pointer_pos()
        {
            let min_zoom = self.config.min_zoom();
            let max_zoom = self.config.max_zoom().max(min_zoom);
            let new_zoom = (self.zoom + 1).clamp(min_zoom, max_zoom);

            if new_zoom != self.zoom {
                // Determine the geo-coordinate under the mouse cursor before the zoom
                let mouse_rel = pointer_pos - rect.min;
                let center_x = lon_to_x(self.center.lon, self.zoom);
                let center_y = lat_to_y(self.center.lat, self.zoom);
                let widget_center_x = f64::from(rect.width()) / 2.0;
                let widget_center_y = f64::from(rect.height()) / 2.0;

                let target_x =
                    center_x + (f64::from(mouse_rel.x) - widget_center_x) / f64::from(TILE_SIZE);
                let target_y =
                    center_y + (f64::from(mouse_rel.y) - widget_center_y) / f64::from(TILE_SIZE);

                let new_center_lon = x_to_lon(target_x, self.zoom);
                let new_center_lat = y_to_lat(target_y, self.zoom);

                // Set the new zoom level and center the map on the clicked location
                self.zoom = new_zoom;
                self.center = (new_center_lon, new_center_lat).into();
            }
        }

        // Handle scroll-to-zoom
        if response.hovered()
            && let Some(mouse_pos) = response.hover_pos()
        {
            let mouse_rel = mouse_pos - rect.min;

            // Determine the geo-coordinate under the mouse cursor.
            let center_x = lon_to_x(self.center.lon, self.zoom);
            let center_y = lat_to_y(self.center.lat, self.zoom);
            let widget_center_x = f64::from(rect.width()) / 2.0;
            let widget_center_y = f64::from(rect.height()) / 2.0;

            let target_x =
                center_x + (f64::from(mouse_rel.x) - widget_center_x) / f64::from(TILE_SIZE);
            let target_y =
                center_y + (f64::from(mouse_rel.y) - widget_center_y) / f64::from(TILE_SIZE);

            let scroll = ui.input(|i| i.smooth_scroll_delta.y);
            if scroll != 0.0 {
                let min_zoom = self.config.min_zoom();
                let max_zoom = self.config.max_zoom().max(min_zoom);
                let old_zoom = self.zoom;
                let mut new_zoom = (i32::from(self.zoom) + scroll.signum() as i32)
                    .clamp(i32::from(min_zoom), i32::from(max_zoom))
                    as u8;

                // If we are zooming out, check if the new zoom level is valid.
                if scroll < 0.0 {
                    let world_pixel_size = 2.0_f64.powi(i32::from(new_zoom)) * f64::from(TILE_SIZE);
                    // If the world size would become smaller than the widget size, reject the zoom.
                    if world_pixel_size < f64::from(rect.width())
                        || world_pixel_size < f64::from(rect.height())
                    {
                        new_zoom = old_zoom; // Effectively cancel the zoom by reverting to the old value.
                    }
                }

                if new_zoom != old_zoom {
                    let target_lon = x_to_lon(target_x, old_zoom);
                    let target_lat = y_to_lat(target_y, old_zoom);

                    // Set the new zoom level
                    self.zoom = new_zoom;

                    // Adjust the map center so the geo-coordinate under the mouse remains the
                    // same
                    let new_target_x = lon_to_x(target_lon, new_zoom);
                    let new_target_y = lat_to_y(target_lat, new_zoom);

                    let new_center_x = new_target_x
                        - (f64::from(mouse_rel.x) - widget_center_x) / f64::from(TILE_SIZE);
                    let new_center_y = new_target_y
                        - (f64::from(mouse_rel.y) - widget_center_y) / f64::from(TILE_SIZE);

                    self.center = (
                        x_to_lon(new_center_x, new_zoom),
                        y_to_lat(new_center_y, new_zoom),
                    )
                        .into();
                }
            }
        }
    }

    /// Draws the attribution text.
    fn draw_attribution(&self, ui: &mut Ui, rect: &Rect) {
        if let Some(attribution) = self.config.attribution() {
            let (_text_color, bg_color) = if ui.visuals().dark_mode {
                (Color32::from_gray(230), Color32::from_black_alpha(150))
            } else {
                (Color32::from_gray(80), Color32::from_white_alpha(150))
            };

            let frame = egui::Frame::NONE
                .inner_margin(egui::Margin::same(5)) // A bit of padding around the label/URL element
                .fill(bg_color)
                .corner_radius(3.0); // Round the edges

            // We use a child UI instead of egui::Area to ensure the attribution
            // stays on the same layer as the map widget. This fixes issues where
            // the attribution would disappear when the map is inside a Window
            // and the user interacts with it.
            let attribution_pos = rect.left_bottom() + egui::vec2(5.0, -5.0);

            // Allocate a small area for the attribution.
            // We use a large enough width but it will be constrained by the content.
            let mut child_ui = ui.new_child(
                egui::UiBuilder::new()
                    .max_rect(Rect::from_min_size(
                        attribution_pos - egui::vec2(0.0, 30.0),
                        egui::vec2(rect.width() - 10.0, 30.0),
                    ))
                    .id_salt("attribution"),
            );

            child_ui.with_layout(egui::Layout::left_to_right(egui::Align::BOTTOM), |ui| {
                frame.show(ui, |ui| {
                    ui.style_mut().override_text_style = Some(egui::TextStyle::Small);
                    ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); // Don't wrap attribution text.

                    if let Some(url) = self.config.attribution_url() {
                        ui.hyperlink_to(attribution, url);
                    } else {
                        ui.label(attribution);
                    }
                });
            });
        }
    }
}

/// Converts longitude to the x-coordinate of a tile at a given zoom level.
fn lon_to_x(lon: f64, zoom: u8) -> f64 {
    (lon + 180.0) / 360.0 * (2.0_f64.powi(i32::from(zoom)))
}

/// Converts latitude to the y-coordinate of a tile at a given zoom level.
fn lat_to_y(lat: f64, zoom: u8) -> f64 {
    (1.0 - lat.to_radians().tan().asinh() / std::f64::consts::PI) / 2.0
        * (2.0_f64.powi(i32::from(zoom)))
}

/// Converts the x-coordinate of a tile to longitude at a given zoom level.
fn x_to_lon(x: f64, zoom: u8) -> f64 {
    x / (2.0_f64.powi(i32::from(zoom))) * 360.0 - 180.0
}

/// Converts the y-coordinate of a tile to latitude at a given zoom level.
fn y_to_lat(y: f64, zoom: u8) -> f64 {
    let n = std::f64::consts::PI - 2.0 * std::f64::consts::PI * y / (2.0_f64.powi(i32::from(zoom)));
    n.sinh().atan().to_degrees()
}

/// Draws the map tiles.
pub(crate) fn draw_map(
    tiles: &mut HashMap<TileId, Tile>,
    config: &dyn MapConfig,
    painter: &egui::Painter,
    projection: &MapProjection,
) {
    let visible_tiles: Vec<_> = visible_tiles(projection).collect();
    for (tile_id, tile_pos) in visible_tiles {
        load_tile(tiles, config, painter.ctx(), tile_id);
        draw_tile(tiles, painter, &tile_id, tile_pos, Color32::WHITE);
    }
}

/// Returns an iterator over the visible tiles.
pub(crate) fn visible_tiles(
    projection: &MapProjection,
) -> impl Iterator<Item = (TileId, egui::Pos2)> {
    let center_x = lon_to_x(projection.center_lon, projection.zoom);
    let center_y = lat_to_y(projection.center_lat, projection.zoom);

    let widget_center_x = projection.widget_rect.width() / 2.0;
    let widget_center_y = projection.widget_rect.height() / 2.0;

    let x_min = (center_x - f64::from(widget_center_x) / f64::from(TILE_SIZE)).floor() as i32;
    let y_min = (center_y - f64::from(widget_center_y) / f64::from(TILE_SIZE)).floor() as i32;
    let x_max = (center_x + f64::from(widget_center_x) / f64::from(TILE_SIZE)).ceil() as i32;
    let y_max = (center_y + f64::from(widget_center_y) / f64::from(TILE_SIZE)).ceil() as i32;

    let zoom = projection.zoom;
    let rect_min = projection.widget_rect.min;
    (x_min..=x_max).flat_map(move |x| {
        (y_min..=y_max).map(move |y| {
            let tile_id = TileId {
                z: zoom,
                x: x as u32,
                y: y as u32,
            };
            let screen_x = widget_center_x + (f64::from(x) - center_x) as f32 * TILE_SIZE as f32;
            let screen_y = widget_center_y + (f64::from(y) - center_y) as f32 * TILE_SIZE as f32;
            let tile_pos = rect_min + Vec2::new(screen_x, screen_y);
            (tile_id, tile_pos)
        })
    })
}

/// map loads tile as a texture
pub(crate) fn load_tile(
    tiles: &mut HashMap<TileId, Tile>,
    config: &dyn MapConfig,
    ctx: &egui::Context,
    tile_id: TileId,
) {
    let tile_state = tiles.entry(tile_id).or_insert_with(|| {
        let url = tile_id.to_url(config);
        let promise =
            Promise::spawn_thread("download_tile", move || -> Result<_, Arc<eyre::Report>> {
                let result: Result<_, eyre::Report> = (|| {
                    debug!("Downloading tile from {}", &url);
                    let response = CLIENT.get(&url).send().map_err(MapError::from)?;

                    if !response.status().is_success() {
                        return Err(MapError::TileDownloadError(response.status().to_string()));
                    }

                    let bytes = response.bytes().map_err(MapError::from)?.to_vec();
                    let image = image::load_from_memory(&bytes)
                        .map_err(MapError::from)?
                        .to_rgba8();

                    let size = [image.width() as _, image.height() as _];
                    let pixels = image.into_raw();
                    Ok(egui::ColorImage::from_rgba_unmultiplied(size, &pixels))
                })()
                .with_context(|| format!("Failed to download tile from {}", &url));

                result.map_err(Arc::new)
            });
        Tile::Loading(promise)
    });

    // If the tile is loading, check if the promise is ready and update the state.
    // This is done before matching on the state, so that we can immediately draw
    // the tile if it has just finished loading.
    if let Tile::Loading(promise) = tile_state
        && let Some(result) = promise.ready()
    {
        match result {
            Ok(color_image) => {
                let texture = ctx.load_texture(
                    format!("tile_{}_{}_{}", tile_id.z, tile_id.x, tile_id.y),
                    color_image.clone(),
                    Default::default(),
                );
                *tile_state = Tile::Loaded(texture);
            }
            Err(e) => {
                error!("{e:?}");
                *tile_state = Tile::Failed(e.clone());
            }
        }
    }
}

/// Draws a single map tile.
pub(crate) fn draw_tile(
    tiles: &HashMap<TileId, Tile>,
    painter: &egui::Painter,
    tile_id: &TileId,
    tile_pos: egui::Pos2,
    tint: Color32,
) {
    let tile_rect = Rect::from_min_size(tile_pos, Vec2::new(TILE_SIZE as f32, TILE_SIZE as f32));
    let default_state = Tile::Unknown;
    let tile_state = tiles.get(tile_id).unwrap_or(&default_state);
    match tile_state {
        Tile::Loading(_) => {
            // Draw a gray background and a border for the placeholder.
            painter.rect_filled(tile_rect, 0.0, Color32::from_gray(220));
            painter.rect_stroke(
                tile_rect,
                0.0,
                egui::Stroke::new(1.0, Color32::GRAY),
                egui::StrokeKind::Inside,
            );

            // Draw a question mark in the center.
            painter.text(
                tile_rect.center(),
                egui::Align2::CENTER_CENTER,
                "⌛",
                egui::FontId::proportional(40.0),
                Color32::ORANGE,
            );

            // The tile is still loading, so we need to tell egui to repaint.
            painter.ctx().request_repaint();
        }
        Tile::Loaded(texture) => {
            painter.image(
                texture.id(),
                tile_rect,
                Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)),
                tint,
            );
        }
        Tile::Failed(e) => {
            // Draw a gray background and a border for the placeholder.
            painter.rect_filled(tile_rect, 0.0, Color32::from_gray(220));
            painter.rect_stroke(
                tile_rect,
                0.0,
                egui::Stroke::new(1.0, Color32::GRAY),
                egui::StrokeKind::Inside,
            );

            // Draw a red exclamation mark in the center.
            painter.text(
                tile_rect.center(),
                egui::Align2::CENTER_CENTER,
                "❌",
                egui::FontId::proportional(40.0),
                Color32::RED,
            );

            // Log the error message
            error!("Failed to load tile: {e:?}");
        }
        Tile::Unknown => {
            // Draw a gray background and a border for the placeholder.
            painter.rect_filled(tile_rect, 0.0, Color32::from_gray(220));
            painter.rect_stroke(
                tile_rect,
                0.0,
                egui::Stroke::new(1.0, Color32::GRAY),
                egui::StrokeKind::Inside,
            );

            // Draw a question mark in the center.
            painter.text(
                tile_rect.center(),
                egui::Align2::CENTER_CENTER,
                "❓",
                egui::FontId::proportional(40.0),
                Color32::RED,
            );

            error!("Tile state not found for {tile_id:?}");
        }
    }
}

impl Widget for &mut Map {
    fn ui(self, ui: &mut Ui) -> Response {
        // Give it a minimum size so that it does not become too small
        // in a horizontal layout. Use tile size as minimum.
        let desired_size = if ui.layout().main_dir().is_horizontal() {
            // In a horizontal layout, we want to be a square of a reasonable size.
            let side = TILE_SIZE as f32;
            Vec2::splat(side)
        } else {
            // In a vertical layout, we want to fill the available space, but only width
            let mut available_size = ui
                .available_size()
                .at_least(Vec2::new(TILE_SIZE as f32, TILE_SIZE as f32));
            available_size.y = TILE_SIZE as f32;
            available_size
        };

        let response = ui.allocate_response(desired_size, Sense::drag().union(Sense::click()));
        let rect = response.rect;

        // Create a projection for input handling, based on the state before any changes.
        let input_projection = MapProjection::new(self.zoom, self.center, rect);

        let mut input_handled_by_layer = false;
        for layer in self.layers.values_mut() {
            if layer.handle_input(&response, &input_projection) {
                input_handled_by_layer = true;
                break; // Stop after the first layer handles the input.
            }
        }

        if !input_handled_by_layer {
            self.handle_input(ui, &rect, &response);

            // Change the cursor icon when dragging or hovering over the map.
            if response.dragged() {
                ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
            } else if response.hovered() {
                ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
            }
        }

        // Update mouse position.
        self.mouse_pos = response
            .hover_pos()
            .map(|pos| input_projection.unproject(pos));

        // Create a new projection for drawing, with the updated map state.
        let draw_projection = MapProjection::new(self.zoom, self.center, rect);

        let painter = ui.painter_at(rect);
        painter.rect_filled(rect, 0.0, Color32::from_rgb(220, 220, 220)); // Background

        draw_map(
            &mut self.tiles,
            self.config.as_ref(),
            &painter,
            &draw_projection,
        );

        for layer in self.layers.values() {
            layer.draw(&painter, &draw_projection);
        }

        self.draw_attribution(ui, &rect);

        response
    }
}

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

    const EPSILON: f64 = 1e-9;

    #[test]
    fn test_coord_conversion_roundtrip() {
        let original_lon = 24.93545;
        let original_lat = 60.16952;
        let zoom: u8 = 10;

        let x = lon_to_x(original_lon, zoom);
        let y = lat_to_y(original_lat, zoom);

        let final_lon = x_to_lon(x, zoom);
        let final_lat = y_to_lat(y, zoom);

        assert!((original_lon - final_lon).abs() < EPSILON);
        assert!((original_lat - final_lat).abs() < EPSILON);

        let original_lon = -122.4194;
        let original_lat = 37.7749;

        let x = lon_to_x(original_lon, zoom);
        let y = lat_to_y(original_lat, zoom);

        let final_lon = x_to_lon(x, zoom);
        let final_lat = y_to_lat(y, zoom);

        assert!((original_lon - final_lon).abs() < EPSILON);
        assert!((original_lat - final_lat).abs() < EPSILON);
    }

    #[test]
    fn test_y_to_lat_conversion() {
        // y, zoom, expected_lat
        let test_cases = vec![
            // Equator
            (0.5, 0, 0.0),
            (128.0, 8, 0.0),
            // Near poles (Mercator projection limits)
            (0.0, 0, 85.0511287798),
            (1.0, 0, -85.0511287798),
            (0.0, 8, 85.0511287798),
            (256.0, 8, -85.0511287798),
            // Helsinki
            (9.262574089998255, 5, 60.16952),
            // London
            (85.12653378959828, 8, 51.5074),
        ];

        for (y, zoom, expected_lat) in test_cases {
            assert!((y_to_lat(y, zoom) - expected_lat).abs() < EPSILON);
        }
    }

    #[test]
    fn test_lat_to_y_conversion() {
        // lat, zoom, expected_y
        let test_cases = vec![
            // Equator
            (0.0, 0, 0.5),
            (0.0, 8, 128.0),
            // Near poles (Mercator projection limits)
            (85.0511287798, 0, 0.0),
            (-85.0511287798, 0, 1.0),
            (85.0511287798, 8, 0.0),
            (-85.0511287798, 8, 256.0),
            // Helsinki
            (60.16952, 5, 9.262574089998255),
            // London
            (51.5074, 8, 85.12653378959828),
        ];

        for (lat, zoom, expected_y) in test_cases {
            assert!((lat_to_y(lat, zoom) - expected_y).abs() < EPSILON);
        }
    }

    #[test]
    fn test_x_to_lon_conversion() {
        // x, zoom, expected_lon
        let test_cases = vec![
            // Center of the map
            (0.5, 0, 0.0),
            (128.0, 8, 0.0),
            // Edges of the map
            (0.0, 0, -180.0),
            (1.0, 0, 180.0),
            (0.0, 8, -180.0),
            (256.0, 8, 180.0),
            // Helsinki
            (18.216484444444444, 5, 24.93545),
        ];

        for (x, zoom, expected_lon) in test_cases {
            assert!((x_to_lon(x, zoom) - expected_lon).abs() < EPSILON);
        }
    }

    #[test]
    fn test_lon_to_x_conversion() {
        // lon, zoom, expected_x
        let test_cases = vec![
            // Center of the map
            (0.0, 0, 0.5),
            (0.0, 8, 128.0),
            // Edges of the map
            (-180.0, 0, 0.0),
            (180.0, 0, 1.0), // upper bound is exclusive for tiles, but not for coordinate space
            (-180.0, 8, 0.0),
            (180.0, 8, 256.0),
            // Helsinki
            (24.93545, 5, 18.216484444444444),
            // London
            (-0.1275, 8, 127.90933333333333),
        ];

        for (lon, zoom, expected_x) in test_cases {
            assert!((lon_to_x(lon, zoom) - expected_x).abs() < EPSILON);
        }
    }

    #[test]
    fn test_tile_id_to_url() {
        let config = OpenStreetMapConfig::default();
        let tile_id = TileId {
            z: 10,
            x: 559,
            y: 330,
        };
        let url = tile_id.to_url(&config);
        assert_eq!(url, "https://tile.openstreetmap.org/10/559/330.png");
    }

    #[test]
    fn test_map_new() {
        let config = OpenStreetMapConfig::default();
        let default_center = config.default_center();
        let default_zoom = config.default_zoom();

        let map = Map::new(config);

        assert_eq!(map.center, default_center.into());
        assert_eq!(map.zoom, default_zoom);
        assert!(map.mouse_pos.is_none());
        assert!(map.tiles.is_empty());
    }

    #[test]
    #[cfg(feature = "openstreetmap")]
    fn test_map_invalid_zoom_limits() {
        let config = OpenStreetMapConfig::default().min_zoom(15).max_zoom(5);
        let map = Map::new(config);
        assert_eq!(map.zoom, 15);
    }
}
