# virtughan_qgis/extractor/extractor_widget.py
import os
import sys
import time
import inspect
import importlib
import traceback
import uuid
import json
import shutil
import tempfile
import subprocess
import threading
from datetime import datetime
from pathlib import Path
from contextlib import contextmanager

from qgis.core import (
    Qgis,
    QgsApplication,
    QgsCoordinateReferenceSystem,
    QgsCoordinateTransform,
    QgsFeature,
    QgsField,
    QgsGeometry,
    QgsMessageLog,
    QgsPointXY,
    QgsProcessingUtils,
    QgsProject,
    QgsRasterLayer,
    QgsRectangle,
    QgsTask,
    QgsVectorLayer,
)
from qgis.gui import QgsMapCanvas
from qgis.PyQt import uic
from qgis.PyQt.QtCore import Qt, QDate, QTimer, QVariant
from qgis.PyQt.QtGui import QCursor, QColor, QPixmap
from qgis.PyQt.QtWidgets import (
    QCheckBox,
    QComboBox,
    QDockWidget,
    QFileDialog,
    QHBoxLayout,
    QLabel,
    QLineEdit,
    QListWidget,
    QListWidgetItem,
    QMessageBox,
    QPlainTextEdit,
    QProgressBar,
    QPushButton,
    QSpinBox,
    QScrollArea,
    QVBoxLayout,
    QWidget,
)

from ..common.aoi import (
    AoiManager,
    AoiRectTool,
    AoiPolygonTool,
    aoi_size_level,
    combined_feature_geometry_in_project_crs,
    polygon_layers,
    rect_to_wgs84_bbox,
    geom_to_wgs84_bbox,
)
from ..common.batch_aoi import AoiTransformError, batch_root, combined_geometry, specs_from_features
from ..common.scene_preview_dialog import ScenePreviewDialog
from ..common.batch_mode_dialog import choose_multi_polygon_mode
from ..common.polygon_selection_dialog import PolygonSelectionDialog
from ..common.common_logic import (
    collection_band_names,
    extra_query_for_collection,
    extract_kwargs_from_params,
    normalize_collection,
    search_stac_features,
)
from ..bootstrap import (
    RUNTIME_ROOT,
    RUNTIME_SITE_PACKAGES_DIR,
    RUNTIME_FALLBACK_ROOT,
    RUNTIME_FALLBACK_SITE_PACKAGES_DIR,
    activate_runtime_paths,
    ensure_runtime_network_ready,
    purge_non_runtime_modules,
)
from ..common.ui_helpers import apply_primary_button_style, hide_single_tab_bar

activate_runtime_paths()

from ..qt_compat import QtCompat, QgisCompat, QgsTaskCompat, QMessageBoxCompat, QAbstractItemViewCompat

COMMON_IMPORT_ERROR = None
CommonParamsWidget = None
try:
    from ..common.common_widget import CommonParamsWidget
except Exception as _e:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
    COMMON_IMPORT_ERROR = _e
    CommonParamsWidget = None

extractor_search_stac_api = None


def _resolve_extractor_search_stac_api():
    global extractor_search_stac_api
    if callable(extractor_search_stac_api):
        return extractor_search_stac_api
    for module_name in ("virtughan.extract", "virtughan.engine"):
        backend = sys.modules.get(module_name)
        if backend is None:
            continue
        for symbol_name in ("search_stac_api", "search_stac"):
            candidate = getattr(backend, symbol_name, None)
            if callable(candidate):
                extractor_search_stac_api = candidate
                return candidate
    return None


def _filter_backend_kwargs(backend_cls, kwargs: dict) -> dict:
    sig = inspect.signature(backend_cls.__init__)
    accepts_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
    if accepts_kwargs:
        return kwargs
    allowed = {name for name in sig.parameters.keys() if name != "self"}
    return {k: v for k, v in kwargs.items() if k in allowed}

UI_PATH = os.path.join(os.path.dirname(__file__), "extractor_form.ui")
FORM_CLASS, _ = uic.loadUiType(UI_PATH)

_PYPROJ_GUARD_APPLIED = False
_EXTRACTOR_INPROCESS_BACKEND_CLASS = None


class _TaskCancelledError(RuntimeError):
    pass


def _apply_pyproj_windows_guard(logger=None):
    global _PYPROJ_GUARD_APPLIED
    if _PYPROJ_GUARD_APPLIED or os.name != "nt":
        return

    selected_proj_path = None
    try:
        prefix = QgsApplication.prefixPath() or ""
        candidates = [
            os.path.join(prefix, "share", "proj"),
            os.path.join(prefix, "..", "share", "proj"),
            os.path.join(prefix, "..", "..", "share", "proj"),
        ]
        for candidate in candidates:
            proj_path = os.path.normpath(candidate)
            proj_db = os.path.join(proj_path, "proj.db")
            if os.path.isdir(proj_path) and os.path.isfile(proj_db):
                os.environ["PROJ_LIB"] = proj_path
                os.environ["PROJ_DATA"] = proj_path
                selected_proj_path = proj_path
                break
    except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
        pass

    if selected_proj_path and logger:
        logger(f"Using PROJ data path from environment: {selected_proj_path}")

    _PYPROJ_GUARD_APPLIED = True
    if logger:
        logger("Applied Windows PROJ path compatibility guard")


def _reload_pyproj_modules(logger=None):
    """No-op: reloading pyproj in-process can destabilize repeated runs on Windows/QGIS."""
    if logger:
        logger("Skipped pyproj module reload for run-to-run stability")


def _run_extractor_inprocess_fallback(params: dict, log_path: str, logf=None, should_cancel=None):
    global _EXTRACTOR_INPROCESS_BACKEND_CLASS
    if logf:
        logf.write("[WARNING] macOS fallback: running extractor in-process because external Python preflight failed.\n")

    runtime_paths = [
        RUNTIME_SITE_PACKAGES_DIR,
        RUNTIME_ROOT,
        RUNTIME_FALLBACK_SITE_PACKAGES_DIR,
        RUNTIME_FALLBACK_ROOT,
    ]
    for dep_path in runtime_paths:
        if dep_path and os.path.isdir(dep_path) and dep_path not in sys.path:
            # Keep runtime paths available, but do not force highest precedence on mac fallback.
            sys.path.append(dep_path)

    if callable(should_cancel) and should_cancel():
        raise _TaskCancelledError("Download cancelled by user.")

    import inspect
    purge_non_runtime_modules(("attr", "attrs", "pystac", "pystac_client", "planetary_computer", "matplotlib"))
    if _EXTRACTOR_INPROCESS_BACKEND_CLASS is None:
        backend_mod = sys.modules.get("virtughan.extract")
        if backend_mod is None:
            import virtughan.extract as backend_mod
        _EXTRACTOR_INPROCESS_BACKEND_CLASS = getattr(backend_mod, "ExtractProcessor")
    ExtractorBackend = _EXTRACTOR_INPROCESS_BACKEND_CLASS

    backend_args = _filter_backend_kwargs(
        ExtractorBackend,
        extract_kwargs_from_params(params, log_file=logf),
    )

    extr = ExtractorBackend(**backend_args)
    extr.extract()


def _run_extractor_inprocess_mac(params: dict, logf=None, should_cancel=None):
    with _with_embedded_python_executable(logf=logf):
        runtime_pairs = [
            (RUNTIME_SITE_PACKAGES_DIR, RUNTIME_ROOT),
            (RUNTIME_FALLBACK_SITE_PACKAGES_DIR, RUNTIME_FALLBACK_ROOT),
        ]

        chosen_paths = []
        for site_pkgs, root in runtime_pairs:
            if not site_pkgs or not os.path.isdir(site_pkgs):
                continue
            if os.path.isdir(os.path.join(site_pkgs, "virtughan")):
                chosen_paths = [site_pkgs]
                if root and os.path.isdir(root):
                    chosen_paths.append(root)
                break

        if not chosen_paths:
            for site_pkgs, root in runtime_pairs:
                if site_pkgs and os.path.isdir(site_pkgs):
                    chosen_paths = [site_pkgs]
                    if root and os.path.isdir(root):
                        chosen_paths.append(root)
                    break

        for dep_path in chosen_paths:
            while dep_path in sys.path:
                try:
                    sys.path.remove(dep_path)
                except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                    break
            sys.path.insert(0, dep_path)

        if callable(should_cancel) and should_cancel():
            raise _TaskCancelledError("Download cancelled by user.")

        purge_non_runtime_modules(("attr", "attrs", "pystac", "pystac_client", "planetary_computer", "matplotlib"))
        backend_mod = sys.modules.get("virtughan.extract")
        if backend_mod is None:
            backend_mod = importlib.import_module("virtughan.extract")
        else:
            backend_mod = importlib.reload(backend_mod)
        ExtractorBackend = getattr(backend_mod, "ExtractProcessor")

        workers = int(params.get("workers", 1) or 1)
        if workers > 1:
            if logf:
                logf.write(
                    "[INFO] macOS in-process extractor: forcing workers=1 to avoid spawning extra QGIS windows.\n"
                )
            workers = 1

        backend_args = _filter_backend_kwargs(
            ExtractorBackend,
            extract_kwargs_from_params(params, log_file=logf, workers=workers),
        )

        extr = ExtractorBackend(**backend_args)
        extr.extract()


def _run_extractor_in_subprocess(params: dict, log_path: str, logf=None, should_cancel=None):
    if sys.platform == "darwin":
        if logf:
            logf.write("[INFO] macOS guard: bypassing external subprocess and using in-process extractor path.\n")
        _run_extractor_inprocess_mac(params, logf=logf, should_cancel=should_cancel)
        return

    python_exe = _resolve_embedded_python_executable()
    if not python_exe:
        raise RuntimeError(
            "Could not locate an embedded Python executable for subprocess execution. "
            f"Current sys.executable={sys.executable}"
        )
    if sys.platform == "darwin" and Path(python_exe).name.lower() == "qgis":
        raise RuntimeError(
            "Refusing to launch subprocess with QGIS GUI executable on macOS. "
            f"Resolved path={python_exe}"
        )
    work_dir = tempfile.mkdtemp(prefix="virtughan-extractor-")
    payload_path = os.path.join(work_dir, "payload.json")
    runner_path = os.path.join(work_dir, "runner.py")

    payload = {
        "bbox": params["bbox"],
        "start_date": params["start_date"],
        "end_date": params["end_date"],
        "cloud_cover": params["cloud_cover"],
        "bands_list": params["bands_list"],
        "output_dir": params["output_dir"],
        "workers": int(params.get("workers", 1) or 1),
        "zip_output": params.get("zip_output", False),
        "smart_filter": params.get("smart_filter", True),
        "collection": normalize_collection(params.get("collection")),
        "extra_query": params.get("extra_query"),
        "runtime_site_packages_dir": RUNTIME_SITE_PACKAGES_DIR,
        "runtime_root": RUNTIME_ROOT,
        "runtime_fallback_site_packages_dir": RUNTIME_FALLBACK_SITE_PACKAGES_DIR,
        "runtime_fallback_root": RUNTIME_FALLBACK_ROOT,
        "log_path": log_path,
    }
    if params.get("polygon_wgs84"):
        payload["polygon_wgs84"] = params["polygon_wgs84"]

    runner_code = "\n".join(
        [
            "import inspect",
            "import json",
            "import os",
            "import sys",
            "import traceback",
            "",
            f"with open(r\"{payload_path}\", \"r\", encoding=\"utf-8\") as pf:",
            "    payload = json.load(pf)",
            "",
            "log_path = payload.pop(\"log_path\")",
            "",
            "with open(log_path, \"a\", encoding=\"utf-8\", buffering=1) as logf:",
            "    try:",
            "        runtime_paths = [",
            "            payload.get('runtime_site_packages_dir', ''),",
            "            payload.get('runtime_root', ''),",
            "            payload.get('runtime_fallback_site_packages_dir', ''),",
            "            payload.get('runtime_fallback_root', ''),",
            "        ]",
            "        for dep_path in reversed(runtime_paths):",
            "            if dep_path and os.path.isdir(dep_path) and dep_path not in sys.path:",
            "                sys.path.insert(0, dep_path)",
            "        import virtughan",
            "        from virtughan.extract import ExtractProcessor as ExtractorBackend",
            "        logf.write(f\"[INFO] subprocess virtughan_version={getattr(virtughan, '__version__', '')}\\n\")",
            "        logf.write(f\"[INFO] subprocess virtughan_file={getattr(virtughan, '__file__', '')}\\n\")",
            "        backend_args = {k: v for k, v in payload.items() if k != \"log_path\"}",
            "        backend_args[\"log_file\"] = logf",
            "",
            "        sig = inspect.signature(ExtractorBackend.__init__)",
            "        accepts_kwargs = any(",
            "            p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()",
            "        )",
            "        if not accepts_kwargs:",
            "            allowed = {name for name in sig.parameters.keys() if name != \"self\"}",
            "            backend_args = {k: v for k, v in backend_args.items() if k in allowed}",
            "",
            "        extr = ExtractorBackend(**backend_args)",
            "        logf.write(\"Please wait: downloading and preparing outputs (this may take few minutes depending on your area and time range).\\n\")",
            "        extr.extract()",
            "        logf.write(\"Extractor finished.\\n\")",
            "    except Exception:",
            "        logf.write(\"[subprocess_exception]\\n\")",
            "        logf.write(traceback.format_exc())",
            "        sys.exit(1)",
            "",
            "sys.exit(0)",
            "",
        ]
    )

    try:
        with open(payload_path, "w", encoding="utf-8") as pf:
            json.dump(payload, pf)
        with open(runner_path, "w", encoding="utf-8") as rf:
            rf.write(runner_code)

        if logf:
            logf.write(f"[INFO] launcher source file={__file__}\n")
            logf.write(f"[INFO] running extractor in subprocess: {python_exe}\n")

        run_kwargs = {
            "args": [python_exe, runner_path],
            "stdout": subprocess.PIPE,
            "stderr": subprocess.PIPE,
            "text": True,
        }

        env = os.environ.copy()

        # Fix PROJ conflict: PostgreSQL/PostGIS ships an older proj.db that
        # breaks rasterio CRS operations. Explicitly set PROJ paths to QGIS's
        # PROJ and remove PostgreSQL directories from PATH.
        prefix = QgsApplication.prefixPath() or ""
        proj_candidates = [
            os.path.join(prefix, "share", "proj"),
            os.path.normpath(os.path.join(prefix, "..", "share", "proj")),
            os.path.normpath(os.path.join(prefix, "..", "..", "share", "proj")),
        ]
        for proj_path in proj_candidates:
            if os.path.isfile(os.path.join(proj_path, "proj.db")):
                env["PROJ_LIB"] = proj_path
                env["PROJ_DATA"] = proj_path
                if logf:
                    logf.write(f"[INFO] subprocess PROJ_LIB={proj_path}\n")
                break

        # Remove PostgreSQL/PostGIS paths from PATH to prevent DLL/proj.db conflicts
        if os.name == "nt":
            path_dirs = env.get("PATH", "").split(os.pathsep)
            clean_path = [
                d for d in path_dirs
                if "postgresql" not in d.lower() and "postgis" not in d.lower()
            ]
            env["PATH"] = os.pathsep.join(clean_path)

        runtime_pairs = [
            (RUNTIME_SITE_PACKAGES_DIR, RUNTIME_ROOT),
            (RUNTIME_FALLBACK_SITE_PACKAGES_DIR, RUNTIME_FALLBACK_ROOT),
        ]
        selected_runtime_paths = []
        for site_pkgs, root in runtime_pairs:
            if not site_pkgs or not os.path.isdir(site_pkgs):
                continue
            if os.path.isdir(os.path.join(site_pkgs, "virtughan")):
                selected_runtime_paths = [site_pkgs]
                break

        if not selected_runtime_paths:
            for site_pkgs, root in runtime_pairs:
                if site_pkgs and os.path.isdir(site_pkgs):
                    selected_runtime_paths = [site_pkgs]
                    break

        if selected_runtime_paths:
            env["PYTHONPATH"] = os.pathsep.join(selected_runtime_paths)
        else:
            env.pop("PYTHONPATH", None)

        # Prevent user-level site-packages from shadowing plugin-managed runtime deps.
        env["PYTHONNOUSERSITE"] = "1"
        run_kwargs["env"] = env

        if logf:
            logf.write(f"[INFO] subprocess PYTHONPATH={env.get('PYTHONPATH', '')}\n")
            logf.write(f"[INFO] subprocess PYTHONNOUSERSITE={env.get('PYTHONNOUSERSITE', '')}\n")
        if os.name == "nt":
            startupinfo = subprocess.STARTUPINFO()
            startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
            startupinfo.wShowWindow = 0
            run_kwargs["startupinfo"] = startupinfo
            run_kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)

        preflight_cmd = [python_exe, "-c", "import encodings,sys; print(sys.executable)"]
        preflight = None
        if sys.platform == "darwin":
            pyhome_candidates = [None]
            try:
                exe_path = Path(python_exe).resolve()
                app_contents = None
                for parent in exe_path.parents:
                    if parent.name == "Contents":
                        app_contents = parent
                        break
                if app_contents is not None:
                    for candidate in (
                        app_contents / "Resources" / "python",
                        app_contents / "Resources",
                        app_contents / "Frameworks" / "Python.framework" / "Versions" / "Current",
                    ):
                        if candidate.is_dir():
                            pyhome_candidates.append(str(candidate))
            except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                pass

            selected_env = None
            for pyhome in pyhome_candidates:
                candidate_env = env.copy()
                if pyhome:
                    candidate_env["PYTHONHOME"] = pyhome
                else:
                    candidate_env.pop("PYTHONHOME", None)

                preflight_run_kwargs = {
                    "stdout": subprocess.PIPE,
                    "stderr": subprocess.PIPE,
                    "text": True,
                    "env": candidate_env,
                    "timeout": 15,
                }
                if os.name == "nt":
                    preflight_startupinfo = subprocess.STARTUPINFO()
                    preflight_startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
                    preflight_startupinfo.wShowWindow = 0
                    preflight_run_kwargs["startupinfo"] = preflight_startupinfo
                    preflight_run_kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)

                preflight = subprocess.run(
                    preflight_cmd,
                    **preflight_run_kwargs,
                )  # nosec B603 - fixed QGIS Python executable and argument list; shell is not used.
                if logf:
                    logf.write(
                        f"[INFO] mac preflight pyhome={pyhome or '<unset>'} returncode={preflight.returncode}\n"
                    )
                if preflight.returncode == 0:
                    selected_env = candidate_env
                    break

            if selected_env is None:
                stderr_text = (preflight.stderr or "").strip() if preflight else ""
                if "encodings" in stderr_text or "init_fs_encoding" in stderr_text:
                    _run_extractor_inprocess_fallback(params, log_path, logf=logf, should_cancel=should_cancel)
                    return
                raise RuntimeError(
                    "Resolved Python executable failed startup preflight. "
                    f"path={python_exe}, returncode={preflight.returncode if preflight else 'n/a'}, "
                    f"stderr={stderr_text or 'n/a'}"
                )
            env = selected_env
            run_kwargs["env"] = env
        else:
            preflight_run_kwargs = {
                "stdout": subprocess.PIPE,
                "stderr": subprocess.PIPE,
                "text": True,
                "env": env,
                "timeout": 15,
            }
            if os.name == "nt":
                preflight_startupinfo = subprocess.STARTUPINFO()
                preflight_startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
                preflight_startupinfo.wShowWindow = 0
                preflight_run_kwargs["startupinfo"] = preflight_startupinfo
                preflight_run_kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)

            preflight = subprocess.run(
                preflight_cmd,
                **preflight_run_kwargs,
            )  # nosec B603 - fixed QGIS Python executable and argument list; shell is not used.
            if preflight.returncode != 0:
                raise RuntimeError(
                    "Resolved Python executable failed startup preflight. "
                    f"path={python_exe}, returncode={preflight.returncode}, stderr={preflight.stderr.strip()}"
                )

        if logf and preflight is not None:
            logf.write(f"[INFO] python preflight returncode={preflight.returncode}\n")
            if preflight.stdout:
                logf.write(f"[INFO] python preflight stdout={preflight.stdout.strip()}\n")
            if preflight.stderr:
                logf.write(f"[INFO] python preflight stderr={preflight.stderr.strip()}\n")

        proc = subprocess.Popen(**run_kwargs)  # nosec B603 - fixed QGIS Python executable and generated runner path; shell is not used.
        stdout_data = ""
        stderr_data = ""
        try:
            # Track log file activity to detect stuck subprocess
            last_log_size = 0
            last_log_activity = time.monotonic()
            stuck_timeout = 90.0  # 90 seconds without log activity = stuck
            deadline = time.monotonic() + 600.0  # 10 minutes max

            while True:
                if callable(should_cancel) and should_cancel():
                    try:
                        proc.terminate()
                    except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                        pass
                    try:
                        proc.wait(timeout=2.0)
                    except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                        try:
                            proc.kill()
                        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                            pass
                    raise _TaskCancelledError("Download cancelled by user.")

                if proc.poll() is not None:
                    break

                now = time.monotonic()

                # Check if log file is still being written to
                try:
                    current_log_size = os.path.getsize(log_path) if os.path.exists(log_path) else 0
                    if current_log_size > last_log_size:
                        last_log_size = current_log_size
                        last_log_activity = now
                except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                    pass

                # Detect stuck subprocess
                if (now - last_log_activity) > stuck_timeout:
                    if logf:
                        logf.write(
                            f"[ERROR] Extractor subprocess appears stuck — no log activity for "
                            f"{int(stuck_timeout)}s. Killing subprocess.\n"
                        )
                    try:
                        proc.terminate()
                    except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                        pass
                    try:
                        proc.wait(timeout=2.0)
                    except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                        try:
                            proc.kill()
                        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                            pass
                    raise RuntimeError(
                        "Extractor subprocess appears stuck (no progress for 90 seconds).\n\n"
                        "This usually means the satellite data service is unreachable or very slow."
                    )

                if now > deadline:
                    if logf:
                        logf.write("[ERROR] Extractor subprocess timed out after 10 minutes.\n")
                    try:
                        proc.terminate()
                    except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                        pass
                    try:
                        proc.wait(timeout=2.0)
                    except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                        try:
                            proc.kill()
                        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                            pass
                    raise RuntimeError(
                        "Extractor subprocess timed out after 10 minutes."
                    )

                time.sleep(0.2)

            stdout_data, stderr_data = proc.communicate(timeout=5)
        finally:
            if proc.poll() is None:
                try:
                    proc.kill()
                except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                    pass

        if logf and stdout_data:
            logf.write("[subprocess_stdout]\n")
            logf.write(stdout_data.strip() + "\n")
        if logf and stderr_data:
            logf.write("[subprocess_stderr]\n")
            logf.write(stderr_data.strip() + "\n")

        if proc.returncode != 0:
            stdout_tail = (stdout_data or "").strip()
            stderr_tail = (stderr_data or "").strip()
            if stdout_tail:
                stdout_tail = stdout_tail[-1200:]
            if stderr_tail:
                stderr_tail = stderr_tail[-800:]
            raise RuntimeError(
                f"Extractor subprocess failed (exit code {proc.returncode}). "
                f"python={python_exe}. stdout_tail={stdout_tail} stderr_tail={stderr_tail}"
            )
    finally:
        try:
            shutil.rmtree(work_dir, ignore_errors=True)
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass


def _log(widget, msg, level=QgisCompat.Info):
    QgsMessageLog.logMessage(str(msg), "VirtuGhan", level)
    try:
        widget.logText.appendPlainText(str(msg))
    except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
        pass


def _resolve_embedded_python_executable() -> str:
    candidates: list[Path] = []
    install_roots: list[Path] = []

    def _add_root(path_value):
        try:
            p = Path(path_value).resolve()
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            return
        if p not in install_roots:
            install_roots.append(p)

    def _add_candidate(path_value):
        try:
            p = Path(path_value)
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            return
        candidates.append(p)

    def _is_within_install_roots(candidate_path: Path) -> bool:
        try:
            resolved = candidate_path.resolve()
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            return False
        for root in install_roots:
            try:
                resolved.relative_to(root)
                return True
            except Exception:  # nosec B110,B112 - defensive QGIS cleanup or optional API fallback.
                continue
        return False

    prefix = QgsApplication.prefixPath() or ""
    if prefix:
        prefix_path = Path(prefix).resolve()
        _add_root(prefix_path)
        for i, parent in enumerate(prefix_path.parents):
            if i >= 4:
                break
            _add_root(parent)

    if sys.executable:
        try:
            exe_path = Path(sys.executable).resolve()
            _add_root(exe_path.parent)
            for i, parent in enumerate(exe_path.parents):
                if i >= 5:
                    break
                _add_root(parent)

            if sys.platform == "darwin":
                for parent in exe_path.parents:
                    if parent.suffix.lower() == ".app":
                        _add_root(parent)
                        _add_root(parent / "Contents")
                        _add_root(parent / "Contents" / "MacOS")
                        break
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

    base_executable = getattr(sys, "_base_executable", "")
    if base_executable:
        _add_candidate(base_executable)
    env_python = os.environ.get("PYTHONEXECUTABLE")
    if env_python:
        _add_candidate(env_python)

    if prefix:
        prefix_path = Path(prefix)
        candidates.extend(
            [
                prefix_path / "python.exe",
                prefix_path / "python3",
                prefix_path / "python",
                prefix_path / "bin" / "python.exe",
                prefix_path / "bin" / "python3",
                prefix_path / "bin" / "python",
            ]
        )
        try:
            bin_dir = prefix_path / "bin"
            if bin_dir.is_dir():
                for entry in sorted(bin_dir.iterdir()):
                    if entry.is_file() and entry.name.lower().startswith("python"):
                        candidates.append(entry)
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

    if sys.platform == "darwin" and sys.executable:
        try:
            exe_path = Path(sys.executable).resolve()
            for parent in exe_path.parents:
                if parent.name == "MacOS":
                    app_contents = parent.parent
                    candidates.extend(
                        [
                            parent / "python",
                            parent / "python3",
                            parent / "python3.12",
                            parent / "bin" / "python3",
                            parent / "bin" / "python",
                            app_contents / "Frameworks" / "Python.framework" / "Versions" / "Current" / "bin" / "python3",
                            app_contents / "Frameworks" / "Python.framework" / "Versions" / "Current" / "bin" / "python",
                            app_contents / "Resources" / "python" / "bin" / "python3",
                            app_contents / "Resources" / "python" / "bin" / "python",
                        ]
                    )
                    try:
                        for entry in sorted(parent.iterdir()):
                            if entry.is_file() and entry.name.lower().startswith("python"):
                                candidates.append(entry)
                    except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                        pass
                    break
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

    if os.name == "nt":
        for root in list(install_roots):
            apps_dir = root / "apps"
            if not apps_dir.is_dir():
                continue
            try:
                for app in sorted(apps_dir.iterdir()):
                    if app.is_dir() and app.name.lower().startswith("python"):
                        candidates.append(app / "python.exe")
                        candidates.append(app / "Scripts" / "python.exe")
            except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                pass

    if sys.executable:
        _add_candidate(sys.executable)

    seen: set[str] = set()
    for candidate in candidates:
        try:
            resolved = candidate.resolve()
        except Exception:  # nosec B110,B112 - defensive QGIS cleanup or optional API fallback.
            continue
        normalized = str(resolved)
        if normalized in seen:
            continue
        seen.add(normalized)

        name = resolved.name.lower()
        if name in {"qgis", "qgis-bin.exe", "qgis-ltr-bin.exe"}:
            continue
        if sys.platform == "darwin":
            posix_path = resolved.as_posix().lower()
            if "/contents/resources/scripts/python" in posix_path:
                # This wrapper can point to a missing versioned python executable.
                continue
        if "python" not in name:
            continue
        if not resolved.is_file():
            continue
        if not _is_within_install_roots(resolved):
            continue
        if os.name != "nt" and not os.access(str(resolved), os.X_OK):
            continue
        return normalized

    return ""


@contextmanager
def _with_embedded_python_executable(logf=None):
    original_executable = sys.executable
    original_python_executable_env = os.environ.get("PYTHONEXECUTABLE")
    embedded_python = _resolve_embedded_python_executable()
    try:
        if embedded_python and embedded_python != original_executable:
            sys.executable = embedded_python
            if logf:
                logf.write(f"Using embedded Python executable: {embedded_python}\n")
        elif logf:
            logf.write("Embedded Python executable not resolved; keeping current sys.executable\n")

        if embedded_python:
            os.environ["PYTHONEXECUTABLE"] = embedded_python

        try:
            import multiprocessing as _mp
            mp_set_executable = getattr(_mp, "set_executable", None)
            if callable(mp_set_executable) and embedded_python:
                mp_set_executable(embedded_python)
                if logf:
                    logf.write(f"Using multiprocessing executable: {embedded_python}\n")
        except Exception as mp_exc:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            if logf:
                logf.write(f"Could not configure multiprocessing executable: {mp_exc}\n")

        yield
    finally:
        if original_python_executable_env is None:
            os.environ.pop("PYTHONEXECUTABLE", None)
        else:
            os.environ["PYTHONEXECUTABLE"] = original_python_executable_env
        sys.executable = original_executable


class _ExtractorTask(QgsTask):
    def __init__(self, desc, params, log_path, on_done=None):
        super().__init__(desc, QgsTaskCompat.CanCancel)
        self.params = params
        self.log_path = log_path
        self.on_done = on_done
        self.exc = None

    def cancel(self):
        try:
            with open(self.log_path, "a", encoding="utf-8", buffering=1) as logf:
                logf.write("[cancel] Download cancellation requested by user.\n")
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass
        return super().cancel()

    def run(self):
        try:
            os.makedirs(self.params["output_dir"], exist_ok=True)
            with open(self.log_path, "a", encoding="utf-8", buffering=1) as logf:
                _apply_pyproj_windows_guard(logger=lambda m, lvl=QgisCompat.Info: logf.write(f"[{lvl}] {m}\n"))
                _reload_pyproj_modules(logger=lambda m: logf.write(f"[INFO] {m}\n"))
                logf.write(f"[INFO] PROJ_LIB={os.environ.get('PROJ_LIB', '')}\n")
                logf.write(f"[INFO] PROJ_DATA={os.environ.get('PROJ_DATA', '')}\n")
                logf.write(
                    f"[{datetime.now().isoformat(timespec='seconds')}] Starting Extractor\n"
                )
                logf.write(f"Params: {self.params}\n")
                if sys.platform == "darwin":
                    logf.write("[INFO] macOS path: running extractor in-process with module reload.\n")
                    _run_extractor_inprocess_mac(
                        self.params,
                        logf=logf,
                        should_cancel=self.isCanceled,
                    )
                else:
                    try:
                        _run_extractor_in_subprocess(
                            self.params,
                            self.log_path,
                            logf=logf,
                            should_cancel=self.isCanceled,
                        )
                    except RuntimeError as sub_err:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                        err_msg = str(sub_err)
                        is_stuck = "appears stuck" in err_msg or "timed out" in err_msg.lower()
                        is_env_issue = "Could not locate" in err_msg or "preflight" in err_msg.lower()
                        retryable = is_stuck or is_env_issue
                        if retryable:
                            logf.write(
                                f"\n[WARNING] Subprocess failed: {err_msg}\n"
                                "[INFO] Falling back to single-process download (workers=1).\n"
                            )
                            fallback_params = dict(self.params)
                            fallback_params["workers"] = 1
                            _run_extractor_inprocess_mac(
                                fallback_params,
                                logf=logf,
                                should_cancel=self.isCanceled,
                            )
                        else:
                            raise
            return True
        except _TaskCancelledError as e:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            self.exc = e
            return False
        except Exception as e:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            self.exc = e
            try:
                with open(self.log_path, "a", encoding="utf-8", buffering=1) as logf:
                    logf.write("[exception]\n")
                    logf.write(traceback.format_exc())
            except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                pass
            return False

    def finished(self, ok):
        if self.on_done:
            try:
                self.on_done(ok, self.exc)
            except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                pass

class _UiLogTailer:
    """Poll a text file and append new content to a QPlainTextEdit without blocking UI."""
    def __init__(
        self,
        log_path: str,
        log_widget: QPlainTextEdit,
        interval_ms: int = 400,
        on_stall=None,
        stall_timeout_sec: float = 180.0,
    ):
        self._path = log_path
        self._widget = log_widget
        self._pos = 0
        self._on_stall = on_stall
        self._stall_timeout_sec = max(30.0, float(stall_timeout_sec))
        self._last_growth_monotonic = 0.0
        self._stalled_notified = False
        self._timer = QTimer()
        self._timer.setInterval(interval_ms)
        self._timer.timeout.connect(self._poll_once)

    def start(self):
        try:
            os.makedirs(os.path.dirname(self._path), exist_ok=True)
            open(self._path, "a", encoding="utf-8").close()
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass
        self._pos = 0
        self._last_growth_monotonic = time.monotonic()
        self._stalled_notified = False
        self._timer.start()

    def stop(self):
        self._timer.stop()

    def _poll_once(self):
        try:
            if not os.path.exists(self._path):
                return
            with open(self._path, "r", encoding="utf-8", errors="replace") as f:
                f.seek(self._pos)
                chunk = f.read()
                if chunk:
                    # tqdm writes progress updates with carriage returns; normalize to line breaks for UI logs.
                    normalized = chunk.replace("\r\n", "\n").replace("\r", "\n")
                    self._widget.appendPlainText(normalized.rstrip("\n"))
                    # Auto-scroll to bottom to show latest log
                    sb = self._widget.verticalScrollBar()
                    sb.setValue(sb.maximum())
                    self._pos = f.tell()
                    self._last_growth_monotonic = time.monotonic()
                    self._stalled_notified = False
                else:
                    idle_sec = time.monotonic() - self._last_growth_monotonic
                    if (not self._stalled_notified) and idle_sec >= self._stall_timeout_sec:
                        self._stalled_notified = True
                        if callable(self._on_stall):
                            try:
                                self._on_stall(idle_sec)
                            except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                                pass
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass
from qgis.PyQt.QtCore import Qt, QTimer


class ExtractorDockWidget(QDockWidget):
    def __init__(self, iface):
        super().__init__("VirtuGhan • Extractor", iface.mainWindow())
        self.iface = iface
        self.setObjectName("VirtuGhanExtractorDock")

        self.ui_root = QWidget(self)
        self._form_owner = FORM_CLASS()
        self._form_owner.setupUi(self.ui_root)
        self._set_header_logo()
        hide_single_tab_bar(self.ui_root)
        self.setWidget(self.ui_root)

        f = self.ui_root.findChild
        self.progressBar = f(QProgressBar, "progressBar")
        self.runButton = f(QPushButton, "runButton")
        self.resetButton = f(QPushButton, "resetButton")
        self.helpButton = f(QPushButton, "helpButton")
        self.logText = f(QPlainTextEdit, "logText")
        self.commonHost = f(QWidget, "commonParamsContainer")

        self.aoiModeCombo = f(QComboBox, "aoiModeCombo")
        self.aoiUseCanvasButton = f(QPushButton, "aoiUseCanvasButton")
        self.aoiStartDrawButton = f(QPushButton, "aoiStartDrawButton")
        self.aoiClearButton = f(QPushButton, "aoiClearButton")
        self.aoiPreviewLabel = f(QLabel, "aoiPreviewLabel")

        self.workersSpin = f(QSpinBox, "workersSpin")
        self.outputPathEdit = f(QLineEdit, "outputPathEdit")
        self.outputBrowseButton = f(QPushButton, "outputBrowseButton")
        self.previewScenesButton = f(QPushButton, "previewScenesButton")
        self.showSceneFootprintsCheck = f(QCheckBox, "showSceneFootprintsCheck")

        self.bandsListWidget = f(QListWidget, "bandsListWidget")
        self.smartFilterCheck = f(QCheckBox, "smartFilterCheck")

        # AOI state
        self._aoi_bbox = None               # [lonmin, latmin, lonmax, latmax] (WGS84)
        self._aoi_polygon_wgs84 = None      # optional [[lon,lat], ...]
        self._batch_aoi_specs = []
        self._batch_state = None
        # Green colors for Download AOI
        self._aoi_fill_color = QColor(76, 175, 80, 60)      # light green with transparency
        self._aoi_stroke_color = QColor(56, 142, 60, 200)   # darker green stroke
        self._aoi = AoiManager(self.iface, layer_name="Download AOI", fill_color=self._aoi_fill_color, stroke_color=self._aoi_stroke_color)
        self._prev_tool = None

        self._init_common_widget()
        apply_primary_button_style(self.runButton)
        self.smartFilterCheck.setChecked(False)
        self.smartFilterCheck.setToolTip(
            "Optional: reduce matching scenes by selecting representative images over time."
        )
        try:
            self.commonWidget.collectionCombo.currentIndexChanged.connect(self._on_collection_changed)
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass
        self._smart_filter_warning_key = None
        self._wire_smart_filter_warning()
        self._on_collection_changed()
        self.progressBar.setVisible(False)
        self.workersSpin.setMinimum(1)
        self.workersSpin.setValue(self._recommended_default_workers())

        # AOI: mode-first flow with hidden actions until a mode is chosen
        self.aoiModeCombo.clear()
        self.aoiModeCombo.addItems(["Select mode", "Map extent", "Draw rectangle", "Draw polygon", "Select layer from Layers Panel"])
        self._init_aoi_layer_selector()
        self.aoiUseCanvasButton.setVisible(False)  # hide legacy button

        self.aoiStartDrawButton.clicked.connect(self._aoi_action_clicked)
        self.aoiClearButton.clicked.connect(self._clear_aoi)
        self.aoiModeCombo.activated.connect(lambda idx: self._aoi_mode_changed(self.aoiModeCombo.itemText(idx)))

        self.outputBrowseButton.clicked.connect(self._browse_output)
        self.resetButton.clicked.connect(self._reset_form)
        self.runButton.clicked.connect(self._run_clicked)
        self.helpButton.clicked.connect(self._open_help)
        self.previewScenesButton.clicked.connect(self._preview_matching_scenes)
        self.showSceneFootprintsCheck.toggled.connect(self._on_show_scene_footprints_toggled)
        self.smartFilterCheck.toggled.connect(lambda *_: self._maybe_warn_smart_filter_timeframe())

        self._update_aoi_preview()
        self._aoi_mode_changed(self.aoiModeCombo.currentText())

        try:
            self.ui_root.setMinimumSize(self.ui_root.sizeHint())
            self.setMinimumSize(self.ui_root.sizeHint())
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

        self._current_task = None
        self._current_log_path = None
        self._tailer = None
        self._default_run_button_text = self.runButton.text() or "Download"
        self._scene_footprints_layer = None
        self._selected_preview_scenes = []
        self._has_successful_run = False
        self._last_output_layer_ids = []

    def _wire_smart_filter_warning(self):
        try:
            self.commonWidget.startDate.dateChanged.connect(lambda *_: self._maybe_warn_smart_filter_timeframe())
            self.commonWidget.endDate.dateChanged.connect(lambda *_: self._maybe_warn_smart_filter_timeframe())
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

    def _maybe_warn_smart_filter_timeframe(self):
        try:
            if not self.commonWidget:
                return
            start_date = self.commonWidget.startDate.date()
            end_date = self.commonWidget.endDate.date()
            if not start_date.isValid() or not end_date.isValid() or start_date > end_date:
                return
            days = start_date.daysTo(end_date)
            if days <= 365:
                self._smart_filter_warning_key = None
                return

            enabled = bool(self.smartFilterCheck.isChecked())
            key = (start_date.toString("yyyy-MM-dd"), end_date.toString("yyyy-MM-dd"), enabled)
            if key == self._smart_filter_warning_key:
                return
            self._smart_filter_warning_key = key

            if enabled:
                msg = (
                    "Smart filter is enabled for a date range longer than 1 year; "
                    "it may reduce the number of downloaded images by selecting representative scenes."
                )
            else:
                msg = (
                    "Date range is longer than 1 year. If you enable Smart filter, "
                    "it may reduce the number of downloaded images by selecting representative scenes."
                )
            _log(self, msg, QgisCompat.Warning)
            try:
                self.iface.messageBar().pushWarning("VirtuGhan", msg)
            except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                pass
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

    def _set_header_logo(self):
        try:
            title_label = self.ui_root.findChild(QLabel, "titleLabel")
            if title_label is None:
                return
            if self.ui_root.findChild(QLabel, "virtughanHeaderLogo") is not None:
                return
            header_layout = self.ui_root.findChild(QHBoxLayout, "headerLayout")
            if header_layout is None:
                return
            logo_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static", "images", "virtughan-logo.png")
            if not os.path.exists(logo_path):
                return

            px = QPixmap(logo_path)
            if px.isNull():
                return

            logo_label = QLabel(self.ui_root)
            logo_label.setObjectName("virtughanHeaderLogo")
            logo_label.setFixedSize(24, 24)
            logo_label.setAlignment(QtCompat.AlignCenter)
            logo_label.setPixmap(px.scaled(24, 24, QtCompat.KeepAspectRatio, QtCompat.SmoothTransformation))

            idx = header_layout.indexOf(title_label)
            header_layout.insertWidget(max(0, idx), logo_label)
            header_layout.setSpacing(6)
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

    def _init_common_widget(self):
        if CommonParamsWidget:
            self.commonWidget = CommonParamsWidget(parent=self.commonHost)
            layout = self.commonHost.layout() or QVBoxLayout(self.commonHost)
            layout.setContentsMargins(0, 0, 0, 0)
            layout.addWidget(self.commonWidget)

            # Hide fields that don't apply to Extractor
            self._hide_bands_and_formula()
        else:
            self.commonWidget = None
            _log(self, f"CommonParamsWidget failed: {COMMON_IMPORT_ERROR}", QgisCompat.Warning)


    def _hide_bands_and_formula(self):
        """Hide Band 1, Band 2, and Formula widgets from CommonParamsWidget."""
        w = getattr(self, "commonWidget", None)
        if not w:
            return

        # These names must match the name in .ui:
        names = ("labelBand1", "band1Combo",
                "labelBand2", "band2Combo",
                "labelFormula", "formulaEdit",
                "hintLabel")

        for name in names:
            child = w.findChild(QWidget, name)
            if child:
                child.hide()

        # Collapse hidden rows in the common grid so no blank vertical space remains.
        try:
            grid = w.findChild(QWidget, "grid")
            if grid is None:
                from qgis.PyQt.QtWidgets import QGridLayout
                layout = w.layout()
                if isinstance(layout, QGridLayout):
                    grid_layout = layout
                else:
                    grid_layout = None
            else:
                grid_layout = grid.layout()

            if grid_layout is not None:
                for row in (2, 3, 4):
                    try:
                        grid_layout.setRowMinimumHeight(row, 0)
                        grid_layout.setRowStretch(row, 0)
                    except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                        pass
                try:
                    grid_layout.setVerticalSpacing(2)
                except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                    pass
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

        # Optional: nudge layouts to recompute sizes
        try:
            w.updateGeometry()
            w.setMinimumHeight(w.sizeHint().height())
            self.ui_root.adjustSize()
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass



    def _get_common_params(self):
        if self.commonWidget:
            return self.commonWidget.get_params()
        return {}

    def _current_collection(self):
        if self.commonWidget:
            try:
                return normalize_collection(self.commonWidget.get_params().get("collection"))
            except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                pass
        return "sentinel-2-l2a"

    def _init_aoi_layer_selector(self):
        self._last_aoi_layer_id = None
        self._aoi_layer_project_signals_connected = False
        self.aoiLayerList = QListWidget(self.ui_root)
        self.aoiLayerList.setToolTip("Choose one polygon layer from the current project")
        self.aoiLayerList.setSelectionMode(QAbstractItemViewCompat.SingleSelection)
        self.aoiLayerList.setVisible(False)
        self.aoiLayerList.setMinimumHeight(48)
        self.aoiLayerList.setMaximumHeight(76)
        self.aoiLayerList.itemClicked.connect(lambda *_: self._use_layer_aoi())
        try:
            layout = self.ui_root.findChild(QWidget, "groupAOI").layout()
            layout.removeWidget(self.aoiPreviewLabel)
            layout.addWidget(self.aoiLayerList, 1, 0, 1, 5)
            layout.addWidget(self.aoiPreviewLabel, 2, 0, 1, 5)
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass
        try:
            QgsProject.instance().layersAdded.connect(self._on_project_layers_changed)
            QgsProject.instance().layersRemoved.connect(self._on_project_layers_changed)
            self._aoi_layer_project_signals_connected = True
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass
        self._populate_aoi_layer_combo()

    def _on_project_layers_changed(self, *_):
        self._populate_aoi_layer_combo()

    def _disconnect_aoi_layer_project_signals(self):
        if not getattr(self, "_aoi_layer_project_signals_connected", False):
            return
        try:
            project = QgsProject.instance()
            try:
                project.layersAdded.disconnect(self._on_project_layers_changed)
            except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                pass
            try:
                project.layersRemoved.disconnect(self._on_project_layers_changed)
            except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                pass
        finally:
            self._aoi_layer_project_signals_connected = False

    def _populate_aoi_layer_combo(self):
        layer_list = getattr(self, "aoiLayerList", None)
        if layer_list is None:
            return
        try:
            current_item = layer_list.currentItem()
            current_id = (
                current_item.data(QtCompat.UserRole) if current_item is not None else None
            ) or getattr(self, "_last_aoi_layer_id", None)
            helper_layer_id = None
            try:
                helper_layer = getattr(getattr(self, "_aoi", None), "layer", None)
                if helper_layer is not None and helper_layer.isValid():
                    helper_layer_id = helper_layer.id()
            except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                helper_layer_id = None
            layer_list.blockSignals(True)
            layer_list.clear()
            layers = polygon_layers(QgsProject.instance())
            if not layers:
                item = QListWidgetItem("No polygon layers")
                item.setData(QtCompat.UserRole, "")
                item.setFlags(QtCompat.NoItemFlags)
                layer_list.addItem(item)
            else:
                for layer in layers:
                    if helper_layer_id and layer.id() == helper_layer_id:
                        continue
                    item = QListWidgetItem(layer.name())
                    item.setData(QtCompat.UserRole, layer.id())
                    layer_list.addItem(item)
                if layer_list.count() == 0:
                    item = QListWidgetItem("No polygon layers")
                    item.setData(QtCompat.UserRole, "")
                    item.setFlags(QtCompat.NoItemFlags)
                    layer_list.addItem(item)
            if current_id:
                found = False
                for row in range(layer_list.count()):
                    if layer_list.item(row).data(QtCompat.UserRole) == current_id:
                        layer_list.setCurrentRow(row)
                        found = True
                        break
                if not found and getattr(self, "_last_aoi_layer_id", None) == current_id:
                    self._last_aoi_layer_id = None
        except RuntimeError as exc:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            if "deleted" not in str(exc).lower():
                raise
        finally:
            try:
                layer_list.blockSignals(False)
            except RuntimeError:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                pass

    def _selected_aoi_layer(self):
        layer_list = getattr(self, "aoiLayerList", None)
        if layer_list is None:
            return None
        item = layer_list.currentItem()
        layer_id = item.data(QtCompat.UserRole) if item is not None else None
        if not layer_id:
            return None
        return QgsProject.instance().mapLayer(layer_id)

    def _use_layer_aoi(self):
        layer = self._selected_aoi_layer()
        if layer is None or not layer.isValid():
            self._update_aoi_preview("AOI: select a polygon layer first.")
            return
        self._last_aoi_layer_id = layer.id()

        selected_fids = {f.id() for f in layer.selectedFeatures()}
        try:
            specs = specs_from_features(layer, list(layer.getFeatures()), QgsProject.instance())
        except AoiTransformError as exc:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            QMessageBox.warning(self, "VirtuGhan", str(exc))
            return
        transform_errors = list(getattr(specs_from_features, "last_errors", []) or [])
        if not specs:
            detail = "\n".join(transform_errors[:5])
            QMessageBox.warning(
                self,
                "VirtuGhan",
                "Selected layer has no valid polygon features that can be transformed to WGS84."
                + (f"\n\n{detail}" if detail else ""),
            )
            return
        if transform_errors:
            _log(
                self,
                f"{len(transform_errors)} feature(s) could not be transformed and were skipped. "
                + " | ".join(transform_errors[:3]),
                QgisCompat.Warning,
            )
            QMessageBox.warning(
                self,
                "VirtuGhan",
                f"{len(transform_errors)} feature(s) could not be transformed and were skipped.\n\n"
                + "\n".join(transform_errors[:5]),
            )
        if len(specs) > 1:
            selected_specs = [spec for spec in specs if spec.get("fid") in selected_fids]
            combined_specs = selected_specs or specs
            mode = choose_multi_polygon_mode(
                self,
                action_label="download images for this AOI",
                total_count=len(specs),
                selected_count=len(selected_specs),
            )
            if mode is None:
                return
            if mode == "combined":
                if len(combined_specs) == 1:
                    specs = combined_specs
                else:
                    self._batch_aoi_specs = []
                    geom = combined_geometry(combined_specs)
                    if geom is None or geom.isEmpty():
                        QMessageBox.warning(self, "VirtuGhan", "Selected layer features have no valid polygon geometry.")
                        return
                    try:
                        self._aoi_bbox = geom_to_wgs84_bbox(geom, QgsProject.instance())
                    except Exception as exc:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                        QMessageBox.warning(
                            self,
                            "VirtuGhan",
                            f"Could not transform combined AOI to WGS84 (EPSG:4326): {exc}",
                        )
                        return
                    self._aoi.replace_geometry(QgsGeometry.fromRect(geom.boundingBox()))
                    self._aoi_polygon_wgs84 = None
                    self._update_aoi_preview(f"Combined AOI: {len(combined_specs)} polygon features.")
                    return
            else:
                dlg = PolygonSelectionDialog(self, specs, selected_fids=selected_fids, title="Select Download Batch Polygons")
                _exec = getattr(dlg, "exec", None) or getattr(dlg, "exec_")
                if _exec() != 1:
                    return
                chosen = dlg.selected_specs()
                if not chosen:
                    return
                if len(chosen) > 1:
                    self._set_batch_aoi(chosen)
                    return
                specs = chosen

        self._batch_aoi_specs = []
        geom = specs[0].get("geometry_project")
        if geom is None or geom.isEmpty():
            QMessageBox.warning(self, "VirtuGhan", "Selected layer feature has no valid polygon geometry.")
            return

        self._aoi.replace_geometry(geom)
        self._aoi_bbox = specs[0].get("bbox")
        self._aoi_polygon_wgs84 = specs[0].get("polygon_wgs84")
        self._update_aoi_preview()

    def _set_batch_aoi(self, specs):
        self._batch_aoi_specs = list(specs or [])
        geom = combined_geometry(self._batch_aoi_specs)
        if geom is not None and not geom.isEmpty():
            self._aoi.replace_geometries([
                (spec.get("geometry_project"), spec.get("label") or f"AOI {idx}")
                for idx, spec in enumerate(self._batch_aoi_specs, start=1)
            ])
            try:
                self._aoi_bbox = geom_to_wgs84_bbox(geom, QgsProject.instance())
            except Exception as exc:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                QMessageBox.warning(
                    self,
                    "VirtuGhan",
                    f"Could not transform combined batch AOI to WGS84 (EPSG:4326): {exc}",
                )
                self._batch_aoi_specs = []
                return
        else:
            self._aoi_bbox = self._batch_aoi_specs[0].get("bbox") if self._batch_aoi_specs else None
        self._aoi_polygon_wgs84 = None
        self._update_aoi_preview(f"Batch AOI: {len(self._batch_aoi_specs)} polygon features selected.")

    def _on_collection_changed(self, *_):
        collection = self._current_collection()
        self._selected_preview_scenes = []
        bands = collection_band_names(collection)
        selected_defaults = {
            "sentinel-2-l2a": {"red", "green", "blue", "nir"},
            "landsat-c2-l2": {"red", "green", "blue", "nir08"},
            "sentinel-1-rtc": {"vv", "vh"},
        }.get(collection, set(bands[:2]))
        self.bandsListWidget.clear()
        for band in bands:
            self.bandsListWidget.addItem(band)
            item = self.bandsListWidget.item(self.bandsListWidget.count() - 1)
            if band in selected_defaults:
                item.setSelected(True)

    def _aoi_mode_changed(self, text):
        t = (text or "").lower()
        if "select" in t:
            self.aoiStartDrawButton.setVisible(False)
            self.aoiClearButton.setVisible(False)
            if "layer" in t:
                if hasattr(self, "aoiLayerList"):
                    self.aoiLayerList.setVisible(True)
                    self._populate_aoi_layer_combo()
                self.aoiClearButton.setVisible(True)
                self._update_aoi_preview("AOI: select one polygon layer from the list.")
            elif hasattr(self, "aoiLayerList"):
                self.aoiLayerList.setVisible(False)
            return

        # Clear previous AOI before applying new mode
        self._clear_aoi()
        if hasattr(self, "aoiLayerList"):
            self.aoiLayerList.setVisible(False)

        # Hide the action button — action is triggered directly from dropdown
        self.aoiStartDrawButton.setVisible(False)
        self.aoiClearButton.setVisible(True)

        if "extent" in t:
            self._use_canvas_extent()
        elif "rectangle" in t:
            self._start_draw_rectangle()
        elif "layer" in t:
            if hasattr(self, "aoiLayerList"):
                self.aoiLayerList.setVisible(True)
                self._populate_aoi_layer_combo()
            self._update_aoi_preview("AOI: select one polygon layer from the list.")
        else:
            self._start_draw_polygon()

    def _aoi_action_clicked(self):
        mode = (self.aoiModeCombo.currentText() or "").lower()
        if "select" in mode:
            return
        if "extent" in mode:
            self._use_canvas_extent()
        elif "rectangle" in mode:
            self._start_draw_rectangle()
        else:
            self._start_draw_polygon()

    def _use_canvas_extent(self):
        canvas: QgsMapCanvas = self.iface.mapCanvas()
        if not canvas or not canvas.extent():
            QMessageBox.warning(self, "VirtuGhan", "No map canvas extent available.")
            return

        rect = canvas.extent()
        # visible AOI (map CRS)
        self._aoi.replace_geometry(QgsGeometry.fromRect(rect))
        # processing bbox (WGS84)
        self._aoi_bbox = rect_to_wgs84_bbox(rect, QgsProject.instance())
        self._aoi_polygon_wgs84 = None
        self._update_aoi_preview()

    def _start_draw_rectangle(self):
        canvas: QgsMapCanvas = self.iface.mapCanvas()
        if not canvas:
            QMessageBox.warning(self, "VirtuGhan", "Map canvas not available.")
            return

        self._prev_tool = canvas.mapTool()

        def _finish(rect: QgsRectangle | None):
            # Only restore previous tool if it's not a drawing tool
            from virtughan_qgis.common.aoi import AoiRectTool, AoiPolygonTool
            try:
                if self._prev_tool and not isinstance(self._prev_tool, (AoiRectTool, AoiPolygonTool)):
                    canvas.setMapTool(self._prev_tool)
                else:
                    canvas.setMapTool(None)  # Reset to default tool
            except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                canvas.setMapTool(None)
            # Restore cursor and message bar
            canvas.setCursor(QCursor(QtCompat.ArrowCursor))
            self.iface.messageBar().clearWidgets()
            
            if not rect or rect.isEmpty():
                _log(self, "AOI rectangle drawing canceled.")
                return

            self._aoi.replace_geometry(QgsGeometry.fromRect(rect))
            self._aoi_bbox = rect_to_wgs84_bbox(rect, QgsProject.instance())
            self._aoi_polygon_wgs84 = None
            self._update_aoi_preview()

        tool = AoiRectTool(canvas, _finish, stroke_color=self._aoi_stroke_color, fill_color=self._aoi_fill_color)
        self._drawing_tool = tool  # Keep reference for cleanup
        canvas.setMapTool(tool)
        # Show message and change cursor
        canvas.setCursor(QCursor(QtCompat.CrossCursor))
        self.iface.messageBar().pushInfo(
            "VirtuGhan",
            "Click and drag on the map to draw a rectangle"
        )
        _log(self, "Draw rectangle: press, drag, release to finish. Esc to cancel.")

    def _start_draw_polygon(self):
        canvas: QgsMapCanvas = self.iface.mapCanvas()
        if not canvas:
            QMessageBox.warning(self, "VirtuGhan", "Map canvas not available.")
            return

        self._prev_tool = canvas.mapTool()

        def _done(geom_map: QgsGeometry | None):
            # Only restore previous tool if it's not a drawing tool
            from virtughan_qgis.common.aoi import AoiRectTool, AoiPolygonTool
            try:
                if self._prev_tool and not isinstance(self._prev_tool, (AoiRectTool, AoiPolygonTool)):
                    canvas.setMapTool(self._prev_tool)
                else:
                    canvas.setMapTool(None)  # Reset to default tool
            except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                canvas.setMapTool(None)
            # Restore cursor and message bar
            canvas.setCursor(QCursor(QtCompat.ArrowCursor))
            self.iface.messageBar().clearWidgets()
            
            if geom_map is None or geom_map.isEmpty():
                _log(self, "AOI polygon drawing canceled.")
                return

            self._aoi.replace_geometry(geom_map)
            self._aoi_bbox = geom_to_wgs84_bbox(geom_map, QgsProject.instance())
            self._aoi_polygon_wgs84 = self._compute_polygon_wgs84_coords(geom_map)
            self._update_aoi_preview()

        tool = AoiPolygonTool(canvas, _done, stroke_color=self._aoi_stroke_color, fill_color=self._aoi_fill_color)
        self._drawing_tool = tool  # Keep reference for cleanup
        canvas.setMapTool(tool)
        # Show message and change cursor
        canvas.setCursor(QCursor(QtCompat.CrossCursor))
        self.iface.messageBar().pushInfo(
            "VirtuGhan",
            "Left-click to add points, right-click or double-click to finish"
        )
        _log(self, "Draw polygon: left-click to add, right-click/Enter/double-click to finish, Esc to cancel.")

    def _compute_polygon_wgs84_coords(self, geom_map: QgsGeometry):
        """Return outer ring coords as [[lon, lat], ...] in WGS84 for the given map-CRS geometry."""
        try:
            g = QgsGeometry(geom_map)  # clone
            g.transform(QgsCoordinateTransform(
                QgsProject.instance().crs(),
                QgsCoordinateReferenceSystem("EPSG:4326"),
                QgsProject.instance()
            ))
            poly = g.asPolygon()
            if not poly:
                mp = g.asMultiPolygon()
                ring = mp[0][0] if mp else []
            else:
                ring = poly[0]
            return [[float(p.x()), float(p.y())] for p in ring]
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            return None

    def _clear_aoi(self):
        # Reset drawing tool if active
        canvas: QgsMapCanvas = self.iface.mapCanvas()
        if canvas:
            # Clean up any rubber band from the drawing tool
            if hasattr(self, '_drawing_tool') and self._drawing_tool and hasattr(self._drawing_tool, 'rb'):
                try:
                    self._drawing_tool.rb.reset()
                except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                    pass
            # Restore previous map tool
            if self._prev_tool:
                canvas.setMapTool(self._prev_tool)
            canvas.setCursor(QCursor(QtCompat.ArrowCursor))
            self.iface.messageBar().clearWidgets()
        
        self._drawing_tool = None  # Clear reference
        self._aoi_bbox = None
        self._aoi_polygon_wgs84 = None
        self._batch_aoi_specs = []
        self._update_aoi_preview()
        try:
            self._aoi.clear()
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

    def _archive_current_aoi(self):
        self._aoi.archive_current()
        self._aoi_bbox = None
        self._aoi_polygon_wgs84 = None
        self._batch_aoi_specs = []
        self._update_aoi_preview("AOI used for download. Select or draw a new AOI to continue.")
        self._populate_aoi_layer_combo()

    def _update_aoi_preview(self, text=None):
        if text:
            self.aoiPreviewLabel.setText(text)
            return
        if self._aoi_bbox:
            x1, y1, x2, y2 = self._aoi_bbox
            _level, area = aoi_size_level(self._aoi_bbox)
            self.aoiPreviewLabel.setText(
                f"AOI (EPSG:4326): ({x1:.6f}, {y1:.6f}, {x2:.6f}, {y2:.6f}) | approx. {area:,.0f} km2"
            )
        else:
            self.aoiPreviewLabel.setText("<i>AOI: not set yet. Use smaller aoi and test first.</i>")

    def _warn_for_aoi_size(self, *, interactive: bool) -> bool:
        level, area = aoi_size_level(self._aoi_bbox)
        if level == "large":
            _log(
                self,
                f"AOI is large (approx. {area:,.0f} km2). Download may be slow; consider reducing AOI size.",
                QgisCompat.Warning,
            )
            return True
        if level == "very_large":
            message = (
                f"The selected AOI is very large (approx. {area:,.0f} km2).\n\n"
                "This can take a long time and may fail because of download size, memory use, or remote service limits.\n\n"
                "Reduce the AOI size for a more reliable download."
            )
            _log(self, message.replace("\n", " "), QgisCompat.Warning)
            if not interactive:
                return True
            reply = QMessageBox.question(
                self,
                "VirtuGhan",
                message + "\n\nContinue anyway?",
                QMessageBoxCompat.Yes | QMessageBoxCompat.No,
                QMessageBoxCompat.No,
            )
            return reply == QMessageBoxCompat.Yes
        return True

    def _show_no_scenes_dialog(self, *, min_count: int = 1, found_count: int = 0):
        if found_count <= 0:
            text = (
                "No matching images were found for the selected AOI, date range, dataset, and filters.\n\n"
                "Try another AOI, expand the date range, or adjust the cloud/filter settings."
            )
        else:
            text = (
                f"Only {found_count} matching image(s) were found. This operation needs at least {min_count}.\n\n"
                "Try expanding the date range or adjusting the filters."
            )
        QMessageBox.warning(self, "VirtuGhan", text)

    def _open_help(self):
        host = self.window()
        if host and hasattr(host, "show_help_for"):
            host.show_help_for("extractor")
            return

    def _browse_output(self):
        folder = QFileDialog.getExistingDirectory(self, "Select Output Folder")
        if folder:
            self.outputPathEdit.setText(folder)

    def _reset_form(self):
        self._aoi_bbox = None
        self._aoi_polygon_wgs84 = None
        self._batch_aoi_specs = []
        self._update_aoi_preview("AOI: not set yet. Use smaller aoi and test first.")
        try:
            self._aoi.clear()
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass
        if self.commonWidget and hasattr(self.commonWidget, "reset"):
            try:
                self.commonWidget.reset()
            except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                pass
        self.smartFilterCheck.setChecked(False)
        self.workersSpin.setValue(self._recommended_default_workers())

    def _recommended_default_workers(self) -> int:
        cpu_count = os.cpu_count() or 1
        return 2 if cpu_count <= 2 else 4

    def _effective_workers(self) -> int:
        return max(1, int(self.workersSpin.value()))

    def _collect_params(self):
        if not self._aoi_bbox:
            raise RuntimeError("Please set AOI before running.")

        # Strict WGS84 bbox validation
        b = self._aoi_bbox
        try:
            x1, y1, x2, y2 = map(float, b)
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            raise RuntimeError(f"AOI bbox must be four numbers: {b}")
        if not (-180.0 <= x1 < x2 <= 180.0 and -90.0 <= y1 < y2 <= 90.0):
            raise RuntimeError(f"Invalid AOI bbox (WGS84): {b}")

        p = self._get_common_params()
        sdt = QDate.fromString(p["start_date"], "yyyy-MM-dd")
        edt = QDate.fromString(p["end_date"], "yyyy-MM-dd")
        if not sdt.isValid() or not edt.isValid():
            raise RuntimeError("Please pick valid start/end dates.")
        if sdt > edt:
            raise RuntimeError("Start date must be before end date.")

        selected_items = self.bandsListWidget.selectedItems()
        bands_list = [i.text().strip() for i in selected_items if i.text().strip()]
        if not bands_list:
            raise RuntimeError("Please select at least one band to extract.")

        zip_out = False
        smart = self.smartFilterCheck.isChecked()

        workers = self._effective_workers()
        out_base = (
            self.outputPathEdit.text() or ""
        ).strip() or QgsProcessingUtils.tempFolder()
        out_dir = os.path.join(out_base, f"virtughan_extractor_{uuid.uuid4().hex[:8]}")

        params = dict(
            bbox=self._aoi_bbox,
            start_date=p["start_date"],
            end_date=p["end_date"],
            cloud_cover=int(p["cloud_cover"]),
            collection=normalize_collection(p.get("collection")),
            extra_query=extra_query_for_collection(p.get("collection")),
            bands_list=bands_list,
            zip_output=zip_out,
            smart_filter=smart,
            workers=workers,
            output_dir=out_dir,
        )

        if self._aoi_polygon_wgs84:
            params["polygon_wgs84"] = self._aoi_polygon_wgs84

        return params

    def _run_clicked(self):
        if self._batch_state is not None:
            self._cancel_current_run()
            self._batch_state["cancelled"] = True
            return
        try:
            if self._current_task is not None:
                status = self._current_task.status()
                if status in (QgisCompat.Queued, QgisCompat.Running):
                    self._cancel_current_run()
                    return
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

        if not ensure_runtime_network_ready(self):
            _log(self, "Runtime network preflight failed; run cancelled.", QgisCompat.Warning)
            return

        if len(self._batch_aoi_specs or []) > 1:
            self._run_batch_clicked()
            return

        try:
            _log(self, "Pre-download stage started: checking matching scenes before download...")
            if not self._warn_for_aoi_size(interactive=True):
                return
            self._validate_minimum_matching_scenes(min_count=1, timeout_s=120.0)
            _log(self, "Pre-download stage completed: scene check passed.")
            params = self._collect_params()
        except TimeoutError:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            _log(self, "Pre-download scene check timed out after 2 minutes.", QgisCompat.Warning)
            QMessageBox.warning(
                self,
                "VirtuGhan",
                "Pre-download stage timed out after 2 minutes before download started.\n\n"
                "Please try again later.",
            )
            return
        except Exception as e:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            if "matching scene" not in str(e).lower():
                QMessageBox.warning(self, "VirtuGhan", str(e))
            return

        out_dir = params["output_dir"]
        try:
            os.makedirs(out_dir, exist_ok=True)
        except Exception as e:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            QMessageBox.critical(
                self, "VirtuGhan", f"Cannot create output folder:\n{out_dir}\n\n{e}"
            )
            return

        log_path = os.path.join(out_dir, "runtime.log")
        _log(self, f"Output: {out_dir}")
        _log(self, f"Log file: {log_path}")
        try:
            open(log_path, "a", encoding="utf-8").close()
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

        self._set_running(True)
        self._start_tailing(log_path)
        self._focus_log_section()
        self._has_successful_run = False
        self._last_output_layer_ids = []
        _log(self, "Download handoff: starting backend task.")

        def _on_done(ok, exc):
            self._stop_tailing()
            self._set_running(False)
            task_was_cancelled = False
            try:
                task_was_cancelled = bool(self._current_task and self._current_task.isCanceled())
            except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                task_was_cancelled = False
            self._current_task = None
            if isinstance(exc, _TaskCancelledError) or task_was_cancelled:
                _log(self, "Download cancelled by user.", QgisCompat.Warning)
                QMessageBox.information(self, "VirtuGhan", "Download cancelled.")
                return
            if not ok or exc:
                _log(self, f"Extractor failed: {exc}", QgisCompat.Critical)
                QMessageBox.critical(
                    self,
                    "VirtuGhan",
                    f"Extractor failed:\n{exc}\n\nSee runtime.log for details.",
                )
            else:
                added = 0
                loaded_layer_ids = []
                for root, _dirs, files in os.walk(out_dir):
                    for fn in files:
                        if fn.lower().endswith((".tif", ".tiff", ".vrt")):
                            path = os.path.join(root, fn)
                            lyr = QgsRasterLayer(path, os.path.splitext(fn)[0], "gdal")
                            if lyr.isValid():
                                QgsProject.instance().addMapLayer(lyr)
                                loaded_layer_ids.append(lyr.id())
                                _log(self, f"Loaded raster: {path}")
                                added += 1
                            else:
                                _log(self, f"Failed to load raster: {path}", QgisCompat.Warning)
                if added == 0:
                    _log(self, "Download completed, but no .tif/.tiff/.vrt output images were found.", QgisCompat.Warning)
                self._last_output_layer_ids = loaded_layer_ids

                self._has_successful_run = added > 0
                if self.showSceneFootprintsCheck.isChecked():
                    scenes_for_map = self._resolve_scenes_for_main_map_footprints()
                    count = self._render_scene_footprints(scenes_for_map, below_layer_ids=self._last_output_layer_ids)
                    if count > 0:
                        _log(self, f"Scene footprints layer added to main map after run completion ({count} scenes).")
                    else:
                        _log(self, "No scene footprints were added (no scenes found for current AOI/date/cloud filters).", QgisCompat.Warning)
                else:
                    self._clear_scene_footprints_layer()

                self._archive_current_aoi()
                if added == 0:
                    QMessageBox.warning(
                        self,
                        "VirtuGhan",
                        "Download completed, but no output images were found for the selected AOI and filters.\n\n"
                        "Try a smaller AOI, a different AOI, or a different date/filter selection.\n\n"
                        f"Output folder checked:\n{out_dir}",
                    )
                else:
                    QMessageBox.information(
                        self, "VirtuGhan", f"Extractor finished.\nOutput: {out_dir}"
                    )

        self._current_task = _ExtractorTask(
            "VirtuGhan Extractor", params, log_path, on_done=_on_done
        )
        QgsApplication.taskManager().addTask(self._current_task)
        _log(self, "Download handoff: backend task queued.")

    def _run_batch_clicked(self):
        try:
            if not self._warn_for_aoi_size(interactive=True):
                return
            base_params = self._collect_params()
        except Exception as e:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            QMessageBox.warning(self, "VirtuGhan", str(e))
            return

        out_base = (self.outputPathEdit.text() or "").strip() or QgsProcessingUtils.tempFolder()
        run_id = uuid.uuid4().hex[:8]
        root_dir = batch_root(out_base, "virtughan_extractor", run_id)
        specs = list(self._batch_aoi_specs or [])
        self._batch_state = {
            "specs": specs,
            "index": 0,
            "completed": 0,
            "skipped": 0,
            "failed": 0,
            "cancelled": False,
            "root_dir": root_dir,
        }
        self._set_running(True)
        self._focus_log_section()
        _log(self, f"Batch download started: {len(specs)} polygon features. Root: {root_dir}")
        self._start_next_batch_download(base_params)

    def _start_next_batch_download(self, base_params):
        state = self._batch_state
        if not state:
            return
        if state.get("cancelled"):
            self._finish_batch_download(cancelled=True)
            return
        if state["index"] >= len(state["specs"]):
            self._finish_batch_download(cancelled=False)
            return

        spec = state["specs"][state["index"]]
        state["index"] += 1
        label = spec.get("label") or f"feature_{state['index']:03d}"
        _log(self, f"[Batch {state['index']}/{len(state['specs'])}] Checking scenes for {label}...")

        params = dict(base_params)
        params["bbox"] = spec["bbox"]
        params["polygon_wgs84"] = spec.get("polygon_wgs84")
        params["output_dir"] = os.path.join(state["root_dir"], spec.get("folder_name") or f"feature_{state['index']:03d}")

        try:
            scenes = self._search_scenes_with_timeout(
                params["bbox"],
                params["start_date"],
                params["end_date"],
                params["cloud_cover"],
                params["collection"],
                params.get("extra_query"),
                timeout_s=120.0,
            )
            if len(scenes or []) < 1:
                state["skipped"] += 1
                _log(self, f"[Batch {state['index']}] No matching images found; skipped.", QgisCompat.Warning)
                self._start_next_batch_download(base_params)
                return
        except Exception as exc:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            state["failed"] += 1
            _log(self, f"[Batch {state['index']}] Scene search failed: {exc}", QgisCompat.Warning)
            self._start_next_batch_download(base_params)
            return

        try:
            os.makedirs(params["output_dir"], exist_ok=True)
            log_path = os.path.join(params["output_dir"], "runtime.log")
            open(log_path, "a", encoding="utf-8").close()
        except Exception as exc:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            state["failed"] += 1
            _log(self, f"[Batch {state['index']}] Cannot create output folder: {exc}", QgisCompat.Warning)
            self._start_next_batch_download(base_params)
            return

        self._stop_tailing()
        self._start_tailing(log_path)
        _log(self, f"[Batch {state['index']}/{len(state['specs'])}] Download started for {label}.")

        def _on_done(ok, exc):
            self._stop_tailing()
            task_was_cancelled = False
            try:
                task_was_cancelled = bool(self._current_task and self._current_task.isCanceled())
            except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                pass
            self._current_task = None
            if isinstance(exc, _TaskCancelledError) or task_was_cancelled or state.get("cancelled"):
                state["cancelled"] = True
                self._finish_batch_download(cancelled=True)
                return
            if not ok or exc:
                state["failed"] += 1
                _log(self, f"[Batch {state['index']}] Download failed for {label}: {exc}", QgisCompat.Warning)
            else:
                state["completed"] += 1
                self._load_rasters_from_dir(params["output_dir"])
                _log(self, f"[Batch {state['index']}] Download completed for {label}.")
            self._start_next_batch_download(base_params)

        self._current_task = _ExtractorTask("VirtuGhan Extractor Batch", params, log_path, on_done=_on_done)
        QgsApplication.taskManager().addTask(self._current_task)

    def _finish_batch_download(self, *, cancelled: bool):
        state = self._batch_state or {}
        self._batch_state = None
        self._current_task = None
        self._stop_tailing()
        self._set_running(False)
        if state.get("completed", 0) > 0:
            self._archive_current_aoi()
        msg = (
            f"Batch download {'cancelled' if cancelled else 'finished'}.\n\n"
            f"Completed: {state.get('completed', 0)}\n"
            f"Skipped: {state.get('skipped', 0)}\n"
            f"Failed: {state.get('failed', 0)}\n\n"
            f"Output: {state.get('root_dir', '')}"
        )
        _log(self, msg.replace("\n", " "))
        QMessageBox.information(self, "VirtuGhan", msg)

    def _load_rasters_from_dir(self, out_dir):
        loaded_layer_ids = []
        for root, _dirs, files in os.walk(out_dir):
            for fn in files:
                if fn.lower().endswith((".tif", ".tiff", ".vrt")):
                    path = os.path.join(root, fn)
                    lyr = QgsRasterLayer(path, os.path.splitext(fn)[0], "gdal")
                    if lyr.isValid():
                        QgsProject.instance().addMapLayer(lyr)
                        loaded_layer_ids.append(lyr.id())
                        _log(self, f"Loaded raster: {path}")
                    else:
                        _log(self, f"Failed to load raster: {path}", QgisCompat.Warning)
        self._last_output_layer_ids = loaded_layer_ids
        return loaded_layer_ids

    def _start_tailing(self, log_path: str):
        self._current_log_path = log_path
        self._tailer = _UiLogTailer(
            log_path,
            self.logText,
            interval_ms=400,
        )
        self._tailer.start()

    def _focus_log_section(self):
        try:
            self.logText.setFocus(QtCompat.OtherFocusReason)
            sb = self.logText.verticalScrollBar()
            sb.setValue(sb.maximum())
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

        # Scroll the outer page scroll area to the bottom so the log is visible
        try:
            parent = self.logText.parentWidget()
            while parent is not None:
                if isinstance(parent, QScrollArea):
                    vsb = parent.verticalScrollBar()
                    if vsb:
                        vsb.setValue(vsb.maximum())
                    break
                parent = parent.parentWidget()
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

    def _stop_tailing(self):
        if self._tailer:
            self._tailer.stop()
            self._tailer = None
        self._current_log_path = None

    def _set_running(self, running: bool):
        self.progressBar.setVisible(running)
        self.progressBar.setRange(0, 0 if running else 1)
        self.runButton.setEnabled(True)
        self.runButton.setText("Cancel" if running else self._default_run_button_text)
        self.resetButton.setEnabled(not running)
        try:
            host = self.window()
            if host and hasattr(host, "set_tab_busy"):
                host.set_tab_busy("extractor", running)
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass
        for w in (self.aoiStartDrawButton, self.aoiClearButton,
                  self.aoiModeCombo, self.outputBrowseButton,
                  self.showSceneFootprintsCheck):
            try:
                w.setEnabled(not running)
            except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                pass
        try:
            if getattr(self, "aoiLayerList", None) is not None:
                self.aoiLayerList.setEnabled(not running)
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass
        try:
            self.previewScenesButton.setEnabled(True)
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

    def _cancel_current_run(self):
        task = self._current_task
        if task is None:
            return
        try:
            status = task.status()
            if status in (QgisCompat.Queued, QgisCompat.Running):
                task.cancel()
                _log(self, "Cancellation requested...", QgisCompat.Warning)
        except Exception as e:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            _log(self, f"Failed to cancel extractor task: {e}", QgisCompat.Warning)

    def _on_show_scene_footprints_toggled(self, checked: bool):
        if not checked:
            self._clear_scene_footprints_layer()
            return
        if self._has_successful_run:
            scenes_for_map = self._resolve_scenes_for_main_map_footprints()
            count = self._render_scene_footprints(scenes_for_map, below_layer_ids=self._last_output_layer_ids)
            if count > 0:
                _log(self, f"Scene footprints layer added to main map ({count} scenes).")
            else:
                _log(self, "No scene footprints were added (no scenes found for current AOI/date/cloud filters).", QgisCompat.Warning)
        else:
            _log(self, "Scene footprints will be added after a successful run.")

    def _resolve_scenes_for_main_map_footprints(self):
        scenes = list(self._selected_preview_scenes or [])
        if scenes:
            return scenes

        try:
            params = self._collect_search_params()
            scenes = search_stac_features(
                params["collection"],
                params["bbox"],
                params["start_date"],
                params["end_date"],
                params["cloud_cover"],
                extra_query=params.get("extra_query"),
            )
            self._selected_preview_scenes = scenes or []
            _log(self, f"Loaded {len(self._selected_preview_scenes)} scenes from current filters for main-map footprints.")
        except Exception as e:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            _log(self, f"Failed to fetch scenes for main-map footprints: {e}", QgisCompat.Warning)
            return []

        return list(self._selected_preview_scenes)

    def _clear_scene_footprints_layer(self):
        if self._scene_footprints_layer and self._scene_footprints_layer.isValid():
            try:
                QgsProject.instance().removeMapLayer(self._scene_footprints_layer.id())
            except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                pass
        self._scene_footprints_layer = None
        try:
            self.iface.mapCanvas().refresh()
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

    def _stac_geometry_to_qgs(self, geom_obj):
        if not isinstance(geom_obj, dict):
            return None
        gtype = geom_obj.get("type")
        coords = geom_obj.get("coordinates")
        if not coords:
            return None
        try:
            if gtype == "Polygon":
                rings = []
                for ring in coords:
                    rings.append([QgsPointXY(float(x), float(y)) for x, y in ring])
                return QgsGeometry.fromPolygonXY(rings)
            if gtype == "MultiPolygon":
                polys = []
                for poly in coords:
                    rings = []
                    for ring in poly:
                        rings.append([QgsPointXY(float(x), float(y)) for x, y in ring])
                    polys.append(rings)
                return QgsGeometry.fromMultiPolygonXY(polys)
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            return None
        return None

    def _collect_search_params(self):
        if not self._aoi_bbox:
            raise RuntimeError("Set AOI first (Map extent / Draw rectangle / Draw polygon).")
        p = self._get_common_params()
        if not p.get("start_date") or not p.get("end_date"):
            raise RuntimeError("Start and End dates are required.")
        return {
            "bbox": self._aoi_bbox,
            "start_date": p["start_date"],
            "end_date": p["end_date"],
            "cloud_cover": int(p.get("cloud_cover", 30)),
            "collection": normalize_collection(p.get("collection")),
            "extra_query": extra_query_for_collection(p.get("collection")),
        }

    def _search_scenes_with_timeout(
        self,
        bbox,
        start_date,
        end_date,
        cloud_cover,
        collection="sentinel-2-l2a",
        extra_query=None,
        timeout_s: float = 30.0,
    ):
        result = {"scenes": None, "error": None}

        def _worker():
            try:
                result["scenes"] = search_stac_features(
                    collection,
                    bbox,
                    start_date,
                    end_date,
                    cloud_cover,
                    extra_query=extra_query,
                )
            except Exception as exc:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                result["error"] = exc

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

        deadline = time.time() + max(1.0, float(timeout_s))
        started_at = time.time()
        next_progress_log_at = started_at + 15.0
        while t.is_alive() and time.time() < deadline:
            now = time.time()
            if now >= next_progress_log_at:
                elapsed = int(now - started_at)
                remaining = max(0, int(deadline - now))
                _log(
                    self,
                    f"Pre-download scene check still running ({elapsed}s elapsed, {remaining}s remaining)...",
                    QgisCompat.Info,
                )
                next_progress_log_at = now + 15.0
            try:
                QgsApplication.processEvents()
            except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                pass
            time.sleep(0.05)

        if t.is_alive():
            raise TimeoutError(
                f"Scene search timed out after {int(max(1.0, float(timeout_s)))}s. "
                "Please check internet/VPN/proxy and try again."
            )
        if result["error"] is not None:
            raise result["error"]
        return list(result["scenes"] or [])

    def _validate_minimum_matching_scenes(self, min_count: int = 1, timeout_s: float = 30.0):
        params = self._collect_search_params()
        scenes = self._search_scenes_with_timeout(
            params["bbox"],
            params["start_date"],
            params["end_date"],
            params["cloud_cover"],
            params["collection"],
            params.get("extra_query"),
            timeout_s=timeout_s,
        )
        count = len(scenes or [])
        if count < min_count:
            self._show_no_scenes_dialog(min_count=min_count, found_count=count)
            raise RuntimeError(
                "Extractor requires at least 1 matching scene for the selected filters. "
                f"Found {count} scene(s). "
                "Try expanding the date range or increasing cloud threshold."
            )

    def _render_scene_footprints(self, scenes, below_layer_ids=None):
        self._clear_scene_footprints_layer()
        if not scenes:
            return 0
        project = QgsProject.instance()
        dst_crs = project.crs()
        layer = QgsVectorLayer(f"Polygon?crs={dst_crs.authid()}", "Extractor Scene Footprints", "memory")
        prov = layer.dataProvider()
        prov.addAttributes([
            QgsField("scene_id", QVariant.String),
            QgsField("datetime", QVariant.String),
            QgsField("cloud", QVariant.Double),
            QgsField("collection", QVariant.String),
        ])
        layer.updateFields()

        xform = QgsCoordinateTransform(QgsCoordinateReferenceSystem("EPSG:4326"), dst_crs, project)
        feats = []
        for scene in scenes:
            geom = self._stac_geometry_to_qgs(scene.get("geometry"))
            if geom is None or geom.isEmpty():
                continue
            try:
                geom.transform(xform)
            except Exception:  # nosec B110,B112 - defensive QGIS cleanup or optional API fallback.
                continue
            props = scene.get("properties", {}) or {}
            feat = QgsFeature(layer.fields())
            feat.setGeometry(geom)
            feat.setAttributes([
                str(scene.get("id", "")),
                str(props.get("datetime", "")),
                float(props.get("eo:cloud_cover", -1) or -1),
                str(scene.get("collection", "")),
            ])
            feats.append(feat)

        if not feats:
            _log(self, "No valid scene footprint geometries found.", QgisCompat.Warning)
            return 0

        prov.addFeatures(feats)
        layer.updateExtents()
        try:
            sym = layer.renderer().symbol()
            sym.setColor(QColor(0, 150, 136, 10))
            sym.symbolLayer(0).setStrokeColor(QColor(0, 150, 136, 120))
            sym.symbolLayer(0).setStrokeWidth(0.35)
            layer.triggerRepaint()
            layer.emitStyleChanged()
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

        project = QgsProject.instance()
        project.addMapLayer(layer, False)
        root = project.layerTreeRoot()
        insert_idx = len(root.children())
        if below_layer_ids:
            ref_indices = []
            for ref_id in below_layer_ids:
                ref_node = root.findLayer(ref_id)
                if ref_node is None:
                    continue
                try:
                    ref_indices.append(root.children().index(ref_node))
                except ValueError:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                    pass
            if ref_indices:
                insert_idx = max(ref_indices) + 1
        root.insertLayer(insert_idx, layer)
        self._scene_footprints_layer = layer
        try:
            self.iface.mapCanvas().refresh()
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass
        return len(feats)

    def _preview_matching_scenes(self):
        try:
            params = self._collect_search_params()
            if not self._warn_for_aoi_size(interactive=True):
                return
        except Exception as e:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            QMessageBox.warning(self, "VirtuGhan", str(e))
            return

        self.progressBar.setVisible(True)
        self.progressBar.setRange(0, 0)
        original_btn_text = self.previewScenesButton.text()
        self.previewScenesButton.setText("Loading Preview…")
        self.previewScenesButton.setEnabled(False)
        QgsApplication.processEvents()
        _log(self, "Searching matching scenes...")
        try:
            scenes = search_stac_features(
                params["collection"],
                params["bbox"],
                params["start_date"],
                params["end_date"],
                params["cloud_cover"],
                extra_query=params.get("extra_query"),
            )
            _log(self, f"Matching scenes found: {len(scenes)}")
            if not scenes:
                self._show_no_scenes_dialog(min_count=1, found_count=0)
                return

            aoi_geom, aoi_crs_authid = self._get_current_aoi_geometry_for_preview()

            dlg = ScenePreviewDialog(
                parent=self,
                scenes=scenes,
                title="Extractor Scene Preview",
                fill_color=QColor(0, 150, 136, 14),
                stroke_color=QColor(0, 150, 136, 170),
                aoi_geometry=aoi_geom,
                aoi_crs_authid=aoi_crs_authid,
                aoi_fill_color=self._aoi_fill_color,
                aoi_stroke_color=self._aoi_stroke_color,
            )
            _exec = getattr(dlg, 'exec', None) or getattr(dlg, 'exec_')
            _exec()

            selected_scenes = dlg.selected_scenes()
            self._selected_preview_scenes = selected_scenes
            _log(self, f"Selected scenes in preview: {len(selected_scenes)}")
            _log(self, "Preview is temporary; main map footprints update only after run completion.")
        except Exception as e:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            _log(self, f"Scene search failed: {e}", QgisCompat.Critical)
            QMessageBox.critical(self, "VirtuGhan", f"Scene search failed:\n{e}")
        finally:
            self.progressBar.setVisible(False)
            self.progressBar.setRange(0, 1)
            self.previewScenesButton.setText(original_btn_text)
            self.previewScenesButton.setEnabled(True)

    def _get_current_aoi_geometry_for_preview(self):
        try:
            layer = getattr(self._aoi, "layer", None)
            if not layer or not layer.isValid():
                return None, None

            feat = None
            for candidate in layer.getFeatures():
                feat = candidate
                break
            if feat is None:
                return None, None

            geom = feat.geometry()
            if geom is None or geom.isEmpty():
                return None, None

            return QgsGeometry(geom), layer.crs().authid()
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            return None, None

    def _teardown_runtime_state(self):
        try:
            self._disconnect_aoi_layer_project_signals()
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

        try:
            self._stop_tailing()
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

        try:
            if self._current_task is not None:
                try:
                    self._current_task.on_done = None
                except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                    pass
                try:
                    self._current_task.cancel()
                except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
                    pass
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass
        self._current_task = None

        try:
            self._clear_scene_footprints_layer()
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

        try:
            self._clear_aoi()
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

        try:
            canvas = self.iface.mapCanvas()
            if canvas is not None:
                from virtughan_qgis.common.aoi import AoiRectTool, AoiPolygonTool

                active_tool = canvas.mapTool()
                if isinstance(active_tool, (AoiRectTool, AoiPolygonTool)):
                    canvas.setMapTool(None)
                canvas.setCursor(QCursor(QtCompat.ArrowCursor))
            self.iface.messageBar().clearWidgets()
        except Exception:  # nosec B110 - defensive QGIS cleanup or optional API fallback.
            pass

        self._drawing_tool = None
        self._prev_tool = None

    def closeEvent(self, event):
        self._teardown_runtime_state()
        super().closeEvent(event)
