import json
import os
from datetime import datetime
from typing import List, Optional

from PyQt5.QtCore import QSize, QSortFilterProxyModel, Qt
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import (
    QAction,
    QApplication,
    QDialog,
    QFileDialog,
    QHeaderView,
    QLabel,
    QLineEdit,
    QListWidgetItem,
    QMessageBox,
    QSplitter,
    QTableWidget,
    QTableWidgetItem,
    QToolBar,
    QVBoxLayout,
    QWidget,
)
from PyQt5.uic import loadUi
from qgis.core import (
    Qgis,
    QgsCoordinateReferenceSystem,
    QgsFeature,
    QgsGeometry,
    QgsLayerTreeLayer,
    QgsLayerTreeModel,
    QgsLayerTreeNode,
    QgsMapLayerProxyModel,
    QgsPointXY,
    QgsProject,
    QgsRectangle,
    QgsVectorFileWriter,
    QgsVectorLayer,
    QgsWkbTypes,
    edit,
)
from qgis.gui import (
    QgsCollapsibleGroupBox,
    QgsLayerTreeMapCanvasBridge,
    QgsLayerTreeView,
    QgsMapCanvas,
    QgsMapLayerComboBox,
    QgsMapTool,
    QgsMapToolPan,
    QgsMapToolZoom,
    QgsProjectionSelectionWidget,
)

from topaze.report_helpers import fmt
from topaze.report_utils import ReportUtils
from topaze.toolbelt import PlgLogger
from topaze.toolbelt.i18n import tr

from ..calc.helmert_transformation import HelmertTransformation
from ..monument_points_manager import MonumentsPointsManager


class CanvasPointPickerTool(QgsMapTool):
    """A simple click-to-pick map tool with optional snapping.

    - Left click picks a point (snapped if available).
    - ESC cancels.
    """

    def __init__(self, canvas, on_picked, is_snapping_enabled, on_cancel=None):
        super().__init__(canvas)
        self._canvas = canvas
        self._on_picked = on_picked
        self._is_snapping_enabled = is_snapping_enabled
        self._on_cancel = on_cancel
        self.setCursor(Qt.CrossCursor)

    def canvasReleaseEvent(self, event):
        if event.button() != Qt.LeftButton:
            return

        pt = None
        if callable(self._is_snapping_enabled) and self._is_snapping_enabled():
            try:
                match = self._canvas.snappingUtils().snapToMap(event.pos())
                if match.isValid():
                    pt = match.point()
            except Exception:
                pt = None

        if pt is None:
            pt = self.toMapCoordinates(event.pos())

        if self._on_picked:
            self._on_picked(QgsPointXY(pt.x(), pt.y()))

    def keyPressEvent(self, event):
        if event.key() == Qt.Key_Escape:
            if self._on_cancel:
                self._on_cancel()


class AdditionalCanvasDialog(QDialog):
    def __init__(self, iface):
        super().__init__()
        self.iface = iface
        self.installEventFilter(self)
        self.setWindowTitle(tr("Local canvas"))
        self.setWindowFlags(
            self.windowFlags() | Qt.WindowMaximizeButtonHint
        )  # Permet la maximisation

        # Charger l'interface
        loadUi(
            os.path.join(os.path.dirname(__file__), "additional_canvas_dialog.ui"), self
        )

        self.mMapLayerComboBox_source.currentTextChanged.connect(
            self.on_source_layer_changed
        )
        self.mMapLayerComboBox_target.currentTextChanged.connect(
            self.on_target_layer_changed
        )

        # remplace le layerListWidget par un layerTreeView
        self.setupLayerTreeView()

        # Promouvoir le QWidget en QgsMapCanvas
        self.setupCanvas()

        # Définir des tailles minimales pour les widgets dans le splitter horizontal
        self.mapCanvas.setMinimumWidth(300)

        # Configurer les tailles initiales des splitters
        self.splitter.setSizes([200, 400])  # Splitter vertical
        self.splitter_2.setSizes([150, 650])  # Splitter horizontal

        # Configurer les facteurs d'étirement pour le splitter horizontal
        # self.splitter_2.setStretchFactor(0, 1)
        # self.splitter_2.setStretchFactor(1, 9)

        # Définir une étendue initiale pour le canvas
        self.mapCanvas.setExtent(QgsRectangle(-5, 41, 10, 52))  # Étendue sur la France
        self.mapCanvas.setVisible(True)
        self.mapCanvas.refresh()

        # Créer les actions pour la barre d'outils
        self.setupToolbarActions()
        self.toolBar.setVisible(True)

        # Configurer le tableWidget pour une sélection par ligne
        self.tableWidget_coordinates.setSelectionBehavior(QTableWidget.SelectRows)
        self.tableWidget_coordinates.setSelectionMode(QTableWidget.MultiSelection)

        hdr = self.tableWidget_coordinates.horizontalHeader()
        hdr.setSectionResizeMode(QHeaderView.Stretch)  # toutes les colonnes s'étirent

        # activer/désactiver Remove selon sélection
        self.tableWidget_coordinates.itemSelectionChanged.connect(
            self._update_remove_action_state
        )

        # état initial : désactivé
        self.auto_neutralize = True  # mode boucle par défaut
        self.actionRunHelmert.setEnabled(False)
        self.actionRemoveMonumentPoint.setEnabled(False)
        self.actionNeutralizeMonumentPoint.setEnabled(False)
        self.actionReactivateMonumentPoint.setEnabled(False)

        self._update_remove_action_state()
        self._update_run_helmert_action_state()

        self.adjustSizeOnScreen()
        # Initialiser les outils et les couches
        self.setupLayers()
        self.setupTools()
        self.setupSnapping()

        self.checkBox_auto_neutralize.setChecked(True)
        self.checkBox_auto_neutralize.stateChanged.connect(
            self.on_checkbox_auto_neutralize_changed
        )

        # Default tolerance (meters) and connection signal/slot
        if hasattr(self, "lineEdit_tolerance"):
            if not self.lineEdit_tolerance.text().strip():
                self.lineEdit_tolerance.setText("100.0")  # "0.400")
            self.lineEdit_tolerance.editingFinished.connect(self.on_tolerance_changed)
            self.lineEdit_tolerance.returnPressed.connect(self.on_tolerance_changed)

        # Internal state for 2-step point picking
        self._pick_active = False
        self._pending_local_point = None
        self._pending_tolerance_m = None
        self._prev_local_map_tool = None
        self._prev_main_map_tool = None
        self._local_picker_tool = None
        self._main_picker_tool = None

    def setupLayerTreeView(self):
        """Configure le QgsLayerTreeView.

        On utilise un clone du layer tree du projet pour que la visibilité et
        l'ordre puissent être indépendants du canevas principal.
        """
        # Clone indépendant du layer tree
        self.localRoot = QgsProject.instance().layerTreeRoot().clone()

        # Modèle (gardé en attribut pour éviter le GC)
        self.layerTreeModel = QgsLayerTreeModel(self.localRoot, self)
        self.layerTreeModel.setFlag(QgsLayerTreeModel.AllowNodeChangeVisibility, True)
        self.layerTreeModel.setFlag(QgsLayerTreeModel.AllowNodeReorder, True)
        self.layerTreeModel.setFlag(QgsLayerTreeModel.AllowNodeRename, True)
        self.layerTreeModel.setFlag(QgsLayerTreeModel.ShowLegendAsTree, True)

        # Vue
        self.layerTreeView = QgsLayerTreeView(self)
        self.layerTreeView.setModel(self.layerTreeModel)
        self.layerTreeView.setHeaderHidden(True)

        # Remplacer le placeholder
        index = self.splitter_2.indexOf(self.layerListWidget)
        if index != -1:
            self.splitter_2.replaceWidget(index, self.layerTreeView)
        self.layerListWidget.setParent(None)
        self.layerListWidget.deleteLater()

        # Signaux
        self.layerTreeView.doubleClicked.connect(self.onLayerDoubleClicked)

        # Masquer/replier le groupe Helmert
        self.filterHelmertLayers()

        self.layerTreeView.setVisible(True)
        self.layerTreeView.update()

    def verifyLayerTree(self):
        """Vérifie la configuration du layer tree"""
        print("Vérification du layer tree:")

        # Vérifier le modèle
        model = self.layerTreeView.layerTreeModel()
        if model:
            print(f"- Model: {model}")

            # Vérifier le modèle source
            source_model = (
                model.sourceModel() if hasattr(model, "sourceModel") else model
            )
            print(f"- Source Model: {source_model}")

            # Vérifier le root
            if hasattr(source_model, "rootNode"):
                root = source_model.rootNode()
                print(f"- Root: {root}")

                if hasattr(root, "children"):
                    print(f"- Nombre d'enfants: {len(root.children())}")
                    for child in root.children():
                        if hasattr(child, "layer"):
                            print(
                                f"  - Couche: {child.layer().name() if child.layer() else 'None'}"
                            )
            else:
                print("- Root non accessible")
        else:
            print("- Model non disponible")

        # Vérifier les couches du projet
        print(f"\nCouches dans le projet:")
        for layer_id, layer in QgsProject.instance().mapLayers().items():
            print(f"- {layer.name()}: {layer.id()}")

    def filterHelmertLayers(self):
        """Masque + replie le groupe 'Helmert' dans l'arbre local."""
        if not hasattr(self, "localRoot"):
            return

        group = self.localRoot.findGroup("Helmert")
        if group:
            group.setItemVisibilityChecked(False)
            group.setExpanded(False)

    def onLayerDoubleClicked(self, index):
        """Gère le double clic sur une couche"""
        node = self.layerTreeView.index2node(index)
        if isinstance(node, QgsLayerTreeLayer):
            layer = node.layer()
            if layer:
                self.mapCanvas.setExtent(layer.extent())
                self.mapCanvas.refresh()

    def setupCanvas(self):
        """Configure le canevas intégré."""
        # Créer le QgsMapCanvas
        self.mapCanvas = QgsMapCanvas(self)
        self.mapCanvas.setCanvasColor(Qt.white)

        # Remplacer le placeholder
        index = self.splitter_2.indexOf(self.mapCanvasPlaceholder)
        if index != -1:
            self.splitter_2.replaceWidget(index, self.mapCanvas)
        self.mapCanvasPlaceholder.setParent(None)
        self.mapCanvasPlaceholder.deleteLater()

        main = self.iface.mapCanvas()

        # Ignorer les thèmes (on veut piloter via le layerTreeView local)
        try:
            self.mapCanvas.setTheme("")
        except Exception:
            pass

        # Copier les réglages de rendu du canevas principal (labels, flags, overrides)
        try:
            self.mapCanvas.setDestinationCrs(main.mapSettings().destinationCrs())
            self.mapCanvas.setMapSettingsFlags(main.mapSettings().flags())
            self.mapCanvas.setLabelingEngineSettings(main.labelingEngineSettings())
            self.mapCanvas.setLayerStyleOverrides(main.layerStyleOverrides())
            self.mapCanvas.enableAntiAliasing(main.antiAliasingEnabled())
            self.mapCanvas.setMagnificationFactor(main.magnificationFactor())
        except Exception:
            pass

        # Bridge entre l'arbre local et le canevas
        self.layerTreeBridge = QgsLayerTreeMapCanvasBridge(
            self.localRoot, self.mapCanvas, self
        )
        self.layerTreeBridge.setCanvasLayers()

        # Vue initiale proche du canevas principal (évite les surprises de labels dépendants de l'échelle)
        try:
            self.mapCanvas.setCenter(main.extent().center())
            self.mapCanvas.setRotation(main.rotation())
            self.mapCanvas.zoomScale(main.scale())
        except Exception:
            pass

        self.mapCanvas.setVisible(True)
        self.mapCanvas.refresh()

    def on_checkbox_auto_neutralize_changed(self, state):
        self.auto_neutralize = state == Qt.Checked
        self.on_tolerance_changed()  # trigger recompute with new auto-neutralize setting

    def on_source_layer_changed(self, index):
        self.source_layer = self.mMapLayerComboBox_source.currentLayer()
        self.update_monuments_table()
        try:
            self.splitter_2.updateGeometry()
        except Exception as e:
            print(f"Erreur lors de l'accès à mapCanvas: {e}")

    def on_target_layer_changed(self, index):
        self.target_layer = self.mMapLayerComboBox_target.currentLayer()
        self.update_monuments_table()

    def on_tolerance_changed(self) -> None:
        """Recompute when user changes tolerance."""
        if not hasattr(self, "monuments_manager") or self.monuments_manager is None:
            self._update_remove_action_state()
            self._update_run_helmert_action_state()

        # si un picking est en cours : on met juste à jour la tolérance "en attente"
        if getattr(self, "_pick_active", False):
            self._pending_tolerance_m = self._read_tolerance_m()
            PlgLogger.log(
                tr("Tolerance updated for current picking."),
                log_level=Qgis.MessageLevel.Info,
                push=True,
            )
            return

        tol = self._read_tolerance_m()

        # 1) réactiver tous les couples seument si on est en auto-neutralisation (pour que le nouvel EMQ soit correct)
        try:
            if self.auto_neutralize:
                self.monuments_manager.reset_all_used()
        except Exception:
            pass

        # 2) recompute avec la nouvelle tolérance
        rmse, neutralized = self.monuments_manager.recompute(
            tolerance_m=tol, auto_neutralize=self.auto_neutralize
        )

        # 3) UI + refresh
        self.update_monuments_table()
        self._update_emq_label(rmse, tol)

        # repaint couches Helmert
        try:
            self.monuments_manager.monuments_layer.triggerRepaint()
        except Exception:
            pass
        try:
            self.monuments_manager.gaps_layer.triggerRepaint()
        except Exception:
            pass

        # refresh des 2 canvases
        try:
            self.mapCanvas.refresh()
        except Exception:
            pass
        try:
            self.iface.mapCanvas().refresh()
        except Exception:
            pass

        # message
        if rmse is None:
            PlgLogger.log(
                tr("Need at least 3 active pairs to compute RMSE."),
                log_level=Qgis.MessageLevel.Info,
                push=True,
            )
        else:
            msg = tr("RMSE = {rmse:.3f} m").format(rmse=rmse)
            if tol is not None:
                msg += tr(" (tol={tol:.3f} m)").format(tol=tol)
            if neutralized:
                msg += tr(" — neutralized: {neutralized}").format(
                    neutralized=", ".join(str(i) for i in neutralized)
                )

            if tol is not None and rmse > tol:
                PlgLogger.log(msg, log_level=Qgis.MessageLevel.Info, push=True)
            else:
                PlgLogger.log(msg, log_level=Qgis.MessageLevel.Info, push=True)
        return

    def set_helmert_group(self, helmert_group):
        self.helmert_group = helmert_group

        monuments_layer = helmert_group.get_monuments_layer()
        gaps_layer = helmert_group.get_gaps_layer()

        excluded = [monuments_layer, gaps_layer]

        self.mMapLayerComboBox_source.setExceptedLayerList(excluded)
        self.mMapLayerComboBox_target.setExceptedLayerList(excluded)

        self.monuments_manager = MonumentsPointsManager(monuments_layer, gaps_layer)
        self._ensure_helmert_in_local_tree(monuments_layer, gaps_layer)
        self.update_monuments_table()

    def eventFilter(self, obj, event):
        if event.type() == event.Resize:
            self.adjustSplitterSizes()
        return super().eventFilter(obj, event)

    def closeEvent(self, event):
        # Always restore map tools if the dialog is closed during a picking session
        try:
            self._cancel_pick(silent=True)
        except Exception:
            pass
        super().closeEvent(event)

    def reject(self):
        # Called when user presses ESC / closes the dialog
        try:
            self._cancel_pick(silent=True)
        except Exception:
            pass
        super().reject()

    def adjustSplitterSizes(self):
        total_width = self.splitter_2.width()
        self.splitter_2.setSizes([int(total_width * 0.1), int(total_width * 0.9)])

    def setupToolbarActions(self):
        # Définir la taille des icônes
        icon_size = QSize(24, 24)
        self.toolBar.setIconSize(icon_size)

        # Définir le style des boutons de la toolbar pour qu'ils soient carrés
        self.toolBar.setToolButtonStyle(Qt.ToolButtonIconOnly)
        self.toolBar.setMinimumHeight(icon_size.height() + 10)

        # Actions de zoom/panoramique
        self.actionPan = QAction(
            QIcon(":/images/themes/default/mActionPan.svg"),
            tr("Panoramic"),
            self,
        )
        self.actionPan.setIconVisibleInMenu(True)
        self.actionZoomIn = QAction(
            QIcon(":/images/themes/default/mActionZoomIn.svg"), tr("Zoom +"), self
        )
        self.actionZoomIn.setIconVisibleInMenu(True)
        self.actionZoomOut = QAction(
            QIcon(":/images/themes/default/mActionZoomOut.svg"), tr("Zoom -"), self
        )
        self.actionZoomOut.setIconVisibleInMenu(True)
        self.actionZoomToLayers = QAction(
            QIcon(":/images/themes/default/mActionZoomToLayer.svg"),
            tr("Zoom to visible layers"),
            self,
        )
        self.actionZoomToLayers.setIconVisibleInMenu(True)
        self.actionZoomToLayer = QAction(
            QIcon(":/images/themes/default/mActionZoomToLayer.svg"),
            tr("Zoom to layer"),
            self,
        )
        self.actionZoomToLayer.setIconVisibleInMenu(True)

        # Actions personnalisées
        self.actionRunHelmert = QAction(
            QIcon(":/images/themes/default/mActionStart.svg"),
            tr("Run Helmert Transformation"),
            self,
        )
        self.actionRunHelmert.setIconVisibleInMenu(True)
        self.actionRunHelmert.triggered.connect(self.run_helmert_transformation)

        self.actionLoadMonumentPoints = QAction(
            QIcon(":/images/themes/default/georeferencer/mActionLoadGCPpoints.svg"),
            tr("Load Monument Points"),
            self,
        )
        self.actionLoadMonumentPoints.setIconVisibleInMenu(True)
        self.actionLoadMonumentPoints.triggered.connect(self.load_monuments_points)

        self.actionSaveMonumentPoints = QAction(
            QIcon(":/images/themes/default/georeferencer/mActionSaveGCPpointsAs.svg"),
            tr("Save Monument Points"),
            self,
        )
        self.actionSaveMonumentPoints.setIconVisibleInMenu(True)
        self.actionSaveMonumentPoints.triggered.connect(self.save_monuments_points)

        self.actionSettings = QAction(
            QIcon(":/images/themes/default/propertyicons/settings.svg"),
            tr("Settings"),
            self,
        )
        self.actionSettings.setIconVisibleInMenu(True)

        self.actionAddMonumentPoint = QAction(
            QIcon(":/images/themes/default/georeferencer/mActionAddGCPPoint.svg"),
            tr("Add Monument Point"),
            self,
        )
        self.actionAddMonumentPoint.setIconVisibleInMenu(True)
        self.actionAddMonumentPoint.triggered.connect(self.add_monument_point)

        self.actionRemoveMonumentPoint = QAction(
            QIcon(":/images/themes/default/georeferencer/mActionDeleteGCPPoint.svg"),
            tr("Remove Monument Point"),
            self,
        )
        self.actionRemoveMonumentPoint.setIconVisibleInMenu(True)
        self.actionRemoveMonumentPoint.triggered.connect(self.remove_monument_point)

        self.actionNeutralizeMonumentPoint = QAction(
            QIcon(":/images/themes/default/mIconProjectionDisabled.svg"),
            tr("Neutralize Monument Point"),
            self,
        )
        self.actionNeutralizeMonumentPoint.setIconVisibleInMenu(True)
        self.actionNeutralizeMonumentPoint.triggered.connect(
            self.neutralize_monument_points
        )

        self.actionReactivateMonumentPoint = QAction(
            QIcon(":/images/themes/default/mActionUndo.svg"),
            tr("Reactivate Monument Point"),
            self,
        )
        self.actionReactivateMonumentPoint.setIconVisibleInMenu(True)
        self.actionReactivateMonumentPoint.triggered.connect(
            self.reactivate_monument_points
        )

        # Ajouter les actions à la barre d'outils
        self.toolBar.addAction(self.actionRunHelmert)
        self.toolBar.addAction(self.actionLoadMonumentPoints)
        self.toolBar.addAction(self.actionSaveMonumentPoints)
        self.toolBar.addAction(self.actionSettings)
        self.toolBar.addAction(self.actionAddMonumentPoint)
        self.toolBar.addAction(self.actionRemoveMonumentPoint)
        self.toolBar.addAction(self.actionNeutralizeMonumentPoint)
        self.toolBar.addAction(self.actionReactivateMonumentPoint)
        self.toolBar.addSeparator()
        self.toolBar.addAction(self.actionPan)
        self.toolBar.addAction(self.actionZoomIn)
        self.toolBar.addAction(self.actionZoomOut)
        self.toolBar.addAction(self.actionZoomToLayers)
        self.toolBar.addAction(self.actionZoomToLayer)

        self.toolBar.addSeparator()
        self.pickHintLabel = QLabel("")
        self.pickHintLabel.setMinimumWidth(380)
        self.toolBar.addWidget(self.pickHintLabel)

    def adjustSizeOnScreen(self):
        """Positionne la fenêtre sur le deuxième écran si disponible"""
        screens = QApplication.screens()
        if len(screens) > 1:
            # Utiliser le deuxième écran
            second_screen = screens[1]
            geometry = second_screen.geometry()
            self.move(geometry.left(), geometry.top())
            self.resize(geometry.width(), geometry.height())
            self.setWindowState(Qt.WindowMaximized)
        else:
            # Positionner sur la moitié droite de l'écran
            screen_geometry = screens[0].geometry()
            new_width = int(screen_geometry.width() / 2.2)
            self.resize(new_width, screen_geometry.height())
            self.move(screen_geometry.width() - new_width, 0)

            # Positionner la fenêtre QGIS sur la moitié gauche de l'écran
            main_window = self.iface.mainWindow()
            main_window.resize(
                screen_geometry.width() - new_width, screen_geometry.height()
            )
            main_window.move(0, 0)

    def setupLayers(self):
        """
        self.mMapLayerComboBox_source.setFilters(QgsMapLayerProxyModel.VectorLayer)
        self.mMapLayerComboBox_target.setFilters(QgsMapLayerProxyModel.VectorLayer)
        self.layerListWidget.clear()
        layers = []
        for layer in QgsProject.instance().mapLayers().values():
            if isinstance(layer, QgsVectorLayer) and not layer.name().startswith(
                "hel_"
            ):
                item = QListWidgetItem(layer.name())
                item.setData(Qt.ItemDataRole.UserRole, layer)
                self.layerListWidget.addItem(item)
                layers.append(layer)

        # Ajouter les couches au canevas
        self.mapCanvas.setLayers(layers)

        # Connecter l'événement de clic sur un item
        self.layerListWidget.itemClicked.connect(self.onLayerItemClicked)
        """
        pass

    def onLayerItemClicked(self, item):
        layer = item.data(Qt.ItemDataRole.UserRole)
        if layer:
            self.setActiveLayer(layer)

    def setActiveLayer(self, layer):
        # Mettre à jour la couche active dans le canevas
        self.mapCanvas.setExtent(layer.extent())
        self.mapCanvas.refresh()

    def setupTools(self):
        self.panTool = QgsMapToolPan(self.mapCanvas)
        self.zoomInTool = QgsMapToolZoom(self.mapCanvas, False)
        self.zoomOutTool = QgsMapToolZoom(self.mapCanvas, True)
        self.mapCanvas.setMapTool(self.panTool)

        self.actionPan.triggered.connect(
            lambda: self.mapCanvas.setMapTool(self.panTool)
        )
        self.actionZoomIn.triggered.connect(
            lambda: self.mapCanvas.setMapTool(self.zoomInTool)
        )
        self.actionZoomOut.triggered.connect(
            lambda: self.mapCanvas.setMapTool(self.zoomOutTool)
        )
        self.actionZoomToLayers.triggered.connect(self.zoomToLayers)
        self.actionZoomToLayer.triggered.connect(self.zoomToSelectedLayer)

        # Synchroniser vue + réglages de rendu avec le canevas principal (sans imposer les couches)
        self.syncWithMainCanvas()

    def syncWithMainCanvas(self):
        """Synchronise (vue + rendu) avec le canevas principal."""
        main = self.iface.mapCanvas()

        try:
            self.mapCanvas.setTheme("")
        except Exception:
            pass

        try:
            self.mapCanvas.setDestinationCrs(main.mapSettings().destinationCrs())
            self.mapCanvas.setMapSettingsFlags(main.mapSettings().flags())
            self.mapCanvas.setLabelingEngineSettings(main.labelingEngineSettings())
            self.mapCanvas.setLayerStyleOverrides(main.layerStyleOverrides())
        except Exception:
            pass

        # Vue
        try:
            self.mapCanvas.setExtent(main.extent())
            self.mapCanvas.setRotation(main.rotation())
        except Exception:
            pass

        self.mapCanvas.refresh()

    def zoomToLayers(self):
        """Zoom sur l'emprise de toutes les couches cochées dans l'arbre local."""
        if not hasattr(self, "localRoot"):
            return
        layers = self.localRoot.checkedLayers()
        if not layers:
            return

        extent = QgsRectangle(layers[0].extent())
        for lyr in layers[1:]:
            extent.combineExtentWith(lyr.extent())

        self.mapCanvas.setExtent(extent)
        self.mapCanvas.refresh()

    def zoomToSelectedLayer(self):
        """Zoom sur la couche sélectionnée dans le layer tree."""
        layers = []
        try:
            layers = self.layerTreeView.selectedLayers()
        except Exception:
            layers = []

        if not layers:
            return

        self.mapCanvas.setExtent(layers[0].extent())
        self.mapCanvas.refresh()

    def load_monuments_points(self):
        # Annuler un picking en cours
        if getattr(self, "_pick_active", False):
            self._cancel_pick(silent=True)

        if not getattr(self, "helmert_group", None) or not getattr(
            self, "monuments_manager", None
        ):
            PlgLogger.log(
                tr("Helmert layers are not initialized."),
                log_level=Qgis.MessageLevel.Critical,
                push=True,
            )
            return

        # Dossier export = <project_dir>/export
        project_path = QgsProject.instance().fileName()
        if not project_path:
            PlgLogger.log(
                tr("Please save the QGIS project (.qgs/.qgz) before importing."),
                log_level=Qgis.MessageLevel.Info,
                push=True,
            )
            return

        export_dir = os.path.join(os.path.dirname(project_path), "export")
        os.makedirs(export_dir, exist_ok=True)

        default_path = os.path.join(export_dir, "helmert_monuments.json")
        start_path = default_path if os.path.exists(default_path) else export_dir

        path, _ = QFileDialog.getOpenFileName(
            self,
            tr("Load Helmert monuments"),
            start_path,
            tr("JSON files (*.json);;All files (*)"),
        )
        if not path:
            return

        # Lire JSON
        try:
            with open(path, "r", encoding="utf-8") as f:
                payload = json.load(f)
        except Exception as e:
            PlgLogger.log(
                tr("Failed to read file:")
                + "\n{path}\n\n{e}".format(path=path, e=str(e)),
                log_level=Qgis.MessageLevel.Critical,
                push=True,
            )
            return

        if not isinstance(payload, dict) or not isinstance(
            payload.get("monuments"), list
        ):
            PlgLogger.log(
                tr("Invalid file format (missing 'monuments' array)."),
                log_level=Qgis.MessageLevel.Critical,
                push=True,
            )
            return

        # Tolérance (optionnelle)
        tol = payload.get("tolerance", None)
        try:
            tol = None if tol in ("", None) else float(tol)
        except Exception:
            tol = None

        if hasattr(self, "lineEdit_tolerance") and tol is not None:
            self.lineEdit_tolerance.setText(str(tol))

        # Récupérer couches
        monuments_layer = (
            self.helmert_group.get_monuments_layer()
            if hasattr(self.helmert_group, "get_monuments_layer")
            else self.helmert_group.get_coordinates_layer()
        )
        gaps_layer = self.helmert_group.get_gaps_layer()

        # Vider couches
        with edit(monuments_layer):
            ids = [f.id() for f in monuments_layer.getFeatures()]
            if ids:
                monuments_layer.deleteFeatures(ids)

        with edit(gaps_layer):
            ids = [f.id() for f in gaps_layer.getFeatures()]
            if ids:
                gaps_layer.deleteFeatures(ids)

        # Importer monuments
        added = 0
        with edit(monuments_layer):
            for m in payload["monuments"]:
                if not isinstance(m, dict):
                    continue
                try:
                    used = int(m.get("used") or 0)
                    x = float(m["x_source"])
                    y = float(m["y_source"])
                    nx = float(m["x_target"])
                    ny = float(m["y_target"])
                    gap = m.get("gap", None)
                    err = None if gap is None else float(gap)
                except Exception:
                    # entrée invalide -> ignore
                    continue

                feat = QgsFeature(monuments_layer.fields())
                feat.setAttribute("used", 1 if used else 0)
                feat.setAttribute("x", x)
                feat.setAttribute("y", y)
                feat.setAttribute("new_x", nx)
                feat.setAttribute("new_y", ny)
                feat.setAttribute(
                    "err", err if used else None
                )  # neutralisé -> indéterminé
                if not monuments_layer.addFeature(feat):
                    continue

                added += 1

        # Recalcul (résidus + gaps + neutralisations éventuelles)
        rmse, neutralized = self.monuments_manager.recompute(
            tolerance_m=tol, auto_neutralize=self.auto_neutralize
        )

        # UI + refresh
        self.update_monuments_table()
        self._update_emq_label(rmse, tol)

        try:
            monuments_layer.triggerRepaint()
            gaps_layer.triggerRepaint()
        except Exception:
            pass

        try:
            self.mapCanvas.refresh()
            self.iface.mapCanvas().refresh()
        except Exception:
            pass

        msg = tr("Imported {added} monument pair(s).").format(added=added)
        if rmse is not None:
            msg += tr(" RMSE = {rmse:.3f} m.").format(rmse=rmse)
        if neutralized:
            msg += tr(" Neutralized: ") + ", ".join(str(i) for i in neutralized)
        self.iface.messageBar().pushSuccess("Helmert", msg)

    def save_monuments_points(self):
        """Export monuments to <project_dir>/export/helmert_monuments(.json|-N.json)."""
        if not hasattr(self, "monuments_manager") or self.monuments_manager is None:
            QMessageBox.information(self, "Helmert", "No monuments to export.")
            return

        try:
            export_dir = self._get_export_dir()
        except ValueError:
            QMessageBox.warning(
                self,
                "Helmert",
                tr("Please save the QGIS project (.qgs/.qgz) before exporting."),
            )
            return

        base_name = "helmert_monuments.json"
        out_path = os.path.join(export_dir, base_name)

        # File exists -> ask overwrite or auto-rename
        if os.path.exists(out_path):
            box = QMessageBox(self)
            box.setIcon(QMessageBox.Warning)
            box.setWindowTitle(tr("Export Helmert monuments"))
            box.setText(
                tr('The file "{name}" already exists in: ').format(name=base_name)
                + "\n{export_dir}".format(export_dir=export_dir)
            )
            box.setInformativeText(tr("Do you want to overwrite it?"))
            btn_overwrite = box.addButton(tr("Overwrite"), QMessageBox.YesRole)
            btn_rename = box.addButton(tr("Auto-rename"), QMessageBox.NoRole)
            btn_cancel = box.addButton(QMessageBox.Cancel)
            box.setDefaultButton(btn_overwrite)
            box.exec_()

            if box.clickedButton() == btn_cancel:
                return
            if box.clickedButton() == btn_rename:
                out_path = self._next_indexed_filename(export_dir, base_name)
            # else overwrite -> keep out_path

        tol = self._read_tolerance_m()

        monuments_data = self.monuments_manager.get_monuments_data()

        # EMQ: prefer last computed (if stored), else compute from gaps if possible
        emq = getattr(self, "_last_rmse", None)
        if emq is None:
            used_rows = [d for d in monuments_data if int(d.get("used") or 0) == 1]
            gaps = [d.get("err") for d in used_rows]
            if len(used_rows) >= 3 and all(g is not None for g in gaps):
                emq = (sum(float(g) ** 2 for g in gaps) / len(gaps)) ** 0.5

        monuments = []
        for d in monuments_data:
            monuments.append(
                {
                    "id": int(d.get("fid")) if d.get("fid") is not None else None,
                    "used": int(d.get("used") or 0),
                    "x_source": float(d.get("x")) if d.get("x") is not None else None,
                    "y_source": float(d.get("y")) if d.get("y") is not None else None,
                    "x_target": (
                        float(d.get("new_x")) if d.get("new_x") is not None else None
                    ),
                    "y_target": (
                        float(d.get("new_y")) if d.get("new_y") is not None else None
                    ),
                    "gap": float(d.get("err")) if d.get("err") is not None else None,
                }
            )

        payload = {
            "tolerance": tol,
            "emq": emq,
            "monuments": monuments,
        }

        try:
            with open(out_path, "w", encoding="utf-8") as f:
                json.dump(payload, f, ensure_ascii=False, indent=2)
        except Exception as e:
            QMessageBox.critical(
                self,
                "Helmert",
                tr("Failed to write file:")
                + "\n{out_path}\n\n{e}".format(out_path=out_path, e=e)
                + tr("Failed to write file:")
                + "\n{out_path}\n\n{e}".format(out_path=out_path, e=e),
            )
            return

        PlgLogger.log(
            tr("Exported: {out_path}").format(out_path=out_path),
            log_level=Qgis.MessageLevel.Success,
            push=True,
        )

    def add_monument_point(self):
        """Ajoute un couple de points homologues via un picking en 2 étapes.

        1) Clic sur le canevas du dialog -> point local
        2) Clic sur le canevas principal -> point géoréférencé
        """
        if not hasattr(self, "monuments_manager") or self.monuments_manager is None:
            self.iface.messageBar().pushCritical(
                "Helmert", tr("Helmert layers not initialized")
            )
            return

        # Cancel any previous picking session
        if getattr(self, "_pick_active", False):
            self._cancel_pick(silent=True)

        self._set_pick_hint(tr("1/2 : cliquez un point LOCAL dans cette fenêtre"))

        self._pending_tolerance_m = self._read_tolerance_m()
        self._pending_local_point = None
        self._pick_active = True

        # Remember current tools to restore them later
        self._prev_local_map_tool = self.mapCanvas.mapTool()
        self._prev_main_map_tool = self.iface.mapCanvas().mapTool()

        # Start picking on local canvas
        self._local_picker_tool = CanvasPointPickerTool(
            self.mapCanvas,
            self._on_local_point_picked,
            self._is_snapping_enabled,
            on_cancel=self._cancel_pick,
        )
        self.mapCanvas.setMapTool(self._local_picker_tool)

        self.iface.messageBar().pushInfo(
            "Helmert",
            tr("Pick LOCAL point on the dialog canvas (ESC to cancel)."),
        )

    def neutralize_monument_points(self):
        if getattr(self, "_pick_active", False):
            self._cancel_pick(silent=True)

        ids = self._get_selected_monument_ids()
        if not ids:
            return

        tol = self._read_tolerance_m()
        rmse, neutralized = self.monuments_manager.neutralize_points(
            ids, tolerance_m=tol, auto_neutralize=self.auto_neutralize
        )

        self.update_monuments_table()
        self._update_emq_label(rmse, tol)
        self.monuments_manager.monuments_layer.triggerRepaint()
        self.monuments_manager.gaps_layer.triggerRepaint()
        self.mapCanvas.refresh()
        self.iface.mapCanvas().refresh()

    def reactivate_monument_points(self):
        if self.auto_neutralize:
            # sécurité : ne devrait pas être cliquable en auto
            return

        if getattr(self, "_pick_active", False):
            self._cancel_pick(silent=True)

        ids = self._get_selected_monument_ids()
        if not ids:
            return

        tol = self._read_tolerance_m()
        rmse, neutralized = self.monuments_manager.reactivate_points(
            ids, tolerance_m=tol, auto_neutralize=False
        )

        self._last_rmse = rmse
        self.update_monuments_table()
        self._update_emq_label(rmse, tol)

        try:
            self.monuments_manager.monuments_layer.triggerRepaint()
            self.monuments_manager.gaps_layer.triggerRepaint()
        except Exception:
            pass

        try:
            self.mapCanvas.refresh()
            self.iface.mapCanvas().refresh()
        except Exception:
            pass

    def _is_snapping_enabled(self) -> bool:
        try:
            return bool(self.snappingCheckBox.isChecked())
        except Exception:
            return False

    def _read_tolerance_m(self):
        """Read tolerance from UI (meters). Empty/invalid -> None."""
        if not hasattr(self, "lineEdit_tolerance"):
            return None
        txt = (self.lineEdit_tolerance.text() or "").strip()
        if not txt:
            return None
        txt = txt.replace(",", ".")
        try:
            return float(txt)
        except Exception:
            PlgLogger.log(
                tr("Invalid tolerance value: {txt}").format(txt=txt),
                log_level=Qgis.MessageLevel.Warning,
                push=True,
            )
            return None

    def _on_local_point_picked(self, pt: QgsPointXY):
        if not getattr(self, "_pick_active", False):
            return
        self._pending_local_point = pt

        # Restore local canvas tool (pan by default)
        try:
            if self._prev_local_map_tool:
                self.mapCanvas.setMapTool(self._prev_local_map_tool)
            else:
                self.mapCanvas.setMapTool(self.panTool)
        except Exception:
            pass

        # Switch to main canvas for 2nd click
        self._main_picker_tool = CanvasPointPickerTool(
            self.iface.mapCanvas(),
            self._on_main_point_picked,
            self._is_snapping_enabled,
            on_cancel=self._cancel_pick,
        )
        self.iface.mapCanvas().setMapTool(self._main_picker_tool)

        self.iface.messageBar().pushInfo(
            "Helmert",
            tr("Now pick GEOREFERENCED point on the main QGIS canvas (ESC to cancel)."),
        )
        self._set_pick_hint(
            tr("2/2 : cliquez le point GEOREF dans le canevas principal QGIS")
        )

    def _on_main_point_picked(self, pt: QgsPointXY):
        if not getattr(self, "_pick_active", False):
            return
        local_pt = self._pending_local_point
        tol = self._pending_tolerance_m

        self._restore_map_tools()
        self._pick_active = False

        self._set_pick_hint("")

        if local_pt is None or pt is None:
            return

        rmse, neutralized = self.monuments_manager.add_monument_point(
            local_pt.x(),
            local_pt.y(),
            pt.x(),
            pt.y(),
            tolerance_m=tol,
            auto_neutralize=self.auto_neutralize,
        )
        self._last_rmse = rmse  # store last EMQ for potential export

        self.update_monuments_table()
        self._update_emq_label(rmse, tol)

        if rmse is None:
            msg = tr("Point pair added. Need at least 3 active pairs to compute RMSE.")
            PlgLogger.log(msg, log_level=Qgis.MessageLevel.Info, push=True)
        else:
            msg = tr("RMSE = {rmse:.3f} m").format(rmse=rmse)
            if tol is not None:
                msg += " " + tr("(tol={tol:.3f} m)").format(tol=tol)
            if neutralized:
                msg += " " + tr("— neutralized: {ids}").format(
                    ids=", ".join(str(i) for i in neutralized)
                )

            if tol is not None and rmse > tol:
                PlgLogger.log(msg, log_level=Qgis.MessageLevel.Critical, push=True)
            else:
                PlgLogger.log(msg, log_level=Qgis.MessageLevel.Info, push=True)

    def _restore_map_tools(self):
        # Restore local canvas tool
        try:
            if self._prev_local_map_tool:
                self.mapCanvas.setMapTool(self._prev_local_map_tool)
            else:
                self.mapCanvas.setMapTool(self.panTool)
        except Exception:
            pass

        # Restore main canvas tool
        try:
            if self._prev_main_map_tool:
                self.iface.mapCanvas().setMapTool(self._prev_main_map_tool)
        except Exception:
            pass

        self._local_picker_tool = None
        self._main_picker_tool = None

    def _cancel_pick(self, silent: bool = False):
        """Cancel the current picking session and restore previous tools."""
        self._set_pick_hint("")
        if not getattr(self, "_pick_active", False):
            return
        self._restore_map_tools()
        self._pick_active = False
        self._pending_local_point = None

        if not silent:
            PlgLogger.log(
                tr("Point picking cancelled."),
                log_level=Qgis.MessageLevel.Info,
                push=True,
            )

    def remove_monument_point(self):
        # annuler un picking en cours
        try:
            if getattr(self, "_pick_active", False):
                self._cancel_pick(silent=True)
        except Exception:
            pass

        selected_ids = self._get_selected_monument_ids()
        if not selected_ids:
            PlgLogger.log(
                tr("No selected monument points."),
                log_level=Qgis.MessageLevel.Warning,
                push=True,
            )
            self.update_remove_action_state()
            return

        tol = self._read_tolerance_m()
        rmse, neutralized = self.monuments_manager.remove_monument_points(
            selected_ids, tolerance_m=tol, auto_neutralize=self.auto_neutralize
        )
        self._last_rmse = rmse  # store last EMQ for potential export

        self.update_monuments_table()
        self._update_emq_label(rmse, tol)

        # forcer repaint des couches Helmert
        try:
            lyr = getattr(self.monuments_manager, "monuments_layer", None)
            if lyr:
                lyr.triggerRepaint()
        except Exception:
            pass
        try:
            lyr = getattr(self.monuments_manager, "gaps_layer", None)
            if lyr:
                lyr.triggerRepaint()
        except Exception:
            pass

        # refresh des 2 canvases
        try:
            self.mapCanvas.refresh()
        except Exception:
            pass
        try:
            self.iface.mapCanvas().refresh()
        except Exception:
            pass

        self._update_remove_action_state()

        # message résultat
        if rmse is None:
            PlgLogger.log(
                tr("Points removed. Need at least 3 active pairs to compute RMSE."),
                log_level=Qgis.MessageLevel.Info,
                push=True,
            )
        else:
            msg = tr("RMSE = {rmse:.3f} m").format(rmse=rmse)
            if tol is not None:
                msg += " " + tr("(tol={tol:.3f} m)").format(tol=tol)
            if neutralized:
                msg += " " + tr("— neutralized: {ids}").format(
                    ids=", ".join(str(i) for i in neutralized)
                )
            PlgLogger.log(msg, log_level=Qgis.MessageLevel.Info, push=True)

    def _get_local_coordinates(self):
        """
        Récupère les coordonnées locales du point sélectionné sur le canevas
        Returns:
            tuple: (x, y) coordonnées locales
        """
        # Récupérer le point d'appui sélectionné sur le canevas
        if self.source_layer is None:
            PlgLogger.log(
                tr("No source layer selected"),
                log_level=Qgis.MessageLevel.Critical,
                push=True,
            )
            return None, None

        # Vérifier qu'une entité est bien sélectionnée
        selected_features = self.source_layer.selectedFeatures()
        if not selected_features:
            PlgLogger.log(
                tr("No entity selected"),
                log_level=Qgis.MessageLevel.Critical,
                push=True,
            )
            return None, None

        # Prendre la première entité sélectionnée
        feature = selected_features[0]
        geometry = feature.geometry()

        if geometry is None or not geometry.isGeos():
            PlgLogger.log(
                tr("Selected entity has no valid geometry"),
                log_level=Qgis.MessageLevel.Critical,
                push=True,
            )
            return None, None

        # Récupérer les coordonnées du premier point de la géométrie
        point = geometry.asPoint()
        return point.x(), point.y()

    def _get_transformed_coordinates(self):
        """
        Récupère les coordonnées transformées (homologues géoréférencées)
        Returns:
            tuple: (new_x, new_y) coordonnées transformées
        """
        # Implémentation à adapter selon votre logique de transformation
        # Pour l'instant, retourne les mêmes coordonnées que locales
        x, y = self._get_local_coordinates()
        if x is None or y is None:
            return None, None

        # TODO: Implémenter la vraie logique de transformation
        # Pour l'instant, on retourne les mêmes coordonnées
        return x, y

    def update_monuments_table(self):
        """Met à jour le tableWidget avec les données des points d'appui."""
        self.tableWidget_coordinates.clearContents()
        self.tableWidget_coordinates.setRowCount(0)

        if not hasattr(self, "monuments_manager") or self.monuments_manager is None:
            return

        monuments_data = self.monuments_manager.get_monuments_data()

        self.tableWidget_coordinates.setColumnCount(7)
        self.tableWidget_coordinates.setHorizontalHeaderLabels(
            [
                tr("ID"),
                tr("Used"),
                tr("X local"),
                tr("Y local"),
                tr("X target"),
                tr("Y target"),
                tr("Gap"),
            ]
        )

        def _fmt(v):
            try:
                return f"{float(v):.3f}"
            except Exception:
                return ""

        for row, data in enumerate(monuments_data):
            self.tableWidget_coordinates.insertRow(row)

            fid_val = data.get("fid")
            used_val = int(data.get("used") or 0)

            self.tableWidget_coordinates.setItem(
                row,
                0,
                self._mk_item(str(fid_val), Qt.AlignVCenter | Qt.AlignRight),
            )
            self.tableWidget_coordinates.setItem(
                row,
                1,
                self._mk_item(str(used_val), Qt.AlignVCenter | Qt.AlignHCenter),
            )

            self.tableWidget_coordinates.setItem(
                row,
                2,
                self._mk_item(_fmt(data.get("x")), Qt.AlignVCenter | Qt.AlignRight),
            )
            self.tableWidget_coordinates.setItem(
                row,
                3,
                self._mk_item(_fmt(data.get("y")), Qt.AlignVCenter | Qt.AlignRight),
            )
            self.tableWidget_coordinates.setItem(
                row,
                4,
                self._mk_item(_fmt(data.get("new_x")), Qt.AlignVCenter | Qt.AlignRight),
            )
            self.tableWidget_coordinates.setItem(
                row,
                5,
                self._mk_item(_fmt(data.get("new_y")), Qt.AlignVCenter | Qt.AlignRight),
            )

            err = data.get("err")
            err_txt = tr("N/A") if err is None else _fmt(err)
            self.tableWidget_coordinates.setItem(
                row, 6, self._mk_item(err_txt, Qt.AlignVCenter | Qt.AlignRight)
            )

        self._update_remove_action_state()
        self._update_run_helmert_action_state()

        # self.tableWidget_coordinates.resizeColumnsToContents()

    def _get_selected_monument_ids(self):
        """
        Récupère les IDs des points d'appui sélectionnés dans le tableWidget
        Returns:
            list: Liste des IDs des points d'appui sélectionnés
        """
        selected_ids = []

        # Récupérer les lignes sélectionnées
        selected_rows = self.tableWidget_coordinates.selectionModel().selectedRows()

        for index in selected_rows:
            try:
                fid = int(self.tableWidget_coordinates.item(index.row(), 0).text())
                selected_ids.append(fid)
            except (AttributeError, ValueError, TypeError):
                continue

        return sorted(set(selected_ids))

    def run_helmert_transformation(self):
        """Create a georeferenced GeoPackage from the active GeoPackage layers.

        This method is triggered by the *Run Helmert Transformation* action. It:

        - checks preconditions (at least 2 monument pairs, valid source layer and target CRS)
        - shows the list of layers that will be transformed and asks for confirmation
        - recomputes RMSE from current monument pairs (with optional auto-neutralization)
        - fits a Helmert transformation from active pairs (``used == 1``)
        - creates a new GeoPackage in ``<project_dir>/db/`` named
          ``georef_<active_gpkg_name>.gpkg``
        - exports **all** layers from the active GeoPackage into this new GeoPackage:
            * layers visible in the dialog canvas (except the selected source layer)
              are exported with geometries transformed by Helmert
            * other layers are copied as-is
            * the source layer is copied as-is **and** exported once more as
              ``georef_<source_layer_name>`` with transformed geometries
        - assigns the target CRS (selected in the dialog) to all transformed layers.

        :returns: None
        :rtype: None
        """
        if not hasattr(self, "monuments_manager") or self.monuments_manager is None:
            PlgLogger.log(
                tr("No monuments layer available."),
                log_level=Qgis.MessageLevel.Critical,
                push=True,
            )
            return

        # UI guard: require at least 2 pairs (rows)
        try:
            n_pairs = int(self.tableWidget_coordinates.rowCount())
        except Exception:
            n_pairs = 0
        if n_pairs < 2:
            PlgLogger.log(
                tr(
                    "At least 2 active monument pairs are required to run the transformation."
                ),
                log_level=Qgis.MessageLevel.Warning,
                push=True,
            )
            return

        source_layer = self.mMapLayerComboBox_source.currentLayer()
        if source_layer is None or not isinstance(source_layer, QgsVectorLayer):
            PlgLogger.log(
                tr("Please select a valid source layer."),
                log_level=Qgis.MessageLevel.Warning,
                push=True,
            )
            return

        target_crs = self.mQgsProjectionSelectionWidget_target.crs()
        if target_crs is None or not target_crs.isValid():
            PlgLogger.log(
                tr("Please select a valid target CRS."),
                log_level=Qgis.MessageLevel.Warning,
                push=True,
            )
            return

        # Determine the active GeoPackage (based on the selected source layer)
        active_gpkg = self._get_gpkg_path_from_layer(source_layer)
        if not active_gpkg:
            PlgLogger.log(
                tr("The selected source layer is not stored in a GeoPackage."),
                log_level=Qgis.MessageLevel.Critical,
                push=True,
            )
            return

        # Project must be saved to resolve <project_dir>/db
        try:
            db_dir = self._get_db_dir()
        except ValueError:
            PlgLogger.log(
                tr("Please save the QGIS project (.qgs/.qgz) before exporting."),
                log_level=Qgis.MessageLevel.Critical,
                push=True,
            )
            return

        out_filename = "georef_{base}".format(base=os.path.basename(active_gpkg))
        desired_out_path = os.path.join(db_dir, out_filename)

        # Collect layers from the active GeoPackage (project layers only)
        gpkg_layers = self._get_layers_from_geopackage(active_gpkg)
        if not gpkg_layers:
            PlgLogger.log(
                tr("No layers found for GeoPackage: {path}").format(path=active_gpkg),
                log_level=Qgis.MessageLevel.Critical,
                push=True,
            )
            return

        # Which layers are currently visible in the local canvas?
        visible_ids = {lyr.id() for lyr in self.mapCanvas.layers() if lyr is not None}

        # Build the list of layers that will be transformed
        transform_names = []
        transform_names.append("georef_{name}".format(name=source_layer.name()))
        for lyr in gpkg_layers:
            if lyr is None:
                continue
            if lyr.name() in ("hel_monuments", "hel_gaps"):
                continue
            if lyr.id() == source_layer.id():
                continue
            if lyr.id() in visible_ids:
                transform_names.append(lyr.name())

        # Unique, preserve order
        seen = set()
        transform_names = [n for n in transform_names if not (n in seen or seen.add(n))]

        crs_label = (
            target_crs.authid() if target_crs.authid() else target_crs.description()
        )
        layers_txt = (
            "\n".join(["- {name}".format(name=n) for n in transform_names]) or "-"
        )

        msg = (
            tr("The following layer(s) will be transformed (Helmert) and written to:")
            + f"\n{desired_out_path}\n\n"
            + tr("Target CRS: ")
            + f"{crs_label}\n\n{layers_txt}\n\n"
            + tr("Other layer(s) from the active GeoPackage will be copied as-is.")
            + "\n\n"
            + tr(
                "If the output file already exists, you will be prompted to overwrite or auto-rename."
            )
            + "\n\n"
            + tr("Do you want to continue ?")
        )

        reply = QMessageBox.question(
            self,
            tr("Run Helmert Transformation"),
            msg,
            QMessageBox.Yes | QMessageBox.No,
            QMessageBox.Yes,
        )
        if reply != QMessageBox.Yes:
            PlgLogger.log(
                tr("Operation cancelled."),
                log_level=Qgis.MessageLevel.Info,
                push=True,
            )
            return

        # Resolve output path collisions *after* confirmation
        out_path = self._prepare_output_geopackage_path(desired_out_path)
        if not out_path:
            return

        # Recompute RMSE / neutralization according to the current mode
        tol = self._read_tolerance_m()
        try:
            if self.auto_neutralize:
                self.monuments_manager.reset_all_used()
        except Exception:
            pass

        rmse, neutralized = self.monuments_manager.recompute(
            tolerance_m=tol, auto_neutralize=self.auto_neutralize
        )
        self._last_rmse = rmse
        self.update_monuments_table()
        self._update_emq_label(rmse, tol)

        if rmse is None:
            PlgLogger.log(
                tr(
                    "RMSE cannot be computed with fewer than 3 active pairs. Proceeding anyway."
                ),
                log_level=Qgis.MessageLevel.Warning,
                push=True,
            )

        # Build fitted Helmert transform from final active set
        try:
            helmert = self.monuments_manager.build_fitted_transformation()
        except Exception as e:
            PlgLogger.log(
                tr("Failed to fit Helmert transformation: {error}").format(
                    error=str(e)
                ),
                log_level=Qgis.MessageLevel.Critical,
                push=True,
            )
            return

        used_names: set = set()
        exported = 0
        transformed = 0

        transformed_layer_names_out: List[str] = []
        copied_layer_names_out: List[str] = []

        # Re-evaluate visible layers just before export (in case user changed visibility)
        visible_ids = {lyr.id() for lyr in self.mapCanvas.layers() if lyr is not None}

        for lyr in gpkg_layers:
            if lyr is None:
                continue
            # Skip helper layers if they somehow belong here
            if lyr.name() in ("hel_monuments", "hel_gaps"):
                continue

            # Decide whether this layer must be transformed
            is_visible = lyr.id() in visible_ids
            is_source = lyr.id() == source_layer.id()

            if is_source:
                # 1) Copy original source layer
                out_name = self._unique_layer_name(lyr.name(), used_names)
                self._export_vector_layer_to_gpkg(
                    src_layer=lyr,
                    gpkg_path=out_path,
                    out_layer_name=out_name,
                    out_crs=lyr.crs(),
                    helmert=None,
                )
                exported += 1

                copied_layer_names_out.append(out_name)

                # 2) Export transformed source layer as georef_<name>
                out_name_georef = self._unique_layer_name(
                    "georef_{name}".format(name=lyr.name()), used_names
                )
                self._export_vector_layer_to_gpkg(
                    src_layer=lyr,
                    gpkg_path=out_path,
                    out_layer_name=out_name_georef,
                    out_crs=target_crs,
                    helmert=helmert,
                )
                exported += 1
                transformed_layer_names_out.append(out_name_georef)
                transformed += 1
                continue

            if is_visible:
                out_name = self._unique_layer_name(lyr.name(), used_names)
                self._export_vector_layer_to_gpkg(
                    src_layer=lyr,
                    gpkg_path=out_path,
                    out_layer_name=out_name,
                    out_crs=target_crs,
                    helmert=helmert,
                )
                exported += 1
                transformed_layer_names_out.append(out_name)
                transformed += 1
            else:
                out_name = self._unique_layer_name(lyr.name(), used_names)
                self._export_vector_layer_to_gpkg(
                    src_layer=lyr,
                    gpkg_path=out_path,
                    out_layer_name=out_name,
                    out_crs=lyr.crs(),
                    helmert=None,
                )
                exported += 1

                copied_layer_names_out.append(out_name)

        PlgLogger.log(
            tr(
                "GeoPackage exported to {path} ({exported} layer(s), {transformed} transformed)."
            ).format(path=out_path, exported=exported, transformed=transformed),
            log_level=Qgis.MessageLevel.Success,
            push=True,
        )

        # --- Report (Markdown / HTML / PDF / ODT) ----------------------------
        try:
            self._write_helmert_report(
                out_gpkg_path=out_path,
                src_gpkg_path=active_gpkg,
                source_layer_name=source_layer.name(),
                target_crs=target_crs,
                tolerance_m=tol,
                rmse=rmse,
                neutralized_fids=neutralized,
                auto_neutralize=self.auto_neutralize,
                helmert=helmert,
                transformed_layers=transformed_layer_names_out,
                copied_layers=copied_layer_names_out,
            )
        except Exception as e:
            PlgLogger.log(
                tr("Report generation failed: {err}").format(err=str(e)),
                log_level=Qgis.MessageLevel.Warning,
                push=True,
            )

    def _get_rapport_dir(self) -> str:
        """Return ``<project_dir>/rapport`` and create it if needed.

        This follows the same convention as other Topaze computations (e.g. free station).

        :returns: Absolute path to the ``rapport`` directory.
        :rtype: str
        :raises ValueError: If the project is not saved and a directory cannot be resolved.
        """
        project_path = QgsProject.instance().fileName()
        if not project_path:
            raise ValueError(tr("Project not saved"))
        project_dir = os.path.dirname(project_path)
        rapport_dir = os.path.join(project_dir, "rapport")
        os.makedirs(rapport_dir, exist_ok=True)
        return rapport_dir

    def _build_helmert_report_markdown(
        self,
        out_gpkg_path: str,
        src_gpkg_path: str,
        source_layer_name: str,
        target_crs: QgsCoordinateReferenceSystem,
        tolerance_m: Optional[float],
        rmse: Optional[float],
        auto_neutralize: bool,
        neutralized_fids: List[int],
        helmert: HelmertTransformation,
        transformed_layers: List[str],
        copied_layers: List[str],
    ) -> str:
        """Build the Helmert transformation report as a Markdown string.

        :param out_gpkg_path: Output (georeferenced) GeoPackage path.
        :type out_gpkg_path: str
        :param src_gpkg_path: Source GeoPackage path.
        :type src_gpkg_path: str
        :param source_layer_name: Name of the selected source layer.
        :type source_layer_name: str
        :param target_crs: Target CRS selected by the user.
        :type target_crs: QgsCoordinateReferenceSystem
        :param tolerance_m: Tolerance in meters (may be ``None``).
        :type tolerance_m: Optional[float]
        :param rmse: Current EMQ/RMSE (may be ``None`` if < 3 active pairs).
        :type rmse: Optional[float]
        :param auto_neutralize: Whether auto-neutralization is enabled.
        :type auto_neutralize: bool
        :param neutralized_fids: List of feature ids neutralized during this run.
        :type neutralized_fids: list[int]
        :param helmert: Fitted Helmert transformation.
        :type helmert: HelmertTransformation
        :param transformed_layers: Output layer names exported with Helmert geometry transformation.
        :type transformed_layers: list[str]
        :param copied_layers: Output layer names copied as-is.
        :type copied_layers: list[str]
        :returns: Markdown report content.
        :rtype: str
        """
        title = tr("Helmert Transformation Report")
        now_txt = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

        crs_label = (
            target_crs.authid()
            if target_crs and target_crs.authid()
            else target_crs.description()
        )

        monuments = []
        try:
            monuments = (
                self.monuments_manager.get_monuments_data()
                if self.monuments_manager
                else []
            )
        except Exception:
            monuments = []

        n_total = len(monuments)
        n_active = sum(1 for d in monuments if int(d.get("used") or 0) == 1)

        yn = tr("Yes") if auto_neutralize else tr("No")
        tol_txt = fmt(tolerance_m, "{:.3f}")
        rmse_txt = fmt(rmse, "{:.3f}")

        coeffs = helmert.coefficients

        lines: List[str] = []
        lines.append("# " + title)
        lines.append("")
        lines.append(tr("Generated on: {dt}").format(dt=now_txt))
        lines.append("")

        # --- Summary -----------------------------------------------------
        lines.append("## " + tr("Summary"))
        lines.append("")
        lines.append("| {k} | {v} |".format(k=tr("Item"), v=tr("Value")))
        lines.append("|---|---|")
        lines.append(
            "| {k} | {v} |".format(
                k=tr("Source GeoPackage"), v="`{p}`".format(p=src_gpkg_path)
            )
        )
        lines.append(
            "| {k} | {v} |".format(
                k=tr("Output GeoPackage"), v="`{p}`".format(p=out_gpkg_path)
            )
        )
        lines.append(
            "| {k} | {v} |".format(
                k=tr("Source layer"), v="`{n}`".format(n=source_layer_name)
            )
        )
        lines.append(
            "| {k} | {v} |".format(k=tr("Target CRS"), v="`{c}`".format(c=crs_label))
        )
        lines.append(
            "| {k} | {v} |".format(k=tr("Tolerance"), v="{t} m".format(t=tol_txt))
        )
        lines.append("| {k} | {v} |".format(k=tr("RMSE"), v="{r} m".format(r=rmse_txt)))
        lines.append("| {k} | {v} |".format(k=tr("Auto neutralize"), v=yn))
        lines.append(
            "| {k} | {v} |".format(
                k=tr("Pairs"), v="{a}/{t}".format(a=n_active, t=n_total)
            )
        )
        if neutralized_fids:
            lines.append(
                "| {k} | {v} |".format(
                    k=tr("Neutralized (this run)"),
                    v=", ".join([str(x) for x in neutralized_fids]),
                )
            )
        lines.append("")

        if rmse is None:
            lines.append(tr("Note: EMQ (RMSE) requires at least 3 active pairs."))
            lines.append("")

        # --- Transformation parameters ----------------------------------
        lines.append("## " + tr("Transformation parameters"))
        lines.append("")
        lines.append("| {k} | {v} |".format(k=tr("Parameter"), v=tr("Value")))
        lines.append("|---|---|")
        lines.append("| {k} | {v} |".format(k=tr("tx"), v=fmt(coeffs.tx, "{:.6f}")))
        lines.append("| {k} | {v} |".format(k=tr("ty"), v=fmt(coeffs.ty, "{:.6f}")))
        lines.append(
            "| {k} | {v} |".format(k=tr("scale"), v=fmt(coeffs.scale, "{:.9f}"))
        )
        lines.append(
            "| {k} | {v} |".format(
                k=tr("rotation (gon)"), v=fmt(coeffs.rotation_gon, "{:.6f}")
            )
        )
        lines.append("| {k} | {v} |".format(k=tr("b"), v=fmt(coeffs.b, "{:.9f}")))
        lines.append("| {k} | {v} |".format(k=tr("c"), v=fmt(coeffs.c, "{:.9f}")))
        lines.append("| {k} | {v} |".format(k=tr("b1"), v=fmt(coeffs.b1, "{:.9f}")))
        lines.append("| {k} | {v} |".format(k=tr("c1"), v=fmt(coeffs.c1, "{:.9f}")))
        lines.append("")

        # --- Layers ------------------------------------------------------
        lines.append("## " + tr("Layers"))
        lines.append("")

        lines.append("### " + tr("Transformed layers"))
        if transformed_layers:
            for n in transformed_layers:
                lines.append("- `{name}`".format(name=n))
        else:
            lines.append(tr("- None"))

        lines.append("")
        lines.append("### " + tr("Copied layers"))
        if copied_layers:
            for n in copied_layers:
                lines.append("- `{name}`".format(name=n))
        else:
            lines.append(tr("- None"))

        lines.append("")

        # --- Control points ---------------------------------------------
        lines.append("## " + tr("Control points"))
        lines.append("")
        lines.append(
            tr("Note: neutralized pairs (used = 0) have an undefined residual (gap).")
        )
        lines.append("")

        headers = [
            tr("ID"),
            tr("Used"),
            tr("X source"),
            tr("Y source"),
            tr("X target"),
            tr("Y target"),
            tr("Gap (m)"),
        ]
        lines.append("| " + " | ".join(headers) + " |")
        lines.append("|" + "|".join(["---"] * len(headers)) + "|")

        for d in monuments:
            gap_val = d.get("err")
            gap_txt = fmt(gap_val, "{:.3f}")
            line = "| {id} | {used} | {xs} | {ys} | {xt} | {yt} | {gap} |".format(
                id=str(d.get("fid")),
                used=str(int(d.get("used") or 0)),
                xs=fmt(d.get("x"), "{:.3f}"),
                ys=fmt(d.get("y"), "{:.3f}"),
                xt=fmt(d.get("new_x"), "{:.3f}"),
                yt=fmt(d.get("new_y"), "{:.3f}"),
                gap=gap_txt,
            )
            lines.append(line)

        lines.append("")
        return "\n".join(lines) + "\n"

    def _write_helmert_report(
        self,
        out_gpkg_path: str,
        src_gpkg_path: str,
        source_layer_name: str,
        target_crs: QgsCoordinateReferenceSystem,
        tolerance_m: Optional[float],
        rmse: Optional[float],
        auto_neutralize: bool,
        neutralized_fids: List[int],
        helmert: HelmertTransformation,
        transformed_layers: List[str],
        copied_layers: List[str],
    ) -> None:
        """Write Helmert report files (MD/HTML/PDF/ODT) in ``<project_dir>/rapport``.

        The report base name matches the output GeoPackage base name so that
        all formats correspond to the same dataset.

        Existing report files are overwritten without confirmation.

        :returns: None
        :rtype: None
        """
        rapport_dir = self._get_rapport_dir()
        base = os.path.splitext(os.path.basename(out_gpkg_path))[0]
        md_name = base + ".md"
        md_path = os.path.join(rapport_dir, md_name)

        md_text = self._build_helmert_report_markdown(
            out_gpkg_path=out_gpkg_path,
            src_gpkg_path=src_gpkg_path,
            source_layer_name=source_layer_name,
            target_crs=target_crs,
            tolerance_m=tolerance_m,
            rmse=rmse,
            auto_neutralize=auto_neutralize,
            neutralized_fids=neutralized_fids,
            helmert=helmert,
            transformed_layers=transformed_layers,
            copied_layers=copied_layers,
        )

        # Write Markdown (UTF-8)
        os.makedirs(rapport_dir, exist_ok=True)
        with open(md_path, "w", encoding="utf-8") as f:
            f.write(md_text)

        title = tr("Helmert Transformation Report")

        # Export HTML
        try:
            ReportUtils.markdown_to_html(md_path, title=title)
        except Exception as ex:
            PlgLogger.log(
                tr("HTML export failed: {e}").format(e=str(ex)),
                log_level=Qgis.MessageLevel.Warning,
                push=True,
            )

        # Export PDF
        try:
            ReportUtils.markdown_to_pdf(md_path, title=title, margins_mm=(7, 15, 7, 15))
        except Exception as ex:
            PlgLogger.log(
                tr("PDF export failed: {e}").format(e=str(ex)),
                log_level=Qgis.MessageLevel.Warning,
                push=True,
            )

        # Export ODT
        try:
            ReportUtils.markdown_to_odt(md_path)
        except Exception as ex:
            PlgLogger.log(
                tr("ODT export failed: {e}").format(e=str(ex)),
                log_level=Qgis.MessageLevel.Warning,
                push=True,
            )

        PlgLogger.log(
            tr("Report written to {path}").format(path=md_path),
            log_level=Qgis.MessageLevel.Success,
            push=True,
        )

    def setupSnapping(self):
        # Init checkbox from current project snapping config
        try:
            cfg = QgsProject.instance().snappingConfig()
            self.snappingCheckBox.setChecked(cfg.enabled())
        except Exception:
            pass

        self.snappingCheckBox.stateChanged.connect(self.toggleSnapping)

    def toggleSnapping(self, state):
        # Update project snapping config (QgsProject.snappingConfig() returns a copy)
        try:
            cfg = QgsProject.instance().snappingConfig()
            cfg.setEnabled(state == Qt.Checked)
            QgsProject.instance().setSnappingConfig(cfg)
        except Exception:
            pass

    # ------------------------------------------------------------------
    #  private methods
    # ------------------------------------------------------------------

    def _ensure_layer_in_local_tree(self, group_name: str, layer):
        """Assure que layer est présent dans self.localRoot, sous group_name, sans doublons."""
        if not layer or not hasattr(self, "localRoot"):
            return

        group = self.localRoot.findGroup(group_name)
        if group is None:
            group = self.localRoot.addGroup(group_name)

        # Existe déjà quelque part dans le clone ?
        existing = self.localRoot.findLayer(layer.id())
        if existing is None:
            group.addLayer(layer)
        else:
            # S'il est ailleurs que sous le groupe, on le déplace
            parent = existing.parent()
            if parent is not None and parent != group:
                parent.removeChildNode(existing)
                group.addChildNode(existing)

    def _ensure_helmert_in_local_tree(self, monuments_layer, gaps_layer):
        self._ensure_layer_in_local_tree("Helmert", monuments_layer)
        self._ensure_layer_in_local_tree("Helmert", gaps_layer)

        self.filterHelmertLayers()

        # IMPORTANT : le bridge doit recalculer la liste des layers du canvas du dialog
        try:
            self.layerTreeBridge.setCanvasLayers()
        except Exception:
            pass

    def _update_remove_action_state(self):
        rows = []
        try:
            rows = self.tableWidget_coordinates.selectionModel().selectedRows()
        except Exception:
            rows = []

        has_sel = bool(rows)

        any_used_1 = False
        any_used_0 = False
        for idx in rows:
            item_used = self.tableWidget_coordinates.item(idx.row(), 1)
            try:
                used_val = int(item_used.text()) if item_used else 0
            except Exception:
                used_val = 0
            if used_val == 1:
                any_used_1 = True
            else:
                any_used_0 = True

        # Remove: toujours possible si sélection
        self.actionRemoveMonumentPoint.setEnabled(has_sel)

        # Neutralize / Reactivate: uniquement en mode manuel
        if not self.auto_neutralize:
            self.actionNeutralizeMonumentPoint.setEnabled(has_sel and any_used_1)
            self.actionReactivateMonumentPoint.setEnabled(has_sel and any_used_0)
        else:
            self.actionNeutralizeMonumentPoint.setEnabled(False)
            self.actionReactivateMonumentPoint.setEnabled(False)

    def _update_run_helmert_action_state(self):
        """Enable/disable the *Run Helmert Transformation* action.

        The action is enabled only when the monuments table contains at least
        two pairs (two rows). This is a lightweight UI guard; the transformation
        method still performs its own checks.

        :returns: None
        :rtype: None
        """
        try:
            n_rows = int(self.tableWidget_coordinates.rowCount())
        except Exception:
            n_rows = 0

        try:
            self.actionRunHelmert.setEnabled(n_rows >= 2)
        except Exception:
            pass

    def _mk_item(self, text: str, align=None) -> QTableWidgetItem:
        item = QTableWidgetItem(text)
        item.setFlags(item.flags() & ~Qt.ItemIsEditable)
        if align is not None:
            item.setTextAlignment(align)
        return item

    def _set_pick_hint(self, text: str):
        if hasattr(self, "pickHintLabel"):
            self.pickHintLabel.setText(text or "")

    def _get_export_dir(self) -> str:
        """Return <project_dir>/export. Raises ValueError if project not saved."""
        project_path = QgsProject.instance().fileName()  # .qgs / .qgz
        if not project_path:
            raise ValueError(tr("Project not saved"))
        project_dir = os.path.dirname(project_path)
        export_dir = os.path.join(project_dir, "export")
        os.makedirs(export_dir, exist_ok=True)
        return export_dir

    def _next_indexed_filename(self, export_dir: str, base_filename: str) -> str:
        """Firefox-like auto rename: helmert_monuments-1.json, -2.json, ..."""
        stem, ext = os.path.splitext(base_filename)
        i = 1
        while True:
            candidate = os.path.join(export_dir, f"{stem}-{i}{ext}")
            if not os.path.exists(candidate):
                return candidate
            i += 1

    def _get_db_dir(self) -> str:
        """Return ``<project_dir>/db``.

        The QGIS project must be saved, otherwise the project directory is undefined.

        :returns: Absolute path to the ``db`` directory.
        :rtype: str
        :raises ValueError: If the project is not saved.
        """
        project_path = QgsProject.instance().fileName()
        if not project_path:
            raise ValueError(tr("Project not saved"))

        project_dir = os.path.dirname(project_path)
        db_dir = os.path.join(project_dir, "db")
        os.makedirs(db_dir, exist_ok=True)
        return db_dir

    def _get_gpkg_path_from_layer(self, layer: QgsVectorLayer) -> Optional[str]:
        """Return the GeoPackage file path for a given layer.

        This helper supports common OGR URI formats:

        - ``/path/to/file.gpkg|layername=...``
        - ``dbname='/path/to/file.gpkg' ...``

        :param layer: Input layer.
        :type layer: QgsVectorLayer
        :returns: Path to the ``.gpkg`` file, or ``None`` if not a GeoPackage layer.
        :rtype: Optional[str]
        """
        if layer is None:
            return None

        src = layer.source() or ""
        if not src:
            return None

        # Most common form: /path/file.gpkg|layername=...
        path = src.split("|")[0]
        if path.lower().endswith(".gpkg"):
            return path

        # Alternate form: dbname='...gpkg' ...
        if "dbname=" in src and ".gpkg" in src.lower():
            import re

            m = re.search(r"dbname=['\"]([^'\"]+\.gpkg)['\"]", src, re.IGNORECASE)
            if m:
                return m.group(1)

        return None

    def _get_layers_from_geopackage(self, gpkg_path: str) -> List[QgsVectorLayer]:
        """List project vector layers coming from a given GeoPackage.

        :param gpkg_path: Path to the source GeoPackage.
        :type gpkg_path: str
        :returns: Vector layers whose data source points to ``gpkg_path``.
        :rtype: list[QgsVectorLayer]
        """
        norm = os.path.normpath(gpkg_path)
        layers: List[QgsVectorLayer] = []
        for lyr in QgsProject.instance().mapLayers().values():
            if not isinstance(lyr, QgsVectorLayer):
                continue
            src = lyr.source() or ""
            src_path = os.path.normpath(src.split("|")[0]) if src else ""
            if src_path == norm:
                layers.append(lyr)
        return layers

    def _prepare_output_geopackage_path(self, out_path: str) -> Optional[str]:
        """Handle output GeoPackage name collisions.

        If ``out_path`` already exists, the user is prompted to overwrite it,
        auto-rename it (Firefox-like ``-1``, ``-2`` suffix), or cancel.

        :param out_path: Desired output path.
        :type out_path: str
        :returns: A path that can be safely written, or ``None`` if cancelled.
        :rtype: Optional[str]
        """
        if not os.path.exists(out_path):
            return out_path

        base_name = os.path.basename(out_path)
        out_dir = os.path.dirname(out_path)

        box = QMessageBox(self)
        box.setIcon(QMessageBox.Warning)
        box.setWindowTitle(tr("Export GeoPackage"))
        box.setText(
            tr('The file "{name}" already exists in:').format(name=base_name)
            + "\n{dir}".format(dir=out_dir)
        )
        box.setInformativeText(tr("Do you want to overwrite it?"))

        btn_overwrite = box.addButton(tr("Overwrite"), QMessageBox.YesRole)
        btn_rename = box.addButton(tr("Auto-rename"), QMessageBox.NoRole)
        btn_cancel = box.addButton(tr("Cancel"), QMessageBox.RejectRole)

        box.setDefaultButton(btn_overwrite)
        box.exec_()

        if box.clickedButton() == btn_cancel:
            return None

        if box.clickedButton() == btn_rename:
            return self._next_indexed_filename(out_dir, base_name)

        # Overwrite requested
        try:
            os.remove(out_path)
        except Exception:
            # Best effort: writer may still overwrite layers, but deleting is safer.
            pass

        return out_path

    def _unique_layer_name(self, base_name: str, used_names: set) -> str:
        """Return a unique layer name within a GeoPackage export run.

        :param base_name: Proposed layer name.
        :type base_name: str
        :param used_names: Set of already used names (will be updated).
        :type used_names: set
        :returns: Unique name.
        :rtype: str
        """
        name = base_name
        i = 1
        while name in used_names:
            name = "{base}-{i}".format(base=base_name, i=i)
            i += 1
        used_names.add(name)
        return name

    def _export_vector_layer_to_gpkg(
        self,
        src_layer: QgsVectorLayer,
        gpkg_path: str,
        out_layer_name: str,
        out_crs: Optional[QgsCoordinateReferenceSystem] = None,
        helmert: Optional[HelmertTransformation] = None,
    ) -> None:
        """Export one vector layer to a GeoPackage, optionally applying Helmert.

        The output is written as a new layer in ``gpkg_path``. If ``helmert`` is
        provided, geometries are transformed by applying the Helmert transform
        to every vertex (2D).

        :param src_layer: Source layer to export.
        :type src_layer: QgsVectorLayer
        :param gpkg_path: Output GeoPackage path.
        :type gpkg_path: str
        :param out_layer_name: Name of the output layer in the GeoPackage.
        :type out_layer_name: str
        :param out_crs: CRS to assign to the output layer.
        :type out_crs: Optional[QgsCoordinateReferenceSystem]
        :param helmert: Helmert transform to apply (source -> target).
        :type helmert: Optional[HelmertTransformation]
        :raises RuntimeError: On write failure.
        """
        crs = out_crs if out_crs is not None else src_layer.crs()

        wkb_str = QgsWkbTypes.displayString(src_layer.wkbType())
        if crs is not None and crs.isValid():
            uri = "{wkb}?crs={crs}".format(wkb=wkb_str, crs=crs.authid())
        else:
            uri = wkb_str

        tmp = QgsVectorLayer(uri, out_layer_name, "memory")
        pr = tmp.dataProvider()
        pr.addAttributes(src_layer.fields())
        tmp.updateFields()

        # Copy features (streaming in batches)
        batch: List[QgsFeature] = []
        batch_size = 2000

        for f in src_layer.getFeatures():
            nf = QgsFeature(tmp.fields())
            nf.setAttributes(f.attributes())

            if (
                QgsWkbTypes.geometryType(src_layer.wkbType())
                != QgsWkbTypes.NullGeometry
            ):
                g = f.geometry()
                if helmert is not None and g is not None and not g.isEmpty():
                    g = self._helmert_transform_geometry(g, helmert)
                nf.setGeometry(g)

            batch.append(nf)
            if len(batch) >= batch_size:
                pr.addFeatures(batch)
                batch = []

        if batch:
            pr.addFeatures(batch)

        tmp.updateExtents()

        opts = QgsVectorFileWriter.SaveVectorOptions()
        opts.driverName = "GPKG"
        opts.layerName = out_layer_name
        opts.fileEncoding = "UTF-8"

        if not os.path.exists(gpkg_path):
            opts.actionOnExistingFile = QgsVectorFileWriter.CreateOrOverwriteFile
        else:
            opts.actionOnExistingFile = QgsVectorFileWriter.CreateOrOverwriteLayer

        res = QgsVectorFileWriter.writeAsVectorFormatV2(
            tmp, gpkg_path, QgsProject.instance().transformContext(), opts
        )
        if isinstance(res, tuple):
            err = res[0]
            err_msg = res[1] if len(res) > 1 else ""
        else:
            err = res
            err_msg = ""

        if err != QgsVectorFileWriter.NoError:
            raise RuntimeError(err_msg)

    def _helmert_transform_geometry(
        self, geom: QgsGeometry, helmert: HelmertTransformation
    ) -> QgsGeometry:
        """Apply Helmert transform to all vertices of a geometry (2D).

        :param geom: Input geometry.
        :type geom: QgsGeometry
        :param helmert: Fitted Helmert transformation.
        :type helmert: HelmertTransformation
        :returns: Transformed geometry.
        :rtype: QgsGeometry
        """
        if geom is None or geom.isEmpty():
            return geom

        wkb = geom.wkbType()
        gtype = QgsWkbTypes.geometryType(wkb)
        is_multi = QgsWkbTypes.isMultiType(wkb)

        def _tp(pt) -> QgsPointXY:
            X, Y = helmert.transform_xy(float(pt.x()), float(pt.y()))
            return QgsPointXY(X, Y)

        # Point
        if gtype == QgsWkbTypes.PointGeometry:
            if is_multi:
                pts = geom.asMultiPoint()
                return QgsGeometry.fromMultiPointXY([_tp(p) for p in pts])
            p = geom.asPoint()
            return QgsGeometry.fromPointXY(_tp(p))

        # Line
        if gtype == QgsWkbTypes.LineGeometry:
            if is_multi:
                lines = geom.asMultiPolyline()
                return QgsGeometry.fromMultiPolylineXY(
                    [[_tp(p) for p in line] for line in lines]
                )
            line = geom.asPolyline()
            return QgsGeometry.fromPolylineXY([_tp(p) for p in line])

        # Polygon
        if gtype == QgsWkbTypes.PolygonGeometry:
            if is_multi:
                polys = geom.asMultiPolygon()
                return QgsGeometry.fromMultiPolygonXY(
                    [[[_tp(p) for p in ring] for ring in poly] for poly in polys]
                )
            poly = geom.asPolygon()
            return QgsGeometry.fromPolygonXY([[_tp(p) for p in ring] for ring in poly])

        # Fallback: return unchanged (and let the caller decide if that's acceptable)
        return geom

    def _update_emq_label(self, rmse, tol):
        """Update label_emq text + color based on tolerance."""
        if not hasattr(self, "label_emq") or self.label_emq is None:
            return

        if rmse is None:
            self.label_emq.setText(tr("N/A"))
            self.label_emq.setStyleSheet("")  # couleur par défaut
            self.label_emq.setStyleSheet("font-weight: bold;")
            return

        self.label_emq.setText(f"{rmse:.3f} m")

        # Si pas de tolérance, on affiche juste la valeur sans code couleur
        if tol is None:
            self.label_emq.setStyleSheet("font-weight: bold;")
            return

        if rmse > tol:
            self.label_emq.setStyleSheet("font-weight: bold;color: red;")
        elif rmse > 0.9 * tol:
            self.label_emq.setStyleSheet("font-weight: bold;color: orange;")
        else:
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
            self.label_emq.setStyleSheet("font-weight: bold;color: green;")
