"""Async Nominatim geocoding with bbox utilities."""

import logging
import math

import httpx

from mcgibs.constants import NOMINATIM_BASE, USER_AGENT
from mcgibs.models import BBox, GeocodingResult

log = logging.getLogger(__name__)


async def geocode(
    client: httpx.AsyncClient,
    query: str,
    rate_limiter=None,
) -> GeocodingResult | None:
    """Geocode a place name via Nominatim.

    Caching is the caller's responsibility (GIBSClient.resolve_place manages
    an LRU cache). This function is a pure HTTP-call-and-parse layer.

    Args:
        client: Shared httpx async client.
        query: Free-form place name (e.g. "Tokyo", "Amazon River").
        rate_limiter: Object with async ``nominatim_rate_limit()`` method
            for enforcing Nominatim's 1 req/sec policy.  When called from
            GIBSClient, this is the client instance itself.

    Returns:
        GeocodingResult on success, None if no results found.
    """
    if rate_limiter is not None and hasattr(rate_limiter, "nominatim_rate_limit"):
        await rate_limiter.nominatim_rate_limit()

    params = {
        "q": query,
        "format": "json",
        "limit": "1",
        "bounded": "0",
    }
    headers = {"User-Agent": USER_AGENT}

    try:
        resp = await client.get(
            f"{NOMINATIM_BASE}/search",
            params=params,
            headers=headers,
        )
        resp.raise_for_status()
        data = resp.json()
    except httpx.HTTPError as exc:
        log.warning("Nominatim request failed for %r: %s", query, exc)
        return None

    if not data:
        log.debug("Nominatim returned no results for %r", query)
        return None

    hit = data[0]

    # Nominatim bbox order: [south_lat, north_lat, west_lon, east_lon]
    raw_bbox = hit.get("boundingbox", [])
    if len(raw_bbox) == 4:
        south, north, west, east = (float(v) for v in raw_bbox)
        bbox = BBox(west=west, south=south, east=east, north=north)
    else:
        # Fall back to a small bbox around the point.
        lat = float(hit["lat"])
        lon = float(hit["lon"])
        bbox = bbox_from_point(lat, lon, radius_deg=0.5)

    result = GeocodingResult(
        display_name=hit.get("display_name", query),
        bbox=bbox,
        lat=float(hit["lat"]),
        lon=float(hit["lon"]),
        osm_type=hit.get("osm_type", ""),
        importance=float(hit.get("importance", 0.0)),
    )

    log.debug("Geocoded %r -> %s", query, result.display_name)
    return result


def expand_bbox(bbox: BBox, factor: float = 0.1) -> BBox:
    """Expand a bounding box by a proportional factor in each direction.

    A factor of 0.1 adds 10% of the width/height as padding on each side.
    Results are clamped to valid geographic ranges.

    Args:
        bbox: Original bounding box.
        factor: Expansion ratio (0.1 = 10% padding per side).

    Returns:
        New BBox with expanded extents, clamped to [-180, 180] lon
        and [-90, 90] lat.
    """
    dlon = (bbox.east - bbox.west) * factor
    dlat = (bbox.north - bbox.south) * factor

    # Ensure a minimum expansion so zero-area boxes still grow.
    dlon = max(dlon, 0.001)
    dlat = max(dlat, 0.001)

    return BBox(
        west=max(-180.0, bbox.west - dlon),
        south=max(-90.0, bbox.south - dlat),
        east=min(180.0, bbox.east + dlon),
        north=min(90.0, bbox.north + dlat),
    )


def bbox_from_point(lat: float, lon: float, radius_deg: float = 0.5) -> BBox:
    """Create a bounding box centered on a geographic point.

    Args:
        lat: Latitude in decimal degrees.
        lon: Longitude in decimal degrees.
        radius_deg: Half-width/height of the box in degrees.

    Returns:
        BBox centered on (lat, lon), clamped to valid ranges.
    """
    return BBox(
        west=max(-180.0, lon - radius_deg),
        south=max(-90.0, lat - radius_deg),
        east=min(180.0, lon + radius_deg),
        north=min(90.0, lat + radius_deg),
    )


# ---------------------------------------------------------------------------
# Polar stereographic coordinate transform
# ---------------------------------------------------------------------------

# WGS84 ellipsoid parameters
_WGS84_A = 6378137.0  # semi-major axis (meters)
_WGS84_E = 0.0818191908426  # eccentricity

# GIBS polar projection parameters
_POLAR_PARAMS = {
    "3413": {"lat_ts": 70.0, "lon0": -45.0, "north": True},
    "3031": {"lat_ts": -71.0, "lon0": 0.0, "north": False},
}

# GIBS documented full extents (meters) for polar projections
POLAR_FULL_EXTENT = {
    "3413": (-4194304.0, -4194304.0, 4194304.0, 4194304.0),
    "3031": (-4194304.0, -4194304.0, 4194304.0, 4194304.0),
}


def _polar_stereo_forward(
    lat: float, lon: float, epsg: str,
) -> tuple[float, float]:
    """Convert geographic (lat, lon) to polar stereographic (x, y) meters.

    Implements the ellipsoidal polar stereographic projection used by
    EPSG:3413 (Arctic, true scale 70N, central meridian -45) and
    EPSG:3031 (Antarctic, true scale 71S, central meridian 0).
    """
    params = _POLAR_PARAMS[epsg]
    phi = math.radians(lat)
    lam = math.radians(lon)
    phi_ts = math.radians(params["lat_ts"])
    lam0 = math.radians(params["lon0"])
    a = _WGS84_A
    e = _WGS84_E

    def _t(phi_val: float) -> float:
        sin_phi = math.sin(abs(phi_val))
        return math.tan(math.pi / 4 - abs(phi_val) / 2) / (
            ((1 - e * sin_phi) / (1 + e * sin_phi)) ** (e / 2)
        )

    def _m(phi_val: float) -> float:
        sin_phi = math.sin(phi_val)
        return math.cos(phi_val) / math.sqrt(1 - e * e * sin_phi * sin_phi)

    t = _t(phi)
    t_ts = _t(phi_ts)
    m_ts = _m(abs(phi_ts))
    rho = a * m_ts * t / t_ts

    if params["north"]:
        x = rho * math.sin(lam - lam0)
        y = -rho * math.cos(lam - lam0)
    else:
        x = rho * math.sin(lam - lam0)
        y = rho * math.cos(lam - lam0)

    return (x, y)


def bbox_to_polar(bbox: BBox, epsg: str) -> tuple[float, float, float, float]:
    """Transform a geographic BBox to polar stereographic coordinates.

    Projects all four corners plus edge midpoints and returns the enclosing
    axis-aligned bbox in projected meters. For full-hemisphere bboxes
    (e.g. -180,60,180,90), returns the GIBS documented full extent.

    Returns:
        (xmin, ymin, xmax, ymax) in projected meters.
    """
    if epsg not in _POLAR_PARAMS:
        return (bbox.west, bbox.south, bbox.east, bbox.north)

    # Full-hemisphere bbox → use documented GIBS extent
    width = bbox.east - bbox.west
    if width >= 350:  # nearly full longitude range
        return POLAR_FULL_EXTENT[epsg]

    # Sample corners and edge midpoints for a tight envelope
    lats = [bbox.south, bbox.north, (bbox.south + bbox.north) / 2]
    lons = [bbox.west, bbox.east, (bbox.west + bbox.east) / 2]

    xs = []
    ys = []
    for lat in lats:
        for lon in lons:
            x, y = _polar_stereo_forward(lat, lon, epsg)
            xs.append(x)
            ys.append(y)

    return (min(xs), min(ys), max(xs), max(ys))
