# -*- coding: utf-8 -*-
"""
/***************************************************************************
 ThreeDiCustomStatsDialog
                                 A QGIS plugin
 This plugin calculates statistics of 3Di results. The user chooses the variable, aggregation method and
 spatiotemperal filtering.
 Generated by Plugin Builder: http://g-sherman.github.io/Qgis-Plugin-Builder/
                             -------------------
        begin                : 2019-11-27
        git sha              : $Format:%H$
        copyright            : (C) 2019 by Leendert van Wolfswinkel | Nelen en Schuurmans
        email                : leendert.vanwolfswinkel@nelen-schuurmans.nl
 ***************************************************************************/

/***************************************************************************
 *                                                                         *
 *   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.                                   *
 *                                                                         *
 ***************************************************************************/
"""
import os
import sys
from types import MethodType
from typing import List

from osgeo.gdal import GetDriverByName

from qgis.PyQt import QtWidgets
from qgis.PyQt import uic
from qgis.PyQt.QtCore import QPersistentModelIndex
from qgis.PyQt.QtCore import Qt
from qgis.core import (
    Qgis,
    QgsApplication,
    QgsCoordinateReferenceSystem,
    QgsProject,
    QgsRasterLayer,
    QgsTask,
)
from qgis.gui import QgsFileWidget
from threedigrid.admin.gridresultadmin import GridH5ResultAdmin

import logging

logger = logging.getLogger(__name__)

from threedi_results_analysis.threedi_plugin_model import ThreeDiResultItem
from threedi_results_analysis.utils.ogr2qgis import as_qgis_memory_layer
from threedi_results_analysis.utils.threedi_result_aggregation.base import (
    aggregate_threedi_results,
    get_threshold_attributes,
)
from threedi_results_analysis.utils.threedi_result_aggregation.aggregation_classes import (
    Aggregation,
    AggregationMethod,
    AggregationSign,
    AggregationVariable,
    filter_demanded_aggregations,
    VT_NAMES,
    VT_FLOW,
    VT_FLOW_HYBRID,
    VT_NODE,
    VT_NODE_HYBRID,
    VT_PUMP,
    VR_INTERFLOW,
    VR_SIMPLE_INFILTRATION,
    VR_INTERCEPTION,
    VR_PUMP,
)
from threedi_results_analysis.utils.threedi_result_aggregation.constants import (
    AGGREGATION_VARIABLES,
    AGGREGATION_METHODS,
    AGGREGATION_SIGNS,
    EXCHANGE_LEVEL_1D2D,
    NA_TEXT,
)
from threedi_results_analysis.utils.user_messages import pop_up_critical

from .presets import PRESETS, Preset, NO_PRESET
from .style import (
    DEFAULT_STYLES,
    STYLES,
    Style
)


# This loads the .ui file so that PyQt can populate the plugin with the elements from Qt Designer
FORM_CLASS, _ = uic.loadUiType(
    os.path.join(
        os.path.dirname(__file__), "threedi_custom_stats_dialog_base.ui"
    )
)

DEFAULT_AGGREGATION = Aggregation(
    variable=AGGREGATION_VARIABLES.get_by_short_name("q"),
    sign=AggregationSign(short_name="net", long_name="Net"),
    method=AGGREGATION_METHODS.get_by_short_name("sum"),
)

COLUMN_VARIABLE = 0
COLUMN_DIRECTION = 1
COLUMN_METHOD = 2
COLUMN_THRESHOLD_ATTRIBUTE = 3
COLUMN_THRESHOLD_VALUE = 4
COLUMN_UNITS = 5

FLOWLINES_TAB = 0
NODES_CELLS_TAB = 1
PUMPS_TAB = 2
RASTERS_TAB = 3


def update_column_widget(
    self: QtWidgets.QComboBox, demanded_aggregations: List[Aggregation], aggregation_variable_types: List
):
    """
    This method is intended to be bound to QComboBox after instantiation

    Adds the `as_column_name()` string representations of the `demanded_aggregations` as items to `self`
    if the aggregation variable type is in `aggregation_variable_types`
    """
    self.clear()
    filtered_das = filter_demanded_aggregations(
        das=demanded_aggregations, variable_types=aggregation_variable_types
    )
    for da in filtered_das:
        column_name = da.as_column_name()
        if column_name is not None:
            self.addItem(da.as_column_name())
    self.addItem("")


class ThreeDiCustomStatsDialog(QtWidgets.QDialog, FORM_CLASS):
    def __init__(self, iface, model, owner, parent=None):
        """Constructor."""
        super(ThreeDiCustomStatsDialog, self).__init__(parent)
        self.setupUi(self)
        self.iface = iface
        self.model = model
        self.owner = owner

        self.tm = QgsApplication.taskManager()

        self.gr = None
        self.result_id = None
        self.demanded_aggregations = []

        for preset in PRESETS:
            self.comboBoxPreset.addItem(preset.name)
            self.comboBoxPreset.setItemData(
                self.comboBoxPreset.count() - 1, preset
            )
        self.comboBoxPreset.currentIndexChanged.connect(
            self.preset_combobox_changed
        )

        self.pushButtonAddAggregation.clicked.connect(self.add_aggregation)
        self.pushButtonRemoveAggregation.clicked.connect(
            self.remove_aggregation
        )
        self.add_aggregation()
        for column_index in [
            COLUMN_VARIABLE,
            COLUMN_DIRECTION,
            COLUMN_METHOD,
            COLUMN_THRESHOLD_ATTRIBUTE,
            COLUMN_THRESHOLD_VALUE,
        ]:
            self.tableWidgetAggregations.horizontalHeader().setSectionResizeMode(
                column_index, QtWidgets.QHeaderView.ResizeMode.Stretch
            )

        # Populate the combobox with the results

        self.resultComboBox.activated.connect(self.results_3di_selected)
        self._populate_results()
        self.resultComboBox.setCurrentIndex(-1)

        self.pushButtonMapCanvas.clicked.connect(self.set_extent_from_map_canvas)
        self.set_extent_from_map_canvas()
        self.mExtentGroupBox.setChecked(False)

        self.init_styling_tab()
        self.set_styling_tab()

        self.dialogButtonBoxOKCancel.button(
            QtWidgets.QDialogButtonBox.StandardButton.Ok
        ).setEnabled(False)
        self.dialogButtonBoxOKCancel.button(
            QtWidgets.QDialogButtonBox.StandardButton.Ok
        ).clicked.connect(self.run)

    def _populate_results(self) -> None:
        self.resultComboBox.clear()
        for result in self.model.get_results(checked_only=False):
            self.resultComboBox.addItem(f"{result.parent().text()} | {result.text()}", result.id)

    def add_aggregation(
        self, *args, aggregation: Aggregation = DEFAULT_AGGREGATION, update_output_layer_names: bool = True
    ):
        """Add a new row to tableWidgetAggregations, always last row"""
        self.tableWidgetAggregations.insertRow(
            self.tableWidgetAggregations.rowCount()
        )
        current_row = self.tableWidgetAggregations.rowCount() - 1

        # variable column
        variable_combobox = QtWidgets.QComboBox()
        for i, variable in enumerate(AGGREGATION_VARIABLES):
            variable_combobox.addItem(
                VT_NAMES[variable.var_type] + ": " + variable.long_name
            )
            variable_combobox.setItemData(i, variable)
        idx = variable_combobox.findText(
            aggregation.variable.long_name, Qt.MatchFlag.MatchEndsWith
        )
        variable_combobox.setCurrentIndex(idx)
        variable_combobox.activated.connect(
            self.variable_combobox_text_changed
        )
        self.tableWidgetAggregations.setCellWidget(
            current_row, COLUMN_VARIABLE, variable_combobox
        )

        # sign column
        direction_combobox = QtWidgets.QComboBox()
        counter = 0
        for s in AGGREGATION_SIGNS:
            direction_combobox.addItem(s.long_name)
            direction_combobox.setItemData(counter, s)
            counter += 1
        direction_combobox.setCurrentText(aggregation.sign.long_name)
        self.tableWidgetAggregations.setCellWidget(
            current_row, COLUMN_DIRECTION, direction_combobox
        )
        direction_combobox.currentTextChanged.connect(
            self.direction_combobox_text_changed
        )

        # method column
        method_combobox = QtWidgets.QComboBox()
        for i, method_str in enumerate(
            aggregation.variable.applicable_methods
        ):
            method = AGGREGATION_METHODS.get_by_short_name(method_str)
            method_combobox.addItem(method.long_name)
            method_combobox.setItemData(i, method)
        if aggregation.method:
            method_combobox.setCurrentText(aggregation.method.long_name)
        self.tableWidgetAggregations.setCellWidget(
            current_row, COLUMN_METHOD, method_combobox
        )
        method_combobox.currentTextChanged.connect(
            self.method_combobox_text_changed
        )

        # threshold attribute and value columns
        method = method_combobox.currentData()
        variable = variable_combobox.currentData()
        self.set_threshold_attribute_widget(row=current_row, variable=variable, method=method)
        if method.has_threshold:
            threshold_widget = self.tableWidgetAggregations.cellWidget(current_row, COLUMN_THRESHOLD_ATTRIBUTE)
            if isinstance(aggregation.threshold, str):
                threshold_widget.setCurrentIndex(threshold_widget.findData(aggregation.threshold))
            elif isinstance(aggregation.threshold, float):
                threshold_widget.setCurrentIndex(threshold_widget.findText("Constant"))
        self.set_threshold_value_widget(row=current_row, method=method)
        if isinstance(aggregation.threshold, float):
            threshold_widget = self.tableWidgetAggregations.cellWidget(current_row, COLUMN_THRESHOLD_VALUE)
            threshold_widget.setValue(aggregation.threshold)

        # units column
        units_combobox = QtWidgets.QComboBox()
        self.tableWidgetAggregations.setCellWidget(
            current_row, COLUMN_UNITS, units_combobox
        )
        self.set_units_widget(
            row=current_row,
            variable=variable_combobox.itemData(
                variable_combobox.currentIndex()
            ),
            method=method,
        )

        # TODO: dit is nu lastig te setten obv aggregation, omdat die wel een attribuut multiplier heeft,
        #  maar niet een attribuut units. laat ik nu even voor wat het is
        units_combobox.currentTextChanged.connect(
            self.units_combobox_text_changed
        )

        self.update_demanded_aggregations()
        self.set_styling_tab()
        if update_output_layer_names:
            self._update_output_layer_fields_based_on_aggregations()
        self.validate()
        self._update_variable_list()

    def remove_aggregation(self):
        index_list = []
        for (
            model_index
        ) in self.tableWidgetAggregations.selectionModel().selectedRows():
            index = QPersistentModelIndex(model_index)
            index_list.append(index)

        for index in index_list:
            self.tableWidgetAggregations.removeRow(index.row())

        self.update_demanded_aggregations()
        self._update_output_layer_fields_based_on_aggregations()
        self.validate()

    def variable_combobox_text_changed(self):
        row = self.tableWidgetAggregations.currentRow()
        variable_widget = self.tableWidgetAggregations.cellWidget(row, COLUMN_VARIABLE)
        variable = variable_widget.itemData(variable_widget.currentIndex())
        self.set_method_widget(row, variable)
        self.set_direction_widget(row, variable)
        self.set_units_widget(row=row, variable=variable, method=None)
        self.update_demanded_aggregations()
        self._update_output_layer_fields_based_on_aggregations()
        self.validate()

    def method_combobox_text_changed(self):
        row = self.tableWidgetAggregations.currentRow()
        variable_widget = self.tableWidgetAggregations.cellWidget(row, COLUMN_VARIABLE)
        variable = variable_widget.itemData(variable_widget.currentIndex())
        method_widget = self.tableWidgetAggregations.cellWidget(row, COLUMN_METHOD)
        method = method_widget.itemData(method_widget.currentIndex())
        self.set_threshold_attribute_widget(row=row, variable=variable, method=method)
        self.set_threshold_value_widget(row=row, method=method)
        self.set_units_widget(row=row, variable=variable, method=method)
        self.update_demanded_aggregations()
        self._update_output_layer_fields_based_on_aggregations()

    def direction_combobox_text_changed(self):
        self.update_demanded_aggregations()
        self._update_output_layer_fields_based_on_aggregations()

    def threshold_attribute_changed(self):
        row = self.tableWidgetAggregations.currentRow()
        method_widget = self.tableWidgetAggregations.cellWidget(row, COLUMN_METHOD)
        method = method_widget.currentData() if method_widget else None
        self.set_threshold_value_widget(row=row, method=method)
        self.update_demanded_aggregations()
        self._update_output_layer_fields_based_on_aggregations()

    def threshold_value_changed(self):
        self.update_demanded_aggregations()
        self._update_output_layer_fields_based_on_aggregations()

    def units_combobox_text_changed(self):
        self.update_demanded_aggregations()
        self._update_output_layer_fields_based_on_aggregations()

    def set_direction_widget(self, row, variable):
        na_index = self.tableWidgetAggregations.cellWidget(row, COLUMN_DIRECTION).findText(
            NA_TEXT
        )
        if variable.signed:
            if na_index != -1:
                self.tableWidgetAggregations.cellWidget(row, COLUMN_DIRECTION).removeItem(
                    na_index
                )
            self.tableWidgetAggregations.cellWidget(row, COLUMN_DIRECTION).setCurrentIndex(0)
        else:
            if na_index == -1:
                self.tableWidgetAggregations.cellWidget(row, COLUMN_DIRECTION).addItem(
                    NA_TEXT
                )
                na_index = self.tableWidgetAggregations.cellWidget(
                    row, 1
                ).findText(NA_TEXT)
            self.tableWidgetAggregations.cellWidget(row, COLUMN_DIRECTION).setCurrentIndex(
                na_index
            )
        self.tableWidgetAggregations.cellWidget(row, COLUMN_DIRECTION).setEnabled(
            variable.signed
        )

    def set_method_widget(self, row, variable):
        method_widget = self.tableWidgetAggregations.cellWidget(row, COLUMN_METHOD)
        method_widget.blockSignals(True)
        method_widget.setEnabled(False)
        method_widget.clear()
        if variable.applicable_methods:
            for method_short_name in variable.applicable_methods:
                method = AGGREGATION_METHODS.get_by_short_name(method_short_name)
                method_widget.addItem(method.long_name, method)
                method_widget.setEnabled(True)
        method_widget.blockSignals(False)
        method = method_widget.itemData(method_widget.currentIndex())
        self.set_threshold_attribute_widget(row=row, variable=variable, method=method)
        self.set_threshold_value_widget(row=row, method=method)

    def set_threshold_attribute_widget(self, row: int, variable: AggregationVariable, method: AggregationMethod):
        threshold_attribute_widget = QtWidgets.QComboBox()
        if method and method.has_threshold and variable and self.gr:
            threshold_attributes = [(None, "constant")]
            threshold_attributes.extend(get_threshold_attributes(gridadmin=self.gr, var_type=variable.var_type))
            if variable.var_type in [VT_NODE, VT_NODE_HYBRID]:
                threshold_attributes.append((EXCHANGE_LEVEL_1D2D, EXCHANGE_LEVEL_1D2D))
            for field_name, display_name in threshold_attributes:
                idx = threshold_attribute_widget.count()
                display_name = display_name.replace("_", " ").capitalize()
                threshold_attribute_widget.addItem(display_name)
                threshold_attribute_widget.setItemData(idx, field_name)

        threshold_attribute_widget.setEnabled(method is not None and method.has_threshold)
        self.tableWidgetAggregations.setCellWidget(row, COLUMN_THRESHOLD_ATTRIBUTE, threshold_attribute_widget)
        threshold_attribute_widget.currentIndexChanged.connect(self.threshold_attribute_changed)

    def set_threshold_value_widget(self, row, method):
        threshold_attribute_widget = self.tableWidgetAggregations.cellWidget(row, COLUMN_THRESHOLD_ATTRIBUTE)
        threshold_value_widget = QtWidgets.QDoubleSpinBox()
        threshold_value_widget.setRange(sys.float_info.min, sys.float_info.max)
        threshold_value_widget.setEnabled(
            method is not None and
            method.has_threshold and
            threshold_attribute_widget and
            threshold_attribute_widget.currentData() is None
        )
        self.tableWidgetAggregations.setCellWidget(row, COLUMN_THRESHOLD_VALUE, threshold_value_widget)
        threshold_value_widget.valueChanged.connect(self.threshold_value_changed)

    def set_units_widget(self, row, variable, method):
        """Called when variable or method changes"""
        units_widget = self.tableWidgetAggregations.cellWidget(row, COLUMN_UNITS)
        units_widget.clear()

        if not method:
            text = next(iter(variable.units.items()))[0][0]
            return units_widget.addItem(text, 1)
        if method.is_percentage:
            return units_widget.addItem("%", 1)
        if method.is_duration:
            return units_widget.addItem("s", 1)

        for i, (units, multiplier_tuple) in enumerate(variable.units.items()):
            multiplier = multiplier_tuple[0]
            if method.integrates_over_time:
                units_str = units[0]
            else:
                units_str = "/".join(units)
                if len(multiplier_tuple) == 2:
                    multiplier *= multiplier_tuple[1]
            # add item to the widget if no similar item exists:
            if not any(
                units_str in units_widget.itemText(x)
                for x in range(units_widget.count())
            ):
                units_widget.addItem(units_str, multiplier)

    def get_styling_parameters(self, output_type):
        params_widgets = {
            "node": self.tableWidgetNodesStyleParams,
            "flowline": self.tableWidgetFlowlinesStyleParams,
            "cell": self.tableWidgetCellsStyleParams,
            "pump": self.tableWidgetPumpsStyleParams,
            "pump_linestring": self.tableWidgetPumpsLinestringStyleParams,
        }
        params_widget = params_widgets[output_type]
        result = {}
        for row in range(params_widget.rowCount()):
            result[
                params_widget.item(row, 0).text()
            ] = params_widget.cellWidget(row, 1).currentText()
        return result

    def init_styling_tab(self):
        type_widgets = {
            "flowline": self.comboBoxFlowlinesStyleType,
            "node": self.comboBoxNodesStyleType,
            "cell": self.comboBoxCellsStyleType,
            "pump": self.comboBoxPumpsStyleType,
            "pump_linestring": self.comboBoxPumpsLinestringStyleType,
        }
        for style in STYLES:
            type_widget = type_widgets[style.output_type]
            row = type_widget.count()
            type_widget.addItem(style.name)
            type_widget.setItemData(row, style)

        self.checkbox_flowlines_state_changed()
        self.checkBoxFlowlines.stateChanged.connect(self.checkbox_flowlines_state_changed)
        self.checkbox_nodes_state_changed()
        self.checkBoxNodes.stateChanged.connect(self.checkbox_nodes_state_changed)
        self.checkbox_cells_state_changed()
        self.checkBoxCells.stateChanged.connect(self.checkbox_cells_state_changed)
        self.checkbox_pumps_state_changed()
        self.checkBoxPumps.stateChanged.connect(self.checkbox_pumps_state_changed)
        self.checkbox_pumps_linestring_state_changed()
        self.checkBoxPumpsLinestring.stateChanged.connect(self.checkbox_pumps_linestring_state_changed)
        self.checkbox_rasters_state_changed()
        self.checkBoxRasters.stateChanged.connect(self.checkbox_rasters_state_changed)

        self.comboBoxFlowlinesStyleType.currentIndexChanged.connect(
            self.flowline_styling_type_changed
        )
        self.comboBoxNodesStyleType.currentIndexChanged.connect(
            self.node_styling_type_changed
        )
        self.comboBoxCellsStyleType.currentIndexChanged.connect(
            self.cell_styling_type_changed
        )
        self.comboBoxPumpsStyleType.currentIndexChanged.connect(
            self.pump_styling_type_changed
        )
        self.comboBoxPumpsLinestringStyleType.currentIndexChanged.connect(
            self.pump_linestring_styling_type_changed
        )
        self.doubleSpinBoxResolution.valueChanged.connect(
            self.raster_resolution_changed
        )
        self.doubleSpinBoxNodesLayerResolution.valueChanged.connect(
            self.nodes_layer_resolution_changed
        )
        self.mQgsFileWidgetRasterFolder.setStorageMode(
            QgsFileWidget.StorageMode.GetDirectory
        )
        self.mQgsFileWidgetRasterFolder.fileChanged.connect(self.validate)

    def set_styling_tab(
        self,
        flowlines_style: Style = None,
        nodes_style: Style = None,
        cells_style: Style = None,
        pumps_style: Style = None,
        pumps_linestring_style: Style = None,

        flowlines_style_param_values: dict = None,
        cells_style_param_values: dict = None,
        nodes_style_param_values: dict = None,
        pumps_style_param_values: dict = None,
        pumps_linestring_style_param_values: dict = None,

        uncheck_flowlines_checkbox: bool = False,
        uncheck_nodes_checkbox: bool = False,
        uncheck_cells_checkbox: bool = False,
        uncheck_pumps_checkbox: bool = False,
        uncheck_pumps_linestring_checkbox: bool = False,
    ):
        """
        Styles can be set (e.g. when a preset is used) or be None so the default for the first variable is used
        """
        # Flowlines
        filtered_das = filter_demanded_aggregations(
            self.demanded_aggregations, [VT_FLOW, VT_FLOW_HYBRID]
        )
        if len(filtered_das) > 0:
            if flowlines_style is None:
                flowlines_style_name = DEFAULT_STYLES[
                    filtered_das[0].variable.short_name
                ]["flowline"].name
            else:
                flowlines_style_name = flowlines_style.name
            idx = self.comboBoxFlowlinesStyleType.findText(
                flowlines_style_name
            )
            if idx > -1:
                self.comboBoxFlowlinesStyleType.setCurrentIndex(idx)
            self.checkBoxFlowlines.setChecked(True)
            self.checkBoxFlowlines.setEnabled(True)
            self.flowline_styling_type_changed(
                param_values=flowlines_style_param_values
            )
        else:
            self.checkBoxFlowlines.setEnabled(False)
            self.checkBoxFlowlines.setChecked(False)
        if uncheck_flowlines_checkbox:
            self.checkBoxFlowlines.setChecked(False)

        # Nodes and cells
        filtered_das = filter_demanded_aggregations(
            self.demanded_aggregations, [VT_NODE, VT_NODE_HYBRID]
        )
        if len(filtered_das) > 0:
            if nodes_style is None:
                nodes_style_name = DEFAULT_STYLES[
                    filtered_das[0].variable.short_name
                ]["node"].name
            else:
                nodes_style_name = nodes_style.name
            idx = self.comboBoxNodesStyleType.findText(nodes_style_name)
            if idx > -1:
                self.comboBoxNodesStyleType.setCurrentIndex(idx)
            self.checkBoxNodes.setEnabled(True)
            self.checkBoxNodes.setChecked(True)
            if uncheck_nodes_checkbox:
                self.checkBoxNodes.setChecked(False)

            if cells_style is None:
                cells_style_name = DEFAULT_STYLES[
                    filtered_das[0].variable.short_name
                ]["cell"].name
            else:
                cells_style_name = cells_style.name

            idx = self.comboBoxCellsStyleType.findText(cells_style_name)
            if idx > -1:
                self.comboBoxCellsStyleType.setCurrentIndex(idx)
            self.checkBoxCells.setEnabled(True)
            self.checkBoxCells.setChecked(True)
            if uncheck_cells_checkbox:
                self.checkBoxCells.setChecked(False)

            # Do not automatically set checkBoxRasters to Checked because this requires follow-up input from the user
            self.checkBoxRasters.setEnabled(True)

            self.node_styling_type_changed(
                param_values=nodes_style_param_values
            )
            self.cell_styling_type_changed(
                param_values=cells_style_param_values
            )

        else:
            self.checkBoxNodes.setEnabled(False)
            self.checkBoxCells.setEnabled(False)
            self.checkBoxRasters.setEnabled(False)

            self.checkBoxNodes.setChecked(False)
            self.checkBoxCells.setChecked(False)
            self.checkBoxRasters.setChecked(False)

        # Pumps
        filtered_das = filter_demanded_aggregations(self.demanded_aggregations, [VT_PUMP])
        if len(filtered_das) > 0:
            # Pump (point)
            if pumps_style is None:
                pumps_style_name = DEFAULT_STYLES[filtered_das[0].variable.short_name]["pump"].name
            else:
                pumps_style_name = pumps_style.name
            idx = self.comboBoxPumpsStyleType.findText(pumps_style_name)
            if idx > -1:
                self.comboBoxPumpsStyleType.setCurrentIndex(idx)
            self.checkBoxPumps.setChecked(True)
            self.checkBoxPumps.setEnabled(True)
            self.pump_styling_type_changed(param_values=pumps_style_param_values)

            # Pump (linestring)
            if pumps_linestring_style is None:
                pumps_linestring_style_name = DEFAULT_STYLES[
                    filtered_das[0].variable.short_name
                ]["pump_linestring"].name
            else:
                pumps_linestring_style_name = pumps_linestring_style.name
            idx = self.comboBoxPumpsLinestringStyleType.findText(pumps_linestring_style_name)
            if idx > -1:
                self.comboBoxPumpsLinestringStyleType.setCurrentIndex(idx)
            self.checkBoxPumpsLinestring.setChecked(True)
            self.checkBoxPumpsLinestring.setEnabled(True)
            self.pump_linestring_styling_type_changed(param_values=pumps_linestring_style_param_values)
        else:
            self.checkBoxPumps.setEnabled(False)
            self.checkBoxPumps.setChecked(False)
            self.checkBoxPumpsLinestring.setEnabled(False)
            self.checkBoxPumpsLinestring.setChecked(False)

        if uncheck_pumps_checkbox:
            self.checkBoxPumps.setChecked(False)

        if uncheck_pumps_linestring_checkbox:
            self.checkBoxPumpsLinestring.setChecked(False)

    def checkbox_flowlines_state_changed(self):
        self.tabWidgetStyling.setTabEnabled(FLOWLINES_TAB, self.checkBoxFlowlines.isChecked())
        self.activate_first_enabled_tab(self.tabWidgetStyling)

    def checkbox_nodes_state_changed(self):
        self.tabWidgetStyling.setTabEnabled(
            NODES_CELLS_TAB,
            self.checkBoxNodes.isChecked() or self.checkBoxCells.isChecked()
        )
        self.groupBoxNodes.setEnabled(self.checkBoxNodes.isChecked())
        self.activate_first_enabled_tab(self.tabWidgetStyling)

    def checkbox_cells_state_changed(self):
        self.tabWidgetStyling.setTabEnabled(
            NODES_CELLS_TAB,
            self.checkBoxNodes.isChecked() or self.checkBoxCells.isChecked()
        )
        self.groupBoxCells.setEnabled(self.checkBoxCells.isChecked())
        self.activate_first_enabled_tab(self.tabWidgetStyling)

    def checkbox_pumps_state_changed(self):
        self.tabWidgetStyling.setTabEnabled(
            PUMPS_TAB,
            self.checkBoxPumps.isChecked() or self.checkBoxPumpsLinestring.isChecked()
        )
        self.groupBoxPumps.setEnabled(self.checkBoxPumps.isChecked())
        self.activate_first_enabled_tab(self.tabWidgetStyling)

    def checkbox_pumps_linestring_state_changed(self):
        self.tabWidgetStyling.setTabEnabled(
            PUMPS_TAB,
            self.checkBoxPumps.isChecked() or self.checkBoxPumpsLinestring.isChecked()
        )
        self.groupBoxPumpsLinestring.setEnabled(self.checkBoxPumpsLinestring.isChecked())
        self.activate_first_enabled_tab(self.tabWidgetStyling)

    def checkbox_rasters_state_changed(self):
        self.tabWidgetStyling.setTabEnabled(RASTERS_TAB, self.checkBoxRasters.isChecked())
        self.activate_first_enabled_tab(self.tabWidgetStyling)
        self.mQgsFileWidgetRasterFolder.setEnabled(self.checkBoxRasters.isChecked())
        self.validate()

    @staticmethod
    def activate_first_enabled_tab(tabwidget):
        """
        If the current active tab is not enabled, switch to the first enabled tab
        or if no tab is enabled, to the first tab
        """
        if not tabwidget.isTabEnabled(tabwidget.currentIndex()):
            for index in range(tabwidget.count() + 1):
                if tabwidget.isTabEnabled(index):
                    tabwidget.setCurrentIndex(index)
                    return
            tabwidget.setCurrentIndex(0)

    def styling_type_changed(
        self, output_type: str, param_values: dict = None
    ):
        if output_type == "flowline":
            params_widget = self.tableWidgetFlowlinesStyleParams
            type_widget = self.comboBoxFlowlinesStyleType
            aggregation_variable_types = [VT_FLOW, VT_FLOW_HYBRID]
        elif output_type == "node":
            params_widget = self.tableWidgetNodesStyleParams
            type_widget = self.comboBoxNodesStyleType
            aggregation_variable_types = [VT_NODE, VT_NODE_HYBRID]
        elif output_type == "cell":
            params_widget = self.tableWidgetCellsStyleParams
            type_widget = self.comboBoxCellsStyleType
            aggregation_variable_types = [VT_NODE, VT_NODE_HYBRID]
        elif output_type == "pump":
            params_widget = self.tableWidgetPumpsStyleParams
            type_widget = self.comboBoxPumpsStyleType
            aggregation_variable_types = [VT_PUMP]
        elif output_type == "pump_linestring":
            params_widget = self.tableWidgetPumpsLinestringStyleParams
            type_widget = self.comboBoxPumpsLinestringStyleType
            aggregation_variable_types = [VT_PUMP]

        else:
            raise ValueError(
                "Invalid output type. Choose one of [node, flowline, cell, pump, pump_linestring]."
            )
        for i in reversed(range(params_widget.rowCount())):
            params_widget.removeRow(i)
        item_data = type_widget.itemData(type_widget.currentIndex())
        type_widget.setEnabled(True)
        if item_data is not None:
            params = item_data.params
            for row, (param_name, param_type) in enumerate(params.items()):
                params_widget.insertRow(row)
                param_name_item = QtWidgets.QTableWidgetItem(param_name)
                params_widget.setItem(row, 0, param_name_item)
                # if param_type == "column":  # here other param types like numbers or text input could be added
                param_input_widget = QtWidgets.QComboBox()

                # Add columns resulting from aggregations to parameter combobox
                param_input_widget.update = MethodType(
                    update_column_widget, param_input_widget
                )
                param_input_widget.update(
                    demanded_aggregations=self.demanded_aggregations,
                    aggregation_variable_types=aggregation_variable_types,
                )
                params_widget.setCellWidget(row, 1, param_input_widget)
        if param_values is not None:
            for param, value in param_values.items():
                row = params_widget.findItems(param, Qt.MatchFlag.MatchFixedString)[0].row()
                params_input_widget = params_widget.cellWidget(row, 1)
                idx = params_input_widget.findText(value)
                params_input_widget.setCurrentIndex(idx)

    def node_styling_type_changed(
        self, signal: int = 1, param_values: dict = None
    ):
        self.styling_type_changed(
            output_type="node", param_values=param_values
        )

    def cell_styling_type_changed(
        self, signal: int = 1, param_values: dict = None
    ):
        self.styling_type_changed(
            output_type="cell", param_values=param_values
        )

    def flowline_styling_type_changed(
        self, signal: int = 1, param_values: dict = None
    ):
        self.styling_type_changed(
            output_type="flowline", param_values=param_values
        )

    def pump_styling_type_changed(
        self, signal: int = 1, param_values: dict = None
    ):
        self.styling_type_changed(
            output_type="pump", param_values=param_values
        )

    def pump_linestring_styling_type_changed(
        self, signal: int = 1, param_values: dict = None
    ):
        self.styling_type_changed(
            output_type="pump_linestring", param_values=param_values
        )

    def raster_resolution_changed(self):
        self.doubleSpinBoxNodesLayerResolution.setValue(
            self.doubleSpinBoxResolution.value()
        )

    def nodes_layer_resolution_changed(self):
        self.doubleSpinBoxResolution.setValue(
            self.doubleSpinBoxNodesLayerResolution.value()
        )

    def update_gr(self, results_3di, gridadmin):
        if os.path.isfile(results_3di) and os.path.isfile(gridadmin):
            self.gr = GridH5ResultAdmin(gridadmin, results_3di)
            crs = QgsCoordinateReferenceSystem(
                "EPSG:{}".format(self.gr.epsg_code)
            )
            self.mExtentGroupBox.setOutputCrs(crs)
            output_timestep_best_guess = int(
                self.gr.nodes.timestamps[-1]
                / (len(self.gr.nodes.timestamps) - 1)
            )
            self.doubleSpinBoxStartTime.setMaximum(
                int(self.gr.nodes.timestamps[-1])
            )
            self.doubleSpinBoxStartTime.setSingleStep(
                output_timestep_best_guess
            )
            self.doubleSpinBoxEndTime.setSingleStep(output_timestep_best_guess)
            self.doubleSpinBoxEndTime.setMaximum(
                int(self.gr.nodes.timestamps[-1])
            )
            self.doubleSpinBoxEndTime.setValue(
                int(self.gr.nodes.timestamps[-1])
            )
            self.doubleSpinBoxResolution.setValue(self.gr.grid.dx[0])
            self.doubleSpinBoxNodesLayerResolution.setValue(self.gr.grid.dx[0])
            if self.mQgsFileWidgetRasterFolder.filePath() == "":
                results_3di_dir = os.path.dirname(results_3di)
                self.mQgsFileWidgetRasterFolder.setFilePath(results_3di_dir)
        else:
            self.gr = None

        self._update_variable_list()

    def results_3di_selected(self, index):
        result_id = self.resultComboBox.itemData(index)
        result = self.model.get_result(result_id)
        results_3di = result.path
        gridadmin = result.parent().path.with_suffix('.h5')
        assert os.path.isfile(results_3di) and os.path.isfile(gridadmin)
        self.update_gr(str(results_3di), str(gridadmin))
        if self.validate():
            self.result_id = result_id

    def add_result(self, result_item: ThreeDiResultItem) -> None:
        currentIndex = self.resultComboBox.currentIndex()
        self.resultComboBox.addItem(f"{result_item.parent().text()} | {result_item.text()}", result_item.id)
        self.resultComboBox.setCurrentIndex(currentIndex)

    def remove_result(self, result_item: ThreeDiResultItem):
        idx = self.resultComboBox.findData(result_item.id)
        logger.info(f"Removing result {result_item.id} at index {idx}")
        assert idx != -1
        if idx == self.resultComboBox.currentIndex():
            # TODO: clean up?
            self.resultComboBox.setCurrentIndex(-1)

        self.resultComboBox.removeItem(idx)

    def change_result(self, result_item: ThreeDiResultItem):
        idx = self.resultComboBox.findData(result_item.id)
        assert idx != -1
        self.resultComboBox.setItemText(idx, f"{result_item.parent().text()} | {result_item.text()}")

    def set_extent_from_map_canvas(self):
        canvas_extent = self.iface.mapCanvas().extent()
        project = QgsProject.instance()
        crs = project.crs()
        self.mExtentGroupBox.setOutputExtentFromUser(canvas_extent, crs)

    def preset_combobox_changed(self, index):
        preset = self.comboBoxPreset.itemData(index)

        # Check whether the currently selected model supports the preset's aggregations
        if self.gr:
            containing_information = self._retrieve_model_info()
            for agg_var in preset.aggregations():
                missing_info = [
                    requirement for requirement in agg_var.variable.requirements
                    if requirement not in containing_information
                ]
                if missing_info:
                    pop_up_critical(
                        f"The currently selected 3Di model does not contain all required info for aggregation "
                        f"'{agg_var.variable.long_name}': {missing_info}")
                    no_preset_idx = self.comboBoxPreset.findText(NO_PRESET.name)
                    self.comboBoxPreset.setCurrentIndex(no_preset_idx)  # reset to no preset
                    return

        self.presetHelpTextBrowser.setText(preset.description)
        self.apply_preset(preset)
        self._update_variable_list()

    def apply_preset(self, preset: Preset):
        """
        Set dialog widgets according to given preset.
        If no styling is given for an output_type, that output type's styling panel checkbox is set to False
        """
        # set manhole filter
        self.onlyManholeCheckBox.setChecked(preset.only_manholes)

        # remove existing aggregations
        self.tableWidgetAggregations.setRowCount(0)

        # add aggregations from preset
        for da in preset.aggregations():
            self.add_aggregation(aggregation=da, update_output_layer_names=(preset == NO_PRESET))

        # set "resample point layer" from preset
        self.checkBoxResample.setChecked(preset.resample_point_layer)

        # set styling from preset
        self.set_styling_tab(
            flowlines_style=preset.flowlines_style,
            nodes_style=preset.nodes_style,
            cells_style=preset.cells_style,
            pumps_style=preset.pumps_style,
            pumps_linestring_style=preset.pumps_linestring_style,

            flowlines_style_param_values=preset.flowlines_style_param_values,
            nodes_style_param_values=preset.nodes_style_param_values,
            cells_style_param_values=preset.cells_style_param_values,
            pumps_style_param_values=preset.pumps_style_param_values,
            pumps_linestring_style_param_values=preset.pumps_linestring_style_param_values,

            uncheck_flowlines_checkbox=preset.flowlines_style is None,
            uncheck_nodes_checkbox=preset.nodes_style is None,
            uncheck_cells_checkbox=preset.cells_style is None,
            uncheck_pumps_checkbox=preset.pumps_style is None,
            uncheck_pumps_linestring_checkbox=preset.pumps_linestring_style is None,
        )

        # Set the default output layer names based on preset, if the current layer name value is not modified yet
        if not self.lineEditOutputFlowLayer.isModified():
            self.lineEditOutputFlowLayer.setText(preset.flowlines_layer_name or "")

        if not self.lineEditOutputCellLayer.isModified():
            self.lineEditOutputCellLayer.setText(preset.cells_layer_name or "")

        if not self.lineEditOutputNodeLayer.isModified():
            self.lineEditOutputNodeLayer.setText(preset.nodes_layer_name or "")

        if not self.lineEditOutputPumpsLayer.isModified():
            self.lineEditOutputPumpsLayer.setText(preset.pumps_layer_name or "")

        if not self.lineEditOutputPumpsLinestringLayer.isModified():
            self.lineEditOutputPumpsLinestringLayer.setText(preset.pumps_linestring_layer_name or "")

        if not self.lineEditOutputRasterLayer.isModified():
            self.lineEditOutputRasterLayer.setText(preset.raster_layer_name or "")

    def _update_output_layer_fields_based_on_aggregations(self):
        logger.info("Output layer suggestion based on selected aggregations")

        # Set the default output layer names based on preset, if the current layer name value is empty
        suggested_flow_output_layer_name = "Flowlines: "
        suggested_cell_output_layer_name = "Cells: "
        suggested_node_output_layer_name = "Nodes: "
        suggested_pump_output_layer_name = "Pumps (points): "
        suggested_pump_linestring_output_layer_name = "Pumps (lines): "
        suggested_raster_output_layer_name = "Raster: "

        if len(self.demanded_aggregations) == 0:
            postfix = "aggregation output layer"
        elif len(self.demanded_aggregations) == 1:
            aggregation = self.demanded_aggregations[0]
            postfix_items = [aggregation.variable.long_name]
            if aggregation.variable.signed:
                postfix_items.append(aggregation.sign.long_name.lower())
            if aggregation.method:
                aggregation_method_string = aggregation.method.long_name.lower()
                if aggregation.threshold:
                    threshold_string = aggregation.threshold.replace("_", " ") \
                        if isinstance(aggregation.threshold, str) \
                        else str(round(aggregation.threshold, 2))
                    aggregation_method_string = aggregation_method_string.replace("threshold", threshold_string)
                postfix_items.append(aggregation_method_string)
            postfix_items.append(f"[{aggregation.unit_str}]")  # attribute attached in update_demanded_aggegrations()
            postfix = " ".join(postfix_items)
        else:
            postfix = "multiple aggregations"

        if not self.lineEditOutputFlowLayer.isModified():
            self.lineEditOutputFlowLayer.setText(suggested_flow_output_layer_name + postfix)

        if not self.lineEditOutputCellLayer.isModified():
            self.lineEditOutputCellLayer.setText(suggested_cell_output_layer_name + postfix)

        if not self.lineEditOutputNodeLayer.isModified():
            self.lineEditOutputNodeLayer.setText(suggested_node_output_layer_name + postfix)

        if not self.lineEditOutputPumpsLayer.isModified():
            self.lineEditOutputPumpsLayer.setText(suggested_pump_output_layer_name + postfix)

        if not self.lineEditOutputPumpsLinestringLayer.isModified():
            self.lineEditOutputPumpsLinestringLayer.setText(suggested_pump_linestring_output_layer_name + postfix)

        if not self.lineEditOutputRasterLayer.isModified():
            self.lineEditOutputRasterLayer.setText(suggested_raster_output_layer_name + postfix)

    def _retrieve_model_info(self):
        containing_information = []
        if self.gr:
            if self.gr.has_simple_infiltration:
                containing_information.append(VR_SIMPLE_INFILTRATION)
            if getattr(self.gr, "has_interflow", True):
                containing_information.append(VR_INTERFLOW)
            elif self.gr.has_interflow:
                containing_information.append(VR_INTERFLOW)
            if self.gr.has_interception:
                containing_information.append(VR_INTERCEPTION)
            if self.gr.has_pumpstations:
                containing_information.append(VR_PUMP)
        logger.info(f"Model contains the following variable requirements: {containing_information}")
        return containing_information

    def _update_variable_list(self):
        # Iterate over the rows and check the items in the variable combobox: disable variable when currently loaded
        # model is not supporting this variable
        containing_information = self._retrieve_model_info()

        row_count = self.tableWidgetAggregations.rowCount()
        for row in range(row_count):
            variable_widget = self.tableWidgetAggregations.cellWidget(row, COLUMN_VARIABLE)
            #  Iterate over the variables in the combobox
            for item_idx in range(variable_widget.count()):
                variable = variable_widget.itemData(item_idx)

                if self.gr:
                    missing_info = [
                        requirement for requirement in variable.requirements
                        if requirement not in containing_information
                    ]
                    if missing_info:
                        if item_idx == variable_widget.currentIndex():
                            pop_up_critical(
                                f"The currently selected model does not contain all required info for aggregation "
                                f"'{variable.long_name}': {missing_info}"
                            )
                        variable_widget.model().item(item_idx).setEnabled(False)
                    else:
                        variable_widget.model().item(item_idx).setEnabled(True)
                else:
                    variable_widget.model().item(item_idx).setEnabled(True)

    def update_demanded_aggregations(self):
        self.demanded_aggregations = []
        row_count = self.tableWidgetAggregations.rowCount()
        for row in range(row_count):
            # Variable
            variable_widget = self.tableWidgetAggregations.cellWidget(row, COLUMN_VARIABLE)
            if not variable_widget:
                logger.info("update_demanded_aggregations: 'not variable_widget'")
                continue
            variable = variable_widget.itemData(variable_widget.currentIndex())
            if not variable:
                logger.info("update_demanded_aggregations: 'not variable'")
                continue

            # Direction
            if variable.signed:
                direction_widget = self.tableWidgetAggregations.cellWidget(row, COLUMN_DIRECTION)
                if not direction_widget:
                    logger.info("update_demanded_aggregations: 'not direction_widget'")
                    continue
                sign = direction_widget.itemData(direction_widget.currentIndex())
                if not sign:
                    logger.info("update_demanded_aggregations: 'not sign'")
                    continue
            else:
                sign = None

            # Method
            method_widget = self.tableWidgetAggregations.cellWidget(row, COLUMN_METHOD)
            if not method_widget:
                logger.info("update_demanded_aggregations: 'not method_widget'")
                continue
            method = method_widget.itemData(method_widget.currentIndex())
            if not method:
                logger.info("update_demanded_aggregations: 'not method'")
                continue

            # Threshold
            if method.has_threshold:
                threshold_attribute_widget = self.tableWidgetAggregations.cellWidget(row, COLUMN_THRESHOLD_ATTRIBUTE)
                if not threshold_attribute_widget:
                    logger.info("update_demanded_aggregations: 'not threshold_attribute_widget'")
                    continue
                threshold_attribute = threshold_attribute_widget.currentData()
                threshold = (
                        threshold_attribute
                        or
                        self.tableWidgetAggregations.cellWidget(row, COLUMN_THRESHOLD_VALUE).value()
                )
                if threshold is None:
                    logger.info("update_demanded_aggregations: 'not threshold'")
                    continue
            else:
                threshold = None

            # Multiplier (unit conversion)
            units_widget = self.tableWidgetAggregations.cellWidget(row, COLUMN_UNITS)
            if not units_widget:
                logger.info("update_demanded_aggregations: 'not units_widget'")
                continue
            multiplier = units_widget.itemData(units_widget.currentIndex())
            if not multiplier:
                logger.info("update_demanded_aggregations: 'not multiplier'")
                continue

            da = Aggregation(
                variable=variable,
                sign=sign,
                method=method,
                threshold=threshold,
                multiplier=multiplier,
            )

            # For visualisation-purposes we also (redundantly) attach the unit text
            da.unit_str = units_widget.currentText()
            da_is_valid, invalid_reason = da.is_valid()

            if da_is_valid:
                self.demanded_aggregations.append(da)

            else:
                # This method is often called due to a signal being fired, but this can also happen when the contents of
                # the row's widgets are not yet complete, i.e. the information in the row cannot be tranlated to a
                # valid Aggregation instance. We just continue, but self.demanded_aggregations_are_valid() will now
                # return False until update_demanded_aggregations() will be called again with valid contents in the
                # aggregations table
                if hasattr(da.variable, "long_name"):
                    logger.info(
                        f"Demanded aggregation not valid. Aggregation variable name: {da.variable.long_name}. "
                        f"Invalid reason: {invalid_reason}."
                    )
                else:
                    logger.info("Demanded aggregation variable has no long_name")
                return

        self.set_styling_tab()
        self.validate()

    def demanded_aggregations_are_valid(self) -> bool:
        """
        Checks if the contents of the table of demanded aggregations can be interpreted in a valid way
        """
        if self.tableWidgetAggregations.rowCount() != len(
            self.demanded_aggregations
        ):
            logger.info(
                f"self.tableWidgetAggregations.rowCount() [{self.tableWidgetAggregations.rowCount()}] "
                f"!= len(self.demanded_aggregations) [{len(self.demanded_aggregations)}]")
            return False
        for da in self.demanded_aggregations:
            da_is_valid, invalid_reason = da.is_valid()
            if not da_is_valid:
                if hasattr(da.variable, "long_name"):
                    logger.info(
                        f"Demanded aggregation not valid. Aggregation variable name: {da.variable.long_name}. "
                        f"Invalid reason: {invalid_reason}."
                    )
                else:
                    logger.info("Demanded aggregation variable has no long_name")
                return False
        return True

    def validate(self) -> bool:
        valid = True
        logger.info("Validating result aggregation inputs...")
        logger.info(
            f"Demanded aggregations agg.variable.long_name: "
            f"{[agg.variable.long_name for agg in self.demanded_aggregations]}"
        )
        if not isinstance(self.gr, GridH5ResultAdmin):
            logger.warning("Invalid or no result file selected")
            valid = False
        if not self.tableWidgetAggregations.rowCount() > 0:
            logger.warning("Zero aggregations selected")
            valid = False
        if (
            self.checkBoxRasters.isChecked()
            and self.mQgsFileWidgetRasterFolder.filePath() == ""
        ):
            logger.warning("No raster folder selected")
            valid = False
        if not self.demanded_aggregations_are_valid():
            logger.warning("Demanded aggregations are not valid")
            valid = False

        # Check whether the demanded aggregations are compatible with the model (or: model contains all required info)
        if self.gr:
            containing_information = self._retrieve_model_info()

            for agg in self.demanded_aggregations:
                missing_info = [
                    requirement for requirement in agg.variable.requirements
                    if requirement not in containing_information
                ]
                if missing_info:
                    logger.warning(f"Model does not contain all info for demanded aggregations: {missing_info}")
                    valid = False
                    break

        logger.info(f"Validated result aggregation inputs. Valid: {valid}")

        self.dialogButtonBoxOKCancel.button(QtWidgets.QDialogButtonBox.StandardButton.Ok).setEnabled(valid)

        return valid

    def run(self):
        # 3Di results
        result = self.model.get_result(self.result_id)

        # Filtering parameters
        start_time = self.doubleSpinBoxStartTime.value()
        end_time = self.doubleSpinBoxEndTime.value()
        bbox_qgs_rectangle = (
            self.mExtentGroupBox.outputExtent()
        )  # bbox is now a https://qgis.org/pyqgis/master/core/QgsRectangle.html#qgis.core.QgsRectangle

        bbox = None
        if bbox_qgs_rectangle is not None:
            if not bbox_qgs_rectangle.isEmpty():
                bbox = [
                    bbox_qgs_rectangle.xMinimum(),
                    bbox_qgs_rectangle.yMinimum(),
                    bbox_qgs_rectangle.xMaximum(),
                    bbox_qgs_rectangle.yMaximum(),
                ]
        only_manholes = self.onlyManholeCheckBox.isChecked()

        # Resolution
        resolution = self.doubleSpinBoxResolution.value()

        # Outputs
        output_flowlines = self.checkBoxFlowlines.isChecked()
        output_nodes = self.checkBoxNodes.isChecked()
        output_cells = self.checkBoxCells.isChecked()
        output_pumps = self.checkBoxPumps.isChecked()
        output_pumps_linestring = self.checkBoxPumpsLinestring.isChecked()
        output_rasters = self.checkBoxRasters.isChecked()

        # Resample point layer
        resample_point_layer = self.checkBoxResample.isChecked()
        if resample_point_layer:
            interpolation_method = "linear"
        else:
            interpolation_method = None

        aggregate_threedi_results_task = Aggregate3DiResults(
            description="Aggregate 3Di Results",
            parent=self,
            layer_groups=self.owner.layer_groups,
            result=result,
            demanded_aggregations=self.demanded_aggregations,
            bbox=bbox,
            start_time=start_time,
            end_time=end_time,
            only_manholes=only_manholes,
            interpolation_method=interpolation_method,
            resample_point_layer=resample_point_layer,
            resolution=resolution,
            output_flowlines=output_flowlines,
            output_cells=output_cells,
            output_nodes=output_nodes,
            output_pumps=output_pumps,
            output_pumps_linestring=output_pumps_linestring,
            output_rasters=output_rasters,
            group_name=self.owner.group_name
        )
        self.tm.addTask(aggregate_threedi_results_task)


class Aggregate3DiResults(QgsTask):
    def __init__(
        self,
        description: str,
        parent: ThreeDiCustomStatsDialog,
        layer_groups,
        result: ThreeDiResultItem,
        demanded_aggregations: List,
        bbox,
        start_time: int,
        end_time: int,
        only_manholes: bool,
        interpolation_method,
        resample_point_layer: bool,
        resolution,
        output_flowlines: bool,
        output_cells: bool,
        output_nodes: bool,
        output_pumps: bool,
        output_pumps_linestring: bool,
        output_rasters: bool,
        group_name: str,
    ):
        super().__init__(description, QgsTask.Flag.CanCancel)
        self.exception = None
        self.parent = parent
        self.parent.setEnabled(False)
        self.result = result
        self.layer_groups = layer_groups
        self.demanded_aggregations = demanded_aggregations
        self.bbox = bbox
        self.start_time = start_time
        self.end_time = end_time
        self.only_manholes = only_manholes
        self.interpolation_method = interpolation_method
        self.resample_point_layer = resample_point_layer
        self.resolution = resolution
        self.output_flowlines = output_flowlines
        self.output_cells = output_cells
        self.output_nodes = output_nodes
        self.output_pumps = output_pumps
        self.output_pumps_linestring = output_pumps_linestring
        self.output_rasters = output_rasters
        self.group_name = group_name

        self.parent.iface.messageBar().pushMessage(
            "3Di Statistics",
            "Started aggregating 3Di results",
            level=Qgis.MessageLevel.Info,
            duration=3,
        )
        self.parent.iface.mainWindow().repaint()  # to show the message before the task starts

    def run(self):
        grid_admin = str(self.result.parent().path.with_suffix('.h5'))
        grid_admin_gpkg = str(self.result.parent().path.with_suffix('.gpkg'))
        results_3di = str(self.result.path)

        try:
            self.ogr_ds, self.mem_rasts = aggregate_threedi_results(
                gridadmin=grid_admin,
                gridadmin_gpkg=grid_admin_gpkg,
                results_3di=results_3di,
                demanded_aggregations=self.demanded_aggregations,
                bbox=self.bbox,
                start_time=self.start_time,
                end_time=self.end_time,
                only_manholes=self.only_manholes,
                interpolation_method=self.interpolation_method,
                resample_point_layer=self.resample_point_layer,
                resolution=self.resolution,
                output_flowlines=self.output_flowlines,
                output_cells=self.output_cells,
                output_nodes=self.output_nodes,
                output_pumps=self.output_pumps,
                output_pumps_linestring=self.output_pumps_linestring,
                output_rasters=self.output_rasters,
            )

            return True

        except Exception as e:
            self.exception = e

        return False

    def _get_or_create_result_group(self, result: ThreeDiResultItem, group_name: str):
        # We'll place the result layers in a dedicated result group
        grid_item = result.parent()
        assert grid_item
        tool_group = grid_item.layer_group.findGroup(group_name)
        if not tool_group:
            tool_group = grid_item.layer_group.insertGroup(0, group_name)
            tool_group.willRemoveChildren.connect(lambda n, i1, i2: self._group_removed(n, i1, i2))

        # Add result group
        result_group = tool_group.findGroup(result.text())
        if not result_group:
            result_group = tool_group.addGroup(result.text())
            self.layer_groups[result.id] = result_group
            # Use to modify result name when QgsLayerTreeNode is renamed. Note that this does not cause a
            # infinite signal loop because the model only emits the result_changed when the text has actually
            # changed.
            result_group.nameChanged.connect(lambda _, txt, result_item=result: result_item.setText(txt))

        return result_group

    def _group_removed(self, n, idxFrom, idxTo):
        for result_id in list(self.layer_groups):
            group = self.layer_groups[result_id]
            for i in range(idxFrom, idxTo+1):
                if n.children()[i] is group:
                    del self.layer_groups[result_id]

    def finished(self, result):
        if self.exception is not None:
            self.parent.setEnabled(True)
            self.parent.repaint()
            raise self.exception
        if result:
            # Add layers to layer tree
            # They are added in order so the raster is below the polygon is below the line is below the point layer

            # raster layer
            if len(self.mem_rasts) > 0:
                for rastname, rast in self.mem_rasts.items():
                    raster_output_dir = (
                        self.parent.mQgsFileWidgetRasterFolder.filePath()
                    )
                    raster_output_fn = os.path.join(
                        raster_output_dir, rastname + ".tif"
                    )
                    drv = GetDriverByName("GTiff")
                    drv.CreateCopy(
                        utf8_path=raster_output_fn, src=rast
                    )
                    layer_name = self.parent.lineEditOutputRasterLayer.text() + f": {rastname}"
                    raster_layer = QgsRasterLayer(
                        raster_output_fn,
                        layer_name or f"Aggregation results: raster {rastname}")
                    result_group = self._get_or_create_result_group(self.result, self.group_name)
                    QgsProject.instance().addMapLayer(raster_layer, addToLegend=False)
                    result_group.insertLayer(0, raster_layer)

            # vector layers
            for output_layer_name, layer_name_widget, style_type_widget in [
                (
                        "cell",
                        self.parent.lineEditOutputCellLayer,
                        self.parent.comboBoxCellsStyleType
                ),
                (
                        "flowline",
                        self.parent.lineEditOutputFlowLayer,
                        self.parent.comboBoxFlowlinesStyleType
                ),
                (
                        "pump",
                        self.parent.lineEditOutputPumpsLayer,
                        self.parent.comboBoxPumpsStyleType
                ),
                (
                        "pump_linestring",
                        self.parent.lineEditOutputPumpsLinestringLayer,
                        self.parent.comboBoxPumpsLinestringStyleType
                ),
                (
                        "node",
                        self.parent.lineEditOutputNodeLayer,
                        self.parent.comboBoxNodesStyleType
                ),
                (
                        "node_resampled",
                        self.parent.lineEditOutputNodeLayer,
                        self.parent.comboBoxNodesStyleType
                ),
            ]:
                ogr_lyr = self.ogr_ds.GetLayerByName(output_layer_name)
                if ogr_lyr is not None:
                    if ogr_lyr.GetFeatureCount() > 0:
                        layer_name = layer_name_widget.text()
                        qgs_lyr = as_qgis_memory_layer(
                            ogr_lyr,
                            layer_name or f"Aggregation results: {output_layer_name}"
                        )
                        result_group = self._get_or_create_result_group(self.result, self.group_name)
                        QgsProject.instance().addMapLayer(qgs_lyr, addToLegend=False)
                        result_group.insertLayer(0, qgs_lyr)
                        style = (style_type_widget.currentData())
                        style_kwargs = self.parent.get_styling_parameters(output_type=style.output_type)
                        style.apply(qgis_layer=qgs_lyr, style_kwargs=style_kwargs)

            self.parent.setEnabled(True)
            self.parent.iface.messageBar().pushMessage(
                "3Di Result aggregation",
                "Finished custom aggregation",
                level=Qgis.MessageLevel.Success,
                duration=3,
            )

        else:
            self.parent.setEnabled(True)
            self.parent.iface.messageBar().pushMessage(
                "3Di Result aggregation",
                "Aggregating 3Di results returned no results",
                level=Qgis.MessageLevel.Warning,
                duration=3,
            )

    def cancel(self):
        self.parent.iface.messageBar().pushMessage(
            "3Di Result aggregation",
            "Pre-processing simulation results cancelled by user",
            level=Qgis.MessageLevel.Info,
            duration=3,
        )
        super().cancel()
