# virtughan_qgis/extractor/extractor_widget.py
import os
import sys
import time
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,
    QMessageBox,
    QPlainTextEdit,
    QProgressBar,
    QPushButton,
    QSpinBox,
    QScrollArea,
    QVBoxLayout,
    QWidget,
)

from ..common.aoi import (
    AoiManager,
    AoiRectTool,
    AoiPolygonTool,
    rect_to_wgs84_bbox,
    geom_to_wgs84_bbox,
)
from ..common.scene_preview_dialog import ScenePreviewDialog
from ..bootstrap import (
    RUNTIME_ROOT,
    RUNTIME_SITE_PACKAGES_DIR,
    RUNTIME_FALLBACK_ROOT,
    RUNTIME_FALLBACK_SITE_PACKAGES_DIR,
    activate_runtime_paths,
    ensure_runtime_network_ready,
)

activate_runtime_paths()

COMMON_IMPORT_ERROR = None
CommonParamsWidget = None
try:
    from ..common.common_widget import CommonParamsWidget
except Exception as _e:
    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

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:
        pass

    if selected_proj_path:
        try:
            from pyproj import datadir as _pyproj_datadir
            _pyproj_datadir.set_data_dir(selected_proj_path)
            if logger:
                logger(f"Pinned pyproj data dir: {selected_proj_path}")
        except Exception as datadir_exc:
            if logger:
                logger(f"Could not pin pyproj data dir: {datadir_exc}")

    _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
    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 = {
        "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),
        "log_file": logf,
    }
    if params.get("polygon_wgs84"):
        backend_args["polygon_wgs84"] = params["polygon_wgs84"]

    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)
    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:
                    break
            sys.path.insert(0, dep_path)

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

        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

        import inspect
        backend_args = {
            "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": workers,
            "zip_output": params.get("zip_output", False),
            "smart_filter": params.get("smart_filter", True),
            "log_file": logf,
        }
        if params.get("polygon_wgs84"):
            backend_args["polygon_wgs84"] = params["polygon_wgs84"]

        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)
        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),
        "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()
        
        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:
                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,
                )
                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,
            )
            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)
        stdout_data = ""
        stderr_data = ""
        try:
            while True:
                if callable(should_cancel) and should_cancel():
                    try:
                        proc.terminate()
                    except Exception:
                        pass
                    try:
                        proc.wait(timeout=2.0)
                    except Exception:
                        try:
                            proc.kill()
                        except Exception:
                            pass
                    raise _TaskCancelledError("Download cancelled by user.")

                if proc.poll() is not None:
                    break
                time.sleep(0.2)

            stdout_data, stderr_data = proc.communicate(timeout=5)
        finally:
            if proc.poll() is None:
                try:
                    proc.kill()
                except Exception:
                    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:
            stderr_tail = (stderr_data or "").strip()
            if stderr_tail:
                stderr_tail = stderr_tail[-800:]
            raise RuntimeError(
                f"Extractor subprocess failed (exit code {proc.returncode}). "
                f"python={python_exe}. stderr_tail={stderr_tail}"
            )
    finally:
        try:
            shutil.rmtree(work_dir, ignore_errors=True)
        except Exception:
            pass


def _log(widget, msg, level=Qgis.Info):
    QgsMessageLog.logMessage(str(msg), "VirtuGhan", level)
    try:
        widget.logText.appendPlainText(str(msg))
    except Exception:
        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:
            return
        if p not in install_roots:
            install_roots.append(p)

    def _add_candidate(path_value):
        try:
            p = Path(path_value)
        except Exception:
            return
        candidates.append(p)

    def _is_within_install_roots(candidate_path: Path) -> bool:
        try:
            resolved = candidate_path.resolve()
        except Exception:
            return False
        for root in install_roots:
            try:
                resolved.relative_to(root)
                return True
            except Exception:
                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:
            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:
            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:
                        pass
                    break
        except Exception:
            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:
                pass

    if sys.executable:
        _add_candidate(sys.executable)

    seen: set[str] = set()
    for candidate in candidates:
        try:
            resolved = candidate.resolve()
        except Exception:
            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:
            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, QgsTask.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:
            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=Qgis.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")
                try:
                    from pyproj import datadir as _pyproj_datadir
                    logf.write(f"[INFO] pyproj_data_dir={_pyproj_datadir.get_data_dir()}\n")
                except Exception as datadir_exc:
                    logf.write(f"[INFO] pyproj_data_dir=unavailable ({datadir_exc})\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:
                    _run_extractor_in_subprocess(
                        self.params,
                        self.log_path,
                        logf=logf,
                        should_cancel=self.isCanceled,
                    )
            return True
        except _TaskCancelledError as e:
            self.exc = e
            return False
        except Exception as e:
            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:
                pass
            return False

    def finished(self, ok):
        if self.on_done:
            try:
                self.on_done(ok, self.exc)
            except Exception:
                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:
            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"))
                    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:
                                pass
        except Exception:
            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()
        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], ...]
        # Green colors for Extractor 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="Extractor AOI", fill_color=self._aoi_fill_color, stroke_color=self._aoi_stroke_color)
        self._prev_tool = None

        self._init_common_widget()
        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"])
        self.aoiUseCanvasButton.setVisible(False)  # hide legacy button

        self.aoiStartDrawButton.clicked.connect(self._aoi_action_clicked)
        self.aoiClearButton.clicked.connect(self._clear_aoi)
        self.aoiModeCombo.currentTextChanged.connect(self._aoi_mode_changed)

        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._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:
            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 _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(Qt.AlignCenter)
            logo_label.setPixmap(px.scaled(24, 24, Qt.KeepAspectRatio, Qt.SmoothTransformation))

            idx = header_layout.indexOf(title_label)
            header_layout.insertWidget(max(0, idx), logo_label)
            header_layout.setSpacing(6)
        except Exception:
            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}", Qgis.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:
                        pass
                try:
                    grid_layout.setVerticalSpacing(2)
                except Exception:
                    pass
        except Exception:
            pass

        # Optional: nudge layouts to recompute sizes
        try:
            w.updateGeometry()
            w.setMinimumHeight(w.sizeHint().height())
            self.ui_root.adjustSize()
        except Exception:
            pass



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

    def _aoi_mode_changed(self, text):
        t = (text or "").lower()
        if "select" in t:
            self.aoiStartDrawButton.setVisible(False)
            self.aoiClearButton.setVisible(False)
            return

        self.aoiStartDrawButton.setVisible(True)
        self.aoiClearButton.setVisible(True)

        if "extent" in t:
            self.aoiStartDrawButton.setText("Use Canvas Extent")
            self.aoiStartDrawButton.setToolTip("Capture current map canvas extent")
        elif "rectangle" in t:
            self.aoiStartDrawButton.setText("Draw Rectangle")
            self.aoiStartDrawButton.setToolTip("Press, drag, release to draw a rectangle")
        else:
            self.aoiStartDrawButton.setText("Draw Polygon")
            self.aoiStartDrawButton.setToolTip("Left-click to add vertices; right-click/Enter/double-click to finish")

    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:
                canvas.setMapTool(None)
            # Restore cursor and message bar
            canvas.setCursor(QCursor(Qt.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(Qt.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:
                canvas.setMapTool(None)
            # Restore cursor and message bar
            canvas.setCursor(QCursor(Qt.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(Qt.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:
            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:
                    pass
            # Restore previous map tool
            if self._prev_tool:
                canvas.setMapTool(self._prev_tool)
            canvas.setCursor(QCursor(Qt.ArrowCursor))
            self.iface.messageBar().clearWidgets()
        
        self._drawing_tool = None  # Clear reference
        self._aoi_bbox = None
        self._aoi_polygon_wgs84 = None
        self._update_aoi_preview()
        try:
            self._aoi.clear()
        except Exception:
            pass

    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
            self.aoiPreviewLabel.setText(
                f"AOI (EPSG:4326): ({x1:.6f}, {y1:.6f}, {x2:.6f}, {y2:.6f})"
            )
        else:
            self.aoiPreviewLabel.setText("<i>AOI: not set yet. Use smaller aoi and test first.</i>")

    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._update_aoi_preview("AOI: not set yet. Use smaller aoi and test first.")
        try:
            self._aoi.clear()
        except Exception:
            pass
        if self.commonWidget and hasattr(self.commonWidget, "reset"):
            try:
                self.commonWidget.reset()
            except Exception:
                pass
        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:
            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"]),
            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):
        try:
            if self._current_task is not None:
                status = self._current_task.status()
                if status in (QgsTask.Queued, QgsTask.Running):
                    self._cancel_current_run()
                    return
        except Exception:
            pass

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

        try:
            _log(self, "Pre-download stage started: checking matching scenes before download...")
            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:
            _log(self, "Pre-download scene check timed out after 2 minutes.", Qgis.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:
            QMessageBox.warning(self, "VirtuGhan", str(e))
            return

        out_dir = params["output_dir"]
        try:
            os.makedirs(out_dir, exist_ok=True)
        except Exception as e:
            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:
            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:
                task_was_cancelled = False
            self._current_task = None
            if isinstance(exc, _TaskCancelledError) or task_was_cancelled:
                _log(self, "Download cancelled by user.", Qgis.Warning)
                QMessageBox.information(self, "VirtuGhan", "Download cancelled.")
                return
            if not ok or exc:
                _log(self, f"Extractor failed: {exc}", Qgis.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}", Qgis.Warning)
                if added == 0:
                    _log(self, "No raster files found to load.")
                self._last_output_layer_ids = loaded_layer_ids

                self._has_successful_run = True
                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).", Qgis.Warning)
                else:
                    self._clear_scene_footprints_layer()

                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 _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(Qt.OtherFocusReason)
            sb = self.logText.verticalScrollBar()
            sb.setValue(sb.maximum())
        except Exception:
            pass

        try:
            parent = self.logText.parentWidget()
            while parent is not None:
                if isinstance(parent, QScrollArea):
                    parent.ensureWidgetVisible(self.logText, 0, 24)
                    break
                parent = parent.parentWidget()
        except Exception:
            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:
            pass
        for w in (self.aoiStartDrawButton, self.aoiClearButton,
                  self.aoiModeCombo, self.outputBrowseButton,
                  self.showSceneFootprintsCheck):
            try:
                w.setEnabled(not running)
            except Exception:
                pass
        try:
            self.previewScenesButton.setEnabled(True)
        except Exception:
            pass

    def _cancel_current_run(self):
        task = self._current_task
        if task is None:
            return
        try:
            status = task.status()
            if status in (QgsTask.Queued, QgsTask.Running):
                task.cancel()
                _log(self, "Cancellation requested...", Qgis.Warning)
        except Exception as e:
            _log(self, f"Failed to cancel extractor task: {e}", Qgis.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).", Qgis.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

        search_fn = _resolve_extractor_search_stac_api()
        if search_fn is None:
            _log(self, "Cannot load scene footprints: search_stac_api is not available.", Qgis.Warning)
            return []

        try:
            params = self._collect_search_params()
            scenes = search_fn(
                params["bbox"],
                params["start_date"],
                params["end_date"],
                params["cloud_cover"],
            )
            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:
            _log(self, f"Failed to fetch scenes for main-map footprints: {e}", Qgis.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:
                pass
        self._scene_footprints_layer = None
        try:
            self.iface.mapCanvas().refresh()
        except Exception:
            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:
            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)),
        }

    def _search_scenes_with_timeout(
        self,
        bbox,
        start_date,
        end_date,
        cloud_cover,
        timeout_s: float = 30.0,
    ):
        search_fn = _resolve_extractor_search_stac_api()
        if search_fn is None:
            raise RuntimeError("search_stac_api is not available in virtughan.extract.")

        result = {"scenes": None, "error": None}

        def _worker():
            try:
                result["scenes"] = search_fn(
                    bbox,
                    start_date,
                    end_date,
                    cloud_cover,
                )
            except Exception as exc:
                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)...",
                    Qgis.Info,
                )
                next_progress_log_at = now + 15.0
            try:
                QgsApplication.processEvents()
            except Exception:
                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):
        if _resolve_extractor_search_stac_api() is None:
            _log(self, "Pre-download STAC validation skipped because in-process resolver is unavailable.", Qgis.Warning)
            return
        search_fn = _resolve_extractor_search_stac_api()
        if search_fn is None:
            raise RuntimeError("search_stac_api is not available in virtughan.extract.")

        params = self._collect_search_params()
        scenes = self._search_scenes_with_timeout(
            params["bbox"],
            params["start_date"],
            params["end_date"],
            params["cloud_cover"],
            timeout_s=timeout_s,
        )
        count = len(scenes or [])
        if count < min_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:
                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.", Qgis.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:
            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:
                    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:
            pass
        return len(feats)

    def _preview_matching_scenes(self):
        search_fn = _resolve_extractor_search_stac_api()
        if search_fn is None:
            QMessageBox.warning(self, "VirtuGhan", "search_stac_api is not available in virtughan.extract.")
            return
        try:
            params = self._collect_search_params()
        except Exception as e:
            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_fn(
                params["bbox"],
                params["start_date"],
                params["end_date"],
                params["cloud_cover"],
            )
            _log(self, f"Matching scenes found: {len(scenes)}")

            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,
            )
            dlg.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:
            _log(self, f"Scene search failed: {e}", Qgis.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:
            return None, None

    def _teardown_runtime_state(self):
        try:
            self._stop_tailing()
        except Exception:
            pass

        try:
            if self._current_task is not None:
                try:
                    self._current_task.on_done = None
                except Exception:
                    pass
                try:
                    self._current_task.cancel()
                except Exception:
                    pass
        except Exception:
            pass
        self._current_task = None

        try:
            self._clear_scene_footprints_layer()
        except Exception:
            pass

        try:
            self._clear_aoi()
        except Exception:
            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(Qt.ArrowCursor))
            self.iface.messageBar().clearWidgets()
        except Exception:
            pass

        self._drawing_tool = None
        self._prev_tool = None

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