# -*- coding: utf-8 -*-
"""Main PotreeCraft dialog implementation."""

from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Tuple

import laspy
from qgis.PyQt import uic
from qgis.PyQt.QtCore import QObject, QLocale, QSettings, Qt, QThread, pyqtSignal
from qgis.PyQt.QtGui import QColor, QDoubleValidator
from qgis.PyQt.QtWidgets import (
    QAbstractItemView,
    QActionGroup,
    QApplication,
    QComboBox,
    QDialog,
    QFileDialog,
    QHeaderView,
    QMenu,
    QMessageBox,
    QProgressDialog,
    QTableWidgetItem,
)
from qgis.core import Qgis, QgsMapLayerType, QgsMessageLog, QgsProject, QgsVectorFileWriter, QgsWkbTypes

from .potreecraft_core import compile_potree_project

FORM_CLASS, _ = uic.loadUiType(
    str(Path(__file__).resolve().with_name("potreecraft_dialog_base.ui"))
)


POINTCLOUD_MODES = ["intensity", "elevation", "rgb"]
RASTER_BACKEND_POTREECRAFT = "potreecraft"
RASTER_BACKEND_BLAST2DEM = "blast2dem"
RASTER_BACKEND_LABELS = {
    RASTER_BACKEND_POTREECRAFT: "PotreeCraft (rasterio + numpy)",
    RASTER_BACKEND_BLAST2DEM: "Legacy blast2dem (LASTools v1.0)",
}
LEGACY_RASTER_MODE_FLAGS = {
    "rgb": "-rgb",
    "intensity": "-intensity",
    "elevation": "-elevation",
}
POTREECONVERTER_SETTINGS_KEY = "PotreeCraft/potreeconverter_path"
CAMERA_MODE_FIT_TO_SCREEN = "fit_to_screen"
CAMERA_MODE_CUSTOM = "custom_camera_position"
VECTOR_FUNCTIONS = {
    "Point": [
        "point (circle)",
        "point (mesh sphere)",
        "point (mesh disc)",
        "annotation",
        "coordinates (measurement)",
    ],
    "LineString": [
        "linestring",
        "distance (measurement)",
        "height (measurement)",
        "height (profile)",
    ],
    "Polygon": [
        "polygon",
        "area (measurement)",
    ],
}

LEGACY_PROJ4_OVERRIDES = {
    "EPSG:23700": (
        "+proj=somerc +lat_0=47.14439372222222 +lon_0=19.04857177777778 "
        "+k_0=0.99993 +x_0=650000 +y_0=200000 +ellps=GRS67 "
        "+towgs84=52.17,-71.82,-14.9 +units=m +no_defs"
    ),
    "EPSG:2177": (
        "+proj=tmerc +lat_0=0 +lon_0=18 +k=0.999923 +x_0=6500000 +y_0=0 "
        "+ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs"
    ),
    "EPSG:2178": (
        "+proj=tmerc +lat_0=0 +lon_0=21 +k=0.999923 +x_0=7500000 +y_0=0 "
        "+ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs"
    ),
}


class RasterConversionWorker(QObject):
    """Background worker that converts LAS/LAZ input into a GeoTIFF raster."""

    finished = pyqtSignal(dict)
    failed = pyqtSignal(str)

    def __init__(
        self,
        backend: str,
        input_las: Path,
        mode: str,
        script_path_raw: str,
        output_dir: Optional[Path] = None,
        output_epsg: Optional[int] = None,
    ):
        """Store raster conversion settings for threaded execution."""
        super().__init__()
        self.backend = backend
        self.input_las = input_las
        self.mode = mode
        self.script_path_raw = script_path_raw
        self.output_dir = output_dir
        self.output_epsg = output_epsg

    def run(self) -> None:
        """Run the selected backend and emit either a result payload or an error."""
        try:
            if self.backend == RASTER_BACKEND_BLAST2DEM:
                self.finished.emit(self._run_blast2dem())
                return

            self.finished.emit(self._run_potreecraft())
        except Exception as exc:
            self.failed.emit(str(exc))

    def _run_blast2dem(self) -> dict:
        """Execute the legacy `blast2dem` backend and capture its outputs."""
        if self.output_dir is None:
            raise ValueError("Output directory is required for blast2dem conversion.")

        raster_dir = self.output_dir / "raster"
        raster_dir.mkdir(parents=True, exist_ok=True)
        tif_out = raster_dir / f"{self.input_las.stem}_{self.mode}.tif"
        blast2dem_path = self.script_path_raw or "blast2dem"
        if self.script_path_raw:
            blast2dem_executable = Path(self.script_path_raw).expanduser()
            if not blast2dem_executable.exists():
                raise FileNotFoundError(f"blast2dem executable not found:\n{blast2dem_executable}")
            blast2dem_path = str(blast2dem_executable)

        legacy_flag = LEGACY_RASTER_MODE_FLAGS.get(self.mode)
        if not legacy_flag:
            raise ValueError(
                f"Raster mode '{self.mode}' is not supported by the legacy blast2dem backend."
            )

        command = [
            blast2dem_path,
            "-i",
            str(self.input_las),
            "-o",
            str(tif_out),
            "-otif",
            "-v",
            legacy_flag,
        ]

        process = subprocess.run(command, capture_output=True, text=True)
        return {
            "backend": self.backend,
            "command": command,
            "stdout": process.stdout or "",
            "stderr": process.stderr or "",
            "returncode": process.returncode,
            "tif_out": str(tif_out),
        }

    def _run_potreecraft(self) -> dict:
        """Execute the built-in PotreeCraft raster conversion backend."""
        if self.output_epsg is None:
            raise ValueError("Output EPSG is required for PotreeCraft conversion.")

        try:
            from .potreecraft_lasreader import convert_las_to_geotiff
        except ModuleNotFoundError as exc:
            raise ModuleNotFoundError(
                "Built-in raster conversion is not available in the deployed plugin copy. "
                "Redeploy or reload the plugin so potreecraft_lasreader.py is included."
            ) from exc

        if self.output_dir is None:
            raise ValueError("Project folder is required for PotreeCraft conversion.")

        raster_dir = self.output_dir / "raster"
        tif_out = convert_las_to_geotiff(self.input_las, raster_dir, self.output_epsg, self.mode)
        return {
            "backend": self.backend,
            "command": None,
            "stdout": "",
            "stderr": "",
            "returncode": 0,
            "tif_out": str(tif_out),
        }


class CompileProjectWorker(QObject):
    """Background worker that compiles the Potree project off the UI thread."""

    finished = pyqtSignal(dict)
    failed = pyqtSignal(str)
    log_message = pyqtSignal(str)

    def __init__(
        self,
        potreeconverter_path: Path,
        input_path: Path,
        output_dir: Path,
        project_name: str,
        vector_data_dir: Path,
        pointcloud_display_mode: str,
        point_radius: float,
        projection: str,
        projection_definitions: Optional[Sequence[dict[str, str]]],
        cesium_map: bool,
        cesium_map_sea_level: float,
        default_camera_mode: str,
        default_camera_position: Optional[
            Tuple[Tuple[float, float, float], Tuple[float, float, float]]
        ],
    ):
        """Store project compilation options for later threaded execution."""
        super().__init__()
        self.potreeconverter_path = potreeconverter_path
        self.input_path = input_path
        self.output_dir = output_dir
        self.project_name = project_name
        self.vector_data_dir = vector_data_dir
        self.pointcloud_display_mode = pointcloud_display_mode
        self.point_radius = point_radius
        self.projection = projection
        self.projection_definitions = list(projection_definitions or [])
        self.cesium_map = cesium_map
        self.cesium_map_sea_level = cesium_map_sea_level
        self.default_camera_mode = default_camera_mode
        self.default_camera_position = default_camera_position

    def run(self) -> None:
        """Compile the Potree project and forward progress messages to the UI."""
        try:
            compile_potree_project(
                potreeconverter_path=self.potreeconverter_path,
                input_path=self.input_path,
                output_dir=self.output_dir,
                project_name=self.project_name,
                vector_data_dir=self.vector_data_dir,
                pointcloud_display_mode=self.pointcloud_display_mode,
                point_radius=self.point_radius,
                projection=self.projection,
                projection_definitions=self.projection_definitions,
                cesium_map=self.cesium_map,
                cesium_map_sea_level=self.cesium_map_sea_level,
                default_camera_mode=self.default_camera_mode,
                default_camera_position=self.default_camera_position,
                log_callback=self.log_message.emit,
            )
            self.finished.emit({"output_dir": str(self.output_dir)})
        except Exception as exc:
            self.failed.emit(str(exc))


class PotreeCraftDialog(QDialog, FORM_CLASS):
    """PotreeCraft UI for vector export and project compilation."""

    def __init__(self, iface, parent=None):
        """Build the dialog, wire signals, and load initial project state."""
        super().__init__(parent)
        self.setupUi(self)
        self.iface = iface
        self._python_executable_path = sys.executable
        self._raster_backend = RASTER_BACKEND_POTREECRAFT

        self._function_boxes: Dict[int, QComboBox] = {}
        self._annotation_title_boxes: Dict[int, QComboBox] = {}
        self._annotation_desc_boxes: Dict[int, QComboBox] = {}
        self._raster_worker_thread: Optional[QThread] = None
        self._raster_worker: Optional[RasterConversionWorker] = None
        self._raster_progress_dialog: Optional[QProgressDialog] = None
        self._compile_worker_thread: Optional[QThread] = None
        self._compile_worker: Optional[CompileProjectWorker] = None
        self._compile_progress_dialog: Optional[QProgressDialog] = None

        self.refresh_layers_button.clicked.connect(self.refresh_vector_layers)
        self.las_browse_button.clicked.connect(self._browse_las_input)
        self.output_browse_button.clicked.connect(self._browse_output_folder)
        self.potreeconverter_browse_button.clicked.connect(self._browse_potreeconverter_path)
        self.potreeconverter_path_edit.editingFinished.connect(self._persist_potreeconverter_path)
        self.raster_script_browse_button.clicked.connect(self._browse_raster_script)
        self.run_raster_conversion_button.clicked.connect(self.run_las_to_tif_conversion)
        self.convert_vectors_button.clicked.connect(self.convert_selected_vectors)
        self.compile_button.clicked.connect(self.compile_project)
        self.copy_python_path_button.clicked.connect(self._copy_python_path)
        self.button_box.rejected.connect(self.reject)
        self.button_box.accepted.connect(self.accept)
        QgsProject.instance().crsChanged.connect(self._update_project_crs_label)

        self.pointcloud_mode_combo.clear()
        self.pointcloud_mode_combo.addItems(POINTCLOUD_MODES)
        self.point_radius_spinbox.setDecimals(3)
        self.point_radius_spinbox.setMinimum(0.001)
        self.point_radius_spinbox.setMaximum(1_000_000.0)
        self.point_radius_spinbox.setSingleStep(0.5)
        self.point_radius_spinbox.setValue(5.0)
        self.point_radius_spinbox.setToolTip(
            "Radius used for point vector overlays in the generated Potree HTML."
        )
        self.default_camera_mode_combo.clear()
        self.default_camera_mode_combo.addItem("Fit to Screen", CAMERA_MODE_FIT_TO_SCREEN)
        self.default_camera_mode_combo.addItem("Custom Camera Position", CAMERA_MODE_CUSTOM)
        self.default_camera_mode_combo.currentIndexChanged.connect(
            self._on_default_camera_mode_changed
        )
        camera_validator = QDoubleValidator(self)
        camera_validator.setNotation(QDoubleValidator.StandardNotation)
        camera_validator.setLocale(QLocale.c())
        for line_edit in self._camera_value_edits():
            line_edit.setValidator(camera_validator)
        self.cesium_elevation_slider.setValue(0)
        self.cesium_elevation_spinbox.setValue(0.0)
        self.cesium_map_checkbox.toggled.connect(self._on_cesium_map_toggled)
        self.cesium_elevation_slider.valueChanged.connect(self._on_cesium_slider_changed)
        self.cesium_elevation_spinbox.valueChanged.connect(self._on_cesium_spinbox_changed)
        self.raster_mode_combo.clear()
        self.raster_mode_combo.addItems(POINTCLOUD_MODES)
        self.raster_backend_status_edit.setMinimumWidth(120)
        self._init_raster_backend_menu()

        self.layers_table.setColumnCount(7)
        self.layers_table.setHorizontalHeaderLabels(
            [
                "Use",
                "Layer",
                "Geometry",
                "Color",
                "Function",
                "Title Field",
                "Description Field",
            ]
        )
        self.layers_table.verticalHeader().setVisible(False)
        self.layers_table.horizontalHeader().setStretchLastSection(True)
        self.layers_table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive)
        self.layers_table.setColumnWidth(0, 55)
        self.layers_table.setColumnWidth(1, 180)
        self.layers_table.setColumnWidth(2, 95)
        self.layers_table.setColumnWidth(3, 95)
        self.layers_table.setColumnWidth(4, 210)
        self.layers_table.setColumnWidth(5, 160)
        self.layers_table.setSelectionMode(QAbstractItemView.NoSelection)
        self.layers_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
        self.copy_python_path_button.setMaximumWidth(90)

        self._update_python_status()
        self._load_potreeconverter_path()
        self._update_project_crs_label()

        self.refresh_vector_layers()
        self._update_default_camera_controls_enabled()

    def log(self, message: str) -> None:
        """Append a message to the dialog log and mirror it into QGIS logs."""
        self.log_box.appendPlainText(message)
        self._log_qgis_message(message, Qgis.Info)

    def _log_qgis_message(self, message: str, level=Qgis.Info) -> None:
        """Send a message to the QGIS message log under the plugin channel."""
        QgsMessageLog.logMessage(message, "PotreeCraft", level)

    def _show_warning(self, message: str) -> None:
        """Log a warning and display it to the user in a modal dialog."""
        self.log(message)
        self._log_qgis_message(message, Qgis.Warning)
        QMessageBox.warning(self, "PotreeCraft", message)

    def _show_conversion_progress_dialog(self) -> QProgressDialog:
        """Create the modal progress dialog shown during raster conversion."""
        progress = QProgressDialog(
            "Converting pointcloud, this may take a few moments...",
            "",
            0,
            0,
            self,
        )
        progress.setWindowTitle("PotreeCraft")
        progress.setCancelButton(None)
        progress.setMinimumDuration(0)
        progress.setWindowModality(Qt.WindowModal)
        progress.setAutoClose(False)
        progress.setAutoReset(False)
        progress.show()
        progress.raise_()
        progress.activateWindow()
        return progress

    def _on_cesium_map_toggled(self, checked: bool) -> None:
        """Enable or disable Cesium-related controls and log the new state."""
        self.cesium_elevation_slider.setEnabled(checked)
        self.cesium_elevation_spinbox.setEnabled(checked)
        state = "enabled" if checked else "disabled"
        self.log(f"Cesium base map {state}.")
        if checked:
            self.log(
                f"Cesium map elevation set to {self.cesium_elevation_spinbox.value():.1f} m."
            )

    def _camera_value_edits(self):
        """Return the six line edits used for custom default camera values."""
        return [
            self.camera_position_x_edit,
            self.camera_position_y_edit,
            self.camera_position_z_edit,
            self.camera_target_x_edit,
            self.camera_target_y_edit,
            self.camera_target_z_edit,
        ]

    def _is_custom_camera_mode_selected(self) -> bool:
        """Return whether the dialog is configured for a custom default camera."""
        return self.default_camera_mode_combo.currentData() == CAMERA_MODE_CUSTOM

    def _update_default_camera_controls_enabled(self) -> None:
        """Enable custom camera inputs only when custom mode is selected."""
        enabled = self._is_custom_camera_mode_selected()
        for line_edit in self._camera_value_edits():
            line_edit.setEnabled(enabled)

    def _on_default_camera_mode_changed(self, *_args) -> None:
        """React to camera mode changes and refresh related controls."""
        self._update_default_camera_controls_enabled()

    def _parse_camera_triplet(
        self, fields, label: str
    ) -> Optional[Tuple[float, float, float]]:
        """Parse three float text boxes into a coordinate tuple."""
        values = []
        for axis_label, field in zip(("x", "y", "z"), fields):
            raw_value = field.text().strip()
            if not raw_value:
                self._show_warning(f"Please enter {label} {axis_label}.")
                field.setFocus()
                return None
            try:
                values.append(float(raw_value.replace(",", ".")))
            except ValueError:
                self._show_warning(f"{label} {axis_label} must be a float value.")
                field.setFocus()
                return None
        return (values[0], values[1], values[2])

    def _on_cesium_slider_changed(self, value: int) -> None:
        """Keep the Cesium elevation spin box synchronized with the slider."""
        spinbox_value = value / 10.0
        if self.cesium_elevation_spinbox.value() != spinbox_value:
            self.cesium_elevation_spinbox.blockSignals(True)
            self.cesium_elevation_spinbox.setValue(spinbox_value)
            self.cesium_elevation_spinbox.blockSignals(False)

    def _on_cesium_spinbox_changed(self, value: float) -> None:
        """Keep the Cesium elevation slider synchronized with the spin box."""
        slider_value = int(round(value * 10))
        if self.cesium_elevation_slider.value() != slider_value:
            self.cesium_elevation_slider.blockSignals(True)
            self.cesium_elevation_slider.setValue(slider_value)
            self.cesium_elevation_slider.blockSignals(False)

    def _show_compile_progress_dialog(self) -> QProgressDialog:
        """Create the modal progress dialog shown during project compilation."""
        progress = QProgressDialog(
            "Compiling Potree project, this may take a few moments...",
            "",
            0,
            0,
            self,
        )
        progress.setWindowTitle("PotreeCraft")
        progress.setCancelButton(None)
        progress.setMinimumDuration(0)
        progress.setWindowModality(Qt.WindowModal)
        progress.setAutoClose(False)
        progress.setAutoReset(False)
        progress.show()
        progress.raise_()
        progress.activateWindow()
        return progress

    def _set_raster_conversion_ui_busy(self, busy: bool) -> None:
        """Toggle raster conversion widgets based on worker activity."""
        self.run_raster_conversion_button.setEnabled(not busy)
        self.raster_backend_button.setEnabled(not busy)
        self.raster_mode_combo.setEnabled(not busy)
        self.las_browse_button.setEnabled(not busy)
        self.output_browse_button.setEnabled(not busy)
        self.raster_script_browse_button.setEnabled(not busy and self._raster_backend == RASTER_BACKEND_BLAST2DEM)

    def _cleanup_raster_worker(self) -> None:
        """Tear down raster worker state after success, failure, or cancellation."""
        if self._raster_progress_dialog is not None:
            self._raster_progress_dialog.close()
            self._raster_progress_dialog.deleteLater()
            self._raster_progress_dialog = None

        self._set_raster_conversion_ui_busy(False)

        if self._raster_worker_thread is not None:
            self._raster_worker_thread.quit()
            self._raster_worker_thread.wait()
            self._raster_worker_thread.deleteLater()
            self._raster_worker_thread = None

        self._raster_worker = None

    def _set_compile_ui_busy(self, busy: bool) -> None:
        """Toggle compilation-related widgets while the compile worker runs."""
        self.compile_button.setEnabled(not busy)
        self.convert_vectors_button.setEnabled(not busy)
        self.refresh_layers_button.setEnabled(not busy)
        self.point_radius_spinbox.setEnabled(not busy)
        self.las_browse_button.setEnabled(not busy)
        self.output_browse_button.setEnabled(not busy)
        self.potreeconverter_browse_button.setEnabled(not busy)
        self.potreeconverter_path_edit.setEnabled(not busy)
        self.project_name_edit.setEnabled(not busy)
        self.pointcloud_mode_combo.setEnabled(not busy)
        self.cesium_map_checkbox.setEnabled(not busy)
        self.default_camera_mode_combo.setEnabled(not busy)
        cesium_controls_enabled = (not busy) and self.cesium_map_checkbox.isChecked()
        self.cesium_elevation_slider.setEnabled(cesium_controls_enabled)
        self.cesium_elevation_spinbox.setEnabled(cesium_controls_enabled)
        camera_controls_enabled = (not busy) and self._is_custom_camera_mode_selected()
        for line_edit in self._camera_value_edits():
            line_edit.setEnabled(camera_controls_enabled)

    def _cleanup_compile_worker(self) -> None:
        """Tear down compile worker state after the background task ends."""
        if self._compile_progress_dialog is not None:
            self._compile_progress_dialog.close()
            self._compile_progress_dialog.deleteLater()
            self._compile_progress_dialog = None

        self._set_compile_ui_busy(False)

        if self._compile_worker_thread is not None:
            self._compile_worker_thread.quit()
            self._compile_worker_thread.wait()
            self._compile_worker_thread.deleteLater()
            self._compile_worker_thread = None

        self._compile_worker = None

    def _start_compile_worker(self, worker: CompileProjectWorker) -> None:
        """Move a compile worker into a thread and connect its lifecycle hooks."""
        self._cleanup_compile_worker()
        self._set_compile_ui_busy(True)
        self._compile_progress_dialog = self._show_compile_progress_dialog()

        thread = QThread(self)
        worker.moveToThread(thread)
        thread.started.connect(worker.run)
        worker.log_message.connect(self.log)
        worker.finished.connect(self._on_compile_finished)
        worker.failed.connect(self._on_compile_failed)
        worker.finished.connect(thread.quit)
        worker.failed.connect(thread.quit)
        thread.finished.connect(worker.deleteLater)

        self._compile_worker = worker
        self._compile_worker_thread = thread
        thread.start()

    def _start_raster_worker(self, worker: RasterConversionWorker) -> None:
        """Move a raster worker into a thread and connect its lifecycle hooks."""
        self._cleanup_raster_worker()
        self._set_raster_conversion_ui_busy(True)
        self._raster_progress_dialog = self._show_conversion_progress_dialog()

        thread = QThread(self)
        worker.moveToThread(thread)
        thread.started.connect(worker.run)
        worker.finished.connect(self._on_raster_conversion_finished)
        worker.failed.connect(self._on_raster_conversion_failed)
        worker.finished.connect(thread.quit)
        worker.failed.connect(thread.quit)
        thread.finished.connect(worker.deleteLater)

        self._raster_worker = worker
        self._raster_worker_thread = thread
        thread.start()

    def _on_raster_conversion_finished(self, result: dict) -> None:
        """Handle successful raster conversion and load the output into QGIS."""
        try:
            backend = result["backend"]
            tif_out = Path(result["tif_out"])

            if backend == RASTER_BACKEND_BLAST2DEM:
                command = result.get("command") or []
                self.log("Running LAS->GeoTIFF conversion:")
                self.log(f"Selected backend: {RASTER_BACKEND_LABELS[backend]}")
                self.log(" ".join(command))
                if result.get("stdout"):
                    self.log(result["stdout"].rstrip())
                if result.get("stderr"):
                    self.log(result["stderr"].rstrip())

                if result.get("returncode") != 0:
                    self._show_warning(
                        f"{RASTER_BACKEND_LABELS[backend]} failed with code {result.get('returncode')}."
                    )
                    return

                if not tif_out.exists():
                    self._show_warning(f"Script finished but output raster was not found:\n{tif_out}")
                    return
            else:
                self.log(f"Created GeoTIFF next to source LAS/LAZ: {tif_out}")

            layer_name = tif_out.stem
            raster = self.iface.addRasterLayer(str(tif_out), layer_name)
            if not raster:
                self._show_warning("GeoTIFF created, but QGIS failed to load raster layer.")
                return

            self.log(f"Raster layer added: {tif_out}")
            QMessageBox.information(
                self, "PotreeCraft", "LAS->GeoTIFF conversion complete and raster layer loaded."
            )
        finally:
            self._cleanup_raster_worker()

    def _on_raster_conversion_failed(self, error_message: str) -> None:
        """Handle raster conversion failures and restore the dialog state."""
        try:
            if self._raster_backend == RASTER_BACKEND_POTREECRAFT:
                error_message = f"PotreeCraft (rasterio + numpy) failed: {error_message}"
            self._show_warning(error_message)
        finally:
            self._cleanup_raster_worker()

    def _on_compile_finished(self, result: dict) -> None:
        """Handle successful project compilation and notify the user."""
        try:
            output_dir = result.get("output_dir")
            if output_dir:
                self.log(f"Compilation completed successfully: {output_dir}")
            QMessageBox.information(self, "PotreeCraft", "Compilation completed successfully.")
        finally:
            self._cleanup_compile_worker()

    def _on_compile_failed(self, error_message: str) -> None:
        """Handle compilation failures and restore the dialog state."""
        try:
            self._show_warning(f"Compilation failed. Check the log for details.\n{error_message}")
        finally:
            self._cleanup_compile_worker()

    def _update_python_status(self) -> None:
        """Show the active QGIS Python version and executable details."""
        version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
        install_root = Path(sys.executable).resolve().parent.parent
        self.python_status_label.setText(
            f"QGIS Python: {version} | Executable: {sys.executable} | Install root: {install_root}"
        )
        self.python_status_label.setToolTip(sys.executable)

    def _copy_python_path(self) -> None:
        """Copy the current QGIS Python executable path to the clipboard."""
        QApplication.clipboard().setText(self._python_executable_path)
        self.log(f"Copied Python executable path: {self._python_executable_path}")

    def _browse_las_input(self) -> None:
        """Prompt for a LAS/LAZ input file and infer a default project name."""
        path, _ = QFileDialog.getOpenFileName(
            self, "Select LAS/LAZ File", "", "LAS/LAZ (*.las *.laz);;All files (*.*)"
        )
        if path:
            self.las_input_edit.setText(path)
            if not self.project_name_edit.text().strip():
                self.project_name_edit.setText(Path(path).stem)

    def _browse_output_folder(self) -> None:
        """Prompt for the output folder used by exports and compilation."""
        path = QFileDialog.getExistingDirectory(self, "Select Project Folder", "")
        if path:
            self.output_folder_edit.setText(path)
            self.log(f"Selected project folder: {path}")

    def _load_potreeconverter_path(self) -> None:
        """Load the saved PotreeConverter executable path from QGIS settings."""
        settings = QSettings()
        saved_path = settings.value(POTREECONVERTER_SETTINGS_KEY, "", type=str)
        if saved_path:
            self.potreeconverter_path_edit.setText(saved_path)

    def _save_potreeconverter_path(self, path: str) -> None:
        """Persist the PotreeConverter executable path into QGIS settings."""
        QSettings().setValue(POTREECONVERTER_SETTINGS_KEY, path)

    def _persist_potreeconverter_path(self) -> None:
        """Save the currently entered PotreeConverter path after editing."""
        path = self.potreeconverter_path_edit.text().strip()
        self._save_potreeconverter_path(path)
        if path:
            self.log(f"PotreeConverter executable path set: {path}")

    def _browse_potreeconverter_path(self) -> None:
        """Prompt for the PotreeConverter executable and save the selection."""
        if sys.platform.startswith("win"):
            file_filter = "Executables (*.exe);;All files (*.*)"
        else:
            file_filter = "All files (*);;Executables (*)"

        path, _ = QFileDialog.getOpenFileName(
            self,
            "Select PotreeConverter Executable",
            self.potreeconverter_path_edit.text().strip(),
            file_filter,
        )
        if path:
            self.potreeconverter_path_edit.setText(path)
            self._save_potreeconverter_path(path)
            self.log(f"Selected PotreeConverter executable: {path}")

    def _browse_raster_script(self) -> None:
        """Prompt for the optional external raster backend executable."""
        if self._raster_backend == RASTER_BACKEND_BLAST2DEM:
            title = "Select blast2dem Executable"
            file_filter = "Executables (*.exe *.bat *.cmd);;All files (*.*)"
        else:
            title = "Select LAS Conversion Script"
            file_filter = "Python (*.py);;All files (*.*)"

        path, _ = QFileDialog.getOpenFileName(self, title, "", file_filter)
        if path:
            self.raster_script_edit.setText(path)

    def _init_raster_backend_menu(self) -> None:
        """Build the raster backend selection menu and connect its actions."""
        menu = QMenu(self)
        action_group = QActionGroup(self)
        action_group.setExclusive(True)

        for backend_key in (RASTER_BACKEND_POTREECRAFT, RASTER_BACKEND_BLAST2DEM):
            action = menu.addAction(RASTER_BACKEND_LABELS[backend_key])
            action.setCheckable(True)
            action.setData(backend_key)
            action_group.addAction(action)
            if backend_key == self._raster_backend:
                action.setChecked(True)

        action_group.triggered.connect(self._on_raster_backend_action_triggered)
        self._raster_backend_action_group = action_group
        self.raster_backend_button.setMenu(menu)
        self._update_raster_backend_ui()

    def _on_raster_backend_action_triggered(self, action) -> None:
        """Switch the active raster backend when the user picks a menu item."""
        backend_key = action.data()
        if backend_key:
            self._raster_backend = backend_key
            self._update_raster_backend_ui()
            self.log(f"Selected raster backend: {RASTER_BACKEND_LABELS[self._raster_backend]}")

    def _update_raster_backend_ui(self) -> None:
        """Refresh labels, placeholders, and control states for the active backend."""
        self.raster_backend_status_edit.setText(RASTER_BACKEND_LABELS[self._raster_backend])

        if self._raster_backend == RASTER_BACKEND_BLAST2DEM:
            self.label_raster_script.setText("blast2dem Executable")
            self.raster_script_edit.setEnabled(True)
            self.raster_script_browse_button.setEnabled(True)
            self.raster_script_edit.setPlaceholderText(
                "Optional. Leave empty to use blast2dem from PATH."
            )
            self.raster_script_edit.setToolTip(
                "Path to blast2dem. Leave blank to run blast2dem from the system PATH."
            )
        else:
            self.label_raster_script.setText("Built-in Converter")
            self.raster_script_edit.clear()
            self.raster_script_edit.setEnabled(False)
            self.raster_script_browse_button.setEnabled(False)
            self.raster_script_edit.setPlaceholderText(
                "Built-in PotreeCraft conversion uses qgis_plugin/potreecraft_lasreader.py."
            )
            self.raster_script_edit.setToolTip(
                "PotreeCraft mode uses the built-in LAS reader and writes the GeoTIFF next to the LAS/LAZ file."
            )

    def refresh_vector_layers(self) -> None:
        """Reload vector layers from the current QGIS project into the table."""
        self.log("Refreshing vector layers from current project...")
        self._update_project_crs_label()
        self.layers_table.setRowCount(0)
        self._function_boxes.clear()
        self._annotation_title_boxes.clear()
        self._annotation_desc_boxes.clear()

        vector_layers = [
            lyr
            for lyr in QgsProject.instance().mapLayers().values()
            if lyr.type() == QgsMapLayerType.VectorLayer
        ]

        for layer in vector_layers:
            self._add_vector_layer_row(layer)

        self.log(f"Loaded {len(vector_layers)} vector layer(s) from current project.")

    def _update_project_crs_label(self) -> None:
        """Display the current project CRS in a readable status label."""
        project_crs = QgsProject.instance().crs()
        authid = project_crs.authid().strip()
        description = project_crs.description().strip()

        if authid and description:
            label_text = f"Project CRS: {authid} ({description})"
        elif authid:
            label_text = f"Project CRS: {authid}"
        elif description:
            label_text = f"Project CRS: {description}"
        else:
            label_text = "Project CRS: not set"

        self.project_crs_label.setText(label_text)

    def _add_vector_layer_row(self, layer) -> None:
        """Insert one vector layer row with export and function controls."""
        row = self.layers_table.rowCount()
        self.layers_table.insertRow(row)

        use_item = QTableWidgetItem()
        use_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsUserCheckable)
        use_item.setCheckState(Qt.Checked)
        self.layers_table.setItem(row, 0, use_item)

        layer_item = QTableWidgetItem(layer.name())
        layer_item.setData(Qt.UserRole, layer.id())
        self.layers_table.setItem(row, 1, layer_item)

        geom_label = self._geometry_label(layer)
        self.layers_table.setItem(row, 2, QTableWidgetItem(geom_label))

        color_hex = self._layer_color_hex(layer)
        color_item = QTableWidgetItem(color_hex)
        color_item.setBackground(QColor(color_hex))
        self.layers_table.setItem(row, 3, color_item)

        function_combo = QComboBox()
        function_options = self._function_options_for_geometry(geom_label)
        function_combo.addItems(function_options)
        self.layers_table.setCellWidget(row, 4, function_combo)

        title_combo = QComboBox()
        title_combo.setEnabled(False)
        self.layers_table.setCellWidget(row, 5, title_combo)

        desc_combo = QComboBox()
        desc_combo.setEnabled(False)
        self.layers_table.setCellWidget(row, 6, desc_combo)

        function_combo.currentTextChanged.connect(
            lambda value, current_row=row, title=title_combo, desc=desc_combo: self._apply_annotation_field_state(
                current_row, value, title, desc
            )
        )
        self._populate_annotation_field_boxes(row, title_combo, desc_combo)
        self._apply_annotation_field_state(row, function_combo.currentText(), title_combo, desc_combo)

        self._function_boxes[row] = function_combo
        self._annotation_title_boxes[row] = title_combo
        self._annotation_desc_boxes[row] = desc_combo

    @staticmethod
    def _function_options_for_geometry(geometry_label: str) -> Sequence[str]:
        """Return the allowed function choices for a geometry label."""
        return VECTOR_FUNCTIONS.get(geometry_label, [geometry_label.lower()])

    @staticmethod
    def _is_annotation_function(function_name: str) -> bool:
        """Return whether the selected function should enable annotation fields."""
        return function_name == "annotation"

    def _point_layer_field_names(self, row: int) -> List[str]:
        """Return current attribute field names for the point layer in a table row."""
        layer = self._layer_from_row(row)
        if layer is None:
            return []
        if self._geometry_label(layer) != "Point":
            return []
        return [field.name() for field in layer.fields()]

    def _populate_annotation_field_boxes(
        self, row: int, title_combo: QComboBox, desc_combo: QComboBox
    ) -> None:
        """Refresh title/description field choices from the current point layer schema."""
        title_current = title_combo.currentText() if title_combo.count() else "(none)"
        desc_current = desc_combo.currentText() if desc_combo.count() else "(none)"
        field_names = self._point_layer_field_names(row)

        for combo, current_value in ((title_combo, title_current), (desc_combo, desc_current)):
            combo.blockSignals(True)
            combo.clear()
            combo.addItem("(none)")
            combo.addItems(field_names)
            index = combo.findText(current_value)
            combo.setCurrentIndex(index if index >= 0 else 0)
            combo.blockSignals(False)

    def _apply_annotation_field_state(
        self, row: int, function_name: str, title_combo: QComboBox, desc_combo: QComboBox
    ) -> None:
        """Enable title/description controls only for point annotations."""
        enabled = self._is_annotation_function(function_name)
        if enabled:
            self._populate_annotation_field_boxes(row, title_combo, desc_combo)
        title_combo.setEnabled(enabled)
        desc_combo.setEnabled(enabled)
        if not enabled:
            title_combo.setCurrentIndex(0)
            desc_combo.setCurrentIndex(0)

    def _layer_from_row(self, row: int):
        """Resolve the QGIS layer instance represented by a table row."""
        item = self.layers_table.item(row, 1)
        if item is None:
            return None
        layer_id = item.data(Qt.UserRole)
        if not layer_id:
            return None
        return QgsProject.instance().mapLayer(layer_id)

    @staticmethod
    def _geometry_label(layer) -> str:
        """Return a human-readable geometry label for a QGIS vector layer."""
        geom_type = QgsWkbTypes.geometryType(layer.wkbType())
        if geom_type == QgsWkbTypes.PointGeometry:
            return "Point"
        if geom_type == QgsWkbTypes.LineGeometry:
            return "LineString"
        if geom_type == QgsWkbTypes.PolygonGeometry:
            return "Polygon"
        return "Unknown"

    @staticmethod
    def _layer_color_hex(layer) -> str:
        """Extract the layer's renderer color or fall back to neutral gray."""
        try:
            symbol = layer.renderer().symbol()
            if symbol and symbol.color().isValid():
                return symbol.color().name(QColor.HexRgb)
        except Exception:
            pass
        return "#808080"

    @staticmethod
    def _epsg_from_project_crs() -> Optional[int]:
        """Return the current QGIS project EPSG code when it is explicitly set."""
        project_authid = QgsProject.instance().crs().authid().strip()
        if not project_authid.startswith("EPSG:"):
            return None

        try:
            return int(project_authid.split(":", 1)[1])
        except (TypeError, ValueError):
            return None

    @staticmethod
    def _epsg_from_las_header(input_las: Path) -> Optional[int]:
        """Return the EPSG code declared in the LAS/LAZ header, if present."""
        suffix = input_las.suffix.lower()

        if suffix == ".laz":
            available_backends = laspy.LazBackend.detect_available()
            if not available_backends:
                return None
            las_handle = laspy.open(input_las, laz_backend=available_backends)
        else:
            las_handle = laspy.open(input_las)

        with las_handle as las_file:
            header = las_file.header
            crs = header.parse_crs()
            if crs is None:
                return None
            return crs.to_epsg()

    @staticmethod
    def _normalize_proj_string(proj_string: str) -> str:
        """Collapse redundant whitespace in PROJ strings for stable output."""
        return " ".join((proj_string or "").strip().split())

    @classmethod
    def _apply_legacy_proj4_override(cls, authid: str, proj4: str) -> str:
        """Prefer known legacy proj4 definitions where proj4js needs exact datum terms."""
        normalized_authid = (authid or "").strip()
        override = LEGACY_PROJ4_OVERRIDES.get(normalized_authid)
        if not override:
            return cls._normalize_proj_string(proj4)
        return cls._normalize_proj_string(override)

    @classmethod
    def _proj_string_from_qgis_crs(cls, crs) -> str:
        """Return a normalized PROJ string from a QGIS CRS when available."""
        for method_name in ("toProj", "toProj4"):
            method = getattr(crs, method_name, None)
            if not callable(method):
                continue
            try:
                value = cls._normalize_proj_string(method())
            except Exception:
                value = ""
            if value:
                return value
        return ""

    @classmethod
    def _crs_info_from_qgis_crs(cls, crs) -> dict[str, str]:
        """Return auth id and PROJ string for a QGIS CRS."""
        authid = ""
        try:
            authid = (crs.authid() or "").strip()
        except Exception:
            authid = ""
        proj4 = cls._proj_string_from_qgis_crs(crs)
        return {"authid": authid, "proj4": cls._apply_legacy_proj4_override(authid, proj4)}

    @classmethod
    def _crs_info_from_las_header(cls, input_las: Path) -> dict[str, str]:
        """Return auth id and PROJ string declared in the LAS/LAZ header."""
        suffix = input_las.suffix.lower()

        if suffix == ".laz":
            available_backends = laspy.LazBackend.detect_available()
            if not available_backends:
                return {"authid": "", "proj4": ""}
            las_handle = laspy.open(input_las, laz_backend=available_backends)
        else:
            las_handle = laspy.open(input_las)

        with las_handle as las_file:
            crs = las_file.header.parse_crs()
            if crs is None:
                return {"authid": "", "proj4": ""}

            epsg = crs.to_epsg()
            proj4 = ""
            to_proj4 = getattr(crs, "to_proj4", None)
            if callable(to_proj4):
                try:
                    proj4 = cls._normalize_proj_string(to_proj4())
                except Exception:
                    proj4 = ""

            authid = f"EPSG:{epsg}" if epsg else ""
            return {
                "authid": authid,
                "proj4": cls._apply_legacy_proj4_override(authid, proj4),
            }

    def _candidate_raster_layers(self, input_las: Path, output_dir: Path) -> List:
        """Return raster layers ordered by how likely they belong to the selected cloud."""
        project_layers = [
            lyr
            for lyr in QgsProject.instance().mapLayers().values()
            if lyr.type() == QgsMapLayerType.RasterLayer
        ]
        preferred = []
        others = []
        las_stem = input_las.stem.lower()
        raster_dir = (output_dir / "raster").resolve()

        for layer in project_layers:
            source = (layer.source() or "").split("|", 1)[0].strip()
            source_path = Path(source).expanduser()
            try:
                resolved_source = source_path.resolve()
            except Exception:
                resolved_source = source_path

            source_name = source_path.stem.lower()
            try:
                in_plugin_raster_dir = raster_dir in resolved_source.parents
            except Exception:
                in_plugin_raster_dir = False

            matches_las_name = bool(las_stem and las_stem in source_name)
            if in_plugin_raster_dir or matches_las_name:
                preferred.append(layer)
            else:
                others.append(layer)

        return preferred + others

    @staticmethod
    def _projection_value_from_crs_info(crs_info: dict[str, str]) -> str:
        """Pick the best runtime CRS token from a CRS info payload."""
        return crs_info.get("authid") or crs_info.get("proj4") or ""

    @staticmethod
    def _projection_definition_from_crs_info(crs_info: dict[str, str]) -> Optional[dict[str, str]]:
        """Convert CRS info into a proj4.js registration payload when possible."""
        authid = (crs_info.get("authid") or "").strip()
        proj4 = (crs_info.get("proj4") or "").strip()
        if not authid or not proj4:
            return None
        return {"name": authid, "proj4": proj4}

    def _resolve_pointcloud_projection(
        self, input_las: Path, output_dir: Path
    ) -> tuple[str, List[dict[str, str]]]:
        """Resolve fallback projection and proj4 definitions for the current workspace."""
        definitions: List[dict[str, str]] = []
        seen_definition_names = set()

        def add_definition(definition: Optional[dict[str, str]]) -> None:
            if not definition:
                return
            name = definition["name"]
            if name in seen_definition_names:
                return
            seen_definition_names.add(name)
            definitions.append(definition)

        for layer in QgsProject.instance().mapLayers().values():
            if layer.type() != QgsMapLayerType.VectorLayer:
                continue
            add_definition(
                self._projection_definition_from_crs_info(
                    self._crs_info_from_qgis_crs(layer.crs())
                )
            )

        fallback_value = ""
        fallback_source = ""

        try:
            las_crs_info = self._crs_info_from_las_header(input_las)
        except Exception as exc:
            self.log(f"Could not read LAS/LAZ CRS metadata from {input_las}: {exc}")
            las_crs_info = {"authid": "", "proj4": ""}

        add_definition(self._projection_definition_from_crs_info(las_crs_info))
        fallback_value = self._projection_value_from_crs_info(las_crs_info)
        if fallback_value:
            fallback_source = "LAS/LAZ header"

        if not fallback_value:
            for raster_layer in self._candidate_raster_layers(input_las, output_dir):
                raster_crs_info = self._crs_info_from_qgis_crs(raster_layer.crs())
                add_definition(self._projection_definition_from_crs_info(raster_crs_info))
                fallback_value = self._projection_value_from_crs_info(raster_crs_info)
                if fallback_value:
                    fallback_source = f"raster layer '{raster_layer.name()}'"
                    break

        project_crs_info = self._crs_info_from_qgis_crs(QgsProject.instance().crs())
        add_definition(self._projection_definition_from_crs_info(project_crs_info))
        if not fallback_value:
            fallback_value = self._projection_value_from_crs_info(project_crs_info)
            if fallback_value:
                fallback_source = "project CRS"

        if not fallback_value:
            fallback_value = "EPSG:4326"
            fallback_source = "default fallback"

        self.log(
            f"Resolved pointcloud fallback projection from {fallback_source}: {fallback_value}"
        )
        self.log(f"Collected {len(definitions)} proj4 definition(s) from the workspace.")
        return fallback_value, definitions

    def _resolve_raster_output_epsg(self, input_las: Path) -> Optional[int]:
        """Resolve raster output EPSG from project CRS or fall back to the LAS header."""
        project_epsg = self._epsg_from_project_crs()
        if project_epsg is not None:
            project_authid = QgsProject.instance().crs().authid().strip()
            self.log(f"Using project CRS: {project_authid}")
            return project_epsg

        try:
            las_epsg = self._epsg_from_las_header(input_las)
        except Exception as exc:
            self.log(f"Could not read LAS/LAZ CRS metadata from {input_las}: {exc}")
            las_epsg = None

        if las_epsg is not None:
            self.log(
                "Project CRS is not set to an EPSG code; "
                f"falling back to LAS/LAZ header CRS: EPSG:{las_epsg}"
            )
            return las_epsg

        self.log(
            "Project CRS is not set to an EPSG code and the LAS/LAZ header has no usable CRS; "
            "defaulting raster output CRS to EPSG:4326."
        )
        return 4326

    def _selected_vector_layers(self) -> List:
        """Collect all vector layers currently marked for export."""
        selected = []
        for row in range(self.layers_table.rowCount()):
            use_item = self.layers_table.item(row, 0)
            if use_item and use_item.checkState() == Qt.Checked:
                layer = self._layer_from_row(row)
                if layer is not None:
                    selected.append((row, layer))
        return selected

    @staticmethod
    def _sanitized_layer_filename(layer_name: str) -> str:
        """Convert a layer name into a filesystem-friendly GeoJSON basename."""
        return layer_name.replace(" ", "_")

    def _clear_previous_vector_exports(self, vector_out_dir: Path, selected_layers: List) -> None:
        """Remove stale GeoJSON exports for layers that are no longer selected."""
        expected_names = {
            f"{self._sanitized_layer_filename(layer.name())}.geojson"
            for _, layer in selected_layers
        }

        removed = 0
        for existing_file in vector_out_dir.glob("*.geojson"):
            if existing_file.name not in expected_names:
                existing_file.unlink()
                removed += 1

        if removed:
            self.log(
                f"Removed {removed} stale GeoJSON export(s) from previous layer selections."
            )

    def _embed_layer_style_metadata(self, geojson_path: Path, color_hex: str) -> None:
        """Write PotreeCraft color metadata into an exported GeoJSON file."""
        payload = json.loads(geojson_path.read_text(encoding="utf-8"))
        payload["potreecraft_style"] = {"color": color_hex}

        for feature in payload.get("features", []):
            properties = feature.setdefault("properties", {})
            properties["potreecraft_color"] = color_hex

        geojson_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")

    def _validate_common_paths(self) -> Optional[Path]:
        """Validate and create the common project output folder when needed."""
        output_dir = self.output_folder_edit.text().strip()
        if not output_dir:
            self._show_warning("Please set a project folder.")
            return None
        out_path = Path(output_dir).expanduser()
        out_path.mkdir(parents=True, exist_ok=True)
        return out_path

    def _write_manifest(self, manifest_path: Path, vector_export_dir: Path) -> None:
        """Write the project manifest describing exported layers and defaults."""
        layers_data = []
        for row in range(self.layers_table.rowCount()):
            layer = self._layer_from_row(row)
            if layer is None:
                continue

            use_item = self.layers_table.item(row, 0)
            use_layer = bool(use_item and use_item.checkState() == Qt.Checked)
            function_name = (
                self._function_boxes[row].currentText()
                if row in self._function_boxes
                else self._function_options_for_geometry(self._geometry_label(layer))[0]
            )
            title_field = self._annotation_title_boxes[row].currentText() if row in self._annotation_title_boxes else "(none)"
            desc_field = self._annotation_desc_boxes[row].currentText() if row in self._annotation_desc_boxes else "(none)"
            annotation_enabled = self._is_annotation_function(function_name)

            layers_data.append(
                {
                    "layer_id": layer.id(),
                    "name": layer.name(),
                    "use": use_layer,
                    "geometry": self._geometry_label(layer),
                    "function": function_name,
                    "color": self._layer_color_hex(layer),
                    "annotation": {
                        "enabled": annotation_enabled,
                        "title_field": (
                            "" if (not annotation_enabled or title_field == "(none)") else title_field
                        ),
                        "description_field": (
                            ""
                            if (not annotation_enabled or desc_field == "(none)")
                            else desc_field
                        ),
                    },
                }
            )

        payload = {
            "pointcloud_default_display": self.pointcloud_mode_combo.currentText(),
            "point_vector_radius": self.point_radius_spinbox.value(),
            "raster_default_display": self.raster_mode_combo.currentText(),
            "vector_export_dir": str(vector_export_dir),
            "layers": layers_data,
        }
        manifest_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")

    def _export_selected_vectors(self, show_messages: bool = True) -> Optional[Path]:
        """Export checked vector layers to GeoJSON and update the project manifest."""
        output_dir = self._validate_common_paths()
        if output_dir is None:
            return None

        selected = self._selected_vector_layers()
        if not selected:
            if show_messages:
                self._show_warning("No vector layers are selected.")
            return None

        self.log(f"Exporting {len(selected)} selected vector layer(s) to GeoJSON...")

        vector_out_dir = output_dir / "vectors" / "cache"
        vector_out_dir.mkdir(parents=True, exist_ok=True)
        self._clear_previous_vector_exports(vector_out_dir, selected)

        transform_context = QgsProject.instance().transformContext()
        errors = []

        for _, layer in selected:
            sanitized = self._sanitized_layer_filename(layer.name())
            out_file = vector_out_dir / f"{sanitized}.geojson"
            layer_color = self._layer_color_hex(layer)
            options = QgsVectorFileWriter.SaveVectorOptions()
            options.driverName = "GeoJSON"
            options.fileEncoding = "UTF-8"
            options.datasourceOptions = ["RFC7946=YES", "WRITE_BBOX=YES"]

            result = QgsVectorFileWriter.writeAsVectorFormatV3(
                layer, str(out_file), transform_context, options
            )

            if isinstance(result, tuple):
                code = result[0]
                message = result[1] if len(result) > 1 else ""
            else:
                code = result
                message = ""

            if code != QgsVectorFileWriter.NoError:
                errors.append(f"{layer.name()}: {message or code}")
            else:
                self._embed_layer_style_metadata(out_file, layer_color)
                self.log(f"Exported {layer.name()} -> {out_file}")

        manifest_path = output_dir / "potreecraft_project_manifest.json"
        self._write_manifest(manifest_path, vector_out_dir)
        self.log(f"Wrote manifest: {manifest_path}")

        if errors:
            error_text = "\n".join(errors)
            self.log("Vector export errors:\n" + error_text)
            if show_messages:
                self._show_warning(f"Some exports failed:\n{error_text}")
        elif show_messages:
            QMessageBox.information(self, "PotreeCraft", "Vector layers exported successfully.")

        return vector_out_dir

    def convert_selected_vectors(self) -> None:
        """Run a manual export of the currently selected vector layers."""
        self.log("Convert Selected Vector Layers to Standard GeoJSON clicked.")
        self._export_selected_vectors(show_messages=True)

    def run_las_to_tif_conversion(self) -> None:
        """Validate inputs and start LAS-to-GeoTIFF conversion in the background."""
        script_path_raw = self.raster_script_edit.text().strip()
        input_las_raw = self.las_input_edit.text().strip()

        if not input_las_raw:
            self._show_warning("Please select an input LAS/LAZ path.")
            return

        input_las = Path(input_las_raw).expanduser()

        if not input_las.exists():
            self._show_warning(f"LAS/LAZ not found:\n{input_las}")
            return

        mode = self.raster_mode_combo.currentText()

        if self._raster_backend == RASTER_BACKEND_BLAST2DEM:
            output_dir = self._validate_common_paths()
            if output_dir is None:
                return

            if script_path_raw:
                blast2dem_executable = Path(script_path_raw).expanduser()
                if not blast2dem_executable.exists():
                    self._show_warning(f"blast2dem executable not found:\n{blast2dem_executable}")
                    return

            legacy_flag = LEGACY_RASTER_MODE_FLAGS.get(mode)
            if not legacy_flag:
                self._show_warning(
                    f"Raster mode '{mode}' is not supported by the legacy blast2dem backend."
                )
                return

            self._start_raster_worker(
                RasterConversionWorker(
                    backend=self._raster_backend,
                    input_las=input_las,
                    mode=mode,
                    script_path_raw=script_path_raw,
                    output_dir=output_dir,
                )
            )
            return

        output_dir = self._validate_common_paths()
        if output_dir is None:
            return

        output_epsg = self._resolve_raster_output_epsg(input_las)
        if output_epsg is None:
            return

        self.log("Running LAS->GeoTIFF conversion:")
        self.log(f"Selected backend: {RASTER_BACKEND_LABELS[self._raster_backend]}")
        self._start_raster_worker(
            RasterConversionWorker(
                backend=self._raster_backend,
                input_las=input_las,
                mode=mode,
                script_path_raw=script_path_raw,
                output_dir=output_dir,
                output_epsg=output_epsg,
            )
        )

    def compile_project(self) -> None:
        """Validate inputs, export vectors, and start Potree project compilation."""
        las_input_raw = self.las_input_edit.text().strip()
        output_dir = self._validate_common_paths()
        if output_dir is None:
            return
        potreeconverter_raw = self.potreeconverter_path_edit.text().strip()
        if not potreeconverter_raw:
            self._show_warning("Please set the PotreeConverter executable path before compiling.")
            return
        if not las_input_raw:
            self._show_warning("Please select an input LAS/LAZ path.")
            return

        potreeconverter_path = Path(potreeconverter_raw).expanduser()
        if not potreeconverter_path.exists() or not potreeconverter_path.is_file():
            self._show_warning(f"PotreeConverter executable not found:\n{potreeconverter_path}")
            return

        self._save_potreeconverter_path(str(potreeconverter_path))
        las_input = Path(las_input_raw).expanduser()
        if not las_input.exists():
            self._show_warning(f"LAS/LAZ not found:\n{las_input}")
            return

        vector_dir = self._export_selected_vectors(show_messages=False)
        if vector_dir is None:
            self._show_warning("Cannot compile without exported vector GeoJSON files.")
            return

        project_name = self.project_name_edit.text().strip() or las_input.stem
        projection_value, projection_definitions = self._resolve_pointcloud_projection(
            las_input, output_dir
        )
        default_camera_mode = (
            self.default_camera_mode_combo.currentData() or CAMERA_MODE_FIT_TO_SCREEN
        )
        default_camera_position = None
        if default_camera_mode == CAMERA_MODE_CUSTOM:
            camera_position = self._parse_camera_triplet(
                [
                    self.camera_position_x_edit,
                    self.camera_position_y_edit,
                    self.camera_position_z_edit,
                ],
                "Camera position",
            )
            if camera_position is None:
                return
            camera_target = self._parse_camera_triplet(
                [
                    self.camera_target_x_edit,
                    self.camera_target_y_edit,
                    self.camera_target_z_edit,
                ],
                "Camera target",
            )
            if camera_target is None:
                return
            default_camera_position = (camera_position, camera_target)

        self.log("Running Potree project compilation:")
        self.log(f"Using PotreeConverter executable: {potreeconverter_path}")
        self.log(f"Selected default pointcloud display: {self.pointcloud_mode_combo.currentText()}")
        self.log(f"Selected point overlay radius: {self.point_radius_spinbox.value():.3f}")
        self.log(
            "Default camera position: "
            + (
                "fit to screen"
                if default_camera_mode == CAMERA_MODE_FIT_TO_SCREEN
                else "custom camera position"
            )
        )
        if default_camera_position is not None:
            self.log(
                f"Camera position: {default_camera_position[0]} target: {default_camera_position[1]}"
            )
        self.log(
            f"Cesium base map: {'enabled' if self.cesium_map_checkbox.isChecked() else 'disabled'}"
        )
        if self.cesium_map_checkbox.isChecked():
            self.log(
                f"Cesium map elevation: {self.cesium_elevation_spinbox.value():.1f} m"
            )
        self._start_compile_worker(
            CompileProjectWorker(
                potreeconverter_path=potreeconverter_path,
                input_path=las_input,
                output_dir=output_dir,
                project_name=project_name,
                vector_data_dir=vector_dir,
                pointcloud_display_mode=self.pointcloud_mode_combo.currentText(),
                point_radius=self.point_radius_spinbox.value(),
                projection=projection_value,
                projection_definitions=projection_definitions,
                cesium_map=self.cesium_map_checkbox.isChecked(),
                cesium_map_sea_level=self.cesium_elevation_spinbox.value(),
                default_camera_mode=default_camera_mode,
                default_camera_position=default_camera_position,
            )
        )
