# -*- coding: utf-8 -*-
"""
Copernicus Data Space Ecosystem (CDSE) API client.

Supports OAuth2 authentication, OData catalog search,
quicklook thumbnail retrieval, and multi-satellite download
via AWS COG (free, no auth needed).

Supports: Sentinel-2, Landsat 8 / 9, Sentinel-1, MODIS.

API Docs: https://documentation.dataspace.copernicus.eu/APIs/OData.html

Author: Loc Vu Trung / Slow Forest
License: GPL-3.0
"""

import os


# API Endpoints
AUTH_URL = (
    "https://identity.dataspace.copernicus.eu"
    "/auth/realms/CDSE/protocol/openid-connect/token"
)
CATALOG_URL = (
    "https://catalogue.dataspace.copernicus.eu"
    "/odata/v1/Products"
)
DOWNLOAD_URL = (
    "https://zipper.dataspace.copernicus.eu"
    "/odata/v1/Products({product_id})/$value"
)

# STAC collections for each satellite
STAC_COLLECTIONS = {
    "S2_SR": "sentinel-2-l2a",
    "L8_SR": "landsat-c2-l2",
    "L9_SR": "landsat-c2-l2",
    "S1_GRD": "sentinel-1-grd",
    "MODIS_NDVI": "modis-13Q1-061",
}

# Band mapping: bands_key display name → STAC asset keys per satellite
_BAND_MAP = {
    "S2_SR": {
        "True Color (B4-B3-B2)": {
            "assets": ["red", "green", "blue"]},
        "False Color NIR (B8-B4-B3)": {
            "assets": ["nir", "red", "green"]},
        "SWIR (B12-B8A-B4)": {
            "assets": ["swir22", "swir16", "red"]},
        "Agriculture (B11-B8-B2)": {
            "assets": ["swir16", "nir", "blue"]},
        "NDVI": {
            "index": "ndvi",
            "assets": ["nir", "red"]},
        "EVI": {
            "index": "evi",
            "assets": ["nir", "red", "blue"]},
        "NDWI": {
            "index": "ndwi",
            "assets": ["green", "nir"]},
        "All Bands (B2-B12)": {
            "assets": ["blue", "green", "red", "rededge1",
                       "rededge2", "rededge3", "nir",
                       "nir08", "swir16", "swir22"]},
    },
    "L8_SR": {
        "True Color (B4-B3-B2)": {
            "assets": ["red", "green", "blue"]},
        "False Color NIR (B5-B4-B3)": {
            "assets": ["nir08", "red", "green"]},
        "SWIR (B7-B5-B4)": {
            "assets": ["swir22", "nir08", "red"]},
        "NDVI": {
            "index": "ndvi",
            "assets": ["nir08", "red"]},
        "All Bands": {
            "assets": ["coastal", "blue", "green", "red",
                       "nir08", "swir16", "swir22"]},
    },
    "L9_SR": {
        "True Color (B4-B3-B2)": {
            "assets": ["red", "green", "blue"]},
        "False Color NIR (B5-B4-B3)": {
            "assets": ["nir08", "red", "green"]},
        "NDVI": {
            "index": "ndvi",
            "assets": ["nir08", "red"]},
        "All Bands": {
            "assets": ["coastal", "blue", "green", "red",
                       "nir08", "swir16", "swir22"]},
    },
    "S1_GRD": {
        "VV Polarization": {
            "assets": ["vv"]},
        "VH Polarization": {
            "assets": ["vh"]},
        "VV + VH Composite": {
            "assets": ["vv", "vh"]},
    },
    "MODIS_NDVI": {
        "NDVI": {
            "assets": ["NDVI"]},
        "EVI": {
            "assets": ["EVI"]},
    },
}


def _force_ipv4():
    """Monkey-patch socket to prefer IPv4."""
    import socket
    if getattr(socket, "_lvt_ipv4_patched", False):
        return
    _orig = socket.getaddrinfo

    def _ipv4_only(host, port, family=0, type_=0, proto=0, flags=0):
        return _orig(host, port, socket.AF_INET, type_, proto, flags)

    socket.getaddrinfo = _ipv4_only
    socket._lvt_ipv4_patched = True


class CopernicusDownloader:
    """Copernicus Data Space Ecosystem API client."""

    def __init__(self, username, password):
        _force_ipv4()
        self._username = username
        self._password = password
        self._token = None

    # ------------------------------------------------------------------
    # Authentication
    # ------------------------------------------------------------------

    def authenticate(self):
        """Get OAuth2 access token using username / password."""
        import requests

        resp = requests.post(AUTH_URL, data={
            "client_id": "cdse-public",
            "username": self._username,
            "password": self._password,
            "grant_type": "password",
        }, timeout=30)

        if resp.status_code != 200:
            error_msg = resp.json().get("error_description", resp.text)
            raise ValueError(
                f"Authentication failed: {error_msg}")

        data = resp.json()
        self._token = data["access_token"]
        return self._token

    def _ensure_token(self):
        """Ensure we have a valid token."""
        if not self._token:
            self.authenticate()
        return self._token

    # ------------------------------------------------------------------
    # Search
    # ------------------------------------------------------------------

    def search_images(self, aoi_geojson, start_date, end_date,
                      max_cloud=20, collection="SENTINEL-2",
                      product_type="S2MSI2A", max_results=50,
                      progress_cb=None):
        """Search Copernicus catalog via OData API.

        Args:
            aoi_geojson: GeoJSON dict (Polygon or bbox).
            start_date: "YYYY-MM-DD"
            end_date: "YYYY-MM-DD"
            max_cloud: Max cloud cover percentage (0-100).
            collection: "SENTINEL-2", "SENTINEL-1", "LANDSAT".
            product_type: "S2MSI2A" (L2A), etc.
            max_results: Max number of results.
            progress_cb: Optional callback(pct) for progress (0.0-1.0).

        Returns:
            List of dicts with id, name, date, cloud, size, quicklook.
        """
        import requests

        _cb = progress_cb or (lambda pct: None)

        # Build WKT polygon from GeoJSON
        wkt = self._geojson_to_wkt(aoi_geojson)

        # Build OData filter
        filters = [
            f"Collection/Name eq '{collection}'",
            (f"OData.CSC.Intersects(area=geography"
             f"'SRID=4326;{wkt}')"),
            (f"ContentDate/Start ge "
             f"{start_date}T00:00:00.000Z"),
            (f"ContentDate/Start le "
             f"{end_date}T23:59:59.999Z"),
        ]

        filter_str = " and ".join(filters)

        # Ensure we have auth token (CDSE requires auth for catalog)
        headers = {}
        try:
            self._ensure_token()
            if self._token:
                headers["Authorization"] = f"Bearer {self._token}"
        except Exception:
            pass  # Try without auth first, may work for recent data

        # Call 1: Attributes (cloud cover, tile ID)
        _cb(0.1)
        params1 = {
            "$filter": filter_str,
            "$orderby": "ContentDate/Start desc",
            "$top": max_results,
            "$expand": "Attributes",
        }

        try:
            resp1 = requests.get(
                CATALOG_URL, params=params1, headers=headers,
                timeout=90)
            # If 401/403, re-authenticate and retry
            if resp1.status_code in (401, 403) and self._username:
                try:
                    self.authenticate()
                    headers["Authorization"] = \
                        f"Bearer {self._token}"
                    resp1 = requests.get(
                        CATALOG_URL, params=params1,
                        headers=headers, timeout=90)
                except Exception:
                    pass
            resp1.raise_for_status()
        except requests.exceptions.RequestException:
            # Retry with simplified bbox
            wkt = self._geojson_to_wkt_bbox(aoi_geojson)
            filters[1] = (
                f"OData.CSC.Intersects(area=geography"
                f"'SRID=4326;{wkt}')")
            filter_str = " and ".join(filters)
            params1["$filter"] = filter_str
            resp1 = requests.get(
                CATALOG_URL, params=params1, headers=headers,
                timeout=90)
            resp1.raise_for_status()

        data1 = resp1.json()
        _cb(0.5)

        # Call 2: Assets (quicklook URLs)
        params2 = {
            "$filter": filter_str,
            "$orderby": "ContentDate/Start desc",
            "$top": max_results,
            "$expand": "Assets",
        }
        resp2 = requests.get(
            CATALOG_URL, params=params2, headers=headers, timeout=90)
        resp2.raise_for_status()
        data2 = resp2.json()
        _cb(0.9)

        # Build asset lookup: product_id -> quicklook URL
        asset_map = {}
        for item in data2.get("value", []):
            pid = item.get("Id", "")
            for asset in item.get("Assets", []):
                if isinstance(asset, dict):
                    atype = asset.get("Type", "")
                    if "QUICKLOOK" in atype.upper():
                        aid = asset.get("Id", "")
                        if aid:
                            asset_map[pid] = (
                                f"https://catalogue.dataspace.copernicus.eu"
                                f"/odata/v1/Assets({aid})/$value"
                            )
                        break

        results = []
        for item in data1.get("value", []):
            product_id = item.get("Id", "")
            name = item.get("Name", "")

            # Parse date
            content_date = item.get("ContentDate", {})
            start_str = content_date.get("Start", "")
            date_str = start_str[:10] if start_str else "?"

            # Parse cloud cover from Attributes
            cloud = None
            for attr in item.get("Attributes", []):
                if isinstance(attr, dict):
                    if attr.get("Name") == "cloudCover":
                        try:
                            cloud = round(
                                float(attr.get("Value", 0)), 1)
                        except (ValueError, TypeError):
                            pass
                        break

            # Client-side cloud filter
            if cloud is not None and cloud > max_cloud:
                continue

            # File size
            content_len = item.get("ContentLength", 0)
            size_mb = round(content_len / (1024 * 1024), 1)

            # Quicklook from merged asset data
            quicklook = asset_map.get(product_id, "")

            # Extract tile ID for display
            tile_id = ""
            for attr in item.get("Attributes", []):
                if isinstance(attr, dict):
                    if attr.get("Name") == "tileId":
                        tile_id = attr.get("Value", "")
                        break
            if not tile_id and name:
                parts = name.split("_")
                for p in parts:
                    if p.startswith("T") and len(p) == 6:
                        tile_id = p
                        break

            results.append({
                "id": product_id,
                "name": name,
                "date": date_str,
                "cloud": cloud,
                "size_mb": size_mb,
                "quicklook": quicklook,
                "tile_id": tile_id,
                "source": "copernicus",
            })

        return results

    def get_quicklook_url(self, product_id):
        """Get quicklook thumbnail URL for a product."""
        return (
            f"https://catalogue.dataspace.copernicus.eu"
            f"/odata/v1/Assets('{product_id}')/$value"
        )

    # ------------------------------------------------------------------
    # Download (multi-satellite)
    # ------------------------------------------------------------------

    def download_product(self, product_id, output_dir,
                         product_name="", aoi_geojson=None,
                         bands_key="", sat_key="S2_SR"):
        """Download satellite bands via free AWS COG (no auth needed).

        Uses Element84 STAC API to find COG URLs, then GDAL to crop
        to AOI — downloading only needed pixels, not full tiles.

        Supports: S2_SR, L8_SR, L9_SR, S1_GRD, MODIS_NDVI.

        Returns path to output GeoTIFF.
        """
        import requests

        if product_name:
            safe_name = (product_name.replace(".SAFE", "")
                         .replace("/", "_")[:80])
        else:
            safe_name = f"SAT_{product_id[:12]}"

        # Resolve band mapping for this satellite
        band_info = self._resolve_bands(sat_key, bands_key)
        asset_keys = band_info["assets"]
        is_index = band_info.get("index")

        # Step 1: Find COG URLs via STAC
        stac_col = STAC_COLLECTIONS.get(sat_key, "sentinel-2-l2a")
        tile_id = self._extract_tile_id(product_name, sat_key)
        date_str = self._extract_date(product_name)

        stac_url = "https://earth-search.aws.element84.com/v1/search"
        stac_body = {
            "collections": [stac_col],
            "datetime": f"{date_str}T00:00:00Z/{date_str}T23:59:59Z",
            "limit": 10,
        }
        if aoi_geojson:
            stac_body["intersects"] = aoi_geojson

        stac_resp = requests.post(stac_url, json=stac_body, timeout=30)
        stac_resp.raise_for_status()
        features = stac_resp.json().get("features", [])

        # Match by tile ID
        matched = None
        for feat in features:
            props = feat.get("properties", {})
            grid = props.get("grid:code", "")
            # Also check mgrs:grid_id and landsat:wrs_path/row
            mgrs = props.get("mgrs:grid_id", "")
            if tile_id:
                if tile_id in grid or tile_id in mgrs:
                    matched = feat
                    break
                # Landsat matching by path/row
                wrs_path = str(props.get("landsat:wrs_path", ""))
                wrs_row = str(props.get("landsat:wrs_row", ""))
                if (wrs_path and wrs_row
                        and f"{wrs_path}{wrs_row}" in tile_id):  # noqa: W503
                    matched = feat
                    break
        if not matched and features:
            matched = features[0]

        if not matched:
            raise ValueError(
                f"Could not find image on AWS: {product_name}")

        assets = matched.get("assets", {})

        # Step 2: Download via GDAL COG crop
        from osgeo import gdal
        gdal.UseExceptions()

        bbox = None
        if aoi_geojson:
            bbox = self._geojson_to_bbox(aoi_geojson)

        out_path = os.path.join(output_dir, f"{safe_name}.tif")

        if is_index:
            # Compute vegetation index (NDVI/EVI/NDWI)
            self._download_and_compute_index(
                assets, asset_keys, is_index, bbox, out_path)
        elif (len(asset_keys) <= 3
              and "visual" in assets  # noqa: W503
              and set(asset_keys) == {"red", "green", "blue"}):  # noqa: W503
            # True Color → try pre-rendered TCI first, fallback
            # to individual R/G/B bands if TCI not available
            tci_url = assets["visual"]["href"]
            if self._check_url_accessible(tci_url):
                vsicurl = f"/vsicurl/{tci_url}"
                opts = {}
                if bbox:
                    opts["projWin"] = [
                        bbox[0], bbox[3], bbox[2], bbox[1]]
                    opts["projWinSRS"] = "EPSG:4326"
                try:
                    gdal.Translate(out_path, vsicurl, **opts)
                except Exception:
                    # TCI failed → fallback to band stack
                    self._download_multi_band(
                        assets, asset_keys, bbox, out_path,
                        output_dir)
            else:
                # TCI not on S3 yet → download R/G/B separately
                self._download_multi_band(
                    assets, asset_keys, bbox, out_path,
                    output_dir)
        elif len(asset_keys) == 1:
            # Single band (VV, VH, NDVI product, etc.)
            key = self._find_best_asset(assets, asset_keys)
            if not key:
                key = asset_keys[0]
            src_url = assets[key]["href"]
            if not self._check_url_accessible(src_url):
                raise IOError(
                    f"Band '{key}' not available on AWS yet: "
                    f"{src_url}")
            vsicurl = f"/vsicurl/{src_url}"
            opts = {}
            if bbox:
                opts["projWin"] = [
                    bbox[0], bbox[3], bbox[2], bbox[1]]
                opts["projWinSRS"] = "EPSG:4326"
            gdal.Translate(out_path, vsicurl, **opts)
        else:
            # Multi-band: download each then stack
            self._download_multi_band(
                assets, asset_keys, bbox, out_path, output_dir)

        if not os.path.exists(out_path):
            raise IOError("Download failed — no output file created")

        return out_path

    # ------------------------------------------------------------------
    # URL check + multi-band download helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _check_url_accessible(url):
        """Check if a remote COG URL is accessible via HEAD request.

        Returns True if the file exists (HTTP 200 / 206).
        Returns False if 404 / 403 / timeout — meaning the COG
        is not yet available on AWS S3.
        """
        import requests
        try:
            resp = requests.head(
                url, timeout=10, allow_redirects=True)
            return resp.status_code in (200, 206)
        except Exception:
            return False

    def _download_multi_band(self, assets, asset_keys, bbox,
                             out_path, output_dir):
        """Download multiple bands and stack into a single GeoTIFF.

        Skips bands that are not accessible on S3.
        """
        from osgeo import gdal
        gdal.UseExceptions()

        tmp_files = []
        for i, key in enumerate(asset_keys):
            actual_key = key
            if key not in assets:
                actual_key = self._find_best_asset(
                    assets, [key])
                if not actual_key:
                    continue

            src_url = assets[actual_key]["href"]

            # Skip bands not yet on S3
            if not self._check_url_accessible(src_url):
                continue

            vsicurl = f"/vsicurl/{src_url}"
            tmp_path = os.path.join(
                output_dir, f"_tmp_band_{i}.tif")

            opts = {}
            if bbox:
                opts["projWin"] = [
                    bbox[0], bbox[3], bbox[2], bbox[1]]
                opts["projWinSRS"] = "EPSG:4326"

            try:
                gdal.Translate(tmp_path, vsicurl, **opts)
                if os.path.exists(tmp_path):
                    tmp_files.append(tmp_path)
            except Exception:
                # Band download failed, skip it
                if os.path.exists(tmp_path):
                    try:
                        os.remove(tmp_path)
                    except OSError:
                        pass

        if not tmp_files:
            raise IOError(
                "No bands available on AWS for this image. "
                "The image may be too recent — try an older date.")

        if len(tmp_files) == 1:
            # Only 1 band available → just rename
            os.replace(tmp_files[0], out_path)
        else:
            vrt = gdal.BuildVRT(
                "", tmp_files, separate=True)
            gdal.Translate(out_path, vrt)
            vrt = None
            for f in tmp_files:
                try:
                    os.remove(f)
                except OSError:
                    pass

    # ------------------------------------------------------------------
    # Index computation (NDVI, EVI, NDWI)
    # ------------------------------------------------------------------

    def _download_and_compute_index(self, assets, asset_keys,
                                    index_name, bbox, out_path):
        """Download bands and compute vegetation index.

        NDVI = (NIR - Red) / (NIR + Red)
        EVI  = 2.5 * (NIR - Red) / (NIR + 6 * Red - 7.5 * Blue + 1)
        NDWI = (Green - NIR) / (Green + NIR)
        """
        import numpy as np
        from osgeo import gdal
        gdal.UseExceptions()

        output_dir = os.path.dirname(out_path)

        # Download required bands
        band_arrays = []
        ref_ds = None
        for i, key in enumerate(asset_keys):
            actual_key = key
            if key not in assets:
                actual_key = self._find_best_asset(assets, [key])
                if not actual_key:
                    raise ValueError(
                        f"Band '{key}' not found in assets: "
                        f"{list(assets.keys())}")

            src_url = assets[actual_key]["href"]

            # Check if band is available on S3
            if not self._check_url_accessible(src_url):
                raise IOError(
                    f"Band '{actual_key}' not available on AWS "
                    f"yet. The image may be too recent.")

            vsicurl = f"/vsicurl/{src_url}"
            tmp_path = os.path.join(
                output_dir, f"_tmp_idx_{i}.tif")

            opts = {}
            if bbox:
                opts["projWin"] = [
                    bbox[0], bbox[3], bbox[2], bbox[1]]
                opts["projWinSRS"] = "EPSG:4326"

            ds = gdal.Translate(tmp_path, vsicurl, **opts)

            if ref_ds is None:
                ref_ds = ds
            band = ds.GetRasterBand(1)
            arr = band.ReadAsArray().astype(np.float64)
            band_arrays.append(arr)

            if ref_ds is not ds:
                ds = None

            # Keep tmp for cleanup
            try:
                os.remove(tmp_path)
            except OSError:
                pass

        # Compute index
        if index_name == "ndvi" and len(band_arrays) >= 2:
            nir, red = band_arrays[0], band_arrays[1]
            denom = nir + red
            result = np.where(
                denom != 0, (nir - red) / denom, 0.0)
        elif index_name == "evi" and len(band_arrays) >= 3:
            nir, red, blue = (band_arrays[0],
                              band_arrays[1],
                              band_arrays[2])
            denom = nir + 6.0 * red - 7.5 * blue + 1.0
            result = np.where(
                denom != 0,
                2.5 * (nir - red) / denom, 0.0)
        elif index_name == "ndwi" and len(band_arrays) >= 2:
            green, nir = band_arrays[0], band_arrays[1]
            denom = green + nir
            result = np.where(
                denom != 0, (green - nir) / denom, 0.0)
        else:
            raise ValueError(
                f"Cannot compute '{index_name}' with "
                f"{len(band_arrays)} bands")

        # Clip to valid range
        result = np.clip(result, -1.0, 1.0)

        # Write output GeoTIFF
        driver = gdal.GetDriverByName("GTiff")
        rows, cols = result.shape
        out_ds = driver.Create(
            out_path, cols, rows, 1, gdal.GDT_Float32)

        if ref_ds:
            out_ds.SetGeoTransform(ref_ds.GetGeoTransform())
            out_ds.SetProjection(ref_ds.GetProjection())

        out_band = out_ds.GetRasterBand(1)
        out_band.WriteArray(result.astype(np.float32))
        out_band.SetNoDataValue(-9999.0)
        out_band.SetDescription(index_name.upper())
        out_ds.FlushCache()
        out_ds = None
        ref_ds = None

    # ------------------------------------------------------------------
    # Band resolution helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _resolve_bands(sat_key, bands_key):
        """Resolve bands_key to STAC asset names for a satellite.

        Returns dict: {"assets": [...], "index": "ndvi"|None}
        """
        sat_map = _BAND_MAP.get(sat_key, {})

        # Direct match
        if bands_key in sat_map:
            info = sat_map[bands_key]
            return {
                "assets": info["assets"],
                "index": info.get("index"),
            }

        # Fuzzy match: try substring
        bk_lower = bands_key.lower()
        for name, info in sat_map.items():
            if name.lower() in bk_lower or bk_lower in name.lower():
                return {
                    "assets": info["assets"],
                    "index": info.get("index"),
                }

        # Default: True Color or first available
        for name in ["True Color", "visual", "NDVI", "VV"]:
            for key, info in sat_map.items():
                if name.lower() in key.lower():
                    return {
                        "assets": info["assets"],
                        "index": info.get("index"),
                    }

        # Ultimate fallback
        return {"assets": ["visual", "red", "green", "blue"],
                "index": None}

    @staticmethod
    def _find_best_asset(assets, wanted_keys):
        """Find the best matching asset key from available assets."""
        for k in wanted_keys:
            if k in assets:
                return k
        # Try case-insensitive
        assets_lower = {k.lower(): k for k in assets}
        for k in wanted_keys:
            if k.lower() in assets_lower:
                return assets_lower[k.lower()]
        # Try partial match
        for k in wanted_keys:
            for ak in assets:
                if k.lower() in ak.lower():
                    return ak
        # Skip metadata assets
        for ak in assets:
            if ak not in ("thumbnail", "info", "metadata",
                          "granule_metadata", "product_metadata",
                          "tilejson", "rendered_preview"):
                return ak
        return None

    # ------------------------------------------------------------------
    # Tile ID extraction (multi-satellite)
    # ------------------------------------------------------------------

    @staticmethod
    def _extract_tile_id(product_name, sat_key="S2_SR"):
        """Extract tile ID from product name."""
        if not product_name:
            return ""
        parts = product_name.replace(".SAFE", "").split("_")

        if sat_key in ("S2_SR",):
            # Sentinel-2: T48QWK → 48QWK
            for p in parts:
                if p.startswith("T") and len(p) == 6:
                    return p[1:]
        elif sat_key in ("L8_SR", "L9_SR"):
            # Landsat: path/row encoded in product name
            for p in parts:
                if len(p) == 6 and p.isdigit():
                    return p  # pathrow
        elif sat_key == "S1_GRD":
            # Sentinel-1: orbit + slice
            for p in parts:
                if len(p) >= 6 and p[:6].isdigit():
                    return p[:6]
        return ""

    @staticmethod
    def _extract_date(product_name):
        """Extract date string from product name."""
        if not product_name:
            return ""
        parts = product_name.replace(".SAFE", "").split("_")
        for p in parts:
            if len(p) >= 8 and p[:8].isdigit():
                return f"{p[:4]}-{p[4:6]}-{p[6:8]}"
        return ""

    @staticmethod
    def _geojson_to_bbox(geojson):
        """Get bounding box from GeoJSON geometry."""
        geom_type = geojson.get("type", "")
        all_coords = []

        if geom_type == "Polygon":
            all_coords = geojson["coordinates"][0]
        elif geom_type == "MultiPolygon":
            for poly in geojson["coordinates"]:
                all_coords.extend(poly[0])
        elif geom_type == "Point":
            lon, lat = geojson["coordinates"]
            return [lon - 0.01, lat - 0.01, lon + 0.01, lat + 0.01]
        else:
            return None

        if not all_coords:
            return None

        lons = [c[0] for c in all_coords]
        lats = [c[1] for c in all_coords]
        return [min(lons), min(lats), max(lons), max(lats)]

    # ------------------------------------------------------------------
    # WKT helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _geojson_to_wkt(geojson):
        """Convert GeoJSON Polygon to WKT string.

        Simplifies complex geometries to bounding box to avoid
        CDSE API 400 errors from overly long URLs.
        """
        geom_type = geojson.get("type", "")

        def _bbox_wkt(all_coords):
            lons = [c[0] for c in all_coords]
            lats = [c[1] for c in all_coords]
            x0, x1 = min(lons), max(lons)
            y0, y1 = min(lats), max(lats)
            return (
                f"POLYGON(({x0} {y0},{x1} {y0},"
                f"{x1} {y1},{x0} {y1},{x0} {y0}))")

        if geom_type == "Polygon":
            coords = geojson["coordinates"][0]
            if len(coords) > 50:
                return _bbox_wkt(coords)
            pts = ", ".join(
                f"{c[0]} {c[1]}" for c in coords)
            return f"POLYGON(({pts}))"

        elif geom_type == "MultiPolygon":
            all_coords = []
            for poly in geojson["coordinates"]:
                all_coords.extend(poly[0])
            return _bbox_wkt(all_coords)

        elif geom_type in ("Point", "LineString"):
            if geom_type == "Point":
                lon, lat = geojson["coordinates"]
                buf = 0.01
                return (
                    f"POLYGON(({lon-buf} {lat-buf},"  # noqa: E226
                    f"{lon+buf} {lat-buf},"  # noqa: E226
                    f"{lon+buf} {lat+buf},"  # noqa: E226
                    f"{lon-buf} {lat+buf},"  # noqa: E226
                    f"{lon-buf} {lat-buf}))")  # noqa: E226
            coords = geojson["coordinates"]
            return _bbox_wkt(coords)

        raise ValueError(
            f"Cannot convert {geom_type} to WKT polygon")

    @staticmethod
    def _geojson_to_wkt_bbox(geojson):
        """Always convert GeoJSON to bbox WKT (for retry)."""
        geom_type = geojson.get("type", "")
        all_coords = []
        if geom_type == "Polygon":
            all_coords = geojson["coordinates"][0]
        elif geom_type == "MultiPolygon":
            for poly in geojson["coordinates"]:
                all_coords.extend(poly[0])
        elif geom_type == "Point":
            lon, lat = geojson["coordinates"]
            buf = 0.01
            all_coords = [
                [lon - buf, lat - buf], [lon + buf, lat + buf]]
        else:
            all_coords = [[0, 0]]

        lons = [c[0] for c in all_coords]
        lats = [c[1] for c in all_coords]
        x0, x1 = min(lons), max(lons)
        y0, y1 = min(lats), max(lats)
        return (
            f"POLYGON(({x0} {y0},{x1} {y0},"
            f"{x1} {y1},{x0} {y1},{x0} {y0}))")

    # ------------------------------------------------------------------
    # Validation
    # ------------------------------------------------------------------

    @staticmethod
    def validate_credentials(username, password):
        """Quick check if credentials are valid."""
        import requests
        try:
            resp = requests.post(AUTH_URL, data={
                "client_id": "cdse-public",
                "username": username,
                "password": password,
                "grant_type": "password",
            }, timeout=15)
            return resp.status_code == 200
        except Exception:
            return False
