"""Async HTTP client for all NASA GIBS API interactions."""

import asyncio
import logging
import time
import xml.etree.ElementTree as ET
from collections import OrderedDict
from io import BytesIO

import defusedxml.ElementTree as DefusedET
import httpx
from PIL import Image

from mcgibs.capabilities import parse_capabilities
from mcgibs.colormaps import explain_colormap, parse_colormap
from mcgibs.constants import (
    COLORMAP_BASE,
    DEFAULT_EPSG,
    LAYER_METADATA_BASE,
    WMS_BASE,
    WMS_DEFAULTS,
    WMTS_CAPABILITIES_URL,
    WMTS_DESCRIBE_DOMAINS_URL,
    WMTS_TILE_URL,
)
from mcgibs.geo import geocode
from mcgibs.models import BBox, ColorMapSet, GeocodingResult, LayerInfo

log = logging.getLogger(__name__)

# Maximum entries per cache before LRU eviction
_MAX_CACHE_SIZE = 500

# Maximum retries for capabilities loading at startup
_INIT_MAX_RETRIES = 3
_INIT_RETRY_DELAYS = [2.0, 4.0, 8.0]

# Maximum image dimensions to prevent OOM from LLM requests
MAX_IMAGE_DIMENSION = 4096

# Content types accepted for legend images (SVG is not API-compatible)
_RASTER_IMAGE_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp"}


class _LRUCache(OrderedDict):
    """Simple LRU cache backed by OrderedDict."""

    def __init__(self, maxsize: int = _MAX_CACHE_SIZE) -> None:
        super().__init__()
        self._maxsize = maxsize

    def get_cached(self, key):
        if key in self:
            self.move_to_end(key)
            return self[key]
        return _SENTINEL

    def put(self, key, value):
        if key in self:
            self.move_to_end(key)
        self[key] = value
        while len(self) > self._maxsize:
            self.popitem(last=False)


_SENTINEL = object()


class GIBSClient:
    """Async client for NASA GIBS APIs.

    Wraps WMTS (discovery), WMS (imagery), layer metadata, and colormaps
    behind a single interface. Designed to be initialized once per session
    via FastMCP middleware and reused across tool calls.

    Rate limiting for Nominatim (1 req/sec) is managed per-client instance
    rather than as module-level globals. Caches are LRU-bounded to prevent
    unbounded memory growth in long-running sessions.
    """

    def __init__(self) -> None:
        self._http: httpx.AsyncClient | None = None
        self.layer_index: dict[str, LayerInfo] = {}
        self._metadata_cache: _LRUCache = _LRUCache()
        self._colormap_cache: _LRUCache = _LRUCache()
        self._geocode_cache: _LRUCache = _LRUCache()

        # Rate limiter state — owned by this instance, not module globals
        self._nominatim_lock = asyncio.Lock()
        self._last_nominatim_time: float = 0.0

    async def initialize(self) -> None:
        """Create HTTP client and load WMTS capabilities with retry."""
        self._http = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
        await self._load_capabilities_with_retry()

    async def close(self) -> None:
        """Shut down the HTTP client."""
        if self._http:
            await self._http.aclose()
            self._http = None

    @property
    def http(self) -> httpx.AsyncClient:
        if self._http is None:
            raise RuntimeError("GIBSClient not initialized — call initialize() first")
        return self._http

    # --- Rate limiting (per-instance) ---

    async def nominatim_rate_limit(self) -> None:
        """Enforce 1-second gap between Nominatim requests."""
        async with self._nominatim_lock:
            now = time.monotonic()
            elapsed = now - self._last_nominatim_time
            if elapsed < 1.0:
                await asyncio.sleep(1.0 - elapsed)
            self._last_nominatim_time = time.monotonic()

    # --- Capabilities ---

    async def _load_capabilities_with_retry(self) -> None:
        """Fetch capabilities with exponential backoff retry."""
        last_exc: Exception | None = None
        for attempt in range(_INIT_MAX_RETRIES):
            try:
                await self._load_capabilities()
                return
            except (httpx.HTTPError, ET.ParseError) as exc:
                last_exc = exc
                if attempt < _INIT_MAX_RETRIES - 1:
                    delay = _INIT_RETRY_DELAYS[attempt]
                    log.warning(
                        "Capabilities fetch failed (attempt %d/%d), retrying in %.0fs: %s",
                        attempt + 1,
                        _INIT_MAX_RETRIES,
                        delay,
                        exc,
                    )
                    await asyncio.sleep(delay)

        log.error(
            "Failed to load capabilities after %d attempts",
            _INIT_MAX_RETRIES,
        )
        raise RuntimeError(
            f"GIBS capabilities unavailable after {_INIT_MAX_RETRIES} attempts: {last_exc}"
        )

    async def _load_capabilities(self) -> None:
        """Fetch and parse WMTS GetCapabilities for EPSG:4326."""
        url = WMTS_CAPABILITIES_URL.format(epsg="4326")
        log.info("Fetching WMTS capabilities from %s", url)
        resp = await self.http.get(url)
        resp.raise_for_status()
        self.layer_index = parse_capabilities(resp.text)
        log.info("Loaded %d layers into index", len(self.layer_index))

    def get_layer(self, layer_id: str) -> LayerInfo | None:
        """Look up a layer by identifier."""
        return self.layer_index.get(layer_id)

    # --- Layer Metadata (enrichment) ---

    async def fetch_layer_metadata(self, layer_id: str) -> dict:
        """Fetch the enriched JSON metadata for a layer."""
        cached = self._metadata_cache.get_cached(layer_id)
        if cached is not _SENTINEL:
            return cached

        url = f"{LAYER_METADATA_BASE}/{layer_id}.json"
        try:
            resp = await self.http.get(url)
            resp.raise_for_status()
            data = resp.json()
        except (httpx.HTTPError, ValueError) as exc:
            log.debug("Layer metadata not available for %s: %s", layer_id, exc)
            data = {}

        self._metadata_cache.put(layer_id, data)

        # Enrich the layer_index entry if it exists
        layer = self.layer_index.get(layer_id)
        if layer and data:
            layer.measurement = data.get("measurement")
            layer.instrument = data.get("instrument")
            layer.platform = data.get("platform")
            layer.period = data.get("period")
            layer.ongoing = data.get("ongoing")
            layer.day_night = data.get("daynight")
            layer.description = data.get("description")

        return data

    # --- Colormaps ---

    async def fetch_colormap(self, layer_id: str) -> ColorMapSet | None:
        """Fetch and parse the colormap XML for a layer."""
        cached = self._colormap_cache.get_cached(layer_id)
        if cached is not _SENTINEL:
            return cached

        layer = self.layer_index.get(layer_id)
        colormap_id = (layer.colormap_id if layer else None) or layer_id
        url = f"{COLORMAP_BASE}/{colormap_id}.xml"

        try:
            resp = await self.http.get(url)
            resp.raise_for_status()
            colormap_set = parse_colormap(resp.text)
        except (httpx.HTTPError, ET.ParseError, ValueError) as exc:
            log.debug("Colormap not available for %s: %s", layer_id, exc)
            return None

        self._colormap_cache.put(layer_id, colormap_set)
        return colormap_set

    async def explain_layer_colormap(self, layer_id: str) -> str:
        """Fetch colormap and generate natural-language explanation."""
        colormap_set = await self.fetch_colormap(layer_id)
        if colormap_set is None:
            return f"No colormap available for layer '{layer_id}'."
        return explain_colormap(colormap_set)

    # --- Geocoding ---

    async def resolve_place(self, place: str) -> GeocodingResult | None:
        """Geocode a place name via Nominatim.

        Uses per-instance rate limiting and LRU-bounded cache.
        """
        key = place.strip().lower()
        cached = self._geocode_cache.get_cached(key)
        if cached is not _SENTINEL:
            return cached

        result = await geocode(self.http, place, rate_limiter=self)
        # Only cache successful lookups — don't permanently cache failures
        # (M7: transient Nominatim failures should be retryable)
        if result is not None:
            self._geocode_cache.put(key, result)
        return result

    # --- WMS Imagery ---

    async def get_wms_image(
        self,
        layer_id: str,
        date: str,
        bbox: BBox,
        width: int = 1024,
        height: int = 1024,
        image_format: str = "image/jpeg",
        epsg: str = DEFAULT_EPSG,
    ) -> bytes:
        """Fetch a WMS GetMap image for the given layer, date, and bbox.

        Returns raw image bytes (JPEG or PNG).
        """
        # H6: clamp dimensions to prevent OOM from unbounded requests
        width = max(1, min(width, MAX_IMAGE_DIMENSION))
        height = max(1, min(height, MAX_IMAGE_DIMENSION))

        params = dict(WMS_DEFAULTS)
        params.update(
            {
                "LAYERS": layer_id,
                "SRS": f"EPSG:{epsg}",
                "BBOX": bbox.wms_bbox,
                "WIDTH": str(width),
                "HEIGHT": str(height),
                "FORMAT": image_format,
                "TIME": date,
            }
        )

        url = WMS_BASE.format(epsg=epsg)
        resp = await self.http.get(url, params=params)
        resp.raise_for_status()

        # H5: verify the response IS an image, not just that it ISN'T XML.
        # WMS can return HTTP 200 with OGC ServiceException XML.
        content_type = resp.headers.get("content-type", "")
        if not content_type.startswith("image/"):
            raise RuntimeError(
                f"WMS returned non-image content-type '{content_type}': {resp.text[:500]}"
            )

        return resp.content

    async def get_wms_composite(
        self,
        layer_ids: list[str],
        date: str,
        bbox: BBox,
        width: int = 1024,
        height: int = 1024,
        image_format: str = "image/jpeg",
        epsg: str = DEFAULT_EPSG,
    ) -> bytes:
        """Fetch a multi-layer WMS composite image."""
        return await self.get_wms_image(
            layer_id=",".join(layer_ids),
            date=date,
            bbox=bbox,
            width=width,
            height=height,
            image_format=image_format,
            epsg=epsg,
        )

    async def compare_dates(
        self,
        layer_id: str,
        date_before: str,
        date_after: str,
        bbox: BBox,
        width: int = 1024,
        height: int = 512,
        image_format: str = "image/jpeg",
    ) -> bytes:
        """Fetch two images and compose a side-by-side comparison."""
        img_before = await self.get_wms_image(
            layer_id,
            date_before,
            bbox,
            width,
            height,
            image_format,
        )
        img_after = await self.get_wms_image(
            layer_id,
            date_after,
            bbox,
            width,
            height,
            image_format,
        )

        # M1: convert to RGB to avoid mode mismatch with RGBA PNGs
        pil_before = Image.open(BytesIO(img_before)).convert("RGB")
        pil_after = Image.open(BytesIO(img_after)).convert("RGB")

        total_width = pil_before.width + pil_after.width
        max_height = max(pil_before.height, pil_after.height)
        composite = Image.new("RGB", (total_width, max_height))
        composite.paste(pil_before, (0, 0))
        composite.paste(pil_after, (pil_before.width, 0))

        buf = BytesIO()
        composite.save(buf, format="JPEG", quality=90)
        return buf.getvalue()

    # --- DescribeDomains (date ranges) ---

    async def describe_domains(
        self,
        layer_id: str,
        epsg: str = DEFAULT_EPSG,
    ) -> dict:
        """Query WMTS DescribeDomains for available date ranges."""
        url = WMTS_DESCRIBE_DOMAINS_URL.format(
            epsg=epsg,
            layer_id=layer_id,
        )
        resp = await self.http.get(url)
        resp.raise_for_status()

        root = DefusedET.fromstring(resp.text)
        result: dict[str, str] = {}

        # M2: look specifically for TimeDomain elements rather than
        # broadly matching any "value" element
        for elem in root.iter():
            local = elem.tag.rpartition("}")[-1] if "}" in elem.tag else elem.tag
            if local.lower() == "timedomain" and elem.text:
                text = elem.text.strip()
                if text:
                    result["time_domain"] = text
                    break

        # Fallback: look in child Value elements of a Dimension
        if "time_domain" not in result:
            for elem in root.iter():
                local = elem.tag.rpartition("}")[-1] if "}" in elem.tag else elem.tag
                if local.lower() == "value" and elem.text:
                    text = elem.text.strip()
                    # Only match ISO 8601-ish values (contain date separators)
                    if "/" in text and len(text) > 8:
                        result["time_domain"] = text
                        break

        return result

    # --- WMTS tile URL builder ---

    def build_tile_url(
        self,
        layer_id: str,
        date: str,
        zoom: int,
        row: int,
        col: int,
        tile_matrix_set: str = "250m",
        ext: str = "jpg",
        epsg: str = DEFAULT_EPSG,
    ) -> str:
        """Build a direct WMTS REST tile URL."""
        return WMTS_TILE_URL.format(
            epsg=epsg,
            layer_id=layer_id,
            date=date,
            tile_matrix_set=tile_matrix_set,
            z=zoom,
            row=row,
            col=col,
            ext=ext,
        )

    # --- Legend image ---

    async def get_legend_image(
        self,
        layer_id: str,
        orientation: str = "horizontal",
    ) -> bytes | None:
        """Fetch the pre-rendered legend image for a layer.

        GIBS capabilities list SVG legend URLs, but PNG versions exist
        at the same path. We prefer PNG for broad client compatibility
        (the Anthropic API and most image viewers don't render SVG).
        """
        layer = self.layer_index.get(layer_id)
        if layer and layer.legend_url:
            url = layer.legend_url
            if url.endswith(".svg"):
                url = url[:-4] + ".png"
            try:
                resp = await self.http.get(url)
                resp.raise_for_status()
                content_type = resp.headers.get("content-type", "").split(";")[0].strip()
                if content_type in _RASTER_IMAGE_TYPES:
                    return resp.content
            except httpx.HTTPError:
                pass

        # Fallback to WMS GetLegendGraphic
        url = WMS_BASE.format(epsg=DEFAULT_EPSG)
        params = {
            "SERVICE": "WMS",
            "VERSION": "1.1.1",
            "REQUEST": "GetLegendGraphic",
            "LAYER": layer_id,
            "FORMAT": "image/png",
        }
        if orientation == "vertical":
            params["LEGEND_OPTIONS"] = "layout:vertical"

        try:
            resp = await self.http.get(url, params=params)
            resp.raise_for_status()
            content_type = resp.headers.get("content-type", "").split(";")[0].strip()
            if content_type in _RASTER_IMAGE_TYPES:
                return resp.content
        except httpx.HTTPError as exc:
            log.debug("Legend not available for %s: %s", layer_id, exc)

        return None
