# -*- coding: utf-8 -*-
"""
/***************************************************************************
 QProto
                                 A QGIS plugin
 QProto postProcessing Algorithm
 Generated by Plugin Builder: http://g-sherman.github.io/Qgis-Plugin-Builder/
                              -------------------
        begin                : 2018-04-19
        copyright            : (C) Matteo Ghetta (Faunalia)
        email                : matteo.ghetta@faunalia.eu
        update               : 2026 Stefano Campus & Codex
        updated email        : skampus@gmail.com
 ***************************************************************************/

/***************************************************************************
 * *
 * 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 2 of the License, or     *
 * (at your option) any later version.                                   *
 * *
 ***************************************************************************/
"""

__author__ = 'Matteo Ghetta (Faunalia); 2026 update by Stefano Campus & Codex'
__date__ = '2018-04-19'
__copyright__ = '(C) 2018 by Matteo Ghetta (Faunalia); 2026 update by Stefano Campus'

from typing import Any, Dict, Optional
import os
import sqlite3
import math
import time
from datetime import datetime
from osgeo import gdal, ogr

from qgis.core import (
    QgsProcessing,
    QgsProcessingAlgorithm,
    QgsProcessingContext,
    QgsProcessingFeedback,
    QgsProcessingMultiStepFeedback,
    QgsProcessingParameterVectorLayer,
    QgsProcessingParameterRasterLayer,
    QgsProcessingParameterBoolean,
    QgsProcessingParameterString,
    QgsProcessingException,
    QgsProcessingUtils,
    QgsProject,
    QgsRasterLayer,
    QgsVectorLayer,
    Qgis,
)
from qgis.gui import QgsMapLayerComboBox, QgsProcessingGui
from qgis.PyQt.QtGui import QIcon
from qgis.PyQt.QtWidgets import QWidget, QHBoxLayout, QToolButton, QInputDialog
from qgis import processing
from processing.gui.wrappers import VectorLayerWidgetWrapper
from processing.gui.wrappers import RasterWidgetWrapper
from processing.tools import dataobjects

try:
    from .utils import OutStyle
except ImportError:
    from utils import OutStyle

pluginPath = os.path.dirname(__file__)


def _set_layout_zero_margins(layout):
    # Qt6 removed setMargin(), while Qt5 still accepts it.
    if hasattr(layout, 'setMargin'):
        layout.setMargin(0)
    layout.setContentsMargins(0, 0, 0, 0)


class Modello(QgsProcessingAlgorithm):

    def __init__(self):
        super().__init__()
        self._generated_rasters = {}
        self._input_base_path = None
        self._input_source = None
        self._target_group_name = None
        self._dtm_source = None
        self._dtm_crs_authid = None
        self._dtm_xres = None
        self._dtm_yres = None
        self._aligned_extent = None
        self._output_folder = None
        self._pending_temp_cleanup = []
        self._input_in_project = False
        self._input_group_name = None
        self._output_name_prefix = ''

    def initAlgorithm(self, config: Optional[Dict[str, Any]] = None):
        input_param = QgsProcessingParameterVectorLayer(
            'final_points', 'Input summary layer', [QgsProcessing.TypeVectorPoint]
        )
        input_param.setMetadata({
            'widget_wrapper': {
                'class': 'qproto_postprocessing.ResultsSummaryLayerWidgetWrapper'
            }
        })
        self.addParameter(input_param)

        dtm_param = QgsProcessingParameterRasterLayer(
            'dtm_ref', 'Reference DTM'
        )
        dtm_param.setMetadata({
            'widget_wrapper': {
                'class': 'qproto_postprocessing.ReferenceDtmLayerWidgetWrapper'
            }
        })
        self.addParameter(dtm_param)

        self.addParameter(QgsProcessingParameterString(
            'output_prefix',
            'Output raster name prefix',
            defaultValue='',
            optional=True
        ))

        self.addParameter(QgsProcessingParameterBoolean('GEN_COUNT', 'Count', defaultValue=False))
        self.addParameter(QgsProcessingParameterBoolean('GEN_SUSCEPTIBILITY', 'Weighted Count (Susceptibility)', defaultValue=False))
        self.addParameter(QgsProcessingParameterBoolean('GEN_E_MIN', 'E min', defaultValue=False))
        self.addParameter(QgsProcessingParameterBoolean('GEN_E_MAX', 'E max', defaultValue=False))
        self.addParameter(QgsProcessingParameterBoolean('GEN_E_MEAN', 'E mean', defaultValue=False))
        self.addParameter(QgsProcessingParameterBoolean('GEN_V_MIN', 'V min', defaultValue=False))
        self.addParameter(QgsProcessingParameterBoolean('GEN_V_MAX', 'V max', defaultValue=False))
        self.addParameter(QgsProcessingParameterBoolean('GEN_V_MEAN', 'V mean', defaultValue=False))
        self.addParameter(QgsProcessingParameterBoolean('GEN_W_EN_MIN', 'W EN min', defaultValue=False))
        self.addParameter(QgsProcessingParameterBoolean('GEN_W_EN_MAX', 'W EN max', defaultValue=False))
        self.addParameter(QgsProcessingParameterBoolean('GEN_W_EN_MEAN', 'W EN mean', defaultValue=False))
        self.addParameter(QgsProcessingParameterBoolean('GEN_W_TOT_EN', 'W TOT EN', defaultValue=False))

    def _drop_gpkg_raster_table(self, gpkg_path: str, table_name: str):
        if not os.path.exists(gpkg_path):
            return
        conn = sqlite3.connect(gpkg_path)
        try:
            cur = conn.cursor()
            cur.execute('DROP TABLE IF EXISTS "{}"'.format(table_name.replace('"', '""')))
            cur.execute("DELETE FROM gpkg_contents WHERE table_name = ?", (table_name,))
            cur.execute("DELETE FROM gpkg_tile_matrix WHERE table_name = ?", (table_name,))
            cur.execute("DELETE FROM gpkg_tile_matrix_set WHERE table_name = ?", (table_name,))
            cur.execute("DELETE FROM gpkg_extensions WHERE table_name = ?", (table_name,))
            # Cleanup optional gridded coverage extension tables, if present.
            cur.execute(
                "SELECT name FROM sqlite_master WHERE type='table' AND name='gpkg_2d_gridded_coverage_ancillary'"
            )
            if cur.fetchone():
                cur.execute(
                    "DELETE FROM gpkg_2d_gridded_coverage_ancillary WHERE tile_matrix_set_name = ?",
                    (table_name,)
                )

            cur.execute(
                "SELECT name FROM sqlite_master WHERE type='table' AND name='gpkg_2d_gridded_tile_ancillary'"
            )
            if cur.fetchone():
                # gdal convention for this table key is tpudt_name
                cur.execute(
                    "DELETE FROM gpkg_2d_gridded_tile_ancillary WHERE tpudt_name = ?",
                    (table_name,)
                )
            conn.commit()
        finally:
            conn.close()

    def _gpkg_table_exists(self, gpkg_path: str, table_name: str) -> bool:
        if not os.path.exists(gpkg_path):
            return False
        conn = sqlite3.connect(gpkg_path)
        try:
            cur = conn.cursor()
            cur.execute(
                "SELECT 1 FROM gpkg_contents WHERE table_name = ? LIMIT 1",
                (table_name,)
            )
            if cur.fetchone() is not None:
                return True

            # Fallback: table may exist even if metadata is inconsistent/missing.
            cur.execute(
                "SELECT 1 FROM sqlite_master WHERE type='table' AND lower(name)=lower(?) LIMIT 1",
                (table_name,)
            )
            if cur.fetchone() is not None:
                return True

            # Extra fallback for partially registered raster tables.
            cur.execute(
                "SELECT name FROM sqlite_master WHERE type='table' AND name='gpkg_tile_matrix_set'"
            )
            if cur.fetchone():
                cur.execute(
                    "SELECT 1 FROM gpkg_tile_matrix_set WHERE table_name = ? LIMIT 1",
                    (table_name,)
                )
                if cur.fetchone() is not None:
                    return True

            return False
        finally:
            conn.close()

    def _unique_raster_table_name(self, gpkg_path: str, base_name: str) -> str:
        if not self._gpkg_table_exists(gpkg_path, base_name):
            return base_name

        stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
        candidate = '{}_{}'.format(base_name, stamp)
        if not self._gpkg_table_exists(gpkg_path, candidate):
            return candidate

        idx = 1
        while True:
            candidate_idx = '{}_{}_{}'.format(base_name, stamp, idx)
            if not self._gpkg_table_exists(gpkg_path, candidate_idx):
                return candidate_idx
            idx += 1

    def _sanitize_table_name_part(self, text: str) -> str:
        if not text:
            return ''
        invalid_chars = '<>:"/\\|?*'
        clean = ''.join('_' if c in invalid_chars else c for c in text)
        clean = clean.strip().strip('.')
        while '__' in clean:
            clean = clean.replace('__', '_')
        return clean.strip('_')

    def _compose_output_base_name(self, prefix: str, field_name: str) -> str:
        safe_prefix = self._sanitize_table_name_part(prefix)
        safe_field = self._sanitize_table_name_part(field_name)
        if safe_prefix:
            return '{}_{}'.format(safe_prefix, safe_field)
        return safe_field

    def _compose_display_name(self, prefix: str, base_label: str) -> str:
        prefix = prefix.strip() if prefix else ''
        return '{}_{}'.format(prefix, base_label) if prefix else base_label

    def prepareAlgorithm(self, parameters, context, feedback):
        input_layer = self.parameterAsVectorLayer(parameters, 'final_points', context)
        if not input_layer or not input_layer.isValid():
            input_source_text = self.parameterAsString(parameters, 'final_points', context)
            if input_source_text:
                input_layer = QgsVectorLayer(input_source_text, 'input_summary_prepare_fallback', 'ogr')
        if not input_layer or not input_layer.isValid():
            feedback.reportError('Input summary layer is not valid')
            return False

        self._input_source = input_layer.source()
        self._input_base_path = self._input_source.split('|')[0]
        self._target_group_name = None
        root = QgsProject.instance().layerTreeRoot()
        node = root.findLayer(input_layer.id())
        self._input_in_project = node is not None
        if node and node.parent() and node.parent() != root:
            self._target_group_name = node.parent().name()
            self._input_group_name = node.parent().name()
        else:
            self._input_group_name = os.path.splitext(os.path.basename(self._input_base_path))[0]
        self._output_name_prefix = self.parameterAsString(parameters, 'output_prefix', context).strip()

        dtm_layer = self.parameterAsRasterLayer(parameters, 'dtm_ref', context)
        if not dtm_layer or not dtm_layer.isValid():
            dtm_source_text = self.parameterAsString(parameters, 'dtm_ref', context)
            if dtm_source_text:
                dtm_layer = QgsRasterLayer(dtm_source_text, 'dtm_ref_fallback', 'gdal')
        if not dtm_layer or not dtm_layer.isValid():
            feedback.reportError('Reference DTM is not valid')
            return False

        self._dtm_source = dtm_layer.source()
        self._dtm_crs_authid = dtm_layer.crs().authid()
        dtm_ds = gdal.Open(self._dtm_source)
        if dtm_ds is None:
            feedback.reportError('Unable to open Reference DTM source')
            return False

        geot = dtm_ds.GetGeoTransform()
        origin_x = geot[0]
        origin_y = geot[3]
        self._dtm_xres = abs(geot[1])
        self._dtm_yres = abs(geot[5])
        if not self._dtm_xres or not self._dtm_yres:
            feedback.reportError('Reference DTM has invalid pixel size')
            return False

        prepare_layer = QgsVectorLayer(self._input_source, 'input_summary_prepare', 'ogr')
        if not prepare_layer or not prepare_layer.isValid():
            feedback.reportError('Input summary layer is not valid')
            return False
        summary_extent = prepare_layer.extent()
        # Build raster extent from point centers: half-cell padding around
        # results_summary extent keeps original points at pixel centers.
        raw_xmin = summary_extent.xMinimum() - (self._dtm_xres / 2.0)
        raw_xmax = summary_extent.xMaximum() + (self._dtm_xres / 2.0)
        raw_ymin = summary_extent.yMinimum() - (self._dtm_yres / 2.0)
        raw_ymax = summary_extent.yMaximum() + (self._dtm_yres / 2.0)

        # Snap raster extent to DTM grid origin to preserve exact registration.
        xmin = origin_x + math.floor(((raw_xmin - origin_x) / self._dtm_xres) + 1e-9) * self._dtm_xres
        xmax = origin_x + math.ceil(((raw_xmax - origin_x) / self._dtm_xres) - 1e-9) * self._dtm_xres
        ymax = origin_y - math.floor(((origin_y - raw_ymax) / self._dtm_yres) + 1e-9) * self._dtm_yres
        ymin = origin_y - math.ceil(((origin_y - raw_ymin) / self._dtm_yres) - 1e-9) * self._dtm_yres
        self._aligned_extent = '{},{},{},{}'.format(xmin, xmax, ymin, ymax)

        return True

    def processAlgorithm(self, parameters: Dict[str, Any], context: QgsProcessingContext, model_feedback: QgsProcessingFeedback) -> Dict[str, Any]:
        feedback = QgsProcessingMultiStepFeedback(30, model_feedback)
        results = {}

        input_source = self._input_source
        if not input_source:
            raise QgsProcessingException('Input summary source not prepared')
        input_layer = QgsVectorLayer(input_source, 'input_summary_local', 'ogr')
        if not input_layer or not input_layer.isValid():
            raise QgsProcessingException('Input summary layer is not valid')

        required_fields = {'COUNT', 'SUM_WEIGHT', 'E_min', 'E_max', 'E_mean', 'V_min', 'V_max', 'V_mean', 'W_EN_min', 'W_EN_max', 'W_EN_mean', 'W_TOT_EN'}
        input_field_names = {f.name() for f in input_layer.fields()}
        if not required_fields.issubset(input_field_names):
            raise QgsProcessingException('Input layer must be a results_summary layer')

        input_base_path = self._input_base_path if self._input_base_path else input_source.split('|')[0]
        if not input_base_path.lower().endswith('.gpkg'):
            raise QgsProcessingException('Input summary layer must come from a GeoPackage')
        output_folder = os.path.dirname(input_base_path)
        gpkg_path = input_base_path

        crs_authid = self._dtm_crs_authid if self._dtm_crs_authid else input_layer.crs().authid()
        extra_args = '-a_srs {}'.format(crs_authid) if crs_authid else ''
        if not self._aligned_extent or not self._dtm_xres or not self._dtm_yres:
            raise QgsProcessingException('Reference DTM grid is not prepared')

        raster_fields = {
            'COUNT': {'param': 'GEN_COUNT', 'label': 'Count', 'friendly': 'Count'},
            'SUM_WEIGHT': {'param': 'GEN_SUSCEPTIBILITY', 'label': 'Weighted Count_Susceptibility', 'friendly': 'Weighted Count (Susceptibility)'},
            'E_min': {'param': 'GEN_E_MIN', 'label': 'E_min'},
            'E_max': {'param': 'GEN_E_MAX', 'label': 'E_max'},
            'E_mean': {'param': 'GEN_E_MEAN', 'label': 'E_mean'},
            'V_min': {'param': 'GEN_V_MIN', 'label': 'V_min'},
            'V_max': {'param': 'GEN_V_MAX', 'label': 'V_max'},
            'V_mean': {'param': 'GEN_V_MEAN', 'label': 'V_mean'},
            'W_EN_min': {'param': 'GEN_W_EN_MIN', 'label': 'W_EN_min'},
            'W_EN_max': {'param': 'GEN_W_EN_MAX', 'label': 'W_EN_max'},
            'W_EN_mean': {'param': 'GEN_W_EN_MEAN', 'label': 'W_EN_mean'},
            'W_TOT_EN': {'param': 'GEN_W_TOT_EN', 'label': 'W_TOT_EN'}
        }

        generated_rasters = {}
        selected_fields = [
            field for field, meta in raster_fields.items()
            if parameters[meta['param']]
        ]
        if not selected_fields:
            raise QgsProcessingException('Select at least one raster output to generate')
        existing_base_names = []
        for field in selected_fields:
            base_name = self._compose_output_base_name(self._output_name_prefix, raster_fields[field]['label'])
            if self._gpkg_table_exists(gpkg_path, base_name):
                existing_base_names.append(base_name)

        if existing_base_names:
            feedback.pushWarning(
                'Raster tables already exist and will be appended with timestamp: {}'.format(', '.join(existing_base_names))
            )

        for field, meta in raster_fields.items():
            if parameters[meta['param']]:
                # Rasterize directly into the target GeoPackage raster table
                # to avoid temporary TIFF files and related file-lock issues.
                output_label = meta['label']
                base_table_name = self._compose_output_base_name(self._output_name_prefix, output_label)
                target_table_name = self._unique_raster_table_name(gpkg_path, base_table_name)

                processing.run('gdal:rasterize', {
                    'INPUT': input_source,
                    'FIELD': field,
                    'BURN': 0,
                    'DATA_TYPE': 5,
                    'NODATA': 0,
                    'UNITS': 1,
                    'WIDTH': self._dtm_xres,
                    'HEIGHT': self._dtm_yres,
                    'EXTENT': self._aligned_extent,
                    'OPTIONS': 'APPEND_SUBDATASET=YES|RASTER_TABLE={}|TILE_FORMAT=TIFF'.format(target_table_name),
                    'EXTRA': extra_args,
                    'OUTPUT': gpkg_path
                }, context=context, feedback=feedback, is_child_algorithm=True)

                if self._gpkg_table_exists(gpkg_path, target_table_name):
                    friendly_name = meta.get('friendly', output_label)
                    display_name = self._compose_display_name(
                        self._output_name_prefix,
                        friendly_name
                    )
                    generated_rasters[target_table_name] = {
                        'uri': 'GPKG:{}:{}'.format(gpkg_path, target_table_name),
                        'style_key': field,
                        'display_name': display_name
                    }
                else:
                    feedback.reportError(
                        'Raster table was not created: {}'.format(target_table_name)
                    )

        self._generated_rasters = generated_rasters
        self._input_base_path = input_base_path
        self._output_folder = output_folder

        results['RASTER_OUTPUT_FOLDER'] = output_folder
        return results

    def postProcessAlgorithm(self, context, feedback):
        if self._pending_temp_cleanup:
            still_locked = []
            for temp_path in self._pending_temp_cleanup:
                if not os.path.exists(temp_path):
                    continue
                removed = False
                for _ in range(8):
                    try:
                        os.remove(temp_path)
                        removed = True
                        break
                    except PermissionError:
                        time.sleep(0.25)
                if not removed and os.path.exists(temp_path):
                    still_locked.append(temp_path)
            self._pending_temp_cleanup = still_locked
            for temp_path in self._pending_temp_cleanup:
                feedback.pushWarning('Temporary file still locked after retry: {}'.format(temp_path))

        root = QgsProject.instance().layerTreeRoot()
        target_parent = root
        if self._target_group_name:
            existing_group = root.findGroup(self._target_group_name)
            if existing_group:
                target_parent = existing_group
        else:
            group_name = self._input_group_name if self._input_group_name else 'QPROTO results'
            existing_group = root.findGroup(group_name)
            if existing_group:
                target_parent = existing_group
            else:
                target_parent = root.addGroup(group_name)

        # If input summary was selected from disk and not already in TOC, add it to the target group.
        if not self._input_in_project and self._input_source:
            input_name = 'results_summary'
            if '|layername=' in self._input_source:
                input_name = self._input_source.split('|layername=', 1)[1]
            input_layer = QgsVectorLayer(self._input_source, input_name, 'ogr')
            if input_layer.isValid():
                QgsProject.instance().addMapLayer(input_layer, False)
                target_parent.addLayer(input_layer)
            else:
                feedback.pushWarning('Input summary layer could not be added to TOC: {}'.format(self._input_source))

        for table_name, raster_meta in self._generated_rasters.items():
            raster_uri = raster_meta['uri']
            style_key = raster_meta['style_key']
            display_name = raster_meta.get('display_name', table_name)
            raster_layer = QgsRasterLayer(raster_uri, display_name, 'gdal')
            if not raster_layer.isValid():
                feedback.reportError('Raster non valido: {}'.format(raster_uri))
                continue

            qml_path = OutStyle(raster_layer, self._output_folder, style_key)
            if qml_path and os.path.exists(qml_path):
                error_message, ok = raster_layer.loadNamedStyle(qml_path)
                if not ok:
                    feedback.reportError('Errore nel caricamento QML {}: {}'.format(qml_path, error_message))
                else:
                    raster_layer.triggerRepaint()

            QgsProject.instance().addMapLayer(raster_layer, False)
            target_parent.addLayer(raster_layer)

        return {'RASTER_OUTPUT_FOLDER': self._output_folder}

    def name(self):
        return 'modello'

    def displayName(self):
        return 'Create raster'

    def icon(self):
        return QIcon(os.path.join(pluginPath, 'icons', 'QPROTO_create_raster_icon.svg'))

    def group(self):
        return 'QPROTO post-processing'

    def groupId(self):
        return 'qproto_postprocessing'

    def shortHelpString(self):
        return '''
            <p>This <b>QPROTO post-processing</b> module creates thematic raster maps based on the attributes of the <u>results summary</u> layer produced by the QPROTO main analysis.</p>
            <p>The raster maps are created in the same GeoPackage as the selected <u>results summary</u> layer.</p>
            <p><strong>Input</strong><br /><u>Results summary layer:</u> vector point layer produced by the QPROTO main analysis.<br /><br /><u>Reference DEM used for raster creation:</u> it is the DEM used for QPROTO main analysis and it provides the grid geometry, including extent, resolution, and alignment, so the output rasters match the original DEM.</p>
            <p><u>Raster to be created:</u> all the parameters computed in the QPROTO main analysis can be rasterized. The user can thick the desired parameter.</p>
            
            <p><strong>Output</strong><br />The output rasters represent the spatial distribution of the selected variables, generated from the QPROTO main analysis and aligned to the reference DEM.</p>
            <p><u>Count</u>: Number of impact points falling within each raster cell.<br /><br /><u>Weighted Count (Susceptibility)</u>: Sum of detachment propensity values within each raster cell, used as a susceptibility indicator.<br /><br /><u>E min</u>: Minimum kinetic energy value in each raster cell.<br /><br /><u>E max</u>: Maximum kinetic energy value in each raster cell.<br /><br /><u>E mean</u>: Mean kinetic energy value in each raster cell.<br /><br /><u>V min</u>: Minimum velocity value in each raster cell.<br /><br /><u>V max</u>: Maximum velocity value in each raster cell.<br /><br /><u>V mean</u>: Mean velocity value in each raster cell.<br /><br /><u>W EN min</u>: Minimum weighted energy value in each raster cell.<br /><br /><u>W EN max</u>: Maximum weighted energy value in each raster cell.<br /><br /><u>W EN mean</u>: Mean weighted energy value in each raster cell.<br /><br /><u>W TOT EN</u>: Total weighted energy summed within each raster cell.</p>
            <p><strong>Important</strong><br />At least one raster output must be selected, otherwise the algorithm stops with a validation error.</p>
            <p><br /><strong>Note</strong><br /><u>W_en (Weighted kinetic energy)</u>. It is calculated by multiplying the estimated kinetic energy in a cell (minimum, maximum, or mean value) by the detachment propensity index of the source point. It identifies the intensity of a rockfall event weighted by the likelihood of its detachment from the source.<br /><br />
            <u>W_tot_en (Total weighted kinetic energy)</u>. It represents the total weighted kinetic energy for each cell in the affected area. It combines the kinetic energy and the detachment propensity from all the cones that reaches that specific cell.<br /><br />
            <u>Weighted Count (Susceptibility / Weighted view frequency)</u>. This raster refers to the susceptibility map generated by the plugin. It represents the sum of the <em>Propensity to Detachment Index</em> of all the cones that affect a cell i.e. the weighted count. It highlights the areas most prone to being affected by rockfall based on the cumulative propensity of their potential sources.</p>
        '''

    def createInstance(self):
        return Modello()


class ResultsSummaryLayerWidgetWrapper(VectorLayerWidgetWrapper):
    """Vector layer selector with browse button and TOC name filter."""

    def __init__(self, param, dialog, row=0, col=0, **kwargs):
        self._manual_value = None
        super().__init__(param, dialog, row, col, **kwargs)

    def _is_results_summary_layer(self, layer):
        layer_name = layer.name().lower()
        return (
            isinstance(layer, QgsVectorLayer)
            and layer.isValid()
            and layer.geometryType() == Qgis.GeometryType.Point
            and ('results_summary' in layer_name or 'results summary' in layer_name)
        )

    def _apply_layer_filter(self):
        project_layers = list(QgsProject.instance().mapLayers().values())
        excluded_layers = [lyr for lyr in project_layers if not self._is_results_summary_layer(lyr)]
        self.combo.setExceptedLayerList(excluded_layers)

        for lyr in project_layers:
            if self._is_results_summary_layer(lyr):
                self.combo.setLayer(lyr)
                break

    def createWidget(self):
        if self.dialogType == QgsProcessingGui.WidgetType.Standard:
            self.context = dataobjects.createContext()
            self.combo = QgsMapLayerComboBox()
            self.combo.setFilters(Qgis.LayerFilter.PointLayer)
            self._apply_layer_filter()
            self.combo.layerChanged.connect(lambda: self.widgetValueHasChanged.emit(self))

            widget = QWidget()
            layout = QHBoxLayout()
            _set_layout_zero_margins(layout)
            layout.setSpacing(6)
            layout.addWidget(self.combo)
            btn = QToolButton()
            btn.setText("...")
            btn.setToolTip(self.tr("Select file"))
            btn.clicked.connect(self.selectFile)
            layout.addWidget(btn)
            widget.setLayout(layout)
            return widget

        return super().createWidget()

    def selectFile(self):
        filename, selected_filter = self.getFileName(self.combo.currentText())
        if filename:
            if filename.lower().endswith('.gpkg'):
                selected_uri = self._select_point_layer_from_gpkg(filename)
                if not selected_uri:
                    return
                filename = selected_uri
            items = self.combo.additionalItems()
            if filename not in items:
                items.append(filename)
                self.combo.setAdditionalItems(items)
            self.combo.setCurrentIndex(self.combo.findText(filename))
            self._manual_value = filename
            self.widgetValueHasChanged.emit(self)

    def _select_point_layer_from_gpkg(self, gpkg_path):
        ds = ogr.Open(gpkg_path)
        if ds is None:
            return None

        point_layer_names = []
        for i in range(ds.GetLayerCount()):
            layer = ds.GetLayerByIndex(i)
            if layer is None:
                continue
            geom_type = layer.GetGeomType()
            flat_type = ogr.GT_Flatten(geom_type)
            if flat_type in (ogr.wkbPoint, ogr.wkbMultiPoint):
                point_layer_names.append(layer.GetName())

        ds = None
        if not point_layer_names:
            return None

        if len(point_layer_names) == 1:
            selected_name = point_layer_names[0]
        else:
            selected_name, ok = QInputDialog.getItem(
                self.widget,
                self.tr('Select layer'),
                self.tr('Choose point layer in GeoPackage:'),
                point_layer_names,
                0,
                False
            )
            if not ok or not selected_name:
                return None

        return '{}|layername={}'.format(gpkg_path, selected_name)

    def setValue(self, value):
        if value is None:
            return

        if self.dialogType == QgsProcessingGui.WidgetType.Standard:
            if isinstance(value, str):
                layer = QgsProject.instance().mapLayer(value)
                if layer is not None:
                    self.combo.setLayer(layer)
                    self._manual_value = None
                else:
                    items = self.combo.additionalItems()
                    if value not in items:
                        items.append(value)
                        self.combo.setAdditionalItems(items)
                    self.combo.setCurrentIndex(self.combo.findText(value))
                    self._manual_value = value
            elif isinstance(value, QgsVectorLayer):
                self.combo.setLayer(value)
                self._manual_value = None
            self.widgetValueHasChanged.emit(self)
            return

        super().setValue(value)

    def value(self):
        if self.dialogType == QgsProcessingGui.WidgetType.Standard:
            layer = self.combo.currentLayer()
            if layer is not None:
                self._manual_value = None
                return layer.id()
            if self._manual_value:
                return self._manual_value
            text_value = self.combo.currentText().strip()
            return text_value if text_value else None

        return super().value()


class ReferenceDtmLayerWidgetWrapper(RasterWidgetWrapper):
    """Raster selector with browse button and GeoPackage raster sublayer pick."""

    def __init__(self, param, dialog, row=0, col=0, **kwargs):
        self._manual_value = None
        super().__init__(param, dialog, row, col, **kwargs)

    def createWidget(self):
        if self.dialogType == QgsProcessingGui.WidgetType.Standard:
            self.context = dataobjects.createContext()
            self.combo = QgsMapLayerComboBox()
            self.combo.setFilters(Qgis.LayerFilter.RasterLayer)
            self.combo.layerChanged.connect(lambda: self.widgetValueHasChanged.emit(self))

            widget = QWidget()
            layout = QHBoxLayout()
            _set_layout_zero_margins(layout)
            layout.setSpacing(6)
            layout.addWidget(self.combo)
            btn = QToolButton()
            btn.setText("...")
            btn.setToolTip(self.tr("Select file"))
            btn.clicked.connect(self.selectFile)
            layout.addWidget(btn)
            widget.setLayout(layout)
            return widget

        return super().createWidget()

    def selectFile(self):
        filename, selected_filter = self.getFileName(self.combo.currentText())
        if not filename:
            return

        if filename.lower().endswith('.gpkg'):
            selected_uri = self._select_raster_from_gpkg(filename)
            if not selected_uri:
                return
            filename = selected_uri

        items = self.combo.additionalItems()
        if filename not in items:
            items.append(filename)
            self.combo.setAdditionalItems(items)
        self.combo.setCurrentIndex(self.combo.findText(filename))
        self._manual_value = filename
        self.widgetValueHasChanged.emit(self)

    def _select_raster_from_gpkg(self, gpkg_path):
        ds = gdal.OpenEx(gpkg_path, gdal.OF_RASTER)
        if ds is None:
            return None

        subdatasets = ds.GetSubDatasets() or []
        if not subdatasets:
            raster_table_names = []
            conn = sqlite3.connect(gpkg_path)
            try:
                cur = conn.cursor()
                cur.execute(
                    "SELECT table_name FROM gpkg_contents WHERE data_type IN ('tiles','2d-gridded-coverage')"
                )
                raster_table_names = [row[0] for row in cur.fetchall()]
            finally:
                conn.close()
            ds = None
            if not raster_table_names:
                return None
            if len(raster_table_names) == 1:
                return 'GPKG:{}:{}'.format(gpkg_path, raster_table_names[0])
            selected_name, ok = QInputDialog.getItem(
                self.widget,
                self.tr('Select raster'),
                self.tr('Choose raster in GeoPackage:'),
                raster_table_names,
                0,
                False
            )
            if not ok or not selected_name:
                return None
            return 'GPKG:{}:{}'.format(gpkg_path, selected_name)
        ds = None

        if len(subdatasets) == 1:
            return subdatasets[0][0]

        labels = []
        value_by_label = {}
        for name, desc in subdatasets:
            label = desc if desc else name
            labels.append(label)
            value_by_label[label] = name

        selected_label, ok = QInputDialog.getItem(
            self.widget,
            self.tr('Select raster'),
            self.tr('Choose raster in GeoPackage:'),
            labels,
            0,
            False
        )
        if not ok or not selected_label:
            return None
        return value_by_label[selected_label]

    def setValue(self, value):
        if value is None:
            return

        if self.dialogType == QgsProcessingGui.WidgetType.Standard:
            if isinstance(value, str):
                layer = QgsProject.instance().mapLayer(value)
                if layer is not None:
                    self.combo.setLayer(layer)
                    self._manual_value = None
                else:
                    items = self.combo.additionalItems()
                    if value not in items:
                        items.append(value)
                        self.combo.setAdditionalItems(items)
                    self.combo.setCurrentIndex(self.combo.findText(value))
                    self._manual_value = value
            elif isinstance(value, QgsRasterLayer):
                self.combo.setLayer(value)
                self._manual_value = None
            self.widgetValueHasChanged.emit(self)
            return

        super().setValue(value)

    def value(self):
        if self.dialogType == QgsProcessingGui.WidgetType.Standard:
            layer = self.combo.currentLayer()
            if layer is not None:
                self._manual_value = None
                return layer.id()
            if self._manual_value:
                return self._manual_value
            text_value = self.combo.currentText().strip()
            return text_value if text_value else None

        return super().value()
