"""
/***************************************************************************
 qgSurf - plugin for Quantum GIS

 Processing of geological planes and surfaces

                              -------------------
        start                : 2011-12-21
        copyright            : (C) 2011-2026 by Mauro Alberti
        email                : alberti.m65@gmail.com

 ***************************************************************************/

# licensed under the terms of GNU GPL 3

/***************************************************************************
 *                                                                         *
 *   This program is free software; you can redistribute it and/or modify  *
 *   it under the terms of the GNU General Public License as published by  *
 *   the Free Software Foundation; either version 3 of the License, or     *
 *   (at your option) any later version.                                   *
 *                                                                         *
 ***************************************************************************/
"""

from typing import Dict, Any, Optional, List

import logging
import numbers
import os
import json
import time

logger = logging.getLogger(__name__)

from qgis.PyQt import uic
from qgis.PyQt.QtCore import pyqtSlot, QFileInfo, Qt
from qgis.PyQt.QtWidgets import QApplication, QDialog, QFileDialog, QMessageBox, QProgressDialog

from qgis.core import (
    QgsCoordinateReferenceSystem,
    QgsCoordinateTransform,
    QgsProject,
    QgsRenderContext,
    QgsVectorLayer,
    QgsRasterLayer,
    Qgis,
    QgsMessageLog,
)


from geogst.core.geometries.shapes.lines import *
from geogst.core.geology.profiles.project_onto_profiles import *
from geogst.core.geology.profiles.structures import *
from geogst.core.utils.strings import *
from geogst.io.geopackages.geopackage import *
from geogst.io.rasters.gdal import *
from geogst.io.rasters.gdal import *
from geogst.io.vectors.ogr import *
from geogst.plots.profiles import *
from geogst.plots.utils import *
from geogst.core.geometries.projections.project import project_xy_to_geographic

from ..apps.qgis.canvas import *
from ..apps.qgis.lines import *
from ..apps.qgis.messages import *
from ..apps.qgis.polygons import *
from ..apps.qgis.rasters import *
from ..apps.qgis.symbology import *
from ..apps.qgis.vectors import *

from ..apps.qt.filesystem import *
from ..apps.qt.messages import *
from ..apps.qt.plots import *
from ..apps.qt.tools import *
from ..apps.qt.colors import *

from ..utils.geoprofiler import export as gp_export
from ..utils.geoprofiler.importer import GeoProfileReader
from ..utils.geoprofiler.importer_dialog import *
from ..utils.validation import safe_float

from .config.structures import *
from .config.qt_windows import *
from .config.constants import (
    ProfileConstants,
    DatabaseConstants,
    UIConstants,
    EPSGCodes,
)


context = QgsRenderContext()


class GeoProfilerWidget(QWidget):

    def __init__(
        self,
        current_directory,
        plugin_name,
        canvas,
        parent,
    ):

        super(GeoProfilerWidget, self).__init__()

        self.project = QgsProject.instance()
        self._project_signal_connected = False

        self._init_basic_params(
            current_directory,
            plugin_name,
            canvas,
            parent,
        )
        self._init_actions()
        self._init_geostorage()
        self._init_profiles_grid_parameters()
        self._init_profile_lines_parameters()
        self._init_geoprofiles_parameters()
        self._init_graphics_parameters()

    def _init_basic_params(
        self,
        current_directory,
        plugin_name,
        canvas,
        parent,
    ):

        self.plugin_name = plugin_name
        self.canvas = canvas
        self.current_directory = current_directory
        self.parent = parent

    def _init_geostorage(self):

        self.storage_geopackage_path = None
        self.storage_layer_name = None

    def _load_storage_layer_into_project(self,
        geopackage_path: str,
        layer_name: str,
    ):
        """
        Load a GeoPackage sublayer into the current QGIS project and keep a reference to it.
        """
        try:
            layer_uri = f"{geopackage_path}|layername={layer_name}"
            layer = QgsVectorLayer(layer_uri, layer_name, "ogr")

            if not layer.isValid():
                error_qgis(
                    header=self.plugin_name,
                    msg=f"\nUnable to load layer '{layer_name}' from '{geopackage_path}'"
                )
                return None

            QgsProject.instance().addMapLayer(layer)

            # utile anche per export / selection helper
            self.working_profiles_layer = layer

            # opzionale ma molto comodo: lo imposti subito come layer 3D corrente
            self.qgis_3d_line_layer = layer

            info_qgis(
                self.plugin_name,
                f"Layer '{layer_name}' loaded into current project"
            )

            return layer

        except Exception as e:
            error_qt(
                self,
                self.plugin_name,
                f"Unable to load storage layer into project: {e!r}"
            )
            return None

    def _init_actions(self):

        self.processings = {
            "Create new geopackage with empty 3D line layer": self.geopackage_create_new,
            "Create new 3D line layer in existing geopackage": self.geopackage_create_new_3d_layer,
            "Append to existing 3D line layer in geopackage": self.geopackage_load_existing,
            "Load trace from 2D line layer": self.define_profile_lines_from_qgis_layer,
            "Digitize 2D trace in canvas": self.define_profile_line_by_rubberband_line,
            "Clear last trace": self.clear_rubberband_line,
            "Clear all traces": self.clear_all_traces,
            "Define DEM as elevation source": self.define_dem_as_elevation_source,
            "Generate 3D profile": self.generate_3d_profiles_lines,
            "1 - Choose 3D topographic profile": self.choose_3d_topographic_line_layer,
            "Point projection": self.define_point_layer_for_projection,
            "Geological attitude projection": self.define_attitude_layer_for_projection,
            "Focal mechanism projection": self.define_focal_mechanisms_layer_for_projection,
            "Line intersection": self.define_line_layer_for_intersection,
            "Polygon intersection": self.define_polygon_layer_for_intersection,
            "Remove all": self.reset_source_geodata_parameters,
            "Remove point projection": self.reset_points_projection_parameters,
            "Remove geological attitude projection": self.reset_attitudes_projection_parameters,
            "Remove focal mechanism projection": self.reset_focal_mechanisms_projection_parameters,
            "Remove line intersection": self.reset_lines_intersection_parameters,
            "Remove polygon intersection": self.reset_polygons_intersection_parameters,
            "Define graphical parameters": self.define_total_graphical_parameters,
            "Save graphical parameters": self.save_graphic_parameters,
            "Load graphical parameters": self.load_graphic_parameters,
            "4 - Plot geoprofile": self.plot_geoprofiles,
            "5 - Export geoprofile": self.export_geoprofiles,
            "6 - Import geoprofile": self.import_geoprofiles,
            "Help": self.open_help,
        }

        uic.loadUi(f"{self.current_directory}/ui/GeoProfiler.ui", self)

        self.actions_qtreewidget = self.actionsTreeWidget
        self.actions_qtreewidget.itemDoubleClicked.connect(self.do_processing)

    def showEvent(self, event):
        super().showEvent(event)
        self.connect_project_signal()

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

    def connect_project_signal(self):
        if not self._project_signal_connected:
            self.project.readProject.connect(self.on_project_changed)
            self._project_signal_connected = True

    def disconnect_project_signal(self):
        if self._project_signal_connected:
            try:
                self.project.readProject.disconnect(self.on_project_changed)
            except TypeError:
                pass
            self._project_signal_connected = False

    @pyqtSlot()
    def on_project_changed(self):
        self.update_tool_for_project()

    def update_tool_for_project(self):
        self._init_profile_lines_parameters()
        self._init_profiles_grid_parameters()
        self._init_topo_labels()
        self._init_graphics_parameters()
        self._init_geoprofiles_parameters()

    def _init_profile_lines_parameters(self):

        self.newly_defined_profile_baselines = []  # line instances, in the specific CRS, undensified

    def _init_profiles_grid_parameters(self):

        self.profiles_grid = None

    def reset_points_projection_parameters(self, warn=True):

        self.points_projection_parameters = None

        if warn:
            warn_qgis(
                self.plugin_name,
                "Points projection parameters reset"
            )

    def reset_attitudes_projection_parameters(self, warn=True):

        self.attitudes_projection_parameters = None

        if warn:
            warn_qgis(
                self.plugin_name,
                "Attitudes projection parameters reset"
            )

    def reset_focal_mechanisms_projection_parameters(self, warn=True):

        self.focal_mechanisms_projection_parameters = None

        if warn:
            warn_qgis(
                self.plugin_name,
                "Focal mechanisms projection parameters reset"
            )

    def reset_lines_intersection_parameters(self, warn=True):

        self.lines_intersection_parameters = None

        if warn:
            warn_qgis(
                self.plugin_name,
                "Lines intersection parameters reset"
            )

    def reset_polygons_intersection_parameters(self, warn=True):

        self.polygons_intersection_parameters = None

        if warn:
            warn_qgis(
                self.plugin_name,
                "Polygons intersection parameters reset"
            )

    def reset_source_geodata_parameters(self,
        warn: bool = False):

        self.reset_points_projection_parameters(warn=False)
        self.reset_attitudes_projection_parameters(warn=False)
        self.reset_focal_mechanisms_projection_parameters(warn=False)
        self.reset_lines_intersection_parameters(warn=False)
        self.reset_polygons_intersection_parameters(warn=False)

        if warn:
            warn_qgis(
                self.plugin_name,
                "All input geodata parameters reset"
            )

    def _init_geoprofiles_parameters(self):

        self.qgis_3d_line_layer = None
        self.geoprofiles = None  # GeoProfiles()  # main instance for the geoprofiles

        # any previously defined geodata (points, attitudes, lines, polygons)
        # was projected/intersected against the geoprofile just discarded
        # above: keeping it defined would let a stage look "already applied"
        # against profiles it never actually ran against, so it is cleared
        # together with the geoprofile rather than left stale.
        self.reset_source_geodata_parameters(warn=False)

    def _init_graphics_parameters(self):

        self.graphical_params = None
        self.polygon_classification_colors = dict()

        # per-profile styles of the exploded profiles, keyed by profile index, and
        # the windows showing them. Both are tied to the current geoprofile, so
        # they are cleared whenever the graphics parameters are, rather than
        # carrying the styles of a discarded profile over to a new one.
        self.exploded_graphical_params = dict()
        self.exploded_windows = []

        # the window an explode request came from, so that an exploded profile
        # opens as its child and stays usable while it is on show
        self._exploding_window = None

    def do_processing(self):

        current_item_text = self.actions_qtreewidget.currentItem().text(0)

        processing = self.processings.get(current_item_text)

        if processing is not None:
            processing()

    def geopackage_create_new(self):

        dialog = CreateNewGeopackageDialog(
            plugin_name=self.plugin_name,
            parent=self.parent)

        if not dialog.exec():
            return

        geopackage_path = dialog.geopackage_path
        layer_name = dialog.layer_name

        # creare un geopackage sopra uno esistente lo ricrea da zero, portandosi via
        # ogni layer che conteneva: va chiesto, e detto cosa comporta
        overwrite = False

        if os.path.exists(geopackage_path):
            overwrite = question_qt(
                self,
                self.plugin_name,
                f"The file:\n{geopackage_path}\n\nalready exists. Creating a new "
                f"GeoPackage over it deletes every layer it contains.\n\n"
                f"Replace it? Answer No to keep it, and use 'Create new 3D line layer "
                f"in existing geopackage' to add a profile layer to it instead."
            )
            if not overwrite:
                return

        success, err = create_geopackage(path=geopackage_path, overwrite=overwrite)
        if not success:
            error_qt(
                self,
                header=self.plugin_name,
                msg=f"Could not create GeoPackage:\n{geopackage_path}\n\n{err}",
            )
            return

        success, err = add_vector_layer(
            geopackage_path=geopackage_path,
            layer_name=layer_name,
            geometry_type="LineString25D",
            epsg_code=EPSGCodes.WGS84,
        )
        if not success:
            error_qgis(
                header=self.plugin_name,
                msg=(
                    f"GeoPackage was created, but the layer '{layer_name}' "
                    f"could not be added: {err}."
                ),
            )
            return

        self.storage_geopackage_path = geopackage_path
        self.storage_layer_name = layer_name

        if dialog.load_into_project:
            self._load_storage_layer_into_project(
                geopackage_path=geopackage_path,
                layer_name=layer_name,
            )

    def geopackage_create_new_3d_layer(self):

        dialog = OpenGeopackageDialog(self.plugin_name)
        if dialog.exec():
            geopackage_path = dialog.existing_geopackage_QLineEdit.text()
            if len(geopackage_path) == 0:
                warn_qgis(
                    self.plugin_name,
                    "Output geopackage path is undefined"
                )
                return
            layer_name = dialog.existing_layer_QLineEdit.text()
            if len(layer_name) == 0:
                warn_qgis(
                    self.plugin_name,
                    "Output layer name is undefined"
                )
                return
        else:
            warn_qgis(
                self.plugin_name,
                "No geopackage defined"
            )
            return

        # il caso di gran lunga più frequente, un nome già usato nel geopackage,
        # merita di essere detto per quello che è, con la via d'uscita: il
        # messaggio generico lasciava l'utente a indovinare cosa fosse andato storto
        existing_layers, err = vector_layer_names(geopackage_path)

        if existing_layers is None:
            error_qt(
                self,
                header=self.plugin_name,
                msg=f"Unable to read the geopackage:\n{geopackage_path}\n\n{err}",
            )
            return

        if layer_name in existing_layers:
            error_qt(
                self,
                header=self.plugin_name,
                msg=f"A layer named '{layer_name}' already exists in the geopackage:\n"
                    f"{geopackage_path}\n\n"
                    f"Give the new layer a different name, or use 'Append to existing "
                    f"3D line layer in geopackage' to add the profile to the existing one.",
            )
            return

        success, err = add_vector_layer(
            geopackage_path=geopackage_path,
            layer_name=layer_name,
            geometry_type="LineString25D",
            epsg_code=EPSGCodes.WGS84,
        )

        if not success:
            error_qt(
                self,
                header=self.plugin_name,
                msg=f"Unable to add the 3D line layer '{layer_name}' to the geopackage:\n"
                    f"{geopackage_path}\n\n{err}",
            )
            return

        self.storage_geopackage_path = geopackage_path
        self.storage_layer_name = layer_name

        self._load_storage_layer_into_project(
            geopackage_path=self.storage_geopackage_path,
            layer_name=self.storage_layer_name,
        )

    def geopackage_load_existing(self):

        dialog = OpenGeopackageDialog(self.plugin_name)
        if dialog.exec():
            geopackage_path = dialog.existing_geopackage_QLineEdit.text()
            if len(geopackage_path) == 0:
                warn_qgis(
                    self.plugin_name,
                    "Output geopackage path is undefined"
                )
                return
            layer_name = dialog.existing_layer_QLineEdit.text()
            if len(layer_name) == 0:
                warn_qgis(
                    self.plugin_name,
                    "Output layer name is undefined"
                )
                return
        else:
            warn_qgis(
                self.plugin_name,
                "No geopackage defined"
            )
            return

        # il layer va verificato ora: se non esiste, o non regge linee 3D, la
        # scrittura fallirebbe solo a profilo generato, quando il calcolo è già stato
        # fatto e il motivo finirebbe nel pannello di log
        usable, err = check_3d_line_layer(geopackage_path, layer_name)

        if not usable:
            error_qt(
                self,
                header=self.plugin_name,
                msg=f"The geopackage:\n{geopackage_path}\n\ncannot store the profile: {err}.",
            )
            return

        self.storage_geopackage_path = geopackage_path
        self.storage_layer_name = layer_name

        self._load_storage_layer_into_project(
            geopackage_path=self.storage_geopackage_path,
            layer_name=self.storage_layer_name,
        )

    def define_profile_lines_from_qgis_layer(self):
        """
        Should define:
         - source type -> self.profile_track_source = ProfileSource.LINE_LAYER
         - list of undensified, inverted-in-case, CRS-projected lines

        """

        self._init_profile_lines_parameters()
        self._init_geoprofiles_parameters()

        self.clear_rubberband_line()

        line_layers_in_project = loaded_line_layers()

        if len(line_layers_in_project) == 0:
            warn_qgis(
                self.plugin_name,
                "No available line layers in current project"
            )
            return

        dialog = DefineSourceLineLayerDialog(
            self.plugin_name,
            line_layers_in_project,
        )

        if dialog.exec():
            qgs_line_layer = line_layers_in_project[dialog.LineLayers_comboBox.currentIndex()]
        else:
            warn_qgis(
                self.plugin_name,
                "No defined line source"
            )
            return

        layer_crs_wkt = get_layer_crs_as_wtk(qgs_line_layer)
        if layer_crs_wkt is None:
            warn_qgis(
                self.plugin_name,
                "Unable to extract layer CRS as WKT"
            )
            return
        
        self.new_profiles_crs_code = layer_crs_wkt

        qgis_line_geometries = extract_selected_geometries(
            layer=qgs_line_layer,
        )

        if qgis_line_geometries is None:
            return

        profile_lines = []
        for qgis_line_geometry in qgis_line_geometries:
            lines = extract_lines_23d_from_qgslinestring_multipart(
                qgis_geometry=qgis_line_geometry
            )
            if lines is None:
                warn_qt(self,
                        self.plugin_name,
                        f"Line geometry '{qgis_line_geometry}' could not be extracted")
                continue
            profile_lines.extend(lines)

        if not profile_lines:
            warn_qt(
                self,
                self.plugin_name,
                f"No profile line could be extracted"
            )
            return

        for profile_line in profile_lines:
            self.newly_defined_profile_baselines.append(
                ProfileBaseLineParameters(
                    "profile",
                    ProfileSource.LINE_LAYER,
                    profile_line,
                    layer_crs_wkt,
                )
            )

        ok_qgis(
            self.plugin_name,
            "Line layer read"
        )

    def extract_line2d_from_point_list(
        self,
         dialog,
    ) -> Union[None, Ln]:

        try:

            raw_point_string = dialog.point_list_qtextedit.toPlainText()
            raw_point_list = raw_point_string.split("\n")
            raw_point_list = [clean_string(str(unicode_txt)) for unicode_txt in raw_point_list]
            data_list = [rp for rp in raw_point_list if rp != ""]

            point_list = [to_float(xy_pair.split(",")) for xy_pair in data_list]
            line_2d = xytuple_list_to_line2d(point_list)

            return line_2d

        except Exception as e:

            error_qgis(
                self.plugin_name,
                f"{e!r}"
            )

            return None

    def qgis_rasterlayer_parameters(self,
        dem: 'qgis.core.QgsRasterLayer',
    ) -> Union[None, QGisRasterParameters]:

        result, err = extract_dem_parameters_from_qgs_raster_layer(dem)

        if err:
            msg = f"Error with {dem.name()} as source: {err!r} "
            warn_qgis(
                self.plugin_name,
                msg
            )
            return None

        return result

    def extract_selected_dem(
        self,
        dialog,
    ) -> Union[None, 'qgis.core.QgsRasterLayer']:

        try:
            current_index = dialog.listDEMs_treeWidget.currentIndex().row()
            if current_index < 0:
                return None
            if not hasattr(dialog, 'singleband_raster_layers_in_project'):
                return None
            if current_index >= len(dialog.singleband_raster_layers_in_project):
                return None
            return dialog.singleband_raster_layers_in_project[current_index]
        except (IndexError, AttributeError, TypeError) as e:

            QgsMessageLog.logMessage(
                f"Error extracting selected DEM: {e!r}",
                "qgSurf",
                Qgis.MessageLevel.Warning
            )
            return None

    def read_dem_layer(self) -> Union[None, Grid]:

        try:

            current_raster_layers = loaded_monoband_raster_layers()
            if len(current_raster_layers) == 0:
                raise Exception("No available raster layers in current project")

            dialog = SourceDEMSelectionDialog(
                self.plugin_name,
                current_raster_layers
            )

            if dialog.exec():
                selected_dem = self.extract_selected_dem(dialog)
            else:
                raise Exception("No chosen DEM")

            if selected_dem is None:
                raise Exception("No selected DEM")

            # Verify layer is valid

            if not isinstance(selected_dem, QgsRasterLayer):
                raise Exception(f"Selected DEM is not a valid raster layer: {type(selected_dem)}")
            if not selected_dem.isValid():
                raise Exception("Selected DEM layer is not valid")
            
            provider = selected_dem.dataProvider()
            if provider is None:
                raise Exception("Selected DEM layer has no data provider")

            dem_pth = provider.dataSourceUri()

            result, err = read_raster_band(raster_source=dem_pth)

            if err:
                raise Exception(f"Unable to read raster band: {err!r}")
            
            if result is None:
                raise Exception("read_raster_band returned None result")
            
            if not isinstance(result, (tuple, list)) or len(result) != 4:
                raise Exception(f"read_raster_band returned invalid result format: {type(result)}")

            geotransform, wkt_crs, band_params, data = result
            
            # Validate data
            import numpy as np
            if data is None:
                raise Exception("Raster data is None")
            if isinstance(data, np.ndarray) and data.size == 0:
                raise Exception("Raster data is empty")

            return Grid(
                array=data,
                geotransform=geotransform,
                crs=wkt_crs
            )

        except Exception as e:

            warn_qgis(
                self.plugin_name,
                f"{e!r}",
            )

            return None

    def define_dem_as_elevation_source(self):

        self._init_profiles_grid_parameters()
        self._init_geoprofiles_parameters()

        profiles_grid = self.read_dem_layer()

        if profiles_grid is None:
            return None

        self.profiles_grid = profiles_grid

        ok_qgis(
            self.plugin_name,
            f"Read DEM with z range {self.profiles_grid.z_range()}"
        )

    def _init_topo_labels(self):
        """
        Initialize topographic label and order parameters.

        :return:
        """

        self.profiles_labels = None
        self.profiles_order = None

    def check_pre_profile(self):

        for geoprofile in self.geoprofiles.geoprofiles:
            if not geoprofile.statistics_calculated:
                warn_qgis(
                    self.plugin_name,
                    "Profile statistics not yet calculated"
                )
                return False

        return True

    ''' 20241222: apparently unused
    def calculate_profile_statistics(self,
        geoprofiles
    ):

        for geoprofile in geoprofiles:

            for name, line3d in geoprofile:

                statistics_elev = [get_statistics(p) for p in line3d.z_array()]
                statistics_dirslopes = [get_statistics(p) for p in line3d.dir_slopes()]
                statistics_slopes = [get_statistics(p) for p in np.absolute(line3d.dir_slopes())]

                profile_length = line3d.incremental_length_2d()[-1] - line3d.incremental_length_2d()[0]
                natural_elev_range = (
                    np.nanmin(np.array([ds_stats["min"] for ds_stats in statistics_elev])),
                    np.nanmax(np.array([ds_stats["max"] for ds_stats in statistics_elev])))
    '''

    def _apply_graphical_params(
        self,
        dialog,
        params,
    ) -> None:
        """
        Writes the styles chosen in the dialog into the given parameters.

        Takes the target explicitly, rather than always writing the stacked
        plot's parameters, so that an exploded profile can be styled on its own.
        """
        # get profile plot parameters

        try:

            width = float(dialog.figure_width_qlineedit.text())
            if width > 0.0:
                params.figure.width = width

        except Exception:
            logger.debug("invalid figure_width_qlineedit value, keeping previous width", exc_info=True)

        try:

            height = float(dialog.figure_height_qlineedit.text())
            if height > 0:
                params.figure.height = height

        except Exception:
            logger.debug("invalid figure_height_qlineedit value, keeping previous height", exc_info=True)

        try:

            params.axis.z_min = float(dialog.z_min_value_qlineedit.text())

        except Exception:
            logger.debug("invalid z_min_value_qlineedit value, keeping previous z_min", exc_info=True)

        try:

            params.axis.z_max = float(dialog.z_max_value_qlineedit.text())

        except Exception:
            logger.debug("invalid z_max_value_qlineedit value, keeping previous z_max", exc_info=True)

        try:
            vertical_exaggeration = float(dialog.vertical_exxageration_ratio_qlineedit.text())
            if vertical_exaggeration > 0.0:
                params.axis.vertical_exaggeration = vertical_exaggeration

        except Exception:
            logger.debug("invalid vertical_exxageration_ratio_qlineedit value, keeping previous ratio", exc_info=True)

        # vertical axis labelling: these change the annotation only, never the
        # plotted values, which stay elevations in metres
        params.axis.z_axis_units = dialog.z_axis_units_combobox.currentText()
        params.axis.z_axis_as_depth = dialog.z_axis_as_depth_qcheckbox.isChecked()
        params.axis.z_axis_max_ticks = int(dialog.z_axis_max_ticks_qspinbox.value())
        params.axis.s_axis_units = dialog.s_axis_units_combobox.currentText()
        params.axis.show_axis_labels = dialog.show_axis_labels_qcheckbox.isChecked()

        if hasattr(dialog, 'elevation_color') and dialog.elevation_color is not None:
            params.elevations.color = qcolor2rgbmpl(dialog.elevation_color)

        if hasattr(dialog, 'point_projections_style'):
            params.point_projections = dialog.point_projections_style

        if hasattr(dialog, 'attitude_projections_style'):
            params.attitude_projections = dialog.attitude_projections_style

        if hasattr(dialog, 'focal_mechanism_projections_style'):
            params.focal_mechanism_projections = dialog.focal_mechanism_projections_style

        if hasattr(dialog, 'line_intersections_style'):
            params.line_intersections = dialog.line_intersections_style

        if hasattr(dialog, "polygon_classification_colors"):
            params.polygon_intersections = dialog.polygon_classification_colors

    def generate_3d_profiles_lines(
            self,
            profiles_arrangement="central",
    ):
        """
        Genera linee profili 3D parallele.
        Coordina validazione, dialog, generazione profili, proiezione e scrittura.
        """

        # Validazione input

        if not self._validate_profile_generation_input():
            return

        # Dialog per parametri

        profiles_spacing, num_parallel_profiles, sampling_distance = self._get_profile_generation_params()
        if profiles_spacing is None or num_parallel_profiles is None or sampling_distance is None:
            return

        # Validazione parametri

        if not self._validate_profile_params(num_parallel_profiles, profiles_spacing, sampling_distance):
            return

        # Generazione profili

        newly_defined_profilers = self._create_parallel_profilers(
            num_parallel_profiles, profiles_spacing, profiles_arrangement
        )

        # Campionamento grid

        points_3d_profiles, err = self._sample_profiles_from_grid(
            newly_defined_profilers,
            sampling_distance,
        )

        if err:
            error_qt(self, header=self.plugin_name, msg=f"Error in grid sampling: {err!r}")
            return

        # Proiezione e scrittura

        profiles_coords_4326 = self._project_profiles_to_4326(points_3d_profiles)
        written = self._write_profiles_to_storage(profiles_coords_4326)

        if getattr(self, "working_profiles_layer", None) is not None:
            self.working_profiles_layer.reload()
            self.working_profiles_layer.triggerRepaint()

        # una generazione che non ha scritto nulla non è un successo: senza
        # questo l'utente vedrebbe il messaggio di conferma e cercherebbe poi
        # un profilo che non esiste, con il motivo confinato nel pannello di log
        expected = len(profiles_coords_4326)

        if written == expected:
            ok_qgis(self.plugin_name, "3D profiles generated")
        elif written == 0:
            error_qt(
                self,
                header=self.plugin_name,
                msg=f"No 3D profile could be written to layer "
                    f"'{self.storage_layer_name}' in '{self.storage_geopackage_path}'.\n\n"
                    f"See the 'qgSurf' log panel for the reason."
            )
        else:
            warn_qt(
                self,
                self.plugin_name,
                f"Only {written} of {expected} 3D profiles could be written to layer "
                f"'{self.storage_layer_name}' in '{self.storage_geopackage_path}'.\n\n"
                f"See the 'qgSurf' log panel for the reason."
            )

    def _validate_profile_generation_input(self):
        """Valida input per generazione profili."""
        if self.newly_defined_profile_baselines == []:
            warn_qt(self, self.plugin_name, "Warning: Profile line(s) not defined")
            return False

        if self.profiles_grid is None:
            warn_qt(self, self.plugin_name, "Warning: DEM source not defined")
            return False

        # senza storage il profilo generato non ha dove essere scritto: la
        # scrittura fallirebbe per ogni linea, lasciando traccia solo nel
        # pannello di log, mentre la generazione si chiuderebbe con successo
        if not self.storage_geopackage_path or not self.storage_layer_name:
            warn_qt(
                self,
                self.plugin_name,
                "Warning: output storage not defined.\n\n"
                "Define it with one of the commands under "
                "'Define output storage geopackage', then generate the profile."
            )
            return False

        return True

    def _get_profile_generation_params(self):
        """Ottiene parametri da dialog."""

        # the DEM cell size is proposed as sampling distance: it is the finest
        # spacing that still extracts information from the raster

        dialog = BaseDataProfilerSettingsDialog(
            self.plugin_name,
            sampling_distance=self.profiles_grid.cellsize_mean if self.profiles_grid is not None else None,
        )

        if not dialog.exec():
            warn_qgis(
                self.plugin_name,
                "Warning: parameters are not yet defined for parallel profiles"
            )
            return None, None, None

        profiles_spacing = dialog.spacing_between_profiles_wdgt.value()
        num_parallel_profiles = dialog.num_parallel_profiles_wdgt.value()
        sampling_distance = dialog.spacing_within_profile_wdgt.value()

        return profiles_spacing, num_parallel_profiles, sampling_distance

    def _validate_profile_params(
            self,
            num_parallel_profiles,
            profiles_spacing,
            sampling_distance: numbers.Real,
    ):
        """Valida parametri profili."""
        if num_parallel_profiles <= 0 or num_parallel_profiles % 2 != 1:
            warn_qgis(
                self.plugin_name,
                "Error: number of parallel profiles must be a positive odd number"
            )
            return False

        if num_parallel_profiles > 1 and profiles_spacing <= 0.0:
            warn_qgis(
                self.plugin_name,
                "Error: spacing between parallel profiles cannot be zero when parallel profiles defined"
            )
            return False

        if sampling_distance <= 0:
            warn_qgis(
                self.plugin_name,
                "Error: sampling distance must be a positive real number"
            )
            return False

        # oversampling: below (a fraction of) the DEM cell size, a finer step
        # does not extract more information from the raster, it only
        # interpolates repeatedly between the same cells

        if self.profiles_grid is not None:

            cell_size = self.profiles_grid.cellsize_mean

            if cell_size > 0.0 and sampling_distance < cell_size * ProfileConstants.OVERSAMPLING_CELL_SIZE_FRACTION:

                if not question_qt(
                    self,
                    self.plugin_name,
                    f"Sampling distance ({sampling_distance:g}) is much smaller than the DEM cell size "
                    f"({cell_size:g}): the profile will be oversampled, with many more points than the "
                    f"raster actually resolves. Proceed anyway?"
                ):
                    return False

        # total sampled points: the cost of sampling, storing and plotting the
        # profiles grows with it, so an implausible value (a wrong unit, a
        # typo) is caught here before it produces a huge or endless run

        total_baseline_length = sum(
            trace_params.baseline.length_2d() for trace_params in self.newly_defined_profile_baselines
        )

        estimated_points = int((total_baseline_length / sampling_distance) * num_parallel_profiles)

        if estimated_points > ProfileConstants.SAMPLED_POINTS_MAX:
            warn_qgis(
                self.plugin_name,
                f"Error: sampling distance {sampling_distance:g} over the profile trace(s) would generate "
                f"about {estimated_points:,} points, more than the {ProfileConstants.SAMPLED_POINTS_MAX:,} "
                f"limit. Increase the sampling distance."
            )
            return False

        if estimated_points > ProfileConstants.SAMPLED_POINTS_CONFIRM_THRESHOLD:

            if not question_qt(
                self,
                self.plugin_name,
                f"Sampling distance {sampling_distance:g} over the profile trace(s) will generate about "
                f"{estimated_points:,} points, which may be slow to sample and plot. Proceed anyway?"
            ):
                return False

        return True

    def _create_parallel_profilers(self, num_profiles, spacing, arrangement):
        """Crea profiler paralleli dalle linee base."""
        return Profilers.make_parallel_from_lines(
            src_traces=self.newly_defined_profile_baselines,
            num_profiles=num_profiles,
            offset=spacing,
            profiles_arrangement=arrangement,
        )

    def _sample_profiles_from_grid(self,
        profilers,
        sampling_distance: numbers.Real,
    ):
        """Campiona profili dal grid DEM."""
        return sample_grid_as_pts3d(
            profilers,
            grid=self.profiles_grid,
            sampling_distance=sampling_distance,
        )

    def _project_profiles_to_4326(self, points_3d_profiles):
        """Proietta coordinate profili a EPSG:4326."""

        profiles_coords_4326 = []
        for line_pts in points_3d_profiles:
            line_coords_4326 = []
            for x, y, z in line_pts:
                xy_4326 = project_xy_to_geographic(
                    x=x,
                    y=y,
                    src_crs=self.new_profiles_crs_code,
                )
                if xy_4326 is not None:
                    line_coords_4326.append([*xy_4326, z])
                else:
                    QgsMessageLog.logMessage(
                        f"Error projecting {x}-{y}-{z} to EPSG:4326 from {self.new_profiles_crs_code}",
                        "qgSurf",
                        Qgis.MessageLevel.Warning
                    )
            profiles_coords_4326.append(line_coords_4326)

        return profiles_coords_4326

    def _write_profiles_to_storage(self, profiles_coords_4326):
        """
        Scrive profili nello storage.

        :return: il numero di linee scritte, per distinguere una generazione
            riuscita da una in cui il profilo non è finito da nessuna parte.
        """

        written = 0

        for line_coords in profiles_coords_4326:
            success = insert_3d_line(
                file_path=self.storage_geopackage_path,
                layer_name=self.storage_layer_name,
                line_coordinates=line_coords,
            )
            if not success:
                QgsMessageLog.logMessage(
                    f"Error writing line coordinates: {line_coords!r}",
                    "qgSurf",
                    Qgis.MessageLevel.Warning
                )
            else:
                written += 1

        return written

    def define_total_graphical_parameters(self):
        """
        Updates in-place graphical parameters.
        """

        if self.graphical_params is None:
            self.graphical_params = GraphicalParameters()

        dialog = StylesTotalGraphicalParamsDialog(
            self.plugin_name,
            self.geoprofiles,
            self.graphical_params,
            self.polygons_intersection_parameters,
        )

        if dialog.exec():
            self._apply_graphical_params(dialog, self.graphical_params)
        else:
            return

        ok_qgis(
            self.plugin_name,
            "Graphic parameters for profiles defined"
        )

    def save_graphic_parameters(self):

        try:

            if self.graphical_params is None:
                warn_qgis(
                    self.plugin_name,
                    "No graphical parameters defined yet: define them, or plot a "
                    "geoprofile once, before saving them"
                )
                return

            file_pth = define_path_new_file(
                self,
                "Save graphical parameters as file",
                "*.json",
                "json (*.json)"
            )

            if not file_pth:
                return

            with open(file_pth, "w", encoding="utf-8") as handle:
                json.dump(self.graphical_params.to_dict(), handle, indent=2)

            info_qgis(
                self.plugin_name,
                f"Graphical parameters saved in {file_pth}"
            )

        except Exception as e:

            warn_qgis(
                self.plugin_name,
                f"Error: {e!r}"
            )

    def load_graphic_parameters(self):

        try:

            file_pth = old_file_path(
                self,
                "Load graphical parameters from file",
                "*.json",
                "json (*.json)"
            )

            if not file_pth:
                return

            with open(file_pth, "r", encoding="utf-8") as handle:
                stored = json.load(handle)

            # rebuilt into a GraphicalParameters, not left as the raw mapping
            # json.load returns: every consumer reaches the styles by attribute
            self.graphical_params = GraphicalParameters.from_dict(stored)

            info_qgis(
                self.plugin_name,
                f"Graphical parameters loaded from {file_pth}"
            )

        except Exception as e:

            warn_qgis(
                self.plugin_name,
                f"Error: {e!r}"
            )

    def _make_stage_progress_callback(self, progress: QProgressDialog, stage_label: str):
        """
        Builds a progress_callback(done, total, unit) that turns the dialog from
        an indeterminate 'busy' bar into a determinate one (as soon as the total
        is known) and shows elapsed and estimated remaining time, so that the
        user knows what pace to expect rather than just that something is
        happening.

        The unit names what is being counted, because the geogst stages report at
        different granularities: point and attitude projection report every block
        of points, across the whole run, so that a long stage keeps the interface
        alive and the estimate meaningful; line and polygon intersection report
        once per parallel profile, and leave the unit at its default.
        """

        stage_start = {"time": None}

        def _callback(done, total, unit="profile"):

            now = time.monotonic()
            if stage_start["time"] is None:
                stage_start["time"] = now

            if progress.maximum() != total:
                progress.setRange(0, total)

            elapsed = now - stage_start["time"]

            if 0 < done < total and elapsed > 0.5:
                remaining = elapsed * (total - done) / done
                timing_text = f" — elapsed {elapsed:.0f}s, ~{remaining:.0f}s remaining"
            else:
                timing_text = ""

            # thousands separators: these counts reach the tens of thousands
            progress.setLabelText(
                f"{stage_label}: {unit} {done:,} of {total:,}{timing_text}"
            )
            progress.setValue(done)
            QApplication.processEvents()

        return _callback

    def _run_geodata_stage(self, stage_label: str, stage_method) -> None:
        """
        Runs a single geodata projection/intersection stage (points, attitudes,
        lines or polygons) right after its defining dialog is confirmed, with
        its own progress dialog. This replaces the former single "Create
        geoprofile" step that batched every enabled stage together: each
        stage is independent, so it can run immediately once its own geodata
        is defined instead of waiting for an extra, separate button.

        :param stage_label: human-readable label for the progress dialog and
            messages (e.g. "Projecting points onto profiles").
        :param stage_method: bound method to run, accepting a single
            'progress_callback' keyword argument (e.g. self.project_points).
        """

        if self.geoprofiles is None:
            warn_qgis(
                self.plugin_name,
                f"{stage_label}: no geoprofile defined yet. Choose or generate the 3D "
                f"topographic profile first, then redefine this geodata.",
            )
            return

        progress = QProgressDialog(
            f"{stage_label}...",
            None,
            0,
            0,
            self,
        )
        progress.setWindowTitle(self.plugin_name)
        progress.setWindowModality(Qt.WindowModality.WindowModal)
        progress.setMinimumDuration(0)
        progress.setValue(0)
        QApplication.processEvents()

        try:

            err = stage_method(
                progress_callback=self._make_stage_progress_callback(progress, stage_label)
            )
            if err:
                error_qt(
                    self,
                    self.plugin_name,
                    f"while {stage_label[0].lower()}{stage_label[1:]}: {err!r}",
                )
                return

            info_qgis(
                self.plugin_name,
                f"{stage_label}: done",
            )

        finally:

            progress.close()

    def plot_geoprofiles(self):

        if self.geoprofiles is None:
            warn_qgis(self.plugin_name, "No geoprofile defined")
            return

        if self.graphical_params is None:

            axis_suggestions = self.precalculate_axis_parameters_suggestions()

            # the suggestions calculation warns the user and returns an Error
            # when profile length or elevations are not usable: stop here,
            # instead of unpacking the Error, and leave graphical parameters
            # undefined, so that a new attempt recalculates them

            if isinstance(axis_suggestions, Error):
                return

            zmin, zmax, ve = axis_suggestions

            self.graphical_params = GraphicalParameters()
            self.graphical_params.axis.z_min = zmin
            self.graphical_params.axis.z_max = zmax
            self.graphical_params.axis.vertical_exaggeration = ve

        # If no polygon-intersection colors have been chosen yet (neither by a
        # previous plot nor by the user, through "define polygon intersections
        # style"), default them to the colors already used, per category, in
        # the source polygon layer's own QGIS symbology, matched by label, so
        # that the very first plot already looks like the project's own map
        # instead of a palette assigned at random.

        if self.graphical_params.polygon_intersections is None \
                and getattr(self.geoprofiles, "_polygons_intersections", None) \
                and self.polygons_intersection_parameters is not None:

            try:
                layer_colors = extract_qgis_colors(self.polygons_intersection_parameters.polygon_layer)
                if layer_colors:
                    self.graphical_params.polygon_intersections = dict(layer_colors)
            except Exception as e:
                QgsMessageLog.logMessage(
                    f"Unable to read polygon intersection colors from the source layer: {e!r}",
                    self.plugin_name,
                    Qgis.MessageLevel.Warning
                )

        # plot the profile

        figure = profiles(
            self.geoprofiles,
            axis_params=self.graphical_params.axis,
            linewidth=ProfileConstants.DEFAULT_PROFILE_LINEWIDTH,
            width=self.graphical_params.figure.width,
            height=self.graphical_params.figure.height,
            elevation_params=self.graphical_params.elevations,
            points=self.graphical_params.point_projections,
            attitudes=self.graphical_params.attitude_projections,
            focal_mechanisms=self.graphical_params.focal_mechanism_projections,
            line_intersections=self.graphical_params.line_intersections,
            polygon_intersections=self.graphical_params.polygon_intersections,
        )

        graphs_win = FigureWindow(
            figure=figure,
            explode_labels=self._profile_labels(),
            on_explode=self.explode_geoprofile,
        )
        graphs_win.setWindowTitle("Geoprofiles")

        # exploded profiles open as children of this window, so they go away with
        # it: drop the references to the ones from a previous plot rather than
        # keeping them pointing at windows that no longer exist
        self.exploded_windows = []
        self._exploding_window = graphs_win

        graphs_win.exec()

    def _profile_labels(self) -> List[str]:
        """
        Labels for the profile chooser, numbered as the stacked plot numbers its
        panels, so that a profile is picked by the number the user is looking at.
        Profiles carry no identifier of their own beyond their order.
        """

        return [
            f"Profile {ndx + 1}"
            for ndx in range(self.geoprofiles.num_profiles())
        ]

    def _graphical_params_for_profile(self, profile_ndx: int) -> GraphicalParameters:
        """
        The graphical parameters of a single exploded profile, kept apart from the
        ones driving the stacked plot so that each exploded profile can be styled
        on its own.

        Seeded with a copy of the stacked plot's parameters, so an exploded
        profile opens looking like the panel it came from, and diverges only where
        the user changes it. The copy goes through the serialised form, which
        already knows how to reproduce every style value.
        """

        if profile_ndx not in self.exploded_graphical_params:
            self.exploded_graphical_params[profile_ndx] = GraphicalParameters.from_dict(
                self.graphical_params.to_dict()
            )

        return self.exploded_graphical_params[profile_ndx]

    def _plot_single_geoprofile(self, profile_ndx: int):
        """Builds the figure of a single profile, with that profile's own styles."""

        params = self._graphical_params_for_profile(profile_ndx)

        return profiles(
            self.geoprofiles[profile_ndx],
            # so that the exploded profile is labelled with its own number, and
            # keeps the colours it had in the stacked plot, instead of falling
            # back to the first profile's
            profile_ndx=profile_ndx,
            axis_params=params.axis,
            linewidth=ProfileConstants.DEFAULT_PROFILE_LINEWIDTH,
            width=params.figure.width,
            height=params.figure.height,
            elevation_params=params.elevations,
            points=params.point_projections,
            attitudes=params.attitude_projections,
            focal_mechanisms=params.focal_mechanism_projections,
            line_intersections=params.line_intersections,
            polygon_intersections=params.polygon_intersections,
        )

    def explode_geoprofile(self, profile_ndx: int):
        """
        Opens a single profile in a window of its own, with its own graphical
        parameters, so that it can be examined and styled without the vertical
        room of the stacked plot being split between every profile.
        """

        try:

            if self.geoprofiles is None or profile_ndx >= self.geoprofiles.num_profiles():
                warn_qgis(self.plugin_name, "No such profile to explode")
                return

            figure = self._plot_single_geoprofile(profile_ndx)

            if figure is None:
                warn_qgis(
                    self.plugin_name,
                    f"Profile {profile_ndx + 1} has no topography, so it cannot be plotted"
                )
                return

            window = FigureWindow(
                figure=figure,
                on_restyle=lambda ndx=profile_ndx: self._restyle_exploded_geoprofile(ndx),
                parent=self._exploding_window,
            )
            window.setWindowTitle(f"Geoprofile {profile_ndx + 1}")

            # held onto so the window is not garbage collected the moment this
            # returns, and so several exploded profiles can stay open side by side
            self.exploded_windows.append(window)

        except Exception as e:

            error_qt(
                self,
                self.plugin_name,
                f"Unable to explode profile {profile_ndx + 1}: {e!r}"
            )

    def _restyle_exploded_geoprofile(self, profile_ndx: int):
        """
        Lets the user restyle one exploded profile, returning its new figure, or
        None when the styles dialog is dismissed.
        """

        params = self._graphical_params_for_profile(profile_ndx)

        dialog = StylesTotalGraphicalParamsDialog(
            self.plugin_name,
            self.geoprofiles,
            params,
            self.polygons_intersection_parameters,
        )

        if not dialog.exec():
            return None

        self._apply_graphical_params(dialog, params)

        return self._plot_single_geoprofile(profile_ndx)

    def import_geoprofiles(self):

        dlg = ImportGeoProfilesDialog(self)

        if dlg.exec():

            opts = dlg.get_values()

            db_path = opts["db_path"]
            rs_id = opts["result_set_id"]
            profile_ids = opts["profile_ids"]

            # se l’utente ha deselezionato tutto, interpreta come "tutti"
            if profile_ids == []:
                profile_ids = None

            gps = GeoProfileReader.from_db(
                db_path,
                result_set_id=rs_id,
                profile_ids=profile_ids
            )

            # opzionale: se vuoi import parziale geodata, qui filtri gps
            # in modo veloce: se l’utente toglie un check, svuoti i contenitori corrispondenti.
            geo = opts["geodata"]
            if not geo.get("points", True):
                gps._points_projections["points"] = [dict() for _ in gps]
            if not geo.get("attitudes", True):
                gps._points_projections["attitudes"] = [dict() for _ in gps]
            if not geo.get("focal_mechanisms", True):
                gps._points_projections["focal_mechanisms"] = [dict() for _ in gps]
            if not geo.get("line_intersections", True):
                gps._lines_intersections = None
            if not geo.get("polygon_intersections", True):
                gps._polygons_intersections = None
            # topography: se la togli, puoi lasciare topo com’è (serve comunque per i plot), oppure metti un placeholder.

            self.geoprofiles = gps

            # a questo punto puoi plottare o aggiornare UI
            self.plot_geoprofiles()

    def export_geoprofiles(self):

        if self.geoprofiles is None:
            warn_qgis(self.plugin_name, "No geoprofile defined")
            return

        ui_path = f"{self.current_directory}/ui/ExportGeoProfilesDialog.ui"

        dlg = QDialog(self)  # parent = GeoProfilerWidget
        uic.loadUi(ui_path, dlg)

        # Enable/disable FIDs box depending on selection mode
        dlg.rbExportSelectedProfiles.toggled.connect(
            lambda checked: (
                dlg.leSelectedFids.setEnabled(checked),
                dlg.btnFillFromSelection.setEnabled(checked),
            )
        )

        # Browse button
        dlg.btnBrowseDb.clicked.connect(lambda: self._browse_export_db(dlg))

        # Optional: fill FIDs from current selection (if you implement it)
        dlg.btnFillFromSelection.clicked.connect(lambda: self._fill_fids_from_selection(dlg))

        if dlg.exec():

            options = self._collect_export_dialog_values(dlg)
            if options is None:
                return

            # 1) assicurati che i risultati esistano (serve geoprofiles popolato)
            if self.geoprofiles is None:
                warn_qt(self, self.plugin_name,
                        "No geoprofiles available. Run 'Plot profile' first (or generate results) before exporting.")
                return

            # 2) crea result set
            rs_id = gp_export.upsert_result_set(
                db_path=options["db_path"],
                name=options["result_set_name"],
                description=options.get("description", ""),
                overwrite=options.get("overwrite", False),
                schema_version=1,
                distance_units="project",
                extra={"plugin": "qgSurf", "module": "GeoProfiler"}
            )

            # 3) (facoltativo ma utile) salva metadati sorgenti
            #    Qui puoi registrare layer/parametri reali (uri, wkt, max distance, ecc.)
            # gp_export.insert_source(...)

            # 4) esporta profili + risultati geologici
            self._do_export_geoprofiles_to_db(rs_id, options)

            info_qgis(
                self.plugin_name,
                f"Profiles exported"
            )

    def _do_export_geoprofiles_to_db(self,
        result_set_id: int,
        options: Dict[str, Any]
    ) -> None:
        """
        Export principale che coordina l'esportazione di tutti i dati del geoprofilo.
        
        Args:
            result_set_id: ID del result set nel database
            options: Opzioni di export (db_path, geodata flags, etc.)
        """
        db_path = options["db_path"]
        mode = options["profiles"]["mode"]
        allowed_fids = set(options["profiles"]["selected_fids"]) if mode == "selected" else None

        for profile_index, geoprofile in enumerate(self.geoprofiles):
            profile_fid = getattr(geoprofile, "rec_id", None)
            if allowed_fids is not None and profile_fid not in allowed_fids:
                continue

            # Export metadati e campioni del profilo
            profile_id = self._export_profile_metadata_and_samples(
                geoprofile, result_set_id, db_path, profile_fid
            )

            # Export dati geologici se richiesti
            if options["geodata"].get("points", False):
                self._export_projected_points(geoprofile, profile_id, profile_index, db_path)

            if options["geodata"].get("attitudes", False):
                self._export_projected_attitudes(geoprofile, profile_id, profile_index, db_path)

            if options["geodata"].get("focal_mechanisms", False):
                self._export_projected_focal_mechanisms(geoprofile, profile_id, profile_index, db_path)

            if options["geodata"].get("line_intersections", False):
                self._export_intersected_lines(geoprofile, profile_id, profile_index, db_path)

            if options["geodata"].get("polygon_intersections", False):
                self._export_intersected_polygons(geoprofile, profile_id, profile_index, db_path)

    def _export_profile_metadata_and_samples(
        self,
        geoprofile,
        result_set_id: int,
        db_path: str,
        profile_fid: Optional[int]
    ) -> int:
        """
        Estrae ed esporta metadati e campioni del profilo topografico.
        
        Returns:
            profile_id: ID del profilo creato nel database
        """

        # Estrai metadati profilo
        s_max = getattr(geoprofile, "s_max", None)() if callable(getattr(geoprofile, "s_max", None)) else None
        z_min = getattr(geoprofile, "z_min", None)() if callable(getattr(geoprofile, "z_min", None)) else None
        z_max = getattr(geoprofile, "z_max", None)() if callable(getattr(geoprofile, "z_max", None)) else None

        # Crea record profilo nel database
        profile_id = gp_export.insert_profile(
            db_path=db_path,
            result_set_id=result_set_id,
            source_profile_fid=profile_fid,
            profile_name=f"profile_{profile_fid}" if profile_fid is not None else "profile",
            wkt_crs=self.geoprofiles.wkt_crs,
            s_max=s_max, z_min=z_min, z_max=z_max,
            extra=None
        )

        # Estrai e valida campioni topografici
        z_trace = geoprofile._topo_profile
        s_arr = z_trace.s_arr()
        z_arr = z_trace.z_arr()
        
        samples = []
        for i, (s, z) in enumerate(zip(s_arr, z_arr)):
            s_val = safe_float(s)
            z_val = safe_float(z)
            if s_val is not None and z_val is not None:
                samples.append((i, s_val, z_val, None, None))
            else:
                QgsMessageLog.logMessage(
                    f"Profile {profile_id}: skipping sample {i} with invalid values (s={s}, z={z})",
                    "qgSurf",
                    Qgis.MessageLevel.Warning
                )

        # Export campioni
        gp_export.insert_profile_samples(
            db_path=db_path,
            profile_id=profile_id,
            samples=samples,
            replace=True,
            use_upsert=False,
        )

        return profile_id

    def _export_projected_points(
        self,
        geoprofile,
        profile_id: int,
        profile_index: int,
        db_path: str
    ) -> None:
        """Esporta punti proiettati sul profilo."""

        points_projections = getattr(self.geoprofiles, "_points_projections", None)
        if not points_projections or "points" not in points_projections:
            return
        
        points_list = points_projections.get("points")
        if not points_list or profile_index >= len(points_list):
            return

        points_dict = points_list[profile_index] or {}
        rows = []
        
        for category, pt_trace in points_dict.items():
            label = str(category)
            src_fid = getattr(category, "fid", None)

            s_val = safe_float(pt_trace.s)
            z_val = safe_float(pt_trace.z)
            dist_val = safe_float(pt_trace.dist) if pt_trace.dist is not None else None
            
            if s_val is None or z_val is None:
                QgsMessageLog.logMessage(
                    f"Profile {profile_id}: skipping point with invalid coordinates (s={pt_trace.s}, z={pt_trace.z})",
                    "qgSurf",
                    Qgis.MessageLevel.Warning
                )
                continue
            
            rows.append({
                "label": label,
                "s": s_val,
                "z": z_val,
                "dist_to_profile": dist_val,
                "src_fid": int(src_fid) if src_fid is not None else None,
                "src_x": None, "src_y": None, "src_z": None,
                "extra": None,
            })

        if rows:
            gp_export.insert_projected_points(
                db_path=db_path,
                profile_id=profile_id,
                rows=rows
            )

    def _export_projected_attitudes(
        self,
        geoprofile,
        profile_id: int,
        profile_index: int,
        db_path: str
    ) -> None:
        """Esporta attitudes (giaciture) proiettate sul profilo."""

        points_projections = getattr(self.geoprofiles, "_points_projections", None)
        if not points_projections or "attitudes" not in points_projections:
            return

        attitudes_list = points_projections.get("attitudes")
        if not attitudes_list or profile_index >= len(attitudes_list):
            return

        att_dict = attitudes_list[profile_index] or {}
        rows = []
        
        for category, plane_trace in att_dict.items():
            label = str(category)
            src_fid = int(category) if isinstance(category, int) else getattr(category, "fid", None)

            s_val = safe_float(plane_trace.s)
            z_val = safe_float(plane_trace.z)
            slope_val = safe_float(plane_trace.slope_degr)
            dip_dir_val = safe_float(plane_trace.src_dip_dir)
            dip_ang_val = safe_float(plane_trace.src_dip_ang)
            dist_val = safe_float(plane_trace.dist) if plane_trace.dist is not None else None
            
            if s_val is None or z_val is None or slope_val is None or dip_dir_val is None or dip_ang_val is None:
                QgsMessageLog.logMessage(
                    f"Profile {profile_id}: skipping attitude with invalid values (s={plane_trace.s}, z={plane_trace.z}, slope={plane_trace.slope_degr})",
                    "qgSurf",
                    Qgis.MessageLevel.Warning
                )
                continue

            rows.append({
                "label": label,
                "s": s_val,
                "z": z_val,
                "slope_degr": slope_val,
                "down_sense": str(plane_trace.down_sense),
                "src_dip_dir": dip_dir_val,
                "src_dip_ang": dip_ang_val,
                "dist_to_profile": dist_val,
                "src_x": None, "src_y": None, "src_z": None,
                "src_fid": src_fid,
                "extra": None,
            })

        if rows:
            gp_export.insert_projected_attitudes(
                db_path=db_path,
                profile_id=profile_id,
                rows=rows
            )

    def _export_projected_focal_mechanisms(
        self,
        geoprofile,
        profile_id: int,
        profile_index: int,
        db_path: str
    ) -> None:
        """Esporta i meccanismi focali proiettati sul profilo."""

        points_projections = getattr(self.geoprofiles, "_points_projections", None)
        if not points_projections or "focal_mechanisms" not in points_projections:
            return

        focal_mechanisms_list = points_projections.get("focal_mechanisms")
        if not focal_mechanisms_list or profile_index >= len(focal_mechanisms_list):
            return

        fmec_dict = focal_mechanisms_list[profile_index] or {}
        rows = []

        for category, fmec_trace in fmec_dict.items():
            label = str(category)
            src_fid = int(category) if isinstance(category, int) else getattr(category, "fid", None)

            s_val = safe_float(fmec_trace.s)
            z_val = safe_float(fmec_trace.z)
            strike_val = safe_float(fmec_trace.strike)
            dip_val = safe_float(fmec_trace.dip)
            rake_val = safe_float(fmec_trace.rake)
            profile_azimuth_val = safe_float(fmec_trace.profile_azimuth)
            dist_val = safe_float(fmec_trace.dist) if fmec_trace.dist is not None else None

            if None in (s_val, z_val, strike_val, dip_val, rake_val, profile_azimuth_val):
                QgsMessageLog.logMessage(
                    f"Profile {profile_id}: skipping focal mechanism with invalid values "
                    f"(s={fmec_trace.s}, z={fmec_trace.z}, strike={fmec_trace.strike}, "
                    f"dip={fmec_trace.dip}, rake={fmec_trace.rake})",
                    "qgSurf",
                    Qgis.MessageLevel.Warning
                )
                continue

            rows.append({
                "label": label,
                "s": s_val,
                "z": z_val,
                "strike": strike_val,
                "dip": dip_val,
                "rake": rake_val,
                "profile_azimuth": profile_azimuth_val,
                "dist_to_profile": dist_val,
                "src_x": None, "src_y": None, "src_z": None,
                "src_fid": src_fid,
                "extra": None,
            })

        if rows:
            gp_export.insert_projected_focal_mechanisms(
                db_path=db_path,
                profile_id=profile_id,
                rows=rows
            )

    def _export_intersected_lines(
        self,
        geoprofile,
        profile_id: int,
        profile_index: int,
        db_path: str
    ) -> None:
        """Esporta linee intersecate con il profilo."""

        lines_intersections = getattr(self.geoprofiles, "_lines_intersections", None)
        if not lines_intersections or profile_index >= len(lines_intersections):
            QgsMessageLog.logMessage(
                f"Profile {profile_id}: No line intersections available to export",
                "qgSurf",
                Qgis.MessageLevel.Info
            )
            return

        profile_line_intersections = lines_intersections[profile_index]
        if profile_line_intersections is None:
            return

        rows = []
        for seg_list in (profile_line_intersections or []):
            QgsMessageLog.logMessage(
                f"Profile {profile_id}: processing segment list",
                "qgSurf",
                Qgis.MessageLevel.Info
            )

            for arrlist in (seg_list or []):
                category = str(arrlist.id)
                if not category:
                    QgsMessageLog.logMessage(
                        f"Profile {profile_id}: skipping line intersection with empty category",
                        "qgSurf",
                        Qgis.MessageLevel.Warning
                    )
                    continue

                arr = arrlist.arrays
                if not arr:
                    continue

                for s in arr:
                    if not isinstance(s, (list, tuple)) or len(s) == 0:
                        continue
                    
                    s_val = safe_float(s[0])
                    if s_val is None:
                        QgsMessageLog.logMessage(
                            f"Profile {profile_id}: skipping line intersection with invalid s value: {s[0]}",
                            "qgSurf",
                            Qgis.MessageLevel.Warning
                        )
                        continue

                    rows.append({
                        "feature_id": category,
                        "s": s_val,
                        "extra": None,
                    })

        if rows:
            gp_export.insert_intersected_lines(
                db_path=db_path,
                profile_id=profile_id,
                rows=rows
            )
        else:
            QgsMessageLog.logMessage(
                f"Profile {profile_id}: No line intersections to export (rows empty after parsing)",
                "qgSurf",
                Qgis.MessageLevel.Info
            )

    def _export_intersected_polygons(
        self,
        geoprofile,
        profile_id: int,
        profile_index: int,
        db_path: str
    ) -> None:
        """Esporta poligoni intersecati con il profilo."""

        polygons_intersections = getattr(self.geoprofiles, "_polygons_intersections", None)
        if not polygons_intersections or profile_index >= len(polygons_intersections):
            return

        profile_polygon_intersections = polygons_intersections[profile_index]
        if profile_polygon_intersections is None:
            return

        rows = []
        for seg_list in (profile_polygon_intersections or []):
            for arrlist in (seg_list or []):
                unit_name = str(arrlist.id).strip()
                if not unit_name:
                    continue

                for part in arrlist.arrays:
                    if part is None or len(part) < 2:
                        continue

                    s_from_val = safe_float(part[0])
                    s_to_val = safe_float(part[1])
                    
                    if s_from_val is None or s_to_val is None:
                        QgsMessageLog.logMessage(
                            f"Profile {profile_id}: skipping polygon intersection with invalid s values (from={part[0]}, to={part[1]})",
                            "qgSurf",
                            Qgis.MessageLevel.Warning
                        )
                        continue

                    # Normalizza (nel caso arrivino invertiti)
                    if s_to_val < s_from_val:
                        s_from_val, s_to_val = s_to_val, s_from_val

                    rows.append({
                        "unit_name": unit_name,
                        "s_from": s_from_val,
                        "s_to": s_to_val,
                        "extra": None,
                    })

        if rows:
            gp_export.insert_intersected_polygons(
                db_path=db_path,
                profile_id=profile_id,
                rows=rows
            )
        else:
            QgsMessageLog.logMessage(
                f"Profile {profile_id}: No polygon intersections to export (rows empty after parsing)",
                "qgSurf",
                Qgis.MessageLevel.Info
            )

    # ---------- Dialog helpers ----------

    def _browse_export_db(self, dlg: QDialog) -> None:
        """
        Let user pick (or create) a GeoPackage/SQLite output file.
        """

        start_dir = os.path.dirname(
            dlg.leDbPath.text().strip()) if dlg.leDbPath.text().strip() else os.path.expanduser("~")

        filters = "SQLite (*.sqlite *.db);;GeoPackage (*.gpkg);;All files (*.*)"
        path, _ = QFileDialog.getSaveFileName(
            dlg,
            "Choose output database",
            start_dir,
            filters
        )
        if not path:
            return

        # Ensure an extension if user omitted it
        root, ext = os.path.splitext(path)
        if not ext:
            # Default to SQLite
            path = root + ".sqlite"

        dlg.leDbPath.setText(path)

    def _fill_fids_from_selection(self, dlg: QDialog) -> None:
        """
        Optional helper: fill selected FIDs from the current working profiles layer selection.
        Implement it if you have a reference to the working layer.
        """
        try:

            # Example: if you store it somewhere like self.working_profiles_layer
            layer = getattr(self, "working_profiles_layer", None)
            if layer is None:
                QMessageBox.information(dlg, "Selection", "No working profiles layer is currently set.")
                return

            fids = [f.id() for f in layer.selectedFeatures()]
            if not fids:
                QMessageBox.information(dlg, "Selection",
                                        "No selected features found in the working profiles layer.")
                return

            dlg.leSelectedFids.setText(", ".join(str(i) for i in fids))
        except Exception as e:
            QMessageBox.warning(dlg, "Selection", f"Could not read selection.\n\n{e}")

    def _collect_export_dialog_values(self,
        dlg: QDialog
    ) -> Optional[Dict[str, Any]]:
        """
        Read all values from the UI and validate them.
        Returns options dict or None if validation fails.
        """

        db_path = dlg.leDbPath.text().strip()
        rs_name = dlg.leResultSetName.text().strip()
        descr = dlg.pteDescription.toPlainText().strip()

        if not db_path:
            QMessageBox.warning(dlg, "Export", "Please choose an output database file.")
            return None

        # Normalize extension / sanity
        root, ext = os.path.splitext(db_path)
        if ext.lower() not in (".gpkg", ".sqlite", ".db"):
            # If user typed something else, default to .sqlite
            db_path = db_path + ".sqlite"

        if not rs_name:
            QMessageBox.warning(dlg, "Export", "Please provide a result set name.")
            return None

        overwrite = bool(dlg.chkOverwrite.isChecked())

        # Profiles selection mode + selected fids

        export_all_profiles = bool(dlg.rbExportAllProfiles.isChecked())

        selected_fids: List[int] = []

        if not export_all_profiles:

            selected_fids_str = dlg.leSelectedFids.text().strip()

            if not selected_fids_str:

                QMessageBox.warning(dlg, "Export", "Please provide selected FIDs (or use “Use current selection”).")
                return None

            try:

                selected_fids = self._parse_fids(selected_fids_str)

            except ValueError as e:

                QMessageBox.warning(dlg, "Export", f"Invalid FIDs list.\n\n{e}")
                return None

        # Include flags

        include_points = bool(dlg.chkIncludePoints.isChecked())
        include_attitudes = bool(dlg.chkIncludeAttitudes.isChecked())
        include_focal_mechanisms = bool(dlg.chkIncludeFocalMechanisms.isChecked())
        include_line_intersections = bool(dlg.chkIncludeLineIntersections.isChecked())
        include_polygon_intersections = bool(dlg.chkIncludePolygonIntervals.isChecked())

        options: Dict[str, Any] = {
            "db_path": db_path,
            "result_set_name": rs_name,
            "description": descr,
            "overwrite": overwrite,

            "profiles": {
                "mode": "all" if export_all_profiles else "selected",
                "selected_fids": selected_fids,
            },

            "geodata": {
                "points": include_points,
                "attitudes": include_attitudes,
                "focal_mechanisms": include_focal_mechanisms,
                "line_intersections": include_line_intersections,
                "polygon_intersections": include_polygon_intersections,
            },

        }

        return options

    def _parse_fids(self, text: str) -> List[int]:
        """
        Accepts '12,13, 18' or '12 13 18' or mixed separators.
        """
        # Replace common separators with comma, then split
        cleaned = text.replace(";", ",").replace(" ", ",")
        parts = [p.strip() for p in cleaned.split(",") if p.strip()]

        fids: List[int] = []
        for p in parts:
            if not p.isdigit():
                raise ValueError(f"'{p}' is not an integer.")
            fids.append(int(p))

        if len(fids) == 0:
            raise ValueError("Empty FIDs list.")
        return fids

    def convert_profile_mline_to_mline(self,
        profile_mline: Tuple[numbers.Integral, str, List[Tuple[numbers.Real, numbers.Real, numbers.Real]]],
    ) -> Union[Error, Union[Ln, MultiLine]]:

        try:

            fid, line_type, coords = profile_mline
            if line_type == "line":
                return (fid, Ln.fromCoordinates(coords))
            elif line_type == "multiline":
                lines = []
                for line in coords:
                    lines.append(Ln.fromCoordinates(line))
                return (fid, MultiLine.fromLines(lines))
            else:
                raise Exception(f"Unknown line type: {line_type!r}")

        except Exception as e:

            return Error(
                True,
                caller_name(),
                e,
                traceback.format_exc(),
            )

    def project_profiles_3d(self,
        lines: List[
            Tuple[
                numbers.Integral,  # the feature fids
                str,  # "line" or "multiline"
                Union[
                    List[Tuple[float, float, float]],  # 3D coordinates of single line
                    List[List[Tuple[float, float, float]]]  # 3D coordinates of multiple lines
                ]
            ]
        ],
        dest_crs,
    ) -> Union[Error, List]:

        projected_profiles = []

        for fid, line_type, line_coords in lines:

            if line_type == "line":

                projected_line = qgis_project_3d_coords_list(
                    coords_3d=line_coords,
                    dest_crs=dest_crs,
                )
                if isinstance(projected_line, Error):
                    return projected_line

                projected_profiles.append((fid, line_type, projected_line))

            elif line_type == "multiline":

                projected_multilines = []
                for coords in line_coords:
                    projected_line = qgis_project_3d_coords_list(
                        coords_3d=coords,
                        dest_crs=dest_crs,
                    )
                    if isinstance(projected_line, Error):
                        return projected_line
                    projected_multilines.append(projected_line)

                projected_profiles.append((fid, line_type, projected_multilines))

            else:

                return Error(
                    True,
                    caller_name(),
                    Exception(f"Unknown line type {line_type!r}"),
                    traceback.format_exc(),
                )

        return projected_profiles

    def define_profile_line_by_rubberband_line(self):

        self._init_profile_lines_parameters()
        self._init_geoprofiles_parameters()

        src_crs = QgsProject.instance().crs()

        if src_crs.isGeographic():
            warn_qt(self,
                    self.plugin_name,
                    f"Project CRS is geographic ('{src_crs}') but planar one required for profile digitation")
            return

        proj_wkt_string = src_crs.toWkt()

        self.new_profiles_crs_code = proj_wkt_string

        self.clear_rubberband_line()

        self.previous_maptool = self.canvas.mapTool()  # Save the standard map tool for restoring it at the end

        info_qgis(
            self.plugin_name,
            "Now you can digitize the trace on the map.\nLeft click: add point\nRight click: end adding point"
        )

        self.rubberband = QgsRubberBand(self.canvas)
        self.rubberband.setWidth(2)
        self.rubberband.setColor(QColor(Qt.GlobalColor.red))

        self.digitize_maptool = MapDigitizeTool(self.canvas)
        self.canvas.setMapTool(self.digitize_maptool)

        self.digitize_maptool.moved.connect(self.canvas_refresh_profile_line)
        self.digitize_maptool.leftClicked.connect(self.profile_add_point)
        self.digitize_maptool.rightClicked.connect(self.canvas_end_profile_line)

    def canvas_refresh_profile_line(self, position):

        x, y = xy_from_canvas(self.canvas, position)

        self.refresh_rubberband(
            self.profile_canvas_points_x + [x],
            self.profile_canvas_points_y + [y]
        )

    def profile_add_point(self, position):

        x, y = xy_from_canvas(self.canvas, position)
        self.profile_canvas_points_x.append(x)
        self.profile_canvas_points_y.append(y)

    def canvas_end_profile_line(self):

        self.refresh_rubberband(
            self.profile_canvas_points_x,
            self.profile_canvas_points_y,
        )

        self.currently_digitized_line = None

        if len(self.profile_canvas_points_x) < 2:
            warn_qgis(
                self.plugin_name,
                "At least two non-coincident points are required"
            )
            return

        digitized_line = Ln(list(zip(self.profile_canvas_points_x, self.profile_canvas_points_y))).remove_coincident_points()

        if digitized_line.num_points() < 2:
            warn_qgis(
                self.plugin_name,
                "Just one non-coincident point"
            )
            return

        self.profile_canvas_points_x = []
        self.profile_canvas_points_y = []
        self.restore_previous_map_tool()

        project_crs_wkt = extract_project_crs_as_wkt()
        if project_crs_wkt is None:
            error_qgis(
                self.plugin_name,
                "Unable to extract GGIS project CRS as WKT"
            )
            return

        profile_baseline_params = ProfileBaseLineParameters(
            "Digitized line",
            ProfileSource.DIGITATION,
            digitized_line,
            project_crs_wkt
        )

        self.newly_defined_profile_baselines.append(profile_baseline_params)

    def restore_previous_map_tool(self):

        self.canvas.unsetMapTool(self.digitize_maptool)
        self.canvas.setMapTool(self.previous_maptool)

    def refresh_rubberband(self,
                           x_list,
                           y_list
                           ):

        self.rubberband.reset(QgsWkbTypes.GeometryType.LineGeometry)
        for x, y in zip(x_list, y_list):
            self.rubberband.addPoint(QgsPointXY(x, y))

    def clear_rubberband_line(self):

        self.profile_canvas_points_x = []
        self.profile_canvas_points_y = []

        self.currently_digitized_line = None

        try:
            self.rubberband.reset()
        except Exception:
            logger.debug("rubberband.reset() failed", exc_info=True)

    def clear_all_traces(self):

        self.clear_rubberband_line()
        self.newly_defined_profile_baselines = []

    def output_profile_line(self,
            output_format,
            output_filepath,
            pts2dt,
            proj_sr
    ):

        points = [[n, pt2dt.x, pt2dt.y] for n, pt2dt in enumerate(pts2dt)]
        if output_format == "csv":
            success, msg = write_generic_csv(
                output_filepath,
                ['id', 'x', 'y'],
                points
            )
            if not success:
                warn_qgis(
                    self.plugin_name,
                    msg
                )
        elif output_format == "shapefile - line":
            success, msg = write_rubberband_profile_lnshp(
                output_filepath,
                ['id'],
                points,
                proj_sr)
            if not success:
                warn_qgis(
                    self.plugin_name,
                    msg
                )
        else:
            error_qt(
                self,
                self.plugin_name,
                "Error: error in export format"
            )
            return

        if success:
            info_qgis(
                self.plugin_name,
                "Ln saved"
            )

    def extract_format_type(self,):

        if dialog.outtype_shapefile_line_QRadioButton.isChecked():
            return "shapefile - line"
        elif dialog.outtype_csv_QRadioButton.isChecked():
            return "csv"
        else:
            return ""

    def save_rubberband_line(self):


        if self.currently_digitized_line is None:

            warn_qgis(
                self.plugin_name,
                "No available line to save [1]"
            )
            return

        elif self.currently_digitized_line.num_points() < 2:

            warn_qgis(
                self.plugin_name,
                "No available line to save [2]"
            )
            return

        dialog = ExportLineDataDialog(self.plugin_name)
        if dialog.exec():
            output_format = extract_format_type()
            if output_format == "":
                warn_qgis(
                    self.plugin_name,
                    "Error in output format"
                )
                return
            output_filepath = dialog.outpath_QLineEdit.text()
            if len(output_filepath) == 0:
                warn_qgis(
                    self.plugin_name,
                    "Error in output path"
                )
                return
            add_to_project = dialog.load_output_checkBox.isChecked()
        else:
            warn_qgis(
                self.plugin_name,
                "No export defined"
            )
            return

        # get project CRS information
        project_crs_osr = proj4str()

        output_profile_line(
            output_format,
            output_filepath,
            self.currently_digitized_line.pts(),
            project_crs_osr)

        # add theme to QGis project
        if output_format == "shapefile - line" and add_to_project:
            try:

                digitized_line_layer = QgsVectorLayer(output_filepath,
                                                      QFileInfo(output_filepath).baseName(),
                                                      "ogr")
                QgsProject.instance().addMapLayer(digitized_line_layer)

            except:

                error_qt(
                    self,
                    self.plugin_name,
                    "Unable to load layer in project"
                )
                return

    def define_point_layer_for_projection(self):

        try:

            # read input data

            current_point_layers = loaded_point_layers()

            if len(current_point_layers) == 0:
                warn_qgis(
                    self.plugin_name,
                    "No available point layers"
                )
                return

            dialog = GeologicalDataProjectPointsDialog(
                self.plugin_name,
                current_point_layers
            )

            if dialog.exec():
                # read parameters
                self.points_projection_parameters = self.extract_point_projection_params(
                    dialog,
                    current_point_layers
                )
                self._run_geodata_stage("Projecting points onto profiles", self.project_points)
            else:
                warn_qgis(
                    self.plugin_name,
                    "No point projection defined"
                )

        except Exception as e:

            error_qt(
                self,
                self.plugin_name,
                f"Unable to define points layer to project: {e!r}"
            )

    def remove_point_layer_for_projection(self):

        self.points_projection_parameters = None

    def project_points(self, progress_callback=None) -> Error:
        """
        Project the points of a point layer as simple point locations.

        :param progress_callback: optional callable invoked as
            progress_callback(done, total) after each of the 'total' parallel
            profiles is projected, e.g. to drive a progress bar.
        """

        try:

            if self.points_projection_parameters is None:
                raise Exception("Points projection parameters are yet to defined")

            # check CRS

            point_layer_wkt_crs = get_layer_crs_as_wtk(
                self.points_projection_parameters.point_layer
            )

            if point_layer_wkt_crs is None:
                raise Exception("Unable to extract layer CRS for attitude projections")

            # Define fields to read

            if self.points_projection_parameters.z_attribute_choice:
                field_list = [
                    self.points_projection_parameters.z_field_name,
                    self.points_projection_parameters.label_field_name,
                ]
            else:
                field_list = [
                    self.points_projection_parameters.label_field_name,
                ]

            # read input data

            data_points = read_qgis_pt_layer(
                pt_layer=self.points_projection_parameters.point_layer,
                field_list=field_list,
                fid_choice=True,
            )

            if not data_points:
                raise Exception("Unable to extract records for source layer")

            pts_3d = []
            fid_label = {}  # fid -> label (per export/plot)
            fid_srcxyz = {}  # fid -> (x,y,z or None)

            if self.points_projection_parameters.z_attribute_choice or self.points_projection_parameters.points_are_3d:
                # record: fid, x, y, z, ..., label  (a seconda di field_list);
                # la quota passa dal costruttore condiviso, che applica anche
                # l'eventuale conversione da profondità e tiene ogni punto
                # accoppiato al proprio record
                located_records = self._locate_records_from_z_values(
                    self.points_projection_parameters, data_points, point_layer_wkt_crs
                )

                for rec, pt_3d in located_records:
                    fid = rec[0]
                    label = rec[-1]  # perché label_field_name è l’ultimo in field_list
                    key = LabeledFid(fid=int(fid), label=label)
                    pts_3d.append((key, pt_3d, None))
                    fid_label[fid] = label
                    fid_srcxyz[fid] = (pt_3d.x, pt_3d.y, pt_3d.z)
            else:
                # record: fid, x, y, ..., label
                for rec in data_points:
                    fid = rec[0]
                    x = rec[1]
                    y = rec[2]
                    label = rec[-1]
                    key = LabeledFid(fid=int(fid), label=label)
                    pts_3d.append((key, Point(x, y, None), None))
                    fid_label[fid] = label
                    fid_srcxyz[fid] = (x, y, None)

            if not pts_3d:
                raise Exception("No 3D valid points found")

            parsed_points = pts_3d  # <-- ora è List[(fid, Point, fault=None)]

            projected_points = project_points(
                self.geoprofiles,
                parsed_points,
                point_layer_wkt_crs,
                self.points_projection_parameters.max_profile_distance,
                ProjectionMethod.NEAREST,
                PointsInput.POINTS,
                progress_callback=progress_callback,
            )

            if isinstance(projected_points, Error):
                raise Exception(f"{projected_points!r}")

            # salva proiezioni e mappe utili
            self.geoprofiles._points_projections["points"] = projected_points

            # metadati fuori da _points_projections (che deve restare solo "per profilo")
            self.geoprofiles._points_meta = {
                "fid_to_label": fid_label,
                "fid_to_srcxyz": fid_srcxyz,
            }

            return Error()

        except Exception as e:

            return Error(
                True,
                caller_name(),
                e,
                traceback.format_exc()
            )

    def extract_point_projection_params(
            self,
            dialog,
            current_point_layers):

        point_layer = current_point_layers[dialog.point_layer_combobox.currentIndex()]
        z_attribute_choice = dialog.radio_attributi_z.isChecked()
        z_field_name = dialog.z_fld_comboBox.currentText()

        points_are_3d = dialog.radio_punti_3d.isChecked()

        max_profile_distance = dialog.max_profile_distance_qdoublespinbox.value()
        label_field_name = dialog.point_label_fld_comboBox.currentText()

        return PointsProjectionParameters(
            point_layer,
            label_field_name,
            z_attribute_choice,
            z_field_name,
            points_are_3d,
            max_profile_distance,
            dialog.depth_options.z_is_depth(),
            dialog.depth_options.depth_reference(),
            dialog.depth_options.depth_unit_factor(),
            # missing selection stays None here and is reported at projection
            # time, rather than raising a bare KeyError on dialog confirmation
            dialog.current_raster_layers.get(dialog.dem_combobox.currentText()),
        )

    def extract_attitude_projections_params(
            self,
            dialog,
            current_point_layers) -> AttitudesProjectionParameters:

        point_layer = current_point_layers[dialog.point_layer_combobox.currentIndex()]
        label_field_name = dialog.point_label_fld_comboBox.currentText()
        use_dipdir = dialog.use_dipdir_qradiobuttom.isChecked()
        use_rhr_strike = dialog.use_rhr_strike_qradiobutton.isChecked()
        orient_fld_name = dialog.orient_fld_combobox.currentText()
        dipangle_fld_name = dialog.dipangle_fld_combobox.currentText()

        dem_choice = dialog.radio_dem.isChecked()
        # missing selection stays None here and is reported at projection time,
        # rather than raising a bare KeyError on dialog confirmation
        dem_layer = dialog.current_raster_layers.get(dialog.dem_combobox.currentText())

        z_attribute_choice = dialog.radio_attributi_z.isChecked()
        z_field_name = dialog.combobox_attributi_z.currentText()

        points_are_3d = dialog.radio_punti_3d.isChecked()

        max_profile_distance = dialog.max_profile_distance_qdoublespinbox.value()

        return AttitudesProjectionParameters(
            point_layer,
            label_field_name,
            use_dipdir,
            use_rhr_strike,
            orient_fld_name,
            dipangle_fld_name,
            dem_choice,
            dem_layer,
            z_attribute_choice,
            z_field_name,
            points_are_3d,
            max_profile_distance,
        )

    def define_attitude_layer_for_projection(self):
        """
        Project the points of a point layer as simple point locations.

        """

        try:

            current_point_layers = loaded_point_layers()

            if len(current_point_layers) == 0:
                warn_qgis(
                    self.plugin_name,
                    "No available point layers"
                )
                return

            dialog = GeologicalDataProjectAttitudesDialog(
                self.plugin_name,
                current_point_layers
            )

            if dialog.exec():

                # read parameters

                self.attitudes_projection_parameters = self.extract_attitude_projections_params(
                    dialog,
                    current_point_layers
                )

                self._run_geodata_stage("Projecting attitudes onto profiles", self.project_attitudes)

            else:
                warn_qgis(
                        self.plugin_name,
                        "No point attitudes projection defined")
                return

        except Exception as e:

            error_qt(
                self,
                self.plugin_name,
                f"Unable to define point layer to project: {e!r}"
            )

    def project_attitudes(self, progress_callback=None) -> Error:
        """
        Proietta attitudes (giaciture) sui geoprofile.
        Coordina validazione, estrazione dati, creazione punti 3D e proiezione.

        :param progress_callback: optional callable invoked as
            progress_callback(done, total) after each of the 'total' parallel
            profiles is projected, e.g. to drive a progress bar.
        """
        try:
            # Validazione parametri
            if self.attitudes_projection_parameters is None:
                raise Exception("Attitudes projection parameters are yet to be defined")

            # Estrazione CRS e dati
            point_layer_wkt_crs = self._get_attitudes_layer_crs()
            if point_layer_wkt_crs is None:
                raise Exception("Unable to extract layer CRS for attitude projections")

            field_list = self._build_attitudes_field_list()
            data_points = self._read_attitudes_data(field_list)
            if not data_points:
                raise Exception("Unable to extract records for source layer")

            # Creazione punti 3D, tenuti accoppiati ai rispettivi record
            located_records = self._locate_records(
                self.attitudes_projection_parameters, data_points, point_layer_wkt_crs
            )
            if not located_records:
                raise Exception("No 3D valid points found")

            # Parsing e proiezione
            parsed_points = self._parse_attitudes_points(located_records)
            if not parsed_points:
                raise Exception("No 3D valid points found")

            projected_attitudes = self._execute_attitudes_projection(
                parsed_points, point_layer_wkt_crs, progress_callback=progress_callback
            )
            if isinstance(projected_attitudes, Error):
                raise Exception(f"{projected_attitudes!r}")

            self.geoprofiles._points_projections["attitudes"] = projected_attitudes
            return Error()

        except Exception as e:
            return Error(True, caller_name(), e, traceback.format_exc())

    def _get_attitudes_layer_crs(self):
        """Estrae CRS del layer attitudes."""
        return get_layer_crs_as_wtk(
            self.attitudes_projection_parameters.point_layer
        )

    def _build_attitudes_field_list(self):
        """Costruisce lista campi da leggere in base alle scelte."""
        params = self.attitudes_projection_parameters
        if params.z_attribute_choice:
            return [
                params.z_field_name,
                params.label_field_name,
                params.orient_fld_name,
                params.dipangle_fld_name,
            ]
        else:
            return [
                params.label_field_name,
                params.orient_fld_name,
                params.dipangle_fld_name,
            ]

    def _read_attitudes_data(self, field_list):
        """Legge dati dal layer punti."""
        return read_qgis_pt_layer(
            pt_layer=self.attitudes_projection_parameters.point_layer,
            field_list=field_list,
            fid_choice=True,
        )

    def _locate_records(self, params, data_points, point_layer_wkt_crs):
        """
        Costruisce le coppie (record, punto 3D) per i record leggibili.

        Restituisce coppie, e non due liste separate, perché i record senza quota
        valida vengono scartati: accoppiare a posteriori due liste filtrate in
        modo indipendente (con zip) assegnerebbe ai record successivi il punto
        sbagliato.

        Condiviso da punti, giaciture e meccanismi focali: la sorgente della quota
        ha la stessa forma nei tre casi, così come la conversione profondità/quota
        (che però solo punti e meccanismi focali espongono nella loro interfaccia).
        """

        if getattr(params, "dem_choice", False):
            return self._locate_records_from_dem(params, data_points, point_layer_wkt_crs)
        elif params.z_attribute_choice or params.points_are_3d:
            return self._locate_records_from_z_values(params, data_points, point_layer_wkt_crs)
        else:
            raise Exception("Got no valid choice for elevation source")

    def _locate_records_from_dem(self, params, data_points, point_layer_wkt_crs):
        """Coppie (record, punto 3D) con le quote interpolate dal DEM."""

        grid = self._dem_grid(params)
        located_records = []

        for rec in data_points:
            surface_z = self._interpolate_dem_elevation(grid, rec[1], rec[2], point_layer_wkt_crs)
            if surface_z is not None:
                located_records.append((rec, Point(rec[1], rec[2], surface_z)))

        return located_records

    def _locate_records_from_z_values(self, params, data_points, point_layer_wkt_crs):
        """
        Coppie (record, punto 3D) con la quota letta dal layer (campo o geometria).

        Quando il layer memorizza profondità invece di quote, il valore viene
        convertito nella quota in cui sono disegnati i profili: cambiato di segno
        rispetto al datum scelto e riportato in metri dall'unità dichiarata.
        """

        z_is_depth = getattr(params, "z_is_depth", False)
        depth_reference = getattr(params, "depth_reference", DepthReference.SEA_LEVEL)
        depth_unit_factor = getattr(params, "depth_unit_factor", 1.0)

        below_surface = z_is_depth and depth_reference == DepthReference.TOPOGRAPHIC_SURFACE

        # il DEM serve solo per le profondità sotto la superficie topografica:
        # aprirlo comunque farebbe fallire ogni altra combinazione senza motivo
        grid = self._dem_grid(params) if below_surface else None

        located_records = []

        for rec in data_points:
            x = rec[1]
            y = rec[2]
            raw_z = safe_float(rec[3])

            if raw_z is None:
                continue

            if not z_is_depth:
                located_records.append((rec, Point(x, y, raw_z)))
                continue

            depth_in_metres = raw_z * depth_unit_factor

            if below_surface:
                surface_z = self._interpolate_dem_elevation(grid, x, y, point_layer_wkt_crs)
                if surface_z is None:
                    continue
                elevation = surface_z - depth_in_metres
            else:
                elevation = -depth_in_metres

            located_records.append((rec, Point(x, y, elevation)))

        return located_records

    def _dem_grid(self, params):
        """Apre come griglia il DEM scelto, con un errore chiaro se manca."""

        source_dem = getattr(params, "dem_layer", None)
        if source_dem is None:
            raise Exception(
                "No DEM selected: load a single-band raster in the project and choose it "
                "as the elevation source (or as the datum for depths below topography)"
            )

        return grid_from_raster_band(raster_source=source_dem.source())

    def _interpolate_dem_elevation(self, grid, x, y, point_layer_wkt_crs):
        """Quota del DEM in (x, y), o None se non definita."""

        pt3d = grid.interpolate_bilinear_point_with_nan(
            pt=Point(x, y),
            pt_crs=point_layer_wkt_crs,
        )

        if pt3d is None or pt3d.z is None or np.isnan(pt3d.z):
            return None

        return pt3d.z

    def _parse_attitudes_points(self, located_records):
        """
        Parsa punti e crea oggetti Plane.

        Come per i meccanismi focali, la chiave è il fid accoppiato all'etichetta:
        con la sola etichetta le giaciture che ne condividono il valore collassavano
        in una, tenuta la più vicina al profilo.
        """
        use_rhr_strike = self.attitudes_projection_parameters.use_rhr_strike
        parsed_points = []

        for rec, pt_3d in located_records:
            fid = rec[0]
            label = rec[-3]
            orientation = rec[-2]
            dipangle = rec[-1]

            plane = Plane(
                azim=orientation,
                dip_ang=dipangle,
                is_rhr_strike=use_rhr_strike
            )
            parsed_points.append((LabeledFid(fid=int(fid), label=label), pt_3d, plane))

        return parsed_points

    def _execute_attitudes_projection(self, parsed_points, point_layer_wkt_crs, progress_callback=None):
        """Esegue proiezione attitudes sui geoprofile."""
        return project_points(
            self.geoprofiles,
            parsed_points,
            point_layer_wkt_crs,
            self.attitudes_projection_parameters.max_profile_distance,
            ProjectionMethod.NEAREST,
            PointsInput.ATTITUDES,
            progress_callback=progress_callback,
        )

    def extract_focal_mechanisms_projections_params(
            self,
            dialog,
            current_point_layers) -> FocalMechanismsProjectionParameters:

        point_layer = current_point_layers[dialog.point_layer_combobox.currentIndex()]
        label_field_name = dialog.point_label_fld_comboBox.currentText()
        use_dipdir = dialog.use_dipdir_qradiobuttom.isChecked()
        use_rhr_strike = dialog.use_rhr_strike_qradiobutton.isChecked()
        orient_fld_name = dialog.orient_fld_combobox.currentText()
        dipangle_fld_name = dialog.dipangle_fld_combobox.currentText()
        rake_fld_name = dialog.rake_fld_combobox.currentText()

        dem_choice = dialog.radio_dem.isChecked()
        # missing selection stays None here and is reported at projection time,
        # rather than raising a bare KeyError on dialog confirmation
        dem_layer = dialog.current_raster_layers.get(dialog.dem_combobox.currentText())

        z_attribute_choice = dialog.radio_attributi_z.isChecked()
        z_field_name = dialog.combobox_attributi_z.currentText()

        points_are_3d = dialog.radio_punti_3d.isChecked()

        max_profile_distance = dialog.max_profile_distance_qdoublespinbox.value()

        return FocalMechanismsProjectionParameters(
            point_layer,
            label_field_name,
            use_dipdir,
            use_rhr_strike,
            orient_fld_name,
            dipangle_fld_name,
            rake_fld_name,
            dem_choice,
            dem_layer,
            z_attribute_choice,
            z_field_name,
            points_are_3d,
            max_profile_distance,
            dialog.depth_options.z_is_depth(),
            dialog.depth_options.depth_reference(),
            dialog.depth_options.depth_unit_factor(),
        )

    def define_focal_mechanisms_layer_for_projection(self):
        """
        Project the earthquake focal mechanisms of a point layer onto the profiles,
        to be plotted as beachballs.
        """

        try:

            # beachballs are rendered by obspy, which is an optional geogst
            # dependency: fail here, while the user is still choosing inputs,
            # rather than at plot time after a full projection run

            try:
                import obspy  # noqa: F401
            except ImportError:
                warn_qgis(
                    self.plugin_name,
                    "Plotting focal mechanisms requires obspy, which is not available in this "
                    "QGIS Python environment. Install it (pip install obspy) and restart QGIS."
                )
                return

            current_point_layers = loaded_point_layers()

            if len(current_point_layers) == 0:
                warn_qgis(
                    self.plugin_name,
                    "No available point layers"
                )
                return

            dialog = GeologicalDataProjectFocalMechanismsDialog(
                self.plugin_name,
                current_point_layers
            )

            if dialog.exec():

                # read parameters

                self.focal_mechanisms_projection_parameters = self.extract_focal_mechanisms_projections_params(
                    dialog,
                    current_point_layers
                )

                self._run_geodata_stage(
                    "Projecting focal mechanisms onto profiles",
                    self.project_focal_mechanisms,
                )

            else:
                warn_qgis(
                        self.plugin_name,
                        "No focal mechanisms projection defined")
                return

        except Exception as e:

            error_qt(
                self,
                self.plugin_name,
                f"Unable to define focal mechanisms layer to project: {e!r}"
            )

    def project_focal_mechanisms(self, progress_callback=None) -> Error:
        """
        Proietta i meccanismi focali sui geoprofile.

        :param progress_callback: optional callable invoked as
            progress_callback(done, total) after each of the 'total' parallel
            profiles is projected, e.g. to drive a progress bar.
        """
        try:

            if self.focal_mechanisms_projection_parameters is None:
                raise Exception("Focal mechanisms projection parameters are yet to be defined")

            point_layer_wkt_crs = self._get_focal_mechanisms_layer_crs()
            if point_layer_wkt_crs is None:
                raise Exception("Unable to extract layer CRS for focal mechanisms projections")

            field_list = self._build_focal_mechanisms_field_list()
            data_points = self._read_focal_mechanisms_data(field_list)
            if not data_points:
                raise Exception("Unable to extract records for source layer")

            located_records = self._locate_records(
                self.focal_mechanisms_projection_parameters, data_points, point_layer_wkt_crs
            )
            if not located_records:
                raise Exception("No 3D valid points found")

            parsed_points = self._parse_focal_mechanisms_points(located_records)
            if not parsed_points:
                raise Exception("No valid focal mechanisms found")

            projected_focal_mechanisms = self._execute_focal_mechanisms_projection(
                parsed_points, point_layer_wkt_crs, progress_callback=progress_callback
            )
            if isinstance(projected_focal_mechanisms, Error):
                raise Exception(f"{projected_focal_mechanisms!r}")

            self.geoprofiles._points_projections["focal_mechanisms"] = projected_focal_mechanisms
            return Error()

        except Exception as e:
            return Error(True, caller_name(), e, traceback.format_exc())

    def _get_focal_mechanisms_layer_crs(self):
        """Estrae CRS del layer dei meccanismi focali."""
        return get_layer_crs_as_wtk(
            self.focal_mechanisms_projection_parameters.point_layer
        )

    def _build_focal_mechanisms_field_list(self):
        """Costruisce lista campi da leggere in base alle scelte."""
        params = self.focal_mechanisms_projection_parameters
        if params.z_attribute_choice:
            return [
                params.z_field_name,
                params.label_field_name,
                params.orient_fld_name,
                params.dipangle_fld_name,
                params.rake_fld_name,
            ]
        else:
            return [
                params.label_field_name,
                params.orient_fld_name,
                params.dipangle_fld_name,
                params.rake_fld_name,
            ]

    def _read_focal_mechanisms_data(self, field_list):
        """Legge dati dal layer punti."""
        return read_qgis_pt_layer(
            pt_layer=self.focal_mechanisms_projection_parameters.point_layer,
            field_list=field_list,
            fid_choice=True,
        )

    def _parse_focal_mechanisms_points(self, located_records):
        """
        Parsa i record e crea le istanze Fault che portano strike/dip/rake.

        Un record con azimuth, immersione o rake non numerici (o con un rake che
        non individua una slickenline sul piano) viene scartato con un warning nel
        log, invece di far fallire l'intera proiezione.

        La chiave di ogni record è il suo fid accoppiato all'etichetta, non
        l'etichetta da sola: la proiezione tiene una sola soluzione per chiave (la
        più vicina al profilo), quindi con la sola etichetta tutti i meccanismi che
        ne condividono il valore collassavano in uno, e sul profilo se ne vedeva
        uno solo. LabeledFid resta unica per feature e continua a stamparsi come
        l'etichetta.
        """
        params = self.focal_mechanisms_projection_parameters
        use_rhr_strike = params.use_rhr_strike
        parsed_points = []

        for rec, pt_3d in located_records:
            fid = rec[0]
            label = rec[-4]
            orientation = safe_float(rec[-3])
            dipangle = safe_float(rec[-2])
            rake = safe_float(rec[-1])

            if orientation is None or dipangle is None or rake is None:
                QgsMessageLog.logMessage(
                    f"Skipping focal mechanism '{label}': non-numeric azimuth/dip/rake "
                    f"({rec[-3]}, {rec[-2]}, {rec[-1]})",
                    self.plugin_name,
                    Qgis.MessageLevel.Warning
                )
                continue

            try:
                fault = Fault(
                    azim=orientation,
                    dip_ang=dipangle,
                    slickenlines=rake,
                    is_rhr_strike=use_rhr_strike,
                )
            except Exception as e:
                QgsMessageLog.logMessage(
                    f"Skipping focal mechanism '{label}': unable to build the fault "
                    f"from azimuth {orientation}, dip {dipangle}, rake {rake}: {e!r}",
                    self.plugin_name,
                    Qgis.MessageLevel.Warning
                )
                continue

            parsed_points.append((LabeledFid(fid=int(fid), label=label), pt_3d, fault))

        return parsed_points

    def _execute_focal_mechanisms_projection(self, parsed_points, point_layer_wkt_crs, progress_callback=None):
        """Esegue proiezione dei meccanismi focali sui geoprofile."""
        return project_points(
            self.geoprofiles,
            parsed_points,
            point_layer_wkt_crs,
            self.focal_mechanisms_projection_parameters.max_profile_distance,
            ProjectionMethod.NEAREST,
            PointsInput.FOCAL_MECHANISMS,
            progress_callback=progress_callback,
        )

    def define_line_layer_for_intersection(self):
        """
        Intersects a line layer (e.g., faults) with the profiles.
        """

        try:

            current_line_layers = loaded_line_layers()

            if len(current_line_layers) == 0:
                warn_qgis(
                    self.plugin_name,
                    "No available line layers"
                )
                return

            dialog = GeologicalDataIntersectLinesDialog(
                self.plugin_name,
                current_line_layers
            )

            if dialog.exec():
                # line_layer, id_field_ndx
                self.lines_intersection_parameters = self.extract_intersection_line_layer_params(dialog, current_line_layers)
                self._run_geodata_stage("Intersecting lines with profiles", self.intersect_lines)

            else:
                warn_qgis(
                    self.plugin_name,
                    "No defined line source"
                )

        except Exception as e:

            error_qt(
                self,
                self.plugin_name,
                f"Unable to define line layer to intersect: {e!r}"
            )

    def intersect_lines(self, progress_callback=None) -> Error:
        """
        :param progress_callback: optional callable invoked as
            progress_callback(done, total) after each of the 'total' parallel
            profiles is intersected, e.g. to drive a progress bar.
        """

        try:

            if self.lines_intersection_parameters is None:
                raise Exception("Lines intersection parameters are yet to be defined")

            # check CRS

            lines_layer_crs_wkt = get_layer_crs_as_wtk(
                self.lines_intersection_parameters.line_layer,
            )

            if lines_layer_crs_wkt is None:
                raise Exception(f"Unable to extract CRS as WKT from chosen line layer")

            # process data

            success, results = extract_coords_2d_with_order_from_selected_features_in_layer_multipart(
                line_layer=self.lines_intersection_parameters.line_layer,
                order_field_ndx=self.lines_intersection_parameters.id_field_ndx
            )

            if not success:
                raise Exception(results)

            line_layer_crs_coordinates, line_layer_ids = results

            line_layer_crs_loaded_lines = defaultdict(list)

            for (id, (type, data)) in zip(line_layer_ids, line_layer_crs_coordinates):
                if type == "multiline":
                    lines = []
                    for ln_data in data:
                        ln = Ln.fromCoordinates(ln_data)
                        lines.append(ln)
                elif type == "line":
                    lines = [Ln.fromCoordinates(data)]
                else:
                    raise Exception(f"Line type is {type}, that is unhandled")

                line_layer_crs_loaded_lines[id].extend(lines)

            err = self.geoprofiles.intersect_lines(
                line_layer_crs_loaded_lines,
                lines_layer_crs_wkt,
                progress_callback=progress_callback,
            )

            if err:
                raise Exception(f"{err!r}")

            return Error()

        except Exception as e:

            # reported by the caller, that stops the processing chain: this
            # method used to swallow the failure by reporting it directly and
            # returning nothing, so that geoprofiles were then plotted with
            # missing or stale line intersections

            return Error(
                True,
                caller_name(),
                e,
                traceback.format_exc()
            )

        finally:
            pass

    def extract_intersection_line_layer_params(
            self,
            dialog,
            current_line_layers
    ) -> LinesIntersectionParameters:

        line_layer = current_line_layers[dialog.LineLayers_comboBox.currentIndex()]
        id_field_ndx = dialog.inters_input_id_fld_line_comboBox.currentIndex()

        return LinesIntersectionParameters(
            line_layer,
            id_field_ndx,
        )

    def intersect_polygons(self, progress_callback=None) -> Error:
        """
        :param progress_callback: optional callable invoked as
            progress_callback(done, total) after each of the 'total' parallel
            profiles is intersected, e.g. to drive a progress bar.
        """

        try:

            if self.polygons_intersection_parameters is None:
                raise Exception("Polygons intersection parameters are yet to be defined")

            # check CRS

            polygons_layer_crs_wkt = get_layer_crs_as_wtk(
                self.polygons_intersection_parameters.polygon_layer
            )

            if polygons_layer_crs_wkt is None:
                raise Exception("Unable to extract CRS as WKT from chosen polygon layer")

            # process

            polygon_lyr = self.polygons_intersection_parameters.polygon_layer

            renderer = polygon_lyr.renderer()

            # 1) Costruisci una mappa {legend_key -> label} una sola volta
            items = renderer.legendSymbolItems()  # lista di QgsLegendSymbolItem
            key_to_label = {it.ruleKey(): it.label() for it in items}

            polygons = defaultdict(list)

            renderer.startRender(context, polygon_lyr.fields())

            try:

                for f in polygon_lyr.getFeatures():

                    if f.hasGeometry():

                        keys = list(renderer.legendKeysForFeature(f, context))  # set -> lista
                        labels = [key_to_label.get(k) for k in keys if key_to_label.get(k)]
                        if labels:
                            name = ', '.join(labels)
                        else:
                            # fallback: nessuna key (es. NullSymbolRenderer). Prova etichetta predefinita
                            name = items[0].label() if items else renderer.type()

                        geom = f.geometry()

                        feature_polygons = qgismpolygon_to_polygons(geom)

                        polygons[name].extend(feature_polygons)

            finally:

                renderer.stopRender(context)

            # regular code for determining intersections

            err = self.geoprofiles.intersect_polygons(
                polygons,
                polygons_layer_crs_wkt,
                progress_callback=progress_callback,
            )

            if err:
                raise Exception(f"{err!r}")

            return Error()

        except Exception as e:

            # reported by the caller, that stops the processing chain: this
            # method used to swallow the failure, so that geoprofiles were
            # then plotted with missing or stale polygon intersections

            return Error(
                True,
                caller_name(),
                e,
                traceback.format_exc()
            )

    def define_polygon_layer_for_intersection(self):
        """
        Intersects a polygonal layer (e.g., outcrops) with the profiles.
        """

        try:

            current_polygonal_layers = loaded_polygon_layers()

            if len(current_polygonal_layers) == 0:
                warn_qgis(
                    self.plugin_name,
                    "No available polygon layers"
                )
                return

            dialog = GeologicalDataIntersectPolygonsDialog(
                self.plugin_name,
                current_polygonal_layers
            )

            if dialog.exec():

                # polygon_layer, id_fld_name
                self.polygons_intersection_parameters = self.extract_intersection_polygon_layer_params(dialog, current_polygonal_layers)

                self._run_geodata_stage("Intersecting polygons with profiles", self.intersect_polygons)

            else:

                warn_qgis(
                    self.plugin_name,
                    "No defined polygonal source"
                )
                return

        except Exception as e:

            error_qt(
                self,
                self.plugin_name,
                f"Unable to define polygon layer to intersect: {e!r}"
            )

    def extract_intersection_polygon_layer_params(
            self,
            dialog,
            current_polygon_layers) -> PolygonsIntersectionParameters:

        polygon_layer = current_polygon_layers[dialog.PolygonLayers_comboBox.currentIndex()]

        return PolygonsIntersectionParameters(
            polygon_layer,
        )

    def choose_3d_topographic_line_layer(self):

        line_3d_layers_in_project = loaded_3d_vector_layers()

        if len(line_3d_layers_in_project) == 0:
            warn_qgis(
                self.plugin_name,
                "No available 3D line layers in current project"
            )
            return

        dialog = DefineSourceLineLayerDialog(
            self.plugin_name,
            line_3d_layers_in_project,
        )

        if dialog.exec():
            self._init_profile_lines_parameters()
            self._init_geoprofiles_parameters()
            qgs_3d_line_layer = line_3d_layers_in_project[dialog.LineLayers_comboBox.currentIndex()]
        else:
            warn_qgis(
                self.plugin_name,
                "No defined 3D line source"
            )
            return

        self.qgis_3d_line_layer = qgs_3d_line_layer

        err = self.convert_3d_lines_into_geoprofiles()
        if err:
            info_qgis(
                self.plugin_name,
                "Error with 3D line source processing",
            )
        else:
            info_qgis(
                self.plugin_name,
                "3D profiles created",
            )

    def convert_3d_lines_into_geoprofiles(self) -> bool:
        """
        Converte linee 3D selezionate in geoprofile.
        Coordina validazione, estrazione, proiezione e conversione.
        """
        try:
            # Validazione input
            if self.qgis_3d_line_layer is None:
                warn_qgis(self.plugin_name, "First choose 3D topographic profile")
                return True

            # Estrazione linee topografiche

            topographic_lines = self._extract_topographic_lines()
            if isinstance(topographic_lines, str):
                raise Exception(topographic_lines)

            # Proiezione al CRS del progetto
            project_crs_lines = self._project_lines_to_project_crs(topographic_lines)
            if isinstance(project_crs_lines, Error):
                raise Exception(f"Error: {project_crs_lines!r}")

            # Conversione in MultiLine
            parsed_mlines = self._convert_to_multilines(project_crs_lines)
            if isinstance(parsed_mlines, Error):
                raise Exception(f"{parsed_mlines!r}")

            # creazione GeoProfiles
            self._create_geoprofiles_from_traces(parsed_mlines)

            return False

        except Exception as e:
            error_qt(self, self.plugin_name, f"{e!r}")
            return True

    def _extract_topographic_lines(self):
        """Estrae coordinate 3D dalle feature selezionate."""
        return extract_coords_3d_with_fid_from_selected_features_in_layer_multipart(
            line_layer=self.qgis_3d_line_layer,
        )

    def _project_lines_to_project_crs(self, topographic_lines):
        """Proietta linee al CRS del progetto."""
        return self.project_profiles_3d(
            topographic_lines,
            dest_crs=QgsProject.instance().crs(),
        )

    def _convert_to_multilines(self, project_crs_lines):
        """Converte linee proiettate in istanze MultiLine."""

        parsed_mlines = []
        for project_crs_line in project_crs_lines:
            parsed_mline = self.convert_profile_mline_to_mline(project_crs_line)
            if isinstance(parsed_mline, Error):
                return parsed_mline
            parsed_mlines.append(parsed_mline)
        return parsed_mlines

    def _create_geoprofiles_from_traces(self, parsed_mlines):
        """Crea GeoProfiles."""

        qgis_project_wkt_crs = QgsProject.instance().crs().toWkt()

        profilers = []

        for _, parsed_mline in parsed_mlines:
            profiler = LineProfiler(
                line=parsed_mline,
                crs=qgis_project_wkt_crs,
            )
            profilers.append(profiler)

        self.geoprofiles = GeoProfiles(
            profilers=profilers,
            crs=Crs(qgis_project_wkt_crs),
        )

    def precalculate_axis_parameters_suggestions(self):

        # pre-process input data for profiles parameters
        
        if self.geoprofiles is None:
            warn_qt(self,
                    self.plugin_name,
                    "No geoprofiles defined. Cannot calculate axis parameters."
                    )
            return Error()

        profile_length = self.geoprofiles.s_max()
        natural_elev_min = self.geoprofiles.z_min()
        natural_elev_max = self.geoprofiles.z_max()

        if np.isnan(profile_length) or profile_length == 0.0:
            warn_qt(self,
                    self.plugin_name,
                    f"Max profile length is {profile_length}.\nCheck profile trace"
                    )
            return Error()

        if np.isnan(natural_elev_min) or np.isnan(natural_elev_max):
            warn_qt(self,
                    self.plugin_name,
                    f"Max elevation in profile(s) is {natural_elev_max} and min is {natural_elev_min}.\nCheck profile trace location vs. DEM(s)"
                    )
            return Error()

        if natural_elev_max <= natural_elev_min:
            warn_qt(self,
                    self.plugin_name,
                    f"Min elevation {natural_elev_min} is greater than max elevation {natural_elev_max}"
                    )
            return Error()

        # calculate proposed plot elevation range

        delta_z = natural_elev_max - natural_elev_min

        if delta_z == 0.0:

            plot_z_min = floor(natural_elev_min) - ProfileConstants.ZERO_DELTA_Z_PADDING
            plot_z_max = ceil(natural_elev_max) + ProfileConstants.ZERO_DELTA_Z_PADDING

        else:

            plot_z_min = floor(natural_elev_min - delta_z * ProfileConstants.Z_PADDING_FACTOR)
            plot_z_max = ceil(natural_elev_max + delta_z * ProfileConstants.Z_PADDING_FACTOR)

        delta_plot_z = plot_z_max - plot_z_min

        # suggested exaggeration value
        
        if delta_plot_z == 0.0:
            warn_qt(self,
                    self.plugin_name,
                    f"Delta plot Z is zero. Cannot calculate vertical exaggeration."
                    )
            return Error()

        w_to_h_rat = float(profile_length) / float(delta_plot_z)
        sugg_ve = ProfileConstants.VERTICAL_EXAGGERATION_FACTOR * w_to_h_rat

        return plot_z_min, plot_z_max, sugg_ve

    def open_help(self):

        dialog = HelpDialog(
            self.plugin_name,
            self.current_directory
        )
        dialog.exec()

    def closeEvent(self, evnt):

        self.clear_rubberband_line()


