"""
/***************************************************************************
 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,
            "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 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_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_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()

    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

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

        success = 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."
                ),
            )
            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

        success = 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"\nError with adding vector layer {layer_name}",
            )
            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

        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 extract_graphical_params(
        self,
        dialog,
    ) -> None:
        # get profile plot parameters

        try:

            width = float(dialog.figure_width_qlineedit.text())
            if width > 0.0:
                self.graphical_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:
                self.graphical_params.figure.height = height

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

        try:

            self.graphical_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:

            self.graphical_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:
                self.graphical_params.axis.vertical_exaggeration = vertical_exaggeration

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

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

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

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

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

        if hasattr(dialog, "polygon_classification_colors"):
            self.graphical_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)
        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()

        ok_qgis(self.plugin_name, "3D profiles generated")

    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

        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."""

        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
                )

    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.extract_graphical_params(dialog)
        else:
            return

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

    def save_graphic_parameters(self):

        try:

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

            with open(file_pth, "w", encoding="utf-8") as handle:
                json.dump(self.graphical_params, 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)"
            )

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

            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), for the geogst intersection
        methods that report progress per parallel profile, that turns the
        dialog from an indeterminate 'busy' bar into a determinate one (as
        soon as the total number of profiles 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.
        """

        stage_start = {"time": None}

        def _callback(done, total):

            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 = ""

            progress.setLabelText(f"{stage_label}: profile {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,
            line_intersections=self.graphical_params.line_intersections,
            polygon_intersections=self.graphical_params.polygon_intersections,
        )

        graphs_win = FigureWindow(
            figure=figure,
        )
        graphs_win.setWindowTitle("Geoprofiles")
        graphs_win.exec()

    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("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("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_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_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,
                "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)
                for rec in data_points:
                    fid = rec[0]
                    x = rec[1];
                    y = rec[2];
                    z = rec[3]
                    label = rec[-1]  # perché label_field_name è l’ultimo in field_list
                    key = LabeledFid(fid=int(fid), label=label)
                    pts_3d.append((key, Point(x, y, z), None))
                    fid_label[fid] = label
                    fid_srcxyz[fid] = (x, y, 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)

            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,
        )

    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()
        dem_layer = dialog.current_raster_layers[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
            pts_3d = self._create_attitudes_3d_points(data_points, point_layer_wkt_crs)
            if not pts_3d:
                raise Exception("No 3D valid points found")

            # Parsing e proiezione
            parsed_points = self._parse_attitudes_points(data_points, pts_3d)
            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 _create_attitudes_3d_points(self, data_points, point_layer_wkt_crs):
        """Crea punti 3D da dati estratti usando DEM o attributo Z."""
        pts_3d = []
        params = self.attitudes_projection_parameters

        if params.dem_choice:
            pts_3d = self._create_3d_points_from_dem(data_points, point_layer_wkt_crs)
        elif params.z_attribute_choice or params.points_are_3d:
            pts_3d = self._create_3d_points_from_attribute(data_points)
        else:
            raise Exception("Got no valid choice for elevation source")

        return pts_3d

    def _create_3d_points_from_dem(self, data_points, point_layer_wkt_crs):
        """Crea punti 3D interpolando da DEM."""
        source_dem = self.attitudes_projection_parameters.dem_layer
        grid = grid_from_raster_band(raster_source=source_dem.source())
        pts_3d = []

        for rec in data_points:
            x = rec[1]
            y = rec[2]
            pt3d = grid.interpolate_bilinear_point_with_nan(
                pt=Point(x, y),
                pt_crs=point_layer_wkt_crs,
            )
            if pt3d is not None and pt3d.z is not None and not np.isnan(pt3d.z):
                pts_3d.append(pt3d)

        return pts_3d

    def _create_3d_points_from_attribute(self, data_points):
        """Crea punti 3D da attributo Z."""
        pts_3d = []
        for rec in data_points:
            x = rec[1]
            y = rec[2]
            z = rec[3]
            if z is not None and not np.isnan(z):
                pts_3d.append(Point(x, y, z))
        return pts_3d

    def _parse_attitudes_points(self, data_points, pts_3d):
        """Parsa punti e crea oggetti Plane."""
        use_rhr_strike = self.attitudes_projection_parameters.use_rhr_strike
        parsed_points = []

        for rec, pt_3d in zip(data_points, pts_3d):
            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((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 _get_points_layer_crs(self):
        """Estrae CRS del layer punti."""
        return get_layer_crs_as_wtk(
            self.points_projection_parameters.point_layer
        )

    def _build_points_field_list(self):
        """Costruisce lista campi da leggere."""
        params = self.points_projection_parameters
        if params.z_attribute_choice:
            return [
                params.z_field_name,
                params.label_field_name,
            ]
        else:
            return [params.label_field_name]

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

    def _create_points_3d_with_metadata(self, data_points):
        """Crea punti 3D e metadati (fid->label, fid->xyz)."""
        pts_3d = []
        fid_label = {}
        fid_srcxyz = {}
        params = self.points_projection_parameters

        if params.z_attribute_choice or params.points_are_3d:
            # record: fid, x, y, z, ..., label
            for rec in data_points:
                fid = rec[0]
                x = rec[1]
                y = rec[2]
                z = rec[3]
                label = rec[-1]
                key = LabeledFid(fid=int(fid), label=label)
                pts_3d.append((key, Point(x, y, z)))
                fid_label[fid] = label
                fid_srcxyz[fid] = (x, y, 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)))
                fid_label[fid] = label
                fid_srcxyz[fid] = (x, y, None)

        return pts_3d, fid_label, fid_srcxyz

    def _execute_points_projection(self, pts_3d, point_layer_wkt_crs):
        """Esegue proiezione punti sui geoprofile."""
        return project_points(
            self.geoprofiles,
            pts_3d,
            point_layer_wkt_crs,
            self.points_projection_parameters.max_profile_distance,
            ProjectionMethod.NEAREST,
            PointsInput.POINTS,
        )

    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()


