# FlightMonitor/map/map_utils.py

import logging
import math
from typing import Tuple, Optional, List, Set, Dict, Any

try:
    import pyproj
    PYPROJ_MODULE_LOCALLY_AVAILABLE = True
except ImportError:
    pyproj = None
    PYPROJ_MODULE_LOCALLY_AVAILABLE = False
    logging.warning("MapUtils: 'pyproj' not found. Geographic calculations will be impaired.")

try:
    import mercantile
    MERCANTILE_MODULE_LOCALLY_AVAILABLE = True
except ImportError:
    mercantile = None
    MERCANTILE_MODULE_LOCALLY_AVAILABLE = False
    logging.warning("MapUtils: 'mercantile' not found. Tile operations will fail.")

try:
    from flightmonitor.utils.logger import get_logger
    logger = get_logger(__name__) 
except ImportError: 
    logger = logging.getLogger(__name__)
    if not logger.hasHandlers():
        logging.basicConfig(level=logging.DEBUG) 
    logger.warning("MapUtils using fallback standard Python logger.")

try:
    from flightmonitor.map import map_constants
except ImportError:
    logger.error("MapUtils: map_constants not found. Using mock constants.")
    class MockMapConstants:
        DMS_DEGREE_SYMBOL, DMS_MINUTE_SYMBOL, DMS_SECOND_SYMBOL = "°", "'", "''"
        MIN_ZOOM_LEVEL, DEFAULT_MAX_ZOOM_FALLBACK = 0, 20
        GRATICULE_MIN_PIXEL_SPACING = 80
    map_constants = MockMapConstants()

class MapCalculationError(Exception):
    """Custom exception for map calculations."""
    pass

def _is_valid_bbox_dict(bbox_dict: Dict[str, Any]) -> bool:
    if not isinstance(bbox_dict, dict): return False
    required_keys = ["lat_min", "lon_min", "lat_max", "lon_max"]
    if not all(key in bbox_dict for key in required_keys): return False
    try:
        lat_min, lon_min = float(bbox_dict["lat_min"]), float(bbox_dict["lon_min"])
        lat_max, lon_max = float(bbox_dict["lat_max"]), float(bbox_dict["lon_max"])
        if not (-90.0 <= lat_min <= 90.0 and -90.0 <= lat_max <= 90.0 and
                -180.0 <= lon_min <= 180.0 and -180.0 <= lon_max <= 180.0):
            return False
        if lat_min >= lat_max or lon_min >= lon_max: return False 
        return True
    except (ValueError, TypeError): return False

def calculate_graticule_interval(
    span_degrees: float, 
    image_pixel_span: int, 
    min_pixel_spacing: Optional[int] = None
) -> float:
    """
    Calculates a reasonable interval for graticule lines based on the map's
    current scale, ensuring a minimum visual separation in pixels.
    """
    if min_pixel_spacing is None:
        min_pixel_spacing = map_constants.GRATICULE_MIN_PIXEL_SPACING

    if span_degrees <= 0 or image_pixel_span <= 0:
        return 5.0

    degrees_per_pixel = span_degrees / image_pixel_span
    min_degree_interval = degrees_per_pixel * min_pixel_spacing

    possible_intervals = [
        90, 45, 30, 20, 15, 10, 5, 2, 1,
        0.5, 0.25, 0.1, 0.05, 0.02, 0.01,
        0.005, 0.002, 0.001, 0.0005, 0.0001
    ]

    for interval in reversed(possible_intervals):
        if interval >= min_degree_interval:
            return interval
            
    return possible_intervals[0]

def get_bounding_box_from_center_size(
    center_latitude_deg: float, center_longitude_deg: float, area_size_km: float
) -> Optional[Tuple[float, float, float, float]]:
    if not PYPROJ_MODULE_LOCALLY_AVAILABLE or not pyproj: return None
    try:
        geod = pyproj.Geod(ellps="WGS84")
        half_side_m = (area_size_km / 2.0) * 1000.0
        _, north_lat, _ = geod.fwd(center_longitude_deg, center_latitude_deg, 0.0, half_side_m)
        _, south_lat, _ = geod.fwd(center_longitude_deg, center_latitude_deg, 180.0, half_side_m)
        east_lon, _, _ = geod.fwd(center_longitude_deg, center_latitude_deg, 90.0, half_side_m)
        west_lon, _, _ = geod.fwd(center_longitude_deg, center_latitude_deg, 270.0, half_side_m)
        return (west_lon, south_lat, east_lon, north_lat)
    except Exception as e:
        logger.exception(f"Error calculating BBox from center/size: {e}")
        return None

def get_tile_ranges_for_bbox(
    bounding_box_deg: Tuple[float, float, float, float], zoom_level: int
) -> Optional[Tuple[Tuple[int, int], Tuple[int, int]]]:
    if not MERCANTILE_MODULE_LOCALLY_AVAILABLE or not mercantile: return None
    try:
        west, south, east, north = bounding_box_deg
        tiles = list(mercantile.tiles(west, south, east, north, zooms=[zoom_level]))
        if not tiles: return None
        x_coords, y_coords = [t.x for t in tiles], [t.y for t in tiles]
        return ((min(x_coords), max(x_coords)), (min(y_coords), max(y_coords)))
    except Exception as e:
        logger.exception(f"Error calculating tile ranges: {e}")
        return None

def calculate_meters_per_pixel(
    latitude_degrees: float, zoom_level: int, tile_pixel_size: int = 256
) -> Optional[float]:
    try:
        EARTH_CIRCUMFERENCE_METERS = 40075016.686 
        clamped_lat = max(-85.05112878, min(85.05112878, latitude_degrees))
        resolution = (EARTH_CIRCUMFERENCE_METERS * math.cos(math.radians(clamped_lat))) / (tile_pixel_size * (2**zoom_level))
        return resolution
    except Exception as e:
        logger.exception(f"Error calculating meters per pixel: {e}")
        return None

def calculate_geographic_bbox_size_km(
    bounding_box_deg: Tuple[float, float, float, float]
) -> Optional[Tuple[float, float]]:
    if not PYPROJ_MODULE_LOCALLY_AVAILABLE or not pyproj: return None
    try:
        west, south, east, north = bounding_box_deg
        geod = pyproj.Geod(ellps="WGS84")
        _, _, width_m = geod.inv(west, (south+north)/2, east, (south+north)/2)
        _, _, height_m = geod.inv((west+east)/2, south, (west+east)/2, north)
        return (abs(width_m) / 1000.0, abs(height_m) / 1000.0)
    except Exception as e:
        logger.exception(f"Error calculating BBox size: {e}")
        return None

def calculate_zoom_level_for_geographic_size(
    latitude_degrees: float, geographic_span_meters: float, target_pixel_span: int, tile_pixel_size: int = 256
) -> Optional[int]:
    try:
        if target_pixel_span <= 0: return None
        required_res = geographic_span_meters / target_pixel_span
        EARTH_CIRCUMFERENCE_METERS = 40075016.686
        clamped_lat = max(-85.05112878, min(85.05112878, latitude_degrees))
        term = (EARTH_CIRCUMFERENCE_METERS * math.cos(math.radians(clamped_lat))) / (tile_pixel_size * required_res)
        if term <= 0: return None
        precise_zoom = math.log2(term)
        return max(map_constants.MIN_ZOOM_LEVEL, min(int(round(precise_zoom)), map_constants.DEFAULT_MAX_ZOOM_FALLBACK))
    except Exception as e:
        logger.exception(f"Error calculating zoom level: {e}")
        return None

def deg_to_dms_string(degree_value: float, coord_type: str) -> str:
    if not isinstance(degree_value, (int, float)) or not math.isfinite(degree_value): return "N/A"
    abs_val = abs(degree_value)
    degrees = int(abs_val)
    minutes = int((abs_val - degrees) * 60)
    seconds = (((abs_val - degrees) * 60) - minutes) * 60
    if coord_type.lower() == "lat": suffix = "N" if degree_value >= 0 else "S"
    elif coord_type.lower() == "lon": suffix = "E" if degree_value >= 0 else "W"
    else: suffix = ""
    return (f"{degrees}{map_constants.DMS_DEGREE_SYMBOL} "
            f"{minutes:02d}{map_constants.DMS_MINUTE_SYMBOL} "
            f"{seconds:05.2f}{map_constants.DMS_SECOND_SYMBOL} {suffix}")

def calculate_geographic_bbox_from_pixel_size_and_zoom(
    center_latitude_deg: float, center_longitude_deg: float,
    target_pixel_width: int, target_pixel_height: int,
    zoom_level: int, tile_pixel_size: int = 256
) -> Optional[Tuple[float, float, float, float]]: 
    if not (MERCANTILE_MODULE_LOCALLY_AVAILABLE and mercantile): return None
    try:
        res = calculate_meters_per_pixel(center_latitude_deg, zoom_level, tile_pixel_size)
        if not res: return None
        center_merc_x, center_merc_y = mercantile.xy(center_longitude_deg, center_latitude_deg)
        half_w_merc, half_h_merc = (target_pixel_width/2) * res, (target_pixel_height/2) * res
        ul_x, ul_y = center_merc_x - half_w_merc, center_merc_y + half_h_merc
        lr_x, lr_y = center_merc_x + half_w_merc, center_merc_y - half_h_merc
        west, north = mercantile.lnglat(ul_x, ul_y)
        east, south = mercantile.lnglat(lr_x, lr_y)
        return (west, south, east, north)
    except Exception as e:
        logger.exception(f"Error calculating geo BBox from pixel size/zoom: {e}")
        return None

def geo_to_pixel_on_unscaled_map(
    latitude_deg: float,
    longitude_deg: float,
    current_map_geo_bounds: Optional[Tuple[float, float, float, float]], 
    current_stitched_map_pixel_shape: Optional[Tuple[int, int]], 
) -> Optional[Tuple[int, int]]: 
    if not (MERCANTILE_MODULE_LOCALLY_AVAILABLE and mercantile): return None
    if not (current_map_geo_bounds and current_stitched_map_pixel_shape): return None
    
    img_w, img_h = current_stitched_map_pixel_shape
    if img_w <= 0 or img_h <= 0: return None

    map_west, map_south, map_east, map_north = current_map_geo_bounds
    
    try:
        map_ul_merc_x, map_ul_merc_y = mercantile.xy(map_west, map_north)
        map_lr_merc_x, map_lr_merc_y = mercantile.xy(map_east, map_south)
        merc_w, merc_h = abs(map_lr_merc_x - map_ul_merc_x), abs(map_ul_merc_y - map_lr_merc_y)
        if merc_w < 1e-9 or merc_h < 1e-9: return None
        
        target_merc_x, target_merc_y = mercantile.xy(longitude_deg, latitude_deg)
        
        rel_x = (target_merc_x - map_ul_merc_x) / merc_w
        rel_y = (map_ul_merc_y - target_merc_y) / merc_h
        
        return (int(round(rel_x * img_w)), int(round(rel_y * img_h)))
    except Exception as e:
        logger.error(f"Error converting geo to pixel: {e}", exc_info=False)
        return None

def pixel_to_geo(
    canvas_x: int,
    canvas_y: int,
    current_map_geo_bounds: Optional[Tuple[float, float, float, float]], 
    current_stitched_map_pixel_shape: Optional[Tuple[int, int]], 
) -> Tuple[Optional[float], Optional[float]]: 
    if not (MERCANTILE_MODULE_LOCALLY_AVAILABLE and mercantile): return None, None
    if not (current_map_geo_bounds and current_stitched_map_pixel_shape): return None, None
    
    img_w, img_h = current_stitched_map_pixel_shape
    if img_w <= 0 or img_h <= 0: return None, None

    map_west, map_south, map_east, map_north = current_map_geo_bounds
    
    try:
        map_ul_merc_x, map_ul_merc_y = mercantile.xy(map_west, map_north, truncate=False)
        map_lr_merc_x, map_lr_merc_y = mercantile.xy(map_east, map_south, truncate=False)
        
        total_merc_w, total_merc_h = abs(map_lr_merc_x - map_ul_merc_x), abs(map_ul_merc_y - map_lr_merc_y)
        if total_merc_w < 1e-9 or total_merc_h < 1e-9: return None, None
            
        rel_x, rel_y = canvas_x / img_w, canvas_y / img_h
        
        target_merc_x = map_ul_merc_x + (rel_x * total_merc_w)
        target_merc_y = map_ul_merc_y - (rel_y * total_merc_h)
        
        lon, lat = mercantile.lnglat(target_merc_x, target_merc_y, truncate=False)
        
        return lon, lat
    except Exception as e:
        logger.error(f"Error converting pixel to geo: {e}", exc_info=True)
        return None, None