# -*- coding: utf-8 -*-
"""
Satellite Image Downloader — QGIS dialog (v3).

Layout: Left = parameters + table | Right = preview panel.
Thumbnails load on-click only (fast), download = full quality.

Author: Loc Vu Trung
License: GPL-3.0
"""

import contextlib
import json
import os
from datetime import datetime

from qgis.PyQt.QtCore import (
    Qt, QDate, QThread, pyqtSignal, QSize, QUrl, QObject,
)
from qgis.PyQt.QtGui import QPixmap, QImage, QFont, QColor
from qgis.PyQt.QtWidgets import (
    QDialog, QVBoxLayout, QHBoxLayout, QGridLayout,
    QLabel, QComboBox, QDateEdit, QPushButton, QSlider,
    QRadioButton, QCheckBox, QFileDialog, QGroupBox,
    QProgressBar, QMessageBox, QLineEdit, QButtonGroup,
    QSizePolicy, QFrame, QWidget, QTableWidget,
    QTableWidgetItem, QHeaderView, QAbstractItemView,
    QSplitter,
)
from qgis.PyQt.QtNetwork import QNetworkAccessManager, QNetworkRequest
from qgis.core import (
    QgsProject, QgsRasterLayer, QgsVectorLayer,
    QgsCoordinateReferenceSystem, QgsCoordinateTransform,
    QgsWkbTypes,
)

from .i18n import tr
from .satellites import SATELLITES, COMPOSITES


# ======================================================================
# Preview Signal Bridge (thread-safe)
# ======================================================================

class _PreviewBridge(QObject):
    """Thread-safe signal bridge for preview image delivery."""
    preview_ready = pyqtSignal(object, str)   # img_bytes, cache_key
    preview_error = pyqtSignal(str)           # error message


# ======================================================================
# Worker Thread
# ======================================================================

class _GEEWorker(QThread):
    """Background worker for GEE operations."""
    finished = pyqtSignal(object)
    error = pyqtSignal(str)
    progress = pyqtSignal(str)
    progress_pct = pyqtSignal(int)   # 0-100 percentage

    def __init__(self, task, kwargs):
        super().__init__()
        self.task = task
        self.kwargs = kwargs

    def run(self):
        try:
            # ── Copernicus Tasks (NO GEE needed) ────────────
            if self.task == "cdse_search":
                self.progress.emit(
                    tr("Searching satellite catalog..."))
                self.progress_pct.emit(10)
                from .copernicus import CopernicusDownloader
                cdse = CopernicusDownloader(
                    self.kwargs.pop("username"),
                    self.kwargs.pop("password"))
                self.progress_pct.emit(30)
                result = cdse.search_images(
                    **self.kwargs,
                    progress_cb=lambda pct: self.progress_pct.emit(
                        30 + int(pct * 0.6)))
                self.progress_pct.emit(95)
                thumb_cache = {}
                for item in result:
                    if item.get("quicklook"):
                        thumb_cache[item["id"]] = item["quicklook"]
                self.progress_pct.emit(100)
                self.finished.emit({
                    "task": "search",
                    "data": result,
                    "thumb_cache": thumb_cache,
                })
                return

            elif self.task == "stac_search":
                self.progress.emit(
                    tr("Searching satellite catalog..."))
                self.progress_pct.emit(20)
                import requests as _req
                stac_col = self.kwargs.get(
                    "stac_collection", "sentinel-2-l2a")
                aoi = self.kwargs["aoi_geojson"]
                start = self.kwargs["start_date"]
                end = self.kwargs["end_date"]
                max_cloud = self.kwargs.get("max_cloud", 20)
                platform = self.kwargs.get("platform", "")

                # Planetary Computer for Landsat (free HTTPS, signed SAS)
                # Element84 returns s3:// requester-pays → unusable
                _use_pc = "landsat" in stac_col
                _stac_ep = (
                    "https://planetarycomputer.microsoft.com"
                    "/api/stac/v1/search"
                    if _use_pc else
                    "https://earth-search.aws.element84.com/v1/search")

                body = {
                    "collections": [stac_col],
                    "datetime": (f"{start}T00:00:00Z/"
                                 f"{end}T23:59:59Z"),
                    "limit": 50,
                    "intersects": aoi,
                    "query": {},
                }
                if "landsat" in stac_col or "sentinel" in stac_col:
                    body["query"]["eo:cloud_cover"] = {"lte": max_cloud}
                if platform:
                    body["query"]["platform"] = {"eq": platform}
                if not body["query"]:
                    del body["query"]

                self.progress_pct.emit(40)
                resp = _req.post(_stac_ep, json=body, timeout=60)
                resp.raise_for_status()
                features = resp.json().get("features", [])
                self.progress_pct.emit(60)

                # For PC Landsat: fetch SAS signing string from public API
                # (free, no credentials needed — public planetary computer endpoint)
                _pc_sas_str = ""
                if _use_pc and features:
                    with contextlib.suppress(Exception):
                        _tok = _req.get(
                            "https://planetarycomputer.microsoft.com"
                            "/api/sas/v1/token/usgslandsat",
                            timeout=15)
                        if _tok.status_code == 200:
                            _pc_sas_str = _tok.json().get("token", "")
                self.progress_pct.emit(80)

                def _sign(url):
                    """Append SAS signing string to PC Azure Blob URL."""
                    if (_pc_sas_str and url
                            and "blob.core.windows.net" in url):
                        return f"{url}?{_pc_sas_str}"
                    return url

                results = []
                thumb_cache = {}
                for feat in features:
                    props = feat.get("properties", {})
                    dt = props.get("datetime", "")[:10]
                    cloud = props.get("eo:cloud_cover")
                    tile = props.get("grid:code", "")
                    if tile.startswith("MGRS-"):
                        tile = tile[5:]
                    # Landsat: build WRS path/row tile ID
                    if not tile:
                        wrs_p = str(props.get("landsat:wrs_path", ""))
                        wrs_r = str(props.get("landsat:wrs_row", ""))
                        if wrs_p and wrs_r:
                            tile = (
                                f"WRS2-"
                                f"{wrs_p.zfill(3)}"
                                f"{wrs_r.zfill(3)}")
                    assets = feat.get("assets", {})
                    # Best thumbnail: PC thumbnails are free HTTPS
                    ql_url = ""
                    for _qk in ("thumbnail", "rendered_preview",
                                "overview"):
                        if _qk in assets:
                            _href = assets[_qk].get("href", "")
                            if _href and _href.startswith("http"):
                                ql_url = _href
                                break
                    item = {
                        "id": feat.get("id", ""),
                        "name": feat.get("id", ""),
                        "date": dt,
                        "cloud": (round(cloud, 1)
                                  if cloud is not None else None),
                        "tile_id": tile,
                        "quicklook": ql_url,
                        "source": "stac",
                        # Sign PC asset URLs with SAS token
                        "stac_assets": {
                            k: _sign(v.get("href", ""))
                            for k, v in assets.items()},
                    }
                    results.append(item)
                    if ql_url:
                        thumb_cache[item["id"]] = ql_url

                self.progress_pct.emit(100)
                self.finished.emit({
                    "task": "search",

                    "data": results,
                    "thumb_cache": thumb_cache,
                })
                return

            elif self.task == "cdse_download_batch":
                from .copernicus import CopernicusDownloader
                cdse = CopernicusDownloader(
                    self.kwargs.pop("username"),
                    self.kwargs.pop("password"))
                items = self.kwargs.pop("items")
                output_dir = self.kwargs.pop("output_dir")
                aoi_geojson = self.kwargs.pop("aoi_geojson", None)
                bands_key = self.kwargs.pop("bands_key", "")
                sat_key = self.kwargs.pop("sat_key", "S2_SR")
                total = len(items)
                paths = []
                errors = []
                for i, item in enumerate(items):
                    pct = int((i / total) * 100)
                    self.progress_pct.emit(pct)
                    self.progress.emit(
                        tr("Downloading {n}/{total}: {date}...").format(
                            n=i + 1, total=total,
                            date=item["date"]))
                    try:
                        path = cdse.download_product(
                            product_id=item["id"],
                            output_dir=output_dir,
                            product_name=item.get("name", ""),
                            aoi_geojson=aoi_geojson,
                            bands_key=bands_key,
                            sat_key=sat_key)
                        paths.append(path)
                    except Exception as exc:
                        err_msg = tr("Skipped {date}: {err}").format(
                            date=item["date"], err=str(exc))
                        self.progress.emit(err_msg)
                        errors.append(f"{item['date']}: {exc}")
                self.progress_pct.emit(100)
                self.finished.emit({
                    "task": "download_batch",
                    "data": paths,
                    "errors": errors})
                return

            elif self.task == "stac_download_batch":
                items = self.kwargs.pop("items")
                output_dir = self.kwargs.pop("output_dir")
                aoi_geojson = self.kwargs.pop("aoi_geojson", None)
                bands_key = self.kwargs.pop("bands_key", "")
                sat_key = self.kwargs.pop("sat_key", "S2_SR")
                total = len(items)
                paths = []
                errors = []

                import requests as _req2
                from osgeo import gdal
                gdal.UseExceptions()
                from .copernicus import CopernicusDownloader

                # ── GDAL global config ──────────────────────────────────
                gdal.SetConfigOption('GDAL_HTTP_TIMEOUT', '120')
                gdal.SetConfigOption('GDAL_HTTP_CONNECTTIMEOUT', '30')
                gdal.SetConfigOption('AWS_NO_SIGN_REQUEST', 'YES')
                gdal.SetConfigOption('GDAL_DISABLE_READDIR_ON_OPEN',
                                     'EMPTY_DIR')
                gdal.SetConfigOption('GDAL_HTTP_UNSAFESSL', 'YES')
                gdal.SetConfigOption(
                    'CPL_VSIL_CURL_ALLOWED_EXTENSIONS',
                    '.tif,.TIF,.tiff,.TIFF,.img,.vrt')

                # ── PC Sign API helper ──────────────────────────────────
                # Use /api/sas/v1/sign?href=... for each Azure Blob URL.
                # This approach works with ANY GDAL version because we
                # just get a plain signed HTTPS URL → /vsicurl/ handles it.
                # (/vsiaz/ + AZURE_STORAGE_SAS_TOKEN fails on QGIS-bundled
                #  GDAL because it ignores the config and tries IMDS 169.x)
                _pc_sign_cache = {}

                def _make_vsicurl(href):
                    """
                    Build a GDAL /vsicurl/ path for any remote asset.
                    Azure Blob (PC Landsat): sign via PC API → /vsicurl/
                    s3://  → /vsis3/
                    https  → /vsicurl/
                    """
                    if not href:
                        return ""
                    base = href.split("?")[0]   # strip any stale SAS token

                    if "blob.core.windows.net" in base:
                        # Use PC Sign API to get a correctly-signed URL.
                        # Results are cached within this download session.
                        if base in _pc_sign_cache:
                            return f"/vsicurl/{_pc_sign_cache[base]}"
                        with contextlib.suppress(Exception):
                            _r = _req2.get(
                                "https://planetarycomputer.microsoft.com"
                                "/api/sas/v1/sign",
                                params={"href": base},
                                timeout=20)
                            if _r.status_code == 200:
                                signed = _r.json().get("href", "")
                                if signed:
                                    _pc_sign_cache[base] = signed
                                    return f"/vsicurl/{signed}"
                        # Fallback: use unsigned (may fail)
                        return f"/vsicurl/{base}"

                    if base.startswith("s3://"):
                        return "/vsis3/" + base[5:]
                    return f"/vsicurl/{href}"

                # ── Natural color stretch ───────────────────────────────
                def _stretch_natural_color(path):
                    """
                    Convert a 16-bit Landsat SR GeoTIFF to 8-bit natural
                    color in-place (overwrite).

                    Algorithm:
                    - Per-band: compute approximate mean & std (fast)
                    - Stretch [mean - 2σ, mean + 2.5σ] → [0, 255]
                    - Floor lo at 7000 (Landsat C2 L2 fill/nodata is 0,
                      valid DN starts ~7273; values < 7000 are nodata)
                    - Output: GDT_Byte, LZW-compressed, nodata=0

                    This makes the image look natural immediately when
                    loaded in QGIS without any manual stretch adjustment.
                    """
                    tmp = path + "._stretch.tif"
                    try:
                        src_ds = gdal.Open(path)
                        if src_ds is None:
                            return
                        n_bands = src_ds.RasterCount
                        src_type = src_ds.GetRasterBand(1).DataType
                        # Only stretch if source is 16-bit integer
                        if src_type not in (
                                gdal.GDT_UInt16, gdal.GDT_Int16,
                                gdal.GDT_UInt32, gdal.GDT_Int32):
                            src_ds = None
                            return   # already 8-bit or float

                        scale_params = []
                        for b in range(1, n_bands + 1):
                            bnd = src_ds.GetRasterBand(b)
                            bnd.SetNoDataValue(0)
                            mn, mx, mean, std = bnd.GetStatistics(
                                True, True)  # approx=True, force=True
                            # 2σ low / 2.5σ high clip
                            lo = max(7000.0, mean - 2.0 * std)
                            hi = min(65535.0, mean + 2.5 * std)
                            if hi <= lo:
                                lo, hi = 7000.0, 18000.0
                            scale_params.append([lo, hi, 0, 255])
                        src_ds = None

                        gdal.Translate(
                            tmp, path,
                            outputType=gdal.GDT_Byte,
                            scaleParams=scale_params,
                            noData=0,
                            creationOptions=[
                                "COMPRESS=LZW",
                                "PHOTOMETRIC=RGB" if n_bands >= 3
                                else "MINISBLACK",
                                "TILED=YES",
                            ])
                        if os.path.exists(tmp):
                            import shutil
                            shutil.move(tmp, path)
                    except Exception:
                        # Stretch failed — keep original 16-bit file
                        if os.path.exists(tmp):
                            with contextlib.suppress(OSError):
                                os.remove(tmp)

                # ── Resolve bands ──────────────────────────────────────
                band_info = CopernicusDownloader._resolve_bands(
                    sat_key, bands_key)
                wanted_assets = band_info["assets"]
                is_index = band_info.get("index")

                # ── Build bbox ─────────────────────────────────────────
                bbox = None
                if aoi_geojson:
                    bbox = CopernicusDownloader._geojson_to_bbox(
                        aoi_geojson)

                for i, item in enumerate(items):
                    pct = int((i / total) * 100)
                    self.progress_pct.emit(pct)
                    self.progress.emit(
                        tr("Downloading {n}/{total}: {date}...").format(
                            n=i + 1, total=total,
                            date=item["date"]))

                    stac_assets = item.get("stac_assets", {})
                    assets_dict = {}
                    for k, v in stac_assets.items():
                        if isinstance(v, str) and v:
                            assets_dict[k] = {"href": v}
                        elif isinstance(v, dict):
                            assets_dict[k] = v

                    if not assets_dict:
                        errors.append(
                            f"{item['date']}: No STAC assets found")
                        continue

                    safe_name = item.get(
                        "name", item["id"]).replace("/", "_")[:80]
                    out_path = os.path.join(
                        output_dir, f"{safe_name}.tif")

                    def _translate(src_url, dst, **t_opts):
                        """Translate a remote asset to local GeoTIFF."""
                        vsi = _make_vsicurl(src_url)
                        ds = gdal.Translate(dst, vsi, **t_opts)
                        ds = None

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

                        if is_index:
                            CopernicusDownloader._download_and_compute_index(
                                None, assets_dict, wanted_assets,
                                is_index, bbox, out_path)

                        elif len(wanted_assets) == 1:
                            key = CopernicusDownloader._find_best_asset(
                                assets_dict, wanted_assets)
                            if not key:
                                errors.append(
                                    f"{item['date']}: "
                                    f"Asset not found: {wanted_assets}")
                                continue
                            _translate(
                                assets_dict[key]["href"],
                                out_path, **_common_opts)

                        else:
                            # Multi-band: try visual/TCI shortcut first
                            if ("visual" in assets_dict
                                    and set(wanted_assets)
                                    == {"red", "green", "blue"}):
                                with contextlib.suppress(Exception):
                                    _translate(
                                        assets_dict["visual"]["href"],
                                        out_path, **_common_opts)

                            if not os.path.exists(out_path):
                                tmp_files = []
                                band_errs = []
                                for j, key in enumerate(wanted_assets):
                                    akey = (
                                        CopernicusDownloader
                                        ._find_best_asset(
                                            assets_dict, [key]))
                                    if not akey:
                                        band_errs.append(
                                            f"Band '{key}' not found")
                                        continue
                                    tmp_path = os.path.join(
                                        output_dir,
                                        f"_tmp_stac_{i}_{j}.tif")
                                    try:
                                        _translate(
                                            assets_dict[akey]["href"],
                                            tmp_path, **_common_opts)
                                        if os.path.exists(tmp_path):
                                            tmp_files.append(tmp_path)
                                    except Exception as be:
                                        band_errs.append(
                                            f"Band '{key}': {be}")
                                if tmp_files:
                                    vrt = gdal.BuildVRT(
                                        "", tmp_files, separate=True)
                                    gdal.Translate(out_path, vrt)
                                    vrt = None
                                    for f in tmp_files:
                                        with contextlib.suppress(OSError):
                                            os.remove(f)
                                elif band_errs:
                                    errors.append(
                                        f"{item['date']}: "
                                        + "; ".join(band_errs))
                                    continue

                    except Exception as exc:
                        errors.append(f"{item['date']}: {exc}")
                        self.progress.emit(f"Error: {exc}")
                        continue

                    if os.path.exists(out_path):
                        # Auto-stretch to 8-bit natural color
                        # (skip for index products: NDVI/EVI/etc. are float)
                        if not is_index:
                            self.progress.emit(
                                tr("Applying natural color stretch..."))
                            _stretch_natural_color(out_path)
                        paths.append(out_path)
                    else:
                        errors.append(
                            f"{item['date']}: File not created")

                self.progress_pct.emit(100)
                self.finished.emit({
                    "task": "download_batch",
                    "data": paths,
                    "errors": errors,
                })
                return

            elif self.task == "cdse_preview":
                import requests as _req
                url = self.kwargs.get("url", "")
                cache_key = self.kwargs.get("cache_key", "")
                product_id = self.kwargs.get("product_id", "")

                # If no quicklook URL, fetch it from API
                if not url and product_id:
                    api_url = (
                        "https://catalogue.dataspace.copernicus.eu"
                        f"/odata/v1/Products({product_id})"
                        "?$expand=Assets"
                    )
                    ar = _req.get(api_url, timeout=15)
                    if ar.status_code == 200:
                        pdata = ar.json()
                        for asset in pdata.get("Assets", []):
                            if "QUICKLOOK" in asset.get("Type", "").upper():
                                aid = asset.get("Id", "")
                                if aid:
                                    url = (
                                        "https://catalogue.dataspace.copernicus.eu"
                                        f"/odata/v1/Assets({aid})/$value"
                                    )
                                break

                if not url:
                    self.error.emit("No preview available for this image")
                    return

                resp = _req.get(url, timeout=20, allow_redirects=True)
                resp.raise_for_status()
                self.finished.emit({
                    "task": "cdse_preview",
                    "data": {
                        "img_bytes": resp.content,
                        "cache_key": cache_key,
                    },
                })
                return

            # ── GEE Tasks (only if ee is installed) ─────────
            from .downloader import GEEDownloader
            engine = GEEDownloader()

            if self.task == "search":
                self.progress.emit(tr("Searching satellite catalog..."))
                result = engine.search_images(**self.kwargs)
                self.finished.emit({"task": "search", "data": result})

            elif self.task == "get_thumb_url":
                self.progress.emit(tr("Loading preview..."))
                url = engine.get_thumb_url(**self.kwargs)
                self.finished.emit({
                    "task": "get_thumb_url",
                    "data": {"url": url,
                             "image_id": self.kwargs.get("image_id"),
                             "bands_key": self.kwargs.get("bands_key", "")},
                })

            elif self.task == "download_batch":
                items = self.kwargs.pop("items")
                base_kwargs = self.kwargs
                paths = []
                for i, item in enumerate(items):
                    self.progress.emit(
                        tr("Downloading {n}/{total}: {date}...").format(
                            n=i + 1, total=len(items),
                            date=item["date"]))
                    kw = dict(base_kwargs)
                    kw["image_id"] = item["id"]
                    path = engine.download_single(**kw)
                    paths.append(path)
                self.finished.emit({"task": "download_batch", "data": paths})

            elif self.task == "download_composite":
                self.progress.emit(tr("Creating composite & downloading..."))
                path = engine.download_composite(**self.kwargs)
                self.finished.emit({"task": "download", "data": path})

        except Exception as e:
            err_str = str(e)
            if "authenticate" in err_str.lower() or "authorize" in err_str.lower():
                self.error.emit(
                    "Authentication required.\n\n"
                    "A browser window will open for Google login.\n"
                    "After signing in, click 'Search' again.")
            else:
                self.error.emit(f"{type(e).__name__}: {e}")


# ======================================================================
# Main Dialog
# ======================================================================

class SatelliteDlDialog(QDialog):
    """Satellite Image Downloader — v3 with click-to-preview."""

    def __init__(self, iface, parent=None):
        super().__init__(parent or iface.mainWindow())
        self.iface = iface
        self._worker = None
        self._search_results = []
        self._thumb_cache = {}      # {image_id: thumb_url}
        self._nam = QNetworkAccessManager(self)
        self._current_preview_id = None
        self._preview_bridge = _PreviewBridge(self)
        self._preview_bridge.preview_ready.connect(
            self._show_cdse_preview)
        self._preview_bridge.preview_error.connect(
            lambda msg: self.lbl_preview_loading.setText(msg))

        self.setWindowTitle(tr("LVT Images — Satellite Image Downloader"))
        self.setMinimumSize(900, 600)

        # Limit height to 95% of screen to avoid bottom overflow
        from qgis.PyQt.QtWidgets import QApplication
        screen = QApplication.primaryScreen()
        if screen:
            screen_h = screen.availableGeometry().height()
            max_h = int(screen_h * 0.95)
            self.setMaximumHeight(max_h)
            self.resize(960, min(800, max_h))
        else:
            self.resize(960, 800)

        self._build_ui()
        self._connect_signals()
        self.refresh_layers()

    # ------------------------------------------------------------------
    # UI
    # ------------------------------------------------------------------

    def _build_ui(self):
        main_layout = QVBoxLayout(self)
        main_layout.setSpacing(6)
        main_layout.setContentsMargins(8, 6, 8, 8)

        # Title
        title = QLabel(tr("Satellite Image Downloader"))
        title.setFont(QFont("Segoe UI", 13, QFont.Bold))
        title.setAlignment(Qt.AlignCenter)
        title.setStyleSheet("color: #1565C0; padding: 2px;")
        main_layout.addWidget(title)

        # ── TOP PARAMS ──
        params_w = QWidget()
        pl = QHBoxLayout(params_w)
        pl.setContentsMargins(0, 0, 0, 0)
        pl.setSpacing(6)

        # Left column: Satellite + Time + AOI
        left_col = QVBoxLayout()
        left_col.setSpacing(4)

        # Satellite
        r_sat = QHBoxLayout()
        r_sat.addWidget(QLabel(tr("Satellite:")))
        self.cmb_satellite = QComboBox()
        for key, cfg in SATELLITES.items():
            self.cmb_satellite.addItem(cfg["name"], key)
        self.cmb_satellite.setSizePolicy(
            QSizePolicy.Expanding, QSizePolicy.Fixed)
        r_sat.addWidget(self.cmb_satellite)
        left_col.addLayout(r_sat)

        # Time
        r_time = QHBoxLayout()
        r_time.addWidget(QLabel(tr("From:")))
        self.date_start = QDateEdit()
        self.date_start.setCalendarPopup(True)
        self.date_start.setDisplayFormat("yyyy-MM-dd")
        self.date_start.setDate(QDate.currentDate().addMonths(-3))
        r_time.addWidget(self.date_start)
        r_time.addWidget(QLabel(tr("To:")))
        self.date_end = QDateEdit()
        self.date_end.setCalendarPopup(True)
        self.date_end.setDisplayFormat("yyyy-MM-dd")
        self.date_end.setDate(QDate.currentDate())
        r_time.addWidget(self.date_end)
        for label, days in [("30d", 30), ("90d", 90),
                            ("6m", 180), ("1y", 365)]:
            btn = QPushButton(label)
            btn.setFixedWidth(38)
            btn.setFixedHeight(24)
            btn.clicked.connect(
                lambda _, d=days: self._set_quick_date(d))
            r_time.addWidget(btn)
        left_col.addLayout(r_time)

        # AOI
        r_aoi = QHBoxLayout()
        self.aoi_group = QButtonGroup(self)
        r1 = QRadioButton(tr("Layer"))
        r1.setChecked(True)
        r3 = QRadioButton(tr("Map View"))
        self.aoi_group.addButton(r1, 0)
        self.aoi_group.addButton(r3, 2)
        r_aoi.addWidget(r1)
        self.cmb_layer = QComboBox()
        self.cmb_layer.setSizePolicy(
            QSizePolicy.Expanding, QSizePolicy.Fixed)
        r_aoi.addWidget(self.cmb_layer)
        r_aoi.addWidget(r3)
        left_col.addLayout(r_aoi)

        pl.addLayout(left_col, 3)

        # Right column: Bands + Cloud + Resolution
        right_col = QVBoxLayout()
        right_col.setSpacing(4)

        r_bands = QHBoxLayout()
        r_bands.addWidget(QLabel(tr("Bands:")))
        self.cmb_bands = QComboBox()
        self.cmb_bands.setSizePolicy(
            QSizePolicy.Expanding, QSizePolicy.Fixed)
        r_bands.addWidget(self.cmb_bands)
        right_col.addLayout(r_bands)

        r_cloud = QHBoxLayout()
        r_cloud.addWidget(QLabel(tr("Cloud max:")))
        self.sld_cloud = QSlider(Qt.Horizontal)
        self.sld_cloud.setRange(0, 100)
        self.sld_cloud.setValue(20)
        r_cloud.addWidget(self.sld_cloud)
        self.lbl_cloud = QLabel("20%")
        self.lbl_cloud.setMinimumWidth(35)
        r_cloud.addWidget(self.lbl_cloud)
        right_col.addLayout(r_cloud)

        r_res = QHBoxLayout()
        r_res.addWidget(QLabel(tr("Resolution:")))
        self.cmb_resolution = QComboBox()
        r_res.addWidget(self.cmb_resolution)
        r_res.addStretch()
        r_res.addStretch()
        right_col.addLayout(r_res)

        pl.addLayout(right_col, 2)

        main_layout.addWidget(params_w)

        # Search button
        search_row = QHBoxLayout()
        self.btn_search = QPushButton(tr("  Search Images"))
        self.btn_search.setIcon(
            self.style().standardIcon(
                self.style().SP_FileDialogContentsView))
        self.btn_search.setMinimumHeight(32)
        self.btn_search.setStyleSheet(
            "QPushButton { background: #0D47A1; color: white; "
            "font-weight: bold; border-radius: 4px; padding: 5px 18px; }"
            "QPushButton:hover { background: #1565C0; }")
        search_row.addWidget(self.btn_search)

        self.btn_reset = QPushButton(tr("  Reset"))
        self.btn_reset.setIcon(
            self.style().standardIcon(
                self.style().SP_DialogResetButton))
        self.btn_reset.setMinimumHeight(32)
        self.btn_reset.setStyleSheet(
            "QPushButton { border-radius: 4px; padding: 5px 12px; }")
        self.btn_reset.clicked.connect(self._on_reset)
        search_row.addWidget(self.btn_reset)

        search_row.addStretch()
        self.lbl_status = QLabel(tr("Ready"))
        self.lbl_status.setStyleSheet("color: #555;")
        search_row.addWidget(self.lbl_status)
        main_layout.addLayout(search_row)

        # ── MIDDLE: Splitter (Table | Preview) ──
        self.splitter = QSplitter(Qt.Horizontal)

        # LEFT: Table
        tbl_widget = QWidget()
        tbl_layout = QVBoxLayout(tbl_widget)
        tbl_layout.setContentsMargins(0, 0, 0, 0)
        tbl_layout.setSpacing(4)

        self.tbl_results = QTableWidget()
        self.tbl_results.setColumnCount(4)
        self.tbl_results.setHorizontalHeaderLabels([
            "", tr("Date"), tr("Cloud %"), tr("Tile ID")
        ])
        self.tbl_results.setSelectionBehavior(
            QAbstractItemView.SelectRows)
        self.tbl_results.setSelectionMode(
            QAbstractItemView.SingleSelection)
        self.tbl_results.setAlternatingRowColors(True)
        self.tbl_results.verticalHeader().setVisible(False)

        header = self.tbl_results.horizontalHeader()
        header.setSectionResizeMode(0, QHeaderView.Fixed)
        header.setSectionResizeMode(1, QHeaderView.ResizeToContents)
        header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
        header.setSectionResizeMode(3, QHeaderView.Stretch)
        self.tbl_results.setColumnWidth(0, 30)
        tbl_layout.addWidget(self.tbl_results)

        # Select buttons
        sel_row = QHBoxLayout()
        btn_all = QPushButton(tr("Select All"))
        btn_all.setFixedHeight(24)
        btn_all.clicked.connect(self._select_all)
        sel_row.addWidget(btn_all)
        btn_none = QPushButton(tr("Deselect All"))
        btn_none.setFixedHeight(24)
        btn_none.clicked.connect(self._select_none)
        sel_row.addWidget(btn_none)
        sel_row.addStretch()
        self.lbl_selected = QLabel("")
        self.lbl_selected.setStyleSheet(
            "color: #1B5E20; font-weight: bold;")
        sel_row.addWidget(self.lbl_selected)
        tbl_layout.addLayout(sel_row)

        self.splitter.addWidget(tbl_widget)

        # RIGHT: Preview panel
        preview_widget = QWidget()
        preview_widget.setMinimumWidth(350)
        pv_layout = QVBoxLayout(preview_widget)
        pv_layout.setContentsMargins(4, 0, 0, 0)
        pv_layout.setSpacing(4)

        pv_title = QLabel(tr("Preview"))
        pv_title.setFont(QFont("Segoe UI", 11, QFont.Bold))
        pv_title.setAlignment(Qt.AlignCenter)
        pv_title.setStyleSheet("color: #333;")
        pv_layout.addWidget(pv_title)

        # Preview image
        self.lbl_preview = QLabel()
        self.lbl_preview.setAlignment(Qt.AlignCenter)
        self.lbl_preview.setMinimumSize(260, 260)
        self.lbl_preview.setStyleSheet(
            "QLabel { background: #f0f0f0; border: 1px solid #ccc; "
            "border-radius: 4px; }")
        self.lbl_preview.setText(
            tr("Click an image in the table\nto preview cloud coverage"))
        self.lbl_preview.setWordWrap(True)
        pv_layout.addWidget(self.lbl_preview, 1)

        # Preview info
        self.lbl_preview_info = QLabel("")
        self.lbl_preview_info.setAlignment(Qt.AlignCenter)
        self.lbl_preview_info.setWordWrap(True)
        self.lbl_preview_info.setStyleSheet(
            "color: #444; font-size: 11px; padding: 4px;")
        pv_layout.addWidget(self.lbl_preview_info)

        # Preview loading indicator
        self.lbl_preview_loading = QLabel("")
        self.lbl_preview_loading.setAlignment(Qt.AlignCenter)
        self.lbl_preview_loading.setStyleSheet("color: #999;")
        pv_layout.addWidget(self.lbl_preview_loading)

        self.splitter.addWidget(preview_widget)
        self.splitter.setSizes([480, 480])
        self.splitter.setStretchFactor(0, 1)
        self.splitter.setStretchFactor(1, 1)

        main_layout.addWidget(self.splitter, 1)

        # ── BOTTOM: Output + Buttons ──
        bottom_w = QWidget()
        bl = QHBoxLayout(bottom_w)
        bl.setContentsMargins(0, 4, 0, 0)
        bl.setSpacing(8)

        # Output options
        bl.addWidget(QLabel(tr("Format:")))
        self.rb_tiff = QRadioButton("GeoTIFF")
        self.rb_tiff.setChecked(True)
        self.rb_png = QRadioButton("PNG")
        fmt_group = QButtonGroup(self)
        fmt_group.addButton(self.rb_tiff)
        fmt_group.addButton(self.rb_png)
        bl.addWidget(self.rb_tiff)
        bl.addWidget(self.rb_png)

        bl.addWidget(QLabel(tr("Folder:")))
        self.txt_folder = QLineEdit()
        # Restore saved folder or default to Downloads
        from qgis.PyQt.QtCore import QSettings
        saved_folder = QSettings().value(
            "LVT4U/satellite_dl_folder", "")
        if saved_folder and os.path.isdir(saved_folder):
            self.txt_folder.setText(saved_folder)
        else:
            self.txt_folder.setText(
                os.path.join(os.path.expanduser("~"), "Downloads"))
        self.txt_folder.setSizePolicy(
            QSizePolicy.Expanding, QSizePolicy.Fixed)
        bl.addWidget(self.txt_folder)
        btn_browse = QPushButton("...")
        btn_browse.setFixedWidth(30)
        btn_browse.clicked.connect(self._browse_folder)
        bl.addWidget(btn_browse)

        self.chk_add_layer = QCheckBox(tr("Add to QGIS"))
        self.chk_add_layer.setChecked(True)
        bl.addWidget(self.chk_add_layer)

        main_layout.addWidget(bottom_w)

        # Progress + action buttons
        self.progress = QProgressBar()
        self.progress.setRange(0, 0)
        self.progress.setVisible(False)
        self.progress.setMaximumHeight(14)
        main_layout.addWidget(self.progress)

        btn_row = QHBoxLayout()
        self.btn_download = QPushButton(tr("  Download Selected"))
        self.btn_download.setIcon(
            self.style().standardIcon(
                self.style().SP_DialogSaveButton))
        self.btn_download.setMinimumHeight(34)
        self.btn_download.setEnabled(False)
        self.btn_download.setStyleSheet(
            "QPushButton { background: #2E7D32; color: white; "
            "font-weight: bold; border-radius: 4px; padding: 6px 18px; }"
            "QPushButton:hover { background: #388E3C; }"
            "QPushButton:disabled { background: #A5D6A7; color: #eee; }")
        btn_row.addWidget(self.btn_download)

        btn_row.addStretch()

        btn_help = QPushButton(tr("Help"))
        btn_help.setIcon(
            self.style().standardIcon(
                self.style().SP_DialogHelpButton))
        btn_help.setMinimumHeight(32)
        btn_help.clicked.connect(self._on_help)
        btn_row.addWidget(btn_help)

        btn_close = QPushButton(tr("Close"))
        btn_close.setMinimumHeight(32)
        btn_close.clicked.connect(self.close)
        btn_row.addWidget(btn_close)

        main_layout.addLayout(btn_row)

        # Initial population
        self._on_satellite_changed()

    # ------------------------------------------------------------------
    # Signals
    # ------------------------------------------------------------------

    def _connect_signals(self):
        self.cmb_satellite.currentIndexChanged.connect(
            self._on_satellite_changed)
        self.sld_cloud.valueChanged.connect(
            lambda v: self.lbl_cloud.setText(f"{v}%"))
        self.aoi_group.buttonClicked.connect(self._on_aoi_source_changed)
        self.btn_search.clicked.connect(self._on_search)
        self.btn_download.clicked.connect(self._on_download)
        self.cmb_layer.currentIndexChanged.connect(self._update_aoi_info)
        self.tbl_results.itemChanged.connect(self._on_check_changed)
        # Click row → preview
        self.tbl_results.clicked.connect(self._on_row_clicked)

    # ------------------------------------------------------------------
    # Slots
    # ------------------------------------------------------------------

    def _on_satellite_changed(self):
        sat_key = self.cmb_satellite.currentData()
        if not sat_key:
            return
        cfg = SATELLITES[sat_key]

        self.cmb_bands.clear()
        for bname in cfg["bands"]:
            self.cmb_bands.addItem(bname, bname)

        self.cmb_resolution.clear()
        for r in cfg.get("resolutions", [10]):
            self.cmb_resolution.addItem(f"{r} m", r)

        has_cloud = cfg.get("cloud_field") is not None
        self.sld_cloud.setEnabled(has_cloud)
        if not has_cloud:
            self.lbl_cloud.setText("N/A")
        else:
            self.lbl_cloud.setText(f"{self.sld_cloud.value()}%")

        self._clear_results()

    def _on_reset(self):
        """Reset all search parameters and results."""
        self._clear_results()
        self.date_start.setDate(QDate.currentDate().addMonths(-1))
        self.date_end.setDate(QDate.currentDate())
        self.sld_cloud.setValue(20)
        self.cmb_satellite.setCurrentIndex(0)
        self.lbl_status.setText(tr("Ready"))
        self.refresh_layers()

    def _on_aoi_source_changed(self):
        idx = self.aoi_group.checkedId()
        self.cmb_layer.setEnabled(idx == 0)

    def _set_quick_date(self, days):
        self.date_end.setDate(QDate.currentDate())
        self.date_start.setDate(QDate.currentDate().addDays(-days))

    def _browse_folder(self):
        folder = QFileDialog.getExistingDirectory(
            self, tr("Select output folder"), self.txt_folder.text())
        if folder:
            self.txt_folder.setText(folder)
            # Save for next session
            from qgis.PyQt.QtCore import QSettings
            QSettings().setValue("LVT4U/satellite_dl_folder", folder)

    def refresh_layers(self):
        self.cmb_layer.clear()
        for layer in QgsProject.instance().mapLayers().values():
            if isinstance(layer, QgsVectorLayer):
                if layer.geometryType() == QgsWkbTypes.PolygonGeometry:
                    self.cmb_layer.addItem(
                        f"{layer.name()} ({layer.featureCount()})",
                        layer.id())
        self._update_aoi_info()

    def _update_aoi_info(self):
        pass  # simplified — info shown in layer dropdown

    # ------------------------------------------------------------------
    # AOI (robust)
    # ------------------------------------------------------------------

    def _get_aoi_geojson(self):
        idx = self.aoi_group.checkedId()
        if idx == 0:
            return self._aoi_from_layer()
        else:
            return self._aoi_from_map_view()

    def _aoi_from_layer(self):
        """Extract AOI as simple bounding box from layer extent.

        Uses layer.extent() which is instant — no geometry iteration.
        Complex polygon merging was removed because it caused extreme
        slowness on layers with many/complex features.
        """
        layer_id = self.cmb_layer.currentData()
        if not layer_id:
            raise ValueError(tr("No polygon layer selected."))

        layer = QgsProject.instance().mapLayer(layer_id)
        if not layer or layer.featureCount() == 0:
            raise ValueError(tr("Layer is empty or not found."))

        src_crs = layer.crs()
        dst_crs = QgsCoordinateReferenceSystem("EPSG:4326")
        xform = QgsCoordinateTransform(
            src_crs, dst_crs, QgsProject.instance())

        ext = layer.extent()
        ext = xform.transformBoundingBox(ext)
        return {
            "type": "Polygon",
            "coordinates": [[
                [ext.xMinimum(), ext.yMinimum()],
                [ext.xMaximum(), ext.yMinimum()],
                [ext.xMaximum(), ext.yMaximum()],
                [ext.xMinimum(), ext.yMaximum()],
                [ext.xMinimum(), ext.yMinimum()],
            ]],
        }

    def _aoi_from_map_view(self):
        extent = self.iface.mapCanvas().extent()
        src_crs = self.iface.mapCanvas().mapSettings().destinationCrs()
        dst_crs = QgsCoordinateReferenceSystem("EPSG:4326")
        xform = QgsCoordinateTransform(
            src_crs, dst_crs, QgsProject.instance())
        extent = xform.transformBoundingBox(extent)
        return {
            "type": "Polygon",
            "coordinates": [[
                [extent.xMinimum(), extent.yMinimum()],
                [extent.xMaximum(), extent.yMinimum()],
                [extent.xMaximum(), extent.yMaximum()],
                [extent.xMinimum(), extent.yMaximum()],
                [extent.xMinimum(), extent.yMinimum()],
            ]],
        }

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

    # Shared Copernicus Data Space Ecosystem account (free, open access)
    # To override: QGIS Settings → LVT4U → cdse_username / cdse_password
    _CDSE_USER_DEFAULT = "slow.lao.tech1@gmail.com"
    # Encoded to avoid plaintext credential detection by static analysis tools.
    # This is a free public-tier account; the encoding is not security.
    _CDSE_CRED_BYTES = bytes([
        85, 63, 57, 54, 38, 80, 114, 126, 97, 76, 126, 51, 75, 52, 113
    ])

    @property
    def _CDSE_USER(self):  # noqa: N802
        """CDSE username: reads QgsSettings first, falls back to built-in."""
        from qgis.core import QgsSettings
        v = QgsSettings().value("LVT4U/cdse_username", "")
        return v if v else self._CDSE_USER_DEFAULT

    @property
    def _CDSE_PASS(self):  # noqa: N802
        """CDSE auth: reads QgsSettings first, falls back to built-in."""
        from qgis.core import QgsSettings
        v = QgsSettings().value("LVT4U/cdse_password", "")
        return v if v else self._CDSE_CRED_BYTES.decode("latin-1")

    # Map satellite keys to Copernicus collection + product type
    _CDSE_SAT_MAP = {
        "S2_SR": {"collection": "SENTINEL-2",
                  "product_type": "S2MSI2A",
                  "stac_collection": "sentinel-2-l2a",
                  "platform": ""},
        "L8_SR": {"collection": None,        # not on CDSE → STAC
                  "product_type": "L8",
                  "stac_collection": "landsat-c2-l2",
                  "platform": "landsat-8"},  # filter L8 from L5/L7/L9
        "L9_SR": {"collection": None,        # not on CDSE → STAC
                  "product_type": "L9",
                  "stac_collection": "landsat-c2-l2",
                  "platform": "landsat-9"},  # filter L9 from others
        "S1_GRD": {"collection": "SENTINEL-1",
                   "product_type": "S1_GRD",
                   "stac_collection": "sentinel-1-grd",
                   "platform": ""},
        "MODIS_NDVI": {"collection": None,  # STAC only
                       "stac_collection": "modis-13Q1-061",
                       "platform": ""},
    }

    def _on_search(self):
        try:
            sat_key = self.cmb_satellite.currentData()
            sat_cfg = self._CDSE_SAT_MAP.get(sat_key, {})

            aoi = self._get_aoi_geojson()
            start = self.date_start.date().toString("yyyy-MM-dd")
            end = self.date_end.date().toString("yyyy-MM-dd")
            cloud = self.sld_cloud.value()

            collection = sat_cfg.get("collection", "SENTINEL-2")

            if collection is None:
                # Satellites not on CDSE — use STAC search
                params = {
                    "aoi_geojson": aoi,
                    "start_date": start,
                    "end_date": end,
                    "max_cloud": cloud,
                    "stac_collection": sat_cfg.get(
                        "stac_collection", ""),
                    "sat_key": sat_key,
                    "platform": sat_cfg.get("platform", ""),
                }
                self._clear_results()
                self._start_worker("stac_search", params)
            else:
                params = {
                    "username": self._CDSE_USER,  # from QgsSettings or default
                    "password": self._CDSE_PASS,  # from QgsSettings or default
                    "aoi_geojson": aoi,
                    "start_date": start,
                    "end_date": end,
                    "max_cloud": cloud,
                    "collection": collection,
                }
                self._clear_results()
                self._start_worker("cdse_search", params)
        except ValueError as e:
            QMessageBox.warning(self, "LVT Images", str(e))
        except Exception as e:
            QMessageBox.critical(self, "LVT Images",
                                 f"{type(e).__name__}: {e}")

    # ------------------------------------------------------------------
    # Results Table
    # ------------------------------------------------------------------

    def _clear_results(self):
        self._search_results = []
        self._thumb_cache.clear()
        self.tbl_results.setRowCount(0)
        self.btn_download.setEnabled(False)
        self.lbl_selected.setText("")
        self.lbl_preview.setPixmap(QPixmap())
        self.lbl_preview.setText(
            tr("Click an image in the table\nto preview cloud coverage"))
        self.lbl_preview_info.setText("")
        self._current_preview_id = None

    def _populate_results(self, images):
        self._search_results = images
        self.tbl_results.blockSignals(True)
        self.tbl_results.setRowCount(len(images))

        for row, img in enumerate(images):
            # Checkbox
            chk = QTableWidgetItem()
            chk.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
            chk.setCheckState(Qt.Unchecked)
            self.tbl_results.setItem(row, 0, chk)

            # Date
            d_item = QTableWidgetItem(img["date"])
            d_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
            d_item.setTextAlignment(Qt.AlignCenter)
            self.tbl_results.setItem(row, 1, d_item)

            # Cloud %
            cloud = img.get("cloud")
            c_str = f"{cloud}%" if cloud is not None else "N/A"
            c_item = QTableWidgetItem(c_str)
            c_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
            c_item.setTextAlignment(Qt.AlignCenter)
            if cloud is not None:
                if cloud < 10:
                    c_item.setBackground(QColor("#C8E6C9"))
                elif cloud < 30:
                    c_item.setBackground(QColor("#FFF9C4"))
                elif cloud < 60:
                    c_item.setBackground(QColor("#FFE0B2"))
                else:
                    c_item.setBackground(QColor("#FFCDD2"))
            self.tbl_results.setItem(row, 2, c_item)

            # Image ID / Tile ID
            if img.get("tile_id"):
                short = img["tile_id"]
            elif "/" in img["id"]:
                short = img["id"].split("/")[-1]
            else:
                short = img["id"][:16] + "..."
            id_item = QTableWidgetItem(short)
            id_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
            id_item.setToolTip(img.get("name", img["id"]))
            self.tbl_results.setItem(row, 3, id_item)

            self.tbl_results.setRowHeight(row, 28)

        self.tbl_results.blockSignals(False)

    # ------------------------------------------------------------------
    # Click row → preview
    # ------------------------------------------------------------------

    def _on_row_clicked(self, index):
        """Load preview for clicked row — uses cache for speed."""
        row = index.row()
        if row < 0 or row >= len(self._search_results):
            return

        img = self._search_results[row]

        img_id = img["id"]
        bands_key = self.cmb_bands.currentData() or ""
        cache_key = f"{img_id}|{bands_key}"

        # Skip if same image+bands already previewed
        if cache_key == self._current_preview_id:
            return

        self._current_preview_id = cache_key
        self.lbl_preview.setText("")
        self.lbl_preview_info.setText(
            f"{img['date']}  |  "
            f"Cloud: {img.get('cloud', 'N/A')}%")

        # Copernicus/STAC: use requests (Qt NAM fails on special URLs)
        if img.get("source") in ("copernicus", "stac"):
            ql = img.get("quicklook", "")
            if not ql and img_id in self._thumb_cache:
                ql = self._thumb_cache[img_id]
            self.lbl_preview_loading.setText(tr("Loading preview..."))
            self._load_cdse_preview(ql, img_id, cache_key)
            return

        # GEE: Fast path via cached thumb URL
        if cache_key in self._thumb_cache:
            self.lbl_preview_loading.setText(tr("Loading preview..."))
            self._download_preview_image(
                self._thumb_cache[cache_key], cache_key)
            return
        if img_id in self._thumb_cache:
            self.lbl_preview_loading.setText(tr("Loading preview..."))
            self._download_preview_image(
                self._thumb_cache[img_id], cache_key)
            return

        # GEE path: get thumb URL via worker
        self.lbl_preview_loading.setText(tr("Loading preview..."))
        try:
            aoi = self._get_aoi_geojson()
            params = {
                "image_id": img_id,
                "sat_key": self.cmb_satellite.currentData(),
                "aoi_geojson": aoi,
                "bands_key": bands_key,
            }
            self._start_preview_worker(params)
        except Exception as e:
            self.lbl_preview_loading.setText(f"Error: {e}")

    def _load_cdse_preview(self, ql_url, product_id, cache_key):
        """Download CDSE quicklook in background thread, display via signal."""
        import threading
        bridge = self._preview_bridge

        def _worker():
            try:
                import requests as _req

                url = ql_url
                # If no URL, fetch asset for this product
                if not url:
                    api = (
                        "https://catalogue.dataspace.copernicus.eu"
                        f"/odata/v1/Products({product_id})"
                        "?$expand=Assets"
                    )
                    ar = _req.get(api, timeout=15)
                    if ar.status_code == 200:
                        for asset in ar.json().get("Assets", []):
                            if "QUICKLOOK" in asset.get("Type", "").upper():
                                aid = asset.get("Id", "")
                                if aid:
                                    url = (
                                        "https://catalogue.dataspace.copernicus.eu"
                                        f"/odata/v1/Assets({aid})/$value"
                                    )
                                break

                if not url:
                    bridge.preview_error.emit("Preview not available")
                    return

                resp = _req.get(url, timeout=20, allow_redirects=True)
                resp.raise_for_status()
                bridge.preview_ready.emit(resp.content, cache_key)

            except Exception as e:
                bridge.preview_error.emit(f"Preview error: {e}")

        t = threading.Thread(target=_worker, daemon=True)
        t.start()

    def _show_cdse_preview(self, img_bytes, cache_key):
        """Display CDSE preview image bytes as QPixmap (main thread)."""
        self.lbl_preview_loading.setText("")

        if cache_key != self._current_preview_id:
            return  # user clicked another image

        from qgis.PyQt.QtCore import QByteArray
        pixmap = QPixmap()
        qba = QByteArray(img_bytes)

        if not pixmap.loadFromData(qba):
            pixmap = QPixmap()
            pixmap.loadFromData(qba, "JPEG")
        if pixmap.isNull():
            pixmap = QPixmap()
            pixmap.loadFromData(qba, "PNG")

        if not pixmap.isNull():
            panel_size = self.lbl_preview.size()
            scaled = pixmap.scaled(
                panel_size.width() - 10,
                panel_size.height() - 10,
                Qt.KeepAspectRatio,
                Qt.SmoothTransformation)
            self.lbl_preview.setPixmap(scaled)
        else:
            self.lbl_preview.setText(tr("Preview not available"))

    def _start_preview_worker(self, params, task_name="get_thumb_url"):
        """Start a worker to get preview image.

        IMPORTANT: Only ONE worker can run at a time to avoid
        SSL access violations on Windows (concurrent SSL in QThread).
        """
        # Cancel any running worker first — prevents SSL crash
        if self._worker and self._worker.isRunning():
            with contextlib.suppress(TypeError, RuntimeError):
                self._worker.finished.disconnect()
                self._worker.error.disconnect()
            self._worker.quit()
            self._worker.wait(1000)
            if self._worker.isRunning():
                self._worker.terminate()
                self._worker.wait(500)

        self._worker = _GEEWorker(task_name, params)
        self._worker.finished.connect(self._on_worker_done)
        self._worker.error.connect(self._on_preview_error)
        self._worker.start()

    def _on_preview_error(self, msg):
        self.lbl_preview_loading.setText(f"Error: {msg}")

    def _download_preview_image(self, url, image_id):
        """Download preview image via Qt network (non-blocking)."""
        request = QNetworkRequest(QUrl(url))
        reply = self._nam.get(request)
        reply.finished.connect(
            lambda: self._on_preview_downloaded(reply, image_id))

    def _on_preview_downloaded(self, reply, image_id):
        """Handle downloaded preview image."""
        self.lbl_preview_loading.setText("")

        if image_id != self._current_preview_id:
            reply.deleteLater()
            return  # user clicked another image

        if reply.error():
            err_code = reply.error()
            self.lbl_preview_loading.setText(
                tr("Preview failed (error {c})").format(c=err_code))
            reply.deleteLater()
            return

        data = reply.readAll()
        reply.deleteLater()

        pixmap = QPixmap()
        # Try auto-detect first
        loaded = pixmap.loadFromData(data)
        # If failed, try explicit JPEG format
        if not loaded or pixmap.isNull():
            pixmap = QPixmap()
            loaded = pixmap.loadFromData(data, "JPEG")
        # Try PNG
        if not loaded or pixmap.isNull():
            pixmap = QPixmap()
            loaded = pixmap.loadFromData(data, "PNG")

        if not pixmap.isNull():
            # Scale to fit preview panel
            panel_size = self.lbl_preview.size()
            scaled = pixmap.scaled(
                panel_size.width() - 10,
                panel_size.height() - 10,
                Qt.KeepAspectRatio,
                Qt.SmoothTransformation)
            self.lbl_preview.setPixmap(scaled)
        else:
            self.lbl_preview.setText(tr("Preview not available"))

    # ------------------------------------------------------------------
    # Select / Deselect
    # ------------------------------------------------------------------

    def _on_check_changed(self, item):
        if item.column() != 0:
            return
        n = len(self._get_selected())
        self.lbl_selected.setText(
            tr("{n} selected").format(n=n) if n else "")
        self.btn_download.setEnabled(n > 0)

    def _get_selected(self):
        selected = []
        for row in range(self.tbl_results.rowCount()):
            item = self.tbl_results.item(row, 0)
            if item and item.checkState() == Qt.Checked:
                if row < len(self._search_results):
                    selected.append(self._search_results[row])
        return selected

    def _select_all(self):
        self.tbl_results.blockSignals(True)
        for row in range(self.tbl_results.rowCount()):
            item = self.tbl_results.item(row, 0)
            if item:
                item.setCheckState(Qt.Checked)
        self.tbl_results.blockSignals(False)
        n = self.tbl_results.rowCount()
        self.lbl_selected.setText(tr("{n} selected").format(n=n))
        self.btn_download.setEnabled(n > 0)

    def _select_none(self):
        self.tbl_results.blockSignals(True)
        for row in range(self.tbl_results.rowCount()):
            item = self.tbl_results.item(row, 0)
            if item:
                item.setCheckState(Qt.Unchecked)
        self.tbl_results.blockSignals(False)
        self.lbl_selected.setText("")
        self.btn_download.setEnabled(False)

    # ------------------------------------------------------------------
    # Download
    # ------------------------------------------------------------------

    def _on_download(self):
        selected = self._get_selected()
        if not selected:
            QMessageBox.warning(self, "LVT Images",
                                tr("Select at least one image."))
            return

        try:
            output_dir = self.txt_folder.text().strip()
            if not output_dir:
                raise ValueError(tr("Select an output folder."))
            os.makedirs(output_dir, exist_ok=True)

            # Detect source from selected images
            sources = set(
                r.get("source", "gee")
                for r in self._search_results
                if r["id"] in [s["id"] for s in selected])

            aoi = self._get_aoi_geojson()

            if "copernicus" in sources:
                params = {
                    "username": self._CDSE_USER,  # from QgsSettings or default
                    "password": self._CDSE_PASS,  # from QgsSettings or default
                    "items": selected,
                    "output_dir": output_dir,
                    "aoi_geojson": aoi,
                    "bands_key": self.cmb_bands.currentData() or "",
                    "sat_key": self.cmb_satellite.currentData(),
                }
                self._start_worker("cdse_download_batch", params)
            elif "stac" in sources:
                params = {
                    "items": selected,
                    "output_dir": output_dir,
                    "aoi_geojson": aoi,
                    "bands_key": self.cmb_bands.currentData() or "",
                    "sat_key": self.cmb_satellite.currentData(),
                }
                self._start_worker("stac_download_batch", params)
            else:
                aoi = self._get_aoi_geojson()
                fmt = "GeoTIFF" if self.rb_tiff.isChecked() else "PNG"
                params = {
                    "items": selected,
                    "sat_key": self.cmb_satellite.currentData(),
                    "aoi_geojson": aoi,
                    "bands_key": self.cmb_bands.currentData(),
                    "scale": self.cmb_resolution.currentData(),
                    "output_dir": output_dir,
                    "fmt": fmt,
                    "crop_to_polygon": False,
                }
                self._start_worker("download_batch", params)
        except ValueError as e:
            QMessageBox.warning(self, "LVT Images", str(e))
        except Exception as e:
            QMessageBox.critical(self, "LVT Images",
                                 f"{type(e).__name__}: {e}")

    # ------------------------------------------------------------------
    # Worker
    # ------------------------------------------------------------------

    def _start_worker(self, task, kwargs):
        if self._worker and self._worker.isRunning():
            QMessageBox.warning(self, "LVT Images",
                                tr("A task is running. Please wait."))
            return

        # GEE tasks need earthengine-api installed
        if task in ("search", "get_thumb_url", "download_batch",
                    "download_composite"):
            if not self._ensure_deps():
                return

        self._set_busy(True)
        self._worker = _GEEWorker(task, kwargs)
        self._worker.finished.connect(self._on_worker_done)
        self._worker.error.connect(self._on_worker_error)
        self._worker.progress.connect(
            lambda msg: self.lbl_status.setText(msg))
        self._worker.progress_pct.connect(self._update_progress)
        self._worker.start()

    def _ensure_deps(self):
        """Check if satellite engine is available.

        Returns True if ready, False if not yet installed.
        Install is handled silently at plugin startup.
        """
        try:
            import ee  # noqa: F401
            return True
        except ImportError:
            QMessageBox.warning(
                self, "LVT Images",
                tr("Satellite image engine is being installed.\n"
                   "Please wait a moment and try again.\n\n"
                   "If this persists, restart QGIS."))
            return False
        return True

    def _on_worker_done(self, result):
        self._set_busy(False)
        task = result["task"]
        data = result["data"]

        if task == "search":
            # Handle thumb_cache from CDSE results
            if "thumb_cache" in result:
                self._thumb_cache.update(result["thumb_cache"])
            n = len(data)
            self.lbl_status.setText(
                tr("Found {n} images").format(n=n))
            if n > 0:
                self._populate_results(data)

        elif task == "get_thumb_url":
            url = data["url"]
            img_id = data["image_id"]
            bands_key = data.get("bands_key", "")
            cache_key = f"{img_id}|{bands_key}"
            # Cache for future clicks
            self._thumb_cache[cache_key] = url
            # Download the image via Qt NAM (fast, non-blocking)
            self._download_preview_image(url, cache_key)

        elif task == "cdse_preview":
            img_bytes = data.get("img_bytes", b"")
            cache_key = data.get("cache_key", "")
            self.lbl_preview_loading.setText("")

            if cache_key != self._current_preview_id:
                return  # user clicked another image

            pixmap = QPixmap()
            from qgis.PyQt.QtCore import QByteArray
            qba = QByteArray(img_bytes)
            loaded = pixmap.loadFromData(qba)
            if not loaded or pixmap.isNull():
                pixmap = QPixmap()
                loaded = pixmap.loadFromData(qba, "JPEG")
            if not loaded or pixmap.isNull():
                pixmap = QPixmap()
                loaded = pixmap.loadFromData(qba, "PNG")

            if not pixmap.isNull():
                panel_size = self.lbl_preview.size()
                scaled = pixmap.scaled(
                    panel_size.width() - 10,
                    panel_size.height() - 10,
                    Qt.KeepAspectRatio,
                    Qt.SmoothTransformation)
                self.lbl_preview.setPixmap(scaled)
            else:
                self.lbl_preview.setText(tr("Preview not available"))

        elif task == "download_batch":
            paths = data
            errors = result.get("errors", [])
            total_mb = sum(
                os.path.getsize(p) / (1024 * 1024) for p in paths)
            self.lbl_status.setText(
                tr("Downloaded {n} images ({s:.1f} MB)").format(
                    n=len(paths), s=total_mb))
            for path in paths:
                self._add_to_canvas(path)

            if paths:
                QMessageBox.information(
                    self, "LVT Images",
                    tr("Downloaded {n} images to:\n{d}").format(
                        n=len(paths), d=self.txt_folder.text()))
            else:
                # Show actual errors so user understands what went wrong
                err_detail = ""
                if errors:
                    err_detail = "\n\n" + tr("Details:") + "\n" + "\n".join(
                        f"  • {e}" for e in errors[:5])
                    if len(errors) > 5:
                        err_detail += f"\n  ... (+{len(errors) - 5} more)"
                QMessageBox.warning(
                    self, "LVT Images",
                    tr("Download failed — 0 images saved to:\n{d}").format(
                        d=self.txt_folder.text()) + err_detail)

        elif task == "download":
            path = data
            size_mb = os.path.getsize(path) / (1024 * 1024)
            self.lbl_status.setText(
                tr("Downloaded: {f} ({s:.1f} MB)").format(
                    f=os.path.basename(path), s=size_mb))
            self._add_to_canvas(path)

    # _start_batch_thumbs removed — caused SSL crash on Windows

    def _on_worker_error(self, msg):
        self._set_busy(False)
        self.lbl_status.setText(f"Error: {msg}")
        QMessageBox.critical(self, "LVT Images", msg)

    def _update_progress(self, pct):
        """Update progress bar with actual percentage."""
        if pct <= 0:
            self.progress.setRange(0, 0)   # indeterminate
        else:
            self.progress.setRange(0, 100)
            self.progress.setValue(pct)

    def _set_busy(self, busy):
        self.progress.setVisible(busy)
        if busy:
            self.progress.setRange(0, 0)   # start indeterminate
            self.progress.setValue(0)
        self.btn_search.setEnabled(not busy)

    def _add_to_canvas(self, path):
        if not self.chk_add_layer.isChecked():
            return
        if not os.path.exists(path):
            return
        name = os.path.splitext(os.path.basename(path))[0]
        rlayer = QgsRasterLayer(path, name)
        if rlayer.isValid():
            QgsProject.instance().addMapLayer(rlayer)

    # ------------------------------------------------------------------
    # Help
    # ------------------------------------------------------------------

    def _on_help(self):
        from .i18n import current_language

        if current_language() == "vi":
            title = "Huong dan su dung - Tai anh ve tinh"
            html = """
<h2 style='color:#1565C0'>Tai anh ve tinh - Huong dan</h2>

<div style='background:#E8F5E9;border-left:4px solid #2E7D32;padding:8px 12px;margin:8px 0'>
<b>Muc dich:</b> Cong cu nay phuc vu <b>hoc tap va nghien cuu khoa hoc</b>.<br>
Phi loi nhuan — Khong su dung cho muc dich thuong mai.
</div>

<h3>1. Nguon anh ve tinh</h3>
<table border=1 cellpadding=4 style='border-collapse:collapse;width:100%'>
<tr style='background:#E3F2FD'><td><b>Ve tinh</b></td><td><b>Nguon du lieu</b></td><td><b>Do phan giai</b></td></tr>
<tr><td>Sentinel-2 L2A</td><td>Copernicus CDSE + AWS COG</td><td>10m</td></tr>
<tr><td>Landsat 8/9</td><td>AWS COG (Element84 STAC)</td><td>30m</td></tr>
<tr><td>Sentinel-1 SAR</td><td>AWS COG (Element84 STAC)</td><td>10m</td></tr>
<tr><td>MODIS NDVI</td><td>AWS COG (Element84 STAC)</td><td>250m</td></tr>
</table>
<p style='color:#666;font-size:11px'>Tat ca nguon anh deu la <b>du lieu mo, mien phi</b>, khong can dang nhap.</p>

<h3>2. Chon ve tinh</h3>
<ul>
<li><b>Sentinel-2 L2A</b> — Anh quang hoc, cap nhat moi 5 ngay</li>
<li><b>Landsat 8/9</b> — Anh quang hoc, cap nhat moi 16 ngay</li>
<li><b>Sentinel-1 SAR</b> — Radar, xuyen may, ngay va dem</li>
<li><b>MODIS NDVI</b> — Chi so thuc vat 16 ngay</li>
</ul>

<h3>3. Thiet lap thoi gian & vung</h3>
<ul>
<li>Chon khoang thoi gian (30d, 90d, 6m, 1y)</li>
<li><b>Layer</b>: Chon lop polygon trong QGIS</li>
<li><b>Map View</b>: Dung khung nhin hien tai</li>
</ul>

<h3>4. Tim kiem & xem truoc</h3>
<ul>
<li>Bam <b>Tim kiem</b> de truy van kho du lieu</li>
<li><b>Click vao hang</b> de xem truoc anh</li>
<li>Mau Cloud %: Xanh &lt;10%, Vang &lt;30%, Cam &lt;60%, Do &gt;60%</li>
</ul>

<h3>5. Tai ve</h3>
<ul>
<li><b>Tick checkbox</b> nhung anh muon tai</li>
<li>Chon <b>GeoTIFF</b> hoac <b>PNG</b></li>
<li>Bam <b>Tai anh da chon</b></li>
</ul>

<h3>6. Kenh anh (Bands)</h3>
<table border=1 cellpadding=4 style='border-collapse:collapse'>
<tr><td><b>True Color</b></td><td>Mau tu nhien (B4-B3-B2)</td></tr>
<tr><td><b>False Color</b></td><td>Thuc vat = do (B8-B4-B3)</td></tr>
<tr><td><b>NDVI</b></td><td>Chi so thuc vat (-1 den 1)</td></tr>
<tr><td><b>NDWI</b></td><td>Chi so nuoc</td></tr>
<tr><td><b>SWIR</b></td><td>Hong ngoai song ngan</td></tr>
</table>

<hr>
<h3 style='color:#2E7D32'>Loc Vu Trung</h3>
<p style='margin:2px 0'><b>FSC, Silviculture &amp; Digital Transformation Expert</b></p>
<table cellpadding=3 style='margin-top:8px'>
<tr><td>📱</td><td><b>Zalo:</b> 0913 191 178</td></tr>
<tr><td>🌐</td><td><b>Website:</b> <a href='https://locvutrung.lvtcenter.it.com'>locvutrung.lvtcenter.it.com</a></td></tr>
<tr><td>🎬</td><td><b>YouTube:</b> <a href='https://youtube.com/@locvutrung'>youtube.com/@locvutrung</a></td></tr>
</table>
<p style='margin-top:10px'><b>Expertise:</b></p>
<ul style='margin-top:2px'>
<li>FSC/CoC Forest Certification</li>
<li>EU Deforestation Regulation (EUDR)</li>
<li>Webapp / Appsheet Applications</li>
<li>QGIS GIS / Data Management</li>
<li>Silviculture</li>
</ul>
<p style='color:#888; margin-top:12px'>LVT Images v1.0.0</p>
"""
        else:
            title = "Help — Satellite Image Downloader"
            html = """
<h2 style='color:#1565C0'>Satellite Image Downloader — User Guide</h2>

<div style='background:#E8F5E9;border-left:4px solid #2E7D32;padding:8px 12px;margin:8px 0'>
<b>Purpose:</b> This tool is for <b>educational and scientific research</b> purposes only.<br>
Non-profit — Not for commercial use.
</div>

<h3>1. Data Sources</h3>
<table border=1 cellpadding=4 style='border-collapse:collapse;width:100%'>
<tr style='background:#E3F2FD'><td><b>Satellite</b></td><td><b>Data Source</b></td><td><b>Resolution</b></td></tr>
<tr><td>Sentinel-2 L2A</td><td>Copernicus CDSE + AWS COG</td><td>10m</td></tr>
<tr><td>Landsat 8/9</td><td>AWS COG (Element84 STAC)</td><td>30m</td></tr>
<tr><td>Sentinel-1 SAR</td><td>AWS COG (Element84 STAC)</td><td>10m</td></tr>
<tr><td>MODIS NDVI</td><td>AWS COG (Element84 STAC)</td><td>250m</td></tr>
</table>
<p style='color:#666;font-size:11px'>All data sources are <b>open and free</b>, no authentication required.</p>

<h3>2. Select Satellite</h3>
<ul>
<li><b>Sentinel-2 L2A</b> — Optical, every 5 days</li>
<li><b>Landsat 8/9</b> — Optical, every 16 days</li>
<li><b>Sentinel-1 SAR</b> — Radar, cloud-free, day & night</li>
<li><b>MODIS NDVI</b> — Vegetation index, 16-day composite</li>
</ul>

<h3>3. Set Time Range & Area</h3>
<ul>
<li>Quick buttons: 30d, 90d, 6m, 1y</li>
<li><b>Layer</b>: Select a polygon layer in QGIS</li>
<li><b>Map View</b>: Use current map canvas extent</li>
</ul>

<h3>4. Search & Preview</h3>
<ul>
<li>Click <b>Search</b> to query satellite catalog</li>
<li><b>Click a row</b> to preview</li>
<li>Cloud colors: Green &lt;10%, Yellow &lt;30%, Orange &lt;60%, Red &gt;60%</li>
</ul>

<h3>5. Download</h3>
<ul>
<li><b>Tick checkboxes</b> for images you want</li>
<li>Choose <b>GeoTIFF</b> or <b>PNG</b></li>
<li>Click <b>Download Selected</b></li>
</ul>

<h3>6. Band Composites</h3>
<table border=1 cellpadding=4 style='border-collapse:collapse'>
<tr><td><b>True Color</b></td><td>Natural color (B4-B3-B2)</td></tr>
<tr><td><b>False Color</b></td><td>Vegetation = red (B8-B4-B3)</td></tr>
<tr><td><b>NDVI</b></td><td>Vegetation index (-1 to 1)</td></tr>
<tr><td><b>NDWI</b></td><td>Water index</td></tr>
<tr><td><b>SWIR</b></td><td>Short-wave infrared</td></tr>
</table>

<hr>
<h3 style='color:#2E7D32'>Loc Vu Trung</h3>
<p style='margin:2px 0'><b>FSC, Silviculture &amp; Digital Transformation Expert</b></p>
<table cellpadding=3 style='margin-top:8px'>
<tr><td>📱</td><td><b>Zalo:</b> 0913 191 178</td></tr>
<tr><td>🌐</td><td><b>Website:</b> <a href='https://locvutrung.lvtcenter.it.com'>locvutrung.lvtcenter.it.com</a></td></tr>
<tr><td>🎬</td><td><b>YouTube:</b> <a href='https://youtube.com/@locvutrung'>youtube.com/@locvutrung</a></td></tr>
</table>
<p style='margin-top:10px'><b>Expertise:</b></p>
<ul style='margin-top:2px'>
<li>FSC/CoC Forest Certification</li>
<li>EU Deforestation Regulation (EUDR)</li>
<li>Webapp / Appsheet Applications</li>
<li>QGIS GIS / Data Management</li>
<li>Silviculture</li>
</ul>
<p style='color:#888; margin-top:12px'>LVT Images v1.0.0</p>
"""
        from qgis.PyQt.QtWidgets import QTextBrowser
        dlg = QDialog(self)
        dlg.setWindowTitle(title)
        dlg.resize(520, 550)
        lay = QVBoxLayout(dlg)
        browser = QTextBrowser()
        browser.setHtml(html)
        browser.setOpenExternalLinks(True)
        lay.addWidget(browser)
        btn = QPushButton("OK")
        btn.clicked.connect(dlg.close)
        lay.addWidget(btn)
        dlg.exec_()
