"""
First-run setup for EMDS Workbench.

When the plugin loads with missing Python dependencies, ``classFactory``
returns :class:`SetupBootstrapPlugin` instead of the real plugin. The
bootstrap shows :class:`FirstRunSetupDialog`, which installs the missing
packages into the Python that QGIS bundles — from *inside* QGIS, where
``sys.exec_prefix`` already points at the right interpreter and the process
environment is already correct (the external scripts under ``scripts/`` need
elaborate discovery and PYTHONHOME repair precisely because they run outside
QGIS; none of that is needed here). The scripts remain the documented
fallback for offline or locked-down machines.

This module must stay importable without Qt, QGIS, or pandas:

- pure logic (candidate discovery, pip planning, engine repair) sits at
  module level and is unit-tested headlessly;
- Qt-dependent classes are defined only when ``qt_compat`` imports, so the
  test suite and headless tools can import this module anywhere.
"""

from __future__ import annotations

import importlib
import os
import shutil
import site
import stat
import subprocess
import sys
import sysconfig
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterator, List, Optional, Sequence, Tuple

from . import debug_logger
from .translator import tr

try:
    from .qt_compat import (
        QAction,
        QCheckBox,
        QDialog,
        QHBoxLayout,
        QLabel,
        QPlainTextEdit,
        QProcess,
        QProcessEnvironment,
        QMessageBox,
        QProgressBar,
        QPushButton,
        QSettings,
        Qt,
        QTimer,
        QVBoxLayout,
    )

    _QT_AVAILABLE = True
except Exception:  # pragma: no cover - exercised only in headless contexts
    _QT_AVAILABLE = False


# Kept in lockstep with python-bridge/requirements.txt and the scripts under
# scripts/. Install-if-missing: only packages the import probe reported
# missing are passed to pip, so a QGIS-bundled pandas is never touched.
REQUIRED_PACKAGE_SPECS = {
    "pandas": "pandas>=2.0,<3",
    "pyarrow": "pyarrow>=14.0.0",
}
OPTIONAL_MATPLOTLIB_SPEC = "matplotlib>=3.7.0"

ENGINE_NAMES = ("NetWeaverCLI", "CDPCLI")

_EXECUTE_BITS = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH


# --------------------------------------------------------------------------
# Locating the QGIS-bundled Python (pure logic + one subprocess validation)
# --------------------------------------------------------------------------

def _macos_bundle_root(path: Path) -> Optional[Path]:
    """Walk up from *path* to the enclosing ``*.app`` bundle, if any."""
    for parent in (path, *path.parents):
        if parent.name.endswith(".app"):
            return parent
    return None


def _python_candidates(
    exec_prefix: str,
    is_windows: bool,
    is_macos: bool,
    which_python3: Optional[str] = None,
    executables: Sequence[Path] = (),
    python_names: Sequence[str] = ("python3",),
) -> List[Path]:
    """Interpreter candidates for the Python this process is running on.

    ``executables`` (tried first) carries ``sys.executable`` /
    ``sys._base_executable`` when they name an actual python binary — in
    some embeddings they point straight at the bundled interpreter.

    ``python_names`` lets callers add versioned binary names ("python3.12"):
    some bundles ship only the versioned binary with no ``python3`` symlink.

    Windows: ``<exec_prefix>\\python.exe`` is the real interpreter for both
    OSGeo4W and standalone installs (never ``<root>\\bin\\python.exe``, a
    launcher shim that only works inside the o4w environment — this path
    simply never surfaces from ``sys.exec_prefix``).
    """
    prefix = Path(exec_prefix)
    candidates: List[Path] = [Path(exe) for exe in executables]
    if is_windows:
        candidates.append(prefix / "python.exe")
    else:
        for name in python_names:
            candidates.append(prefix / "bin" / name)
            candidates.append(prefix / name)
        if is_macos:
            bundle = _macos_bundle_root(prefix)
            if bundle is not None:
                contents = bundle / "Contents"
                for name in python_names:
                    candidates.extend(
                        (
                            contents / "MacOS" / "bin" / name,
                            contents / "MacOS" / name,
                            contents / "Resources" / "python" / "bin" / name,
                        )
                    )
        if which_python3:
            candidates.append(Path(which_python3))

    seen = set()
    unique: List[Path] = []
    for candidate in candidates:
        key = os.path.normcase(str(candidate))
        if key not in seen:
            seen.add(key)
            unique.append(candidate)
    return unique


@dataclass(frozen=True)
class PythonRuntime:
    """A validated interpreter plus the environment that makes it behave
    like the one running inside QGIS. Every subsequent probe, pip run, and
    verify step uses this same (python, env) pair."""

    python: Path
    env: Dict[str, str]
    strategy: str


def _home_value() -> str:
    if os.name == "nt" or sys.prefix == sys.exec_prefix:
        return sys.prefix
    return f"{sys.prefix}:{sys.exec_prefix}"


def _env_strategies() -> List[Tuple[str, Dict[str, str]]]:
    """Child-environment candidates, least invasive first.

    (1) QGIS's own environment untouched — right whenever the launcher
    already exports what the interpreter needs (typical Windows/Linux).
    (2) PYTHONHOME pinned to this process's prefix — repairs bundles whose
    python3 binary has build-machine paths baked in.
    (3) Additionally mirror this process's sys.path into PYTHONPATH — the
    strongest repair, covering macOS vcpkg bundles with flat stdlib
    layouts that PYTHONHOME alone cannot describe (the same fix the
    scripts/macos installer performs from outside QGIS).
    """
    inherited = os.environ.copy()
    pinned = dict(inherited)
    pinned["PYTHONHOME"] = _home_value()
    mirrored = dict(pinned)
    mirrored["PYTHONPATH"] = os.pathsep.join(entry for entry in sys.path if entry)
    return [
        ("inherited", inherited),
        ("pythonhome", pinned),
        ("pythonhome+pythonpath", mirrored),
    ]


def _no_window_kwargs() -> dict:
    if os.name == "nt":
        return {"creationflags": subprocess.CREATE_NO_WINDOW}
    return {}


def _run_quiet(
    argv: Sequence[str], timeout: float, env: Dict[str, str]
) -> subprocess.CompletedProcess:
    return subprocess.run(
        list(argv),
        capture_output=True,
        text=True,
        timeout=timeout,
        env=env,
        **_no_window_kwargs(),
    )


def _same_path(reported: str, expected: str) -> bool:
    def canon(value: str) -> str:
        return os.path.normcase(os.path.normpath(os.path.realpath(value)))

    return canon(reported) == canon(expected)


# Child prints its site-packages and prefix; purelib equality is the
# invariant that actually matters (pip installs where our imports look).
# Lines are sentinel-tagged because interpreter startup can emit unrelated
# stdout (printing .pth files, sitecustomize banners) before — or atexit
# handlers after — the probe's own output.
_IDENTITY_SENTINEL = "EMDS-ID:"
_IDENTITY_PROBE = (
    "import sys, sysconfig\n"
    f"print('{_IDENTITY_SENTINEL}' + sysconfig.get_paths()['purelib'])\n"
    f"print('{_IDENTITY_SENTINEL}' + sys.prefix)\n"
)


def _validate_python(candidate: Path, env: Dict[str, str]) -> bool:
    """Accept a candidate only if pip through it targets the site-packages
    this process imports from (purelib match), with a prefix match as the
    fallback for layouts where purelib strings differ only cosmetically."""
    try:
        proc = _run_quiet([str(candidate), "-c", _IDENTITY_PROBE], timeout=20, env=env)
    except Exception:
        return False
    if proc.returncode != 0:
        return False
    tagged = [
        line.strip()[len(_IDENTITY_SENTINEL):]
        for line in proc.stdout.splitlines()
        if line.strip().startswith(_IDENTITY_SENTINEL)
    ]
    if len(tagged) != 2:
        return False
    child_purelib, child_prefix = tagged
    return _same_path(child_purelib, sysconfig.get_paths()["purelib"]) or _same_path(
        child_prefix, sys.prefix
    )


def find_qgis_python() -> Optional[PythonRuntime]:
    """Resolve the QGIS-bundled Python interpreter from inside QGIS,
    together with an environment it verifiably works under."""
    executables: List[Path] = []
    for exe in (getattr(sys, "_base_executable", None), sys.executable):
        if exe and "python" in Path(exe).name.lower():
            executables.append(Path(exe))
    candidates = _python_candidates(
        sys.exec_prefix,
        is_windows=os.name == "nt",
        is_macos=sys.platform == "darwin",
        which_python3=None if os.name == "nt" else shutil.which("python3"),
        executables=executables,
        python_names=(
            "python3",
            f"python{sys.version_info.major}.{sys.version_info.minor}",
            # Some bundles (QGIS 4.0.3 macOS) ship only a versioned binary
            # plus a bare "python" — no "python3" symlink at all.
            "python",
        ),
    )
    strategies = _env_strategies()
    attempts: List[str] = []
    for candidate in candidates:
        if not candidate.is_file():
            attempts.append(f"{candidate} (missing)")
            continue
        for strategy, env in strategies:
            if _validate_python(candidate, env):
                debug_logger.debug(
                    "Resolved QGIS Python for setup",
                    {"python": str(candidate), "env_strategy": strategy},
                )
                return PythonRuntime(python=candidate, env=env, strategy=strategy)
            attempts.append(f"{candidate} [{strategy}]")
    debug_logger.warning("Could not resolve QGIS Python for setup", {"attempts": attempts})
    return None


# --------------------------------------------------------------------------
# pip planning (pure logic) and quick probes
# --------------------------------------------------------------------------

@dataclass(frozen=True)
class PipPlan:
    """A fully-decided pip invocation (kept pure for unit tests)."""

    python: str
    package_specs: Tuple[str, ...]
    user_install: bool = False
    break_system_packages: bool = False

    @property
    def argv(self) -> List[str]:
        argv = [
            self.python,
            "-m",
            "pip",
            "install",
            "--no-input",
            "--disable-pip-version-check",
        ]
        if self.user_install:
            argv.append("--user")
        if self.break_system_packages:
            argv.append("--break-system-packages")
        argv.extend(self.package_specs)
        return argv


def plan_pip_install(
    python,
    missing_packages: Sequence[str],
    include_matplotlib: bool = False,
    user_install: bool = False,
    break_system_packages: bool = False,
) -> PipPlan:
    specs = [
        spec
        for name, spec in REQUIRED_PACKAGE_SPECS.items()
        if name in missing_packages
    ]
    if include_matplotlib:
        specs.append(OPTIONAL_MATPLOTLIB_SPEC)
    return PipPlan(
        python=str(python),
        package_specs=tuple(specs),
        user_install=user_install,
        break_system_packages=break_system_packages,
    )


_WRITABLE_PROBE = (
    "import os, sysconfig, uuid\n"
    "target = sysconfig.get_paths()['purelib']\n"
    "probe = os.path.join(target, '_emds_write_probe_' + uuid.uuid4().hex)\n"
    "try:\n"
    "    with open(probe, 'w') as handle:\n"
    "        handle.write('probe')\n"
    "    os.remove(probe)\n"
    "    print('writable')\n"
    "except OSError:\n"
    "    print('readonly')\n"
)


def pip_available(runtime: PythonRuntime) -> bool:
    try:
        proc = _run_quiet(
            [str(runtime.python), "-m", "pip", "--version"], timeout=60, env=runtime.env
        )
    except Exception:
        return False
    return proc.returncode == 0


def site_packages_writable(runtime: PythonRuntime) -> bool:
    """Probe the *target* interpreter's site-packages for writability.

    Mirrors scripts/windows/install-deps.ps1: a standalone QGIS under Program
    Files is not writable without elevation, so pip falls back to --user.
    """
    try:
        proc = _run_quiet(
            [str(runtime.python), "-c", _WRITABLE_PROBE], timeout=30, env=runtime.env
        )
    except Exception:
        return False
    return proc.returncode == 0 and "writable" in proc.stdout


def pip_supports_break_system_packages(runtime: PythonRuntime) -> bool:
    """PEP 668 flag probe (needed on Ubuntu 24.04+ system Python)."""
    if os.name == "nt":
        return False
    try:
        proc = _run_quiet(
            [str(runtime.python), "-m", "pip", "install", "--help"],
            timeout=60,
            env=runtime.env,
        )
    except Exception:
        return False
    return proc.returncode == 0 and "--break-system-packages" in proc.stdout


def optional_matplotlib_missing() -> bool:
    """find_spec never imports (or dlopens) the package — cheap and safe."""
    try:
        return importlib.util.find_spec("matplotlib") is None
    except Exception:
        return True


# --------------------------------------------------------------------------
# macOS Library Validation repair (ad-hoc re-sign of pip-installed wheels)
# --------------------------------------------------------------------------

_PACKAGE_DIR_PROBE = (
    "import importlib.util, sys\n"
    "spec = importlib.util.find_spec(sys.argv[1])\n"
    "if spec and spec.submodule_search_locations:\n"
    "    print(list(spec.submodule_search_locations)[0])\n"
)


def _importable_in_runtime(runtime: PythonRuntime, module: str) -> bool:
    try:
        proc = _run_quiet(
            [str(runtime.python), "-c", f"import {module}"], timeout=30, env=runtime.env
        )
    except Exception:
        return False
    return proc.returncode == 0


def packages_needing_resign(
    runtime: PythonRuntime, packages: Sequence[str]
) -> List[str]:
    """Only packages that currently fail to import need re-signing.

    Never re-sign a package that already imports — most importantly the
    QGIS-bundled pandas, whose valid Team-ID signature an ad-hoc re-sign
    would REPLACE, breaking it (macOS Library Validation then rejects the
    now-ad-hoc library). Mirrors the early-return in the scripts/macos
    installer's ensure_python_package.
    """
    return [p for p in packages if not _importable_in_runtime(runtime, p)]


def native_library_paths(runtime: PythonRuntime, package: str) -> List[Path]:
    """Locate a package's compiled libraries without importing (dlopening) it."""
    try:
        proc = _run_quiet(
            [str(runtime.python), "-c", _PACKAGE_DIR_PROBE, package],
            timeout=30,
            env=runtime.env,
        )
    except Exception:
        return []
    if proc.returncode != 0:
        return []
    package_dir = proc.stdout.strip().splitlines()[-1] if proc.stdout.strip() else ""
    if not package_dir:
        return []
    root = Path(package_dir)
    if not root.is_dir():
        return []
    libraries: List[Path] = []
    for pattern in ("*.so", "*.dylib"):
        libraries.extend(root.rglob(pattern))
    return sorted(set(libraries))


def codesign_commands(libraries: Sequence[Path], chunk_size: int = 100) -> List[List[str]]:
    """Ad-hoc re-sign commands, chunked to keep argv lengths sane.

    QGIS's hardened Python can refuse to load wheels signed with a different
    Team ID ("Library Validation"). Re-signing ad-hoc (`--sign -`) matches
    what scripts/macos/install-emds-runtime-deps.zsh does.
    """
    commands: List[List[str]] = []
    batch: List[str] = []
    for library in libraries:
        batch.append(str(library))
        if len(batch) >= chunk_size:
            commands.append(["/usr/bin/codesign", "--force", "--sign", "-", *batch])
            batch = []
    if batch:
        commands.append(["/usr/bin/codesign", "--force", "--sign", "-", *batch])
    return commands


# --------------------------------------------------------------------------
# Engine payload repair (runs on every plugin load; also after every update)
# --------------------------------------------------------------------------

def _iter_engine_apphosts(engines_root: Path) -> Iterator[Path]:
    """Yield every packaged engine apphost, whichever RIDs shipped."""
    if not engines_root.is_dir():
        return
    for rid_dir in sorted(engines_root.iterdir()):
        if not rid_dir.is_dir():
            continue
        for engine_name in ENGINE_NAMES:
            apphost = rid_dir / engine_name / engine_name
            if apphost.is_file():
                yield apphost


def _set_exec_bits(apphost: Path) -> bool:
    """Restore execute bits; returns True when a repair was needed."""
    current_mode = apphost.stat().st_mode
    if (current_mode & _EXECUTE_BITS) == _EXECUTE_BITS:
        return False
    apphost.chmod(current_mode | _EXECUTE_BITS)
    return True


def _clear_quarantine(target: Path) -> bool:
    """Strip the macOS quarantine attribute; True when xattr ran clean."""
    try:
        proc = subprocess.run(
            ["/usr/bin/xattr", "-dr", "com.apple.quarantine", str(target)],
            capture_output=True,
            text=True,
            timeout=60,
        )
    except Exception:
        return False
    return proc.returncode == 0


def _is_windows() -> bool:
    return os.name == "nt"


def _is_macos() -> bool:
    return sys.platform == "darwin"


def repair_engine_payload(plugin_dir) -> List[str]:
    """Self-heal the bundled engines. Idempotent and cheap; called on every
    plugin load because QGIS installs/updates plugins by re-extracting the
    ZIP with Python's ``zipfile``, which strips Unix execute bits, and a
    downloaded ZIP can leave macOS quarantine flags on the binaries.
    """
    repairs: List[str] = []
    if _is_windows():
        return repairs

    engines_root = Path(plugin_dir) / "engines"
    for apphost in _iter_engine_apphosts(engines_root):
        try:
            if _set_exec_bits(apphost):
                repairs.append(f"chmod +x {apphost}")
        except OSError as exc:
            debug_logger.warning(
                "Could not set engine execute bits",
                {"engine": str(apphost), "error": str(exc)},
            )
    if _is_macos() and engines_root.is_dir():
        if _clear_quarantine(engines_root):
            repairs.append(f"cleared quarantine under {engines_root}")

    if repairs:
        debug_logger.debug("Repaired engine payload", {"repairs": repairs})
    return repairs


def _manual_script_hint() -> str:
    """The fallback script for this platform (shipped in the -setup bundle)."""
    if os.name == "nt":
        return "scripts\\windows\\install-deps.ps1"
    if sys.platform == "darwin":
        return "scripts/macos/install-emds-runtime-deps.zsh"
    if sys.platform.startswith("linux"):
        return "scripts/linux/install-emds-runtime-deps.sh"
    return "pip install into the QGIS Python environment"


# --------------------------------------------------------------------------
# QGIS plugin-repository registration (automatic updates)
# --------------------------------------------------------------------------

UPDATE_REPOSITORY_BASE_URL = "https://emds.mountain-viewgroup.com/downloads/qgis"
UPDATE_REPOSITORY_NAME = "EMDS"
_REPO_SETTINGS_GROUP = "app/plugin_repositories"
_REPO_DECLINED_KEY = "EMDS/updateRepo/promptDeclined"


def platform_repository_id() -> str:
    """Feed platform folder for this machine (win-x64 / macos / linux-x64)."""
    if _is_windows():
        return "win-x64"
    if _is_macos():
        return "macos"
    return "linux-x64"


def platform_repository_url() -> str:
    return f"{UPDATE_REPOSITORY_BASE_URL}/{platform_repository_id()}/plugins.xml"


def _qgis_settings():
    from qgis.core import QgsSettings

    return QgsSettings()


def update_repository_registered() -> bool:
    """True when any QGIS plugin repository already points at this platform's
    EMDS feed. Checked on every plugin load."""
    target = platform_repository_url()
    try:
        settings = _qgis_settings()
        settings.beginGroup(_REPO_SETTINGS_GROUP)
        try:
            for group in settings.childGroups():
                url = str(settings.value(f"{group}/url", "") or "").strip()
                if url.rstrip("/") == target:
                    return True
        finally:
            settings.endGroup()
    except Exception as exc:
        debug_logger.warning(
            "Could not inspect QGIS plugin repositories", {"error": str(exc)}
        )
        return True  # cannot inspect -> assume present rather than nag
    return False


def register_update_repository() -> bool:
    """Add the platform's EMDS repository to QGIS's Plugin Manager settings.
    Takes effect the next time the Plugin Manager fetches repositories."""
    try:
        settings = _qgis_settings()
        base = f"{_REPO_SETTINGS_GROUP}/{UPDATE_REPOSITORY_NAME}"
        settings.setValue(f"{base}/url", platform_repository_url())
        settings.setValue(f"{base}/enabled", True)
        settings.setValue(f"{base}/authcfg", "")
        debug_logger.debug(
            "Registered EMDS plugin repository", {"url": platform_repository_url()}
        )
        return True
    except Exception as exc:
        debug_logger.error(
            "Could not register EMDS plugin repository", {"error": str(exc)}
        )
        return False


def _reload_emds_plugin() -> None:
    """Hot-enable after setup: re-runs classFactory, which now loads the
    real plugin. Failure is non-fatal — the user restarts QGIS instead."""
    try:
        from qgis import utils as qgis_utils

        qgis_utils.reloadPlugin("emds_qgis_plugin")
    except Exception as exc:
        debug_logger.error("Could not hot-enable EMDS after setup", {"error": str(exc)})


# --------------------------------------------------------------------------
# Qt UI (defined only when Qt is importable)
# --------------------------------------------------------------------------

if _QT_AVAILABLE:

    def ensure_update_repository(parent=None) -> None:
        """Presence check runs on every plugin load; the consent prompt is
        one-time (a decline is remembered and never re-asked)."""
        try:
            if update_repository_registered():
                return
            settings = QSettings()
            if settings.value(_REPO_DECLINED_KEY, False, type=bool):
                return
            answer = QMessageBox.question(
                parent,
                tr("first_run.repo_title"),
                tr("first_run.repo_body", url=platform_repository_url()),
            )
            if answer == QMessageBox.StandardButton.Yes:
                register_update_repository()
            else:
                settings.setValue(_REPO_DECLINED_KEY, True)
        except Exception as exc:
            debug_logger.warning(
                "Update-repository offer failed", {"error": str(exc)}
            )

    class FirstRunSetupDialog(QDialog):
        """Consent + progress dialog that runs the dependency install."""

        def __init__(self, missing_packages: Sequence[str], parent=None):
            super().__init__(parent)
            self._missing = list(missing_packages)
            self._offer_matplotlib = optional_matplotlib_missing()
            self._runtime: Optional[PythonRuntime] = None
            self._plan: Optional[PipPlan] = None
            # Steps are (label, argv, ignore_failure) triples.
            self._steps: List[Tuple[str, List[str], bool]] = []
            self._process: Optional[QProcess] = None
            self._current_step: Optional[Tuple[str, List[str], bool]] = None
            self._resign_attempted = False
            self._canceled = False
            self._verify_label = tr("first_run.step_verify")
            self._ensure_pip_label = tr("first_run.step_ensure_pip")
            self._build_ui()

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

        def _build_ui(self):
            self.setWindowTitle(tr("first_run.window_title"))
            self.setMinimumWidth(560)
            layout = QVBoxLayout(self)

            heading = QLabel(f"<b>{tr('first_run.heading')}</b>")
            layout.addWidget(heading)

            intro = QLabel(
                tr("first_run.missing_intro") + "\n    " + ", ".join(self._missing)
            )
            intro.setWordWrap(True)
            layout.addWidget(intro)

            explain = QLabel(tr("first_run.explain"))
            explain.setWordWrap(True)
            layout.addWidget(explain)

            python_row = QHBoxLayout()
            python_row.addWidget(QLabel(tr("first_run.python_label")))
            self._python_value = QLabel("…")
            self._python_value.setWordWrap(True)
            self._python_value.setTextInteractionFlags(
                Qt.TextInteractionFlag.TextSelectableByMouse
            )
            python_row.addWidget(self._python_value, stretch=1)
            layout.addLayout(python_row)

            self._matplotlib_checkbox = QCheckBox(tr("first_run.include_matplotlib"))
            self._matplotlib_checkbox.setChecked(True)
            self._matplotlib_checkbox.setVisible(self._offer_matplotlib)
            layout.addWidget(self._matplotlib_checkbox)

            layout.addWidget(QLabel(tr("first_run.command_label")))
            self._command_preview = QPlainTextEdit()
            self._command_preview.setReadOnly(True)
            self._command_preview.setMaximumHeight(64)
            self._command_preview.setPlainText(self._provisional_command_text())
            layout.addWidget(self._command_preview)

            self._manual_panel = QLabel(
                f"<b>{tr('first_run.manual_title')}</b><br>"
                + tr("first_run.manual_body", script=_manual_script_hint()).replace("\n", "<br>")
            )
            self._manual_panel.setWordWrap(True)
            self._manual_panel.setVisible(False)
            layout.addWidget(self._manual_panel)

            layout.addWidget(QLabel(tr("first_run.log_label")))
            self._log = QPlainTextEdit()
            self._log.setReadOnly(True)
            self._log.setMinimumHeight(140)
            layout.addWidget(self._log)

            self._progress = QProgressBar()
            self._progress.setRange(0, 0)
            self._progress.setVisible(False)
            layout.addWidget(self._progress)

            self._status_label = QLabel("")
            self._status_label.setWordWrap(True)
            layout.addWidget(self._status_label)

            buttons = QHBoxLayout()
            self._install_button = QPushButton(tr("first_run.btn_install"))
            self._install_button.setDefault(True)
            self._install_button.clicked.connect(self._on_install_clicked)
            buttons.addWidget(self._install_button)

            self._manual_button = QPushButton(tr("first_run.btn_manual"))
            self._manual_button.clicked.connect(self._toggle_manual_panel)
            buttons.addWidget(self._manual_button)

            buttons.addStretch(1)

            self._enable_button = QPushButton(tr("first_run.btn_enable_now"))
            self._enable_button.setVisible(False)
            self._enable_button.clicked.connect(self._on_enable_clicked)
            buttons.addWidget(self._enable_button)

            self._cancel_button = QPushButton(tr("first_run.btn_cancel"))
            self._cancel_button.setVisible(False)
            self._cancel_button.clicked.connect(self._on_cancel_clicked)
            buttons.addWidget(self._cancel_button)

            self._close_button = QPushButton(tr("first_run.btn_not_now"))
            self._close_button.clicked.connect(self.reject)
            buttons.addWidget(self._close_button)

            layout.addLayout(buttons)

        def _provisional_command_text(self) -> str:
            plan = plan_pip_install(
                "<QGIS python>",
                self._missing,
                include_matplotlib=self._offer_matplotlib,
            )
            return " ".join(plan.argv)

        def _toggle_manual_panel(self):
            self._manual_panel.setVisible(not self._manual_panel.isVisible())

        def _append_log(self, text: str):
            if text:
                self._log.appendPlainText(text.rstrip("\n"))

        # -- install flow ----------------------------------------------------

        def _on_install_clicked(self):
            self._canceled = False
            self._resign_attempted = False
            self._install_button.setEnabled(False)
            self._cancel_button.setVisible(True)
            self._progress.setVisible(True)
            self._status_label.setText(tr("first_run.installing"))
            # Let the dialog repaint before the short synchronous probes run.
            QTimer.singleShot(50, self._begin_install)

        def _begin_install(self):
            if self._canceled:
                return
            runtime = find_qgis_python()
            if runtime is None:
                self._finish_failed(tr("first_run.no_python"))
                return
            self._runtime = runtime
            self._python_value.setText(str(runtime.python))

            if pip_available(runtime):
                self._steps = []
                self._queue_main_steps()
            else:
                # If ensurepip fails but pip actually works, the install step
                # still succeeds — so its own failure is not fatal. The pip
                # probes (--user, PEP 668) must run AFTER this step so they
                # interrogate the pip it just bootstrapped: the main steps are
                # queued from _handle_step_result once this one completes.
                self._steps = [
                    (
                        self._ensure_pip_label,
                        [str(runtime.python), "-m", "ensurepip", "--upgrade"],
                        True,
                    )
                ]
            self._advance()

        def _queue_main_steps(self):
            include_matplotlib = (
                self._offer_matplotlib and self._matplotlib_checkbox.isChecked()
            )
            self._plan = plan_pip_install(
                self._runtime.python,
                self._missing,
                include_matplotlib=include_matplotlib,
                user_install=not site_packages_writable(self._runtime),
                break_system_packages=pip_supports_break_system_packages(self._runtime),
            )
            self._command_preview.setPlainText(" ".join(self._plan.argv))
            self._steps.append((tr("first_run.step_install"), self._plan.argv, False))
            self._steps.append((self._verify_label, self._verify_argv(), False))

        def _verify_argv(self) -> List[str]:
            modules = ", ".join(sorted(REQUIRED_PACKAGE_SPECS))
            return [str(self._runtime.python), "-c", f"import {modules}"]

        def _advance(self):
            if self._canceled:
                return
            if not self._steps:
                self._finish_succeeded()
                return
            step = self._steps.pop(0)
            self._current_step = step
            label, argv, _ignore_failure = step
            self._status_label.setText(label)
            self._append_log("$ " + " ".join(argv))

            process = QProcess(self)
            process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
            # Run every step under the exact environment that validated the
            # interpreter (see find_qgis_python / _env_strategies).
            environment = QProcessEnvironment()
            for key, value in self._runtime.env.items():
                environment.insert(key, value)
            process.setProcessEnvironment(environment)
            process.readyReadStandardOutput.connect(self._on_process_output)
            process.finished.connect(self._on_process_finished)
            process.errorOccurred.connect(self._on_process_error)
            self._process = process
            process.start(argv[0], argv[1:])

        def _on_process_output(self):
            if self._process is None:
                return
            data = bytes(self._process.readAllStandardOutput()).decode(
                "utf-8", errors="replace"
            )
            self._append_log(data)

        def _on_process_error(self, process_error):
            # FailedToStart never emits finished; everything else does.
            if self._process is None or self._current_step is None:
                return
            if process_error == QProcess.ProcessError.FailedToStart:
                self._append_log("(process failed to start)")
                self._process = None
                self._handle_step_result(exit_code=-1)

        def _on_process_finished(self, exit_code, _exit_status):
            if self._process is None:
                return
            self._on_process_output()
            self._process = None
            self._handle_step_result(exit_code=exit_code)

        def _handle_step_result(self, exit_code: int):
            step = self._current_step
            self._current_step = None
            if self._canceled:
                self._finish_canceled()
                return
            if step is None:
                return
            label, _argv, ignore_failure = step
            if exit_code == 0 or ignore_failure:
                if label == self._ensure_pip_label:
                    self._queue_main_steps()
                self._advance()
                return
            if label == self._verify_label and sys.platform == "darwin":
                if not self._resign_attempted:
                    self._resign_attempted = True
                    resign_steps = self._build_resign_steps()
                    if resign_steps:
                        self._append_log(
                            "(retrying after ad-hoc re-sign of native wheels)"
                        )
                        self._steps = resign_steps + [
                            (self._verify_label, self._verify_argv(), False)
                        ]
                        self._advance()
                        return
                # The standalone interpreter enforces Library Validation more
                # strictly than the QGIS process, which loads these libraries
                # fine (a pip-installed native wheel imports in QGIS even when
                # `python3 -c` rejects it). Don't dead-end on a subprocess-only
                # failure — the authoritative in-process import in
                # _finish_succeeded decides enable-now vs restart.
                self._append_log(
                    "(standalone check inconclusive on macOS; verifying inside QGIS)"
                )
                self._finish_succeeded()
                return
            self._finish_failed(tr("first_run.failed"))

        def _build_resign_steps(self) -> List[Tuple[str, List[str], bool]]:
            steps: List[Tuple[str, List[str], bool]] = []
            # Only failing packages — never re-sign the QGIS-bundled pandas,
            # which imports fine and whose valid signature we would break.
            for package in packages_needing_resign(
                self._runtime, sorted(REQUIRED_PACKAGE_SPECS)
            ):
                libraries = native_library_paths(self._runtime, package)
                for command in codesign_commands(libraries):
                    # codesign failures are non-fatal: the re-verify decides.
                    steps.append((tr("first_run.step_resign"), command, True))
            return steps

        # -- terminal states -------------------------------------------------

        def _reset_buttons(self):
            self._progress.setVisible(False)
            self._cancel_button.setVisible(False)
            self._install_button.setEnabled(True)

        def _finish_failed(self, message: str):
            self._reset_buttons()
            self._status_label.setText(message)
            self._append_log(message)
            self._manual_panel.setVisible(True)

        def _finish_canceled(self):
            self._reset_buttons()
            self._status_label.setText(tr("first_run.canceled"))
            self._append_log(tr("first_run.canceled"))

        def _finish_succeeded(self):
            self._progress.setVisible(False)
            self._cancel_button.setVisible(False)
            self._install_button.setVisible(False)
            self._close_button.setText(tr("first_run.btn_close_done"))

            importlib.invalidate_caches()
            if self._plan is not None and self._plan.user_install:
                try:
                    user_site = site.getusersitepackages()
                    if user_site and user_site not in sys.path:
                        site.addsitedir(user_site)
                except Exception:
                    pass

            try:
                for module_name in sorted(REQUIRED_PACKAGE_SPECS):
                    importlib.import_module(module_name)
                in_process_ok = True
            except Exception:
                in_process_ok = False

            if in_process_ok:
                self._status_label.setText(tr("first_run.success_enable"))
                self._enable_button.setVisible(True)
            else:
                self._status_label.setText(tr("first_run.success_restart"))
            self._append_log(self._status_label.text())

        def _on_enable_clicked(self):
            self.accept()
            QTimer.singleShot(0, _reload_emds_plugin)

        def _on_cancel_clicked(self):
            # Also covers the pre-_begin_install window: with _canceled set,
            # the pending QTimer.singleShot fires into a no-op.
            self._canceled = True
            if self._process is not None:
                self._process.kill()
            else:
                self._finish_canceled()

        def _dismiss_running_work(self):
            self._canceled = True
            if self._process is not None:
                self._process.kill()

        def reject(self):
            # "Not now" and the Esc key land here. QDialog.reject() only
            # hides the dialog — it does NOT deliver a QCloseEvent — so a
            # running pip would otherwise keep mutating the environment
            # invisibly (and a reopened dialog could start a second one).
            self._dismiss_running_work()
            super().reject()

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

    class SetupBootstrapPlugin:
        """Minimal stand-in plugin QGIS loads while dependencies are missing.

        Keeps the plugin "loaded" in the Plugin Manager (instead of a broken
        import traceback), repairs the engine payload, and gives the user a
        persistent re-entry point: EMDS → Finish EMDS Setup…
        """

        def __init__(self, iface, missing_packages: Sequence[str]):
            self.iface = iface
            self.missing_packages = list(missing_packages)
            self.action: Optional[QAction] = None
            self.dialog = None
            self._unloaded = False

        def initGui(self):
            try:
                repair_engine_payload(Path(__file__).resolve().parent)
            except Exception as exc:
                debug_logger.warning("Engine payload repair failed", {"error": str(exc)})

            self.action = QAction(
                tr("first_run.menu_finish_setup"), self.iface.mainWindow()
            )
            self.action.triggered.connect(self.show_setup_dialog)
            self.iface.addPluginToMenu("&EMDS", self.action)
            QTimer.singleShot(800, self.show_setup_dialog)

        def show_setup_dialog(self):
            if self._unloaded:
                return
            if self.dialog is None:
                self.dialog = FirstRunSetupDialog(
                    self.missing_packages, parent=self.iface.mainWindow()
                )
                self.dialog.finished.connect(self._on_dialog_finished)
            self.dialog.show()
            self.dialog.raise_()
            self.dialog.activateWindow()

        def _on_dialog_finished(self, _result):
            if self.dialog is not None:
                self.dialog.deleteLater()
            self.dialog = None

        def unload(self):
            self._unloaded = True
            if self.dialog is not None:
                dialog = self.dialog
                self.dialog = None
                dialog.close()
                dialog.deleteLater()
            if self.action is not None:
                self.iface.removePluginMenu("&EMDS", self.action)
                self.action = None
