# -*- coding: utf-8 -*-
"""
/***************************************************************************
 dbpSimulator
                                 A QGIS plugin
 This plugin created for the IntoDBP project.
 Generated by Plugin Builder: http://g-sherman.github.io/Qgis-Plugin-Builder/
                              -------------------
        begin                : 2024-12-20
        git sha              : $Format:%H$
        copyright            : (C) 2024 by KIOS Water Team
        email                : mkiria01@ucy.ac.cy
 ***************************************************************************/

/***************************************************************************
 *                                                                         *
 *   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 csv
import html
import importlib
import json
import os
import platform
import re
import shutil
import subprocess
import sys
import time
from shutil import copyfile

import pandas as pd
import processing
from PyQt5.QtCore import Qt, QUrl, QSize
from PyQt5.QtGui import QColor, QFont, QDesktopServices
from PyQt5.QtWidgets import (QComboBox, QDialog, QVBoxLayout, QLabel, QDialogButtonBox, 
                              QTextEdit, QPushButton, QToolBar, QFileDialog, QCompleter, 
                              QTextBrowser, QAction, QToolButton, QMenu, QWidgetAction,
                              QDockWidget, QWidget, QMessageBox, QTableWidgetItem,
                              QGridLayout, QSizePolicy, QTableWidget, QHeaderView, QAbstractItemView)
from processing.core.Processing import Processing
from qgis import processing
from qgis.PyQt import uic
from qgis.PyQt.QtCore import QSettings, QTranslator, QCoreApplication, QTimer
from qgis.PyQt.QtGui import QFontDatabase, QIcon
# from .app.processing_task import ProcessingTask
from qgis.core import (
    Qgis, QgsCoordinateReferenceSystem, QgsVectorLayer, QgsProject,
    QgsGraduatedSymbolRenderer, QgsRendererRange, QgsSymbol,
    QgsStyle, QgsMessageLog, QgsTask, QgsApplication,
    QgsField, QgsVectorLayerSimpleLabeling, QgsTextFormat, QgsPalLayerSettings, QgsSettings,
    QgsVectorLayerJoinInfo
)
from qgis.utils import iface, plugins

# Import the code for the DockWidget
from .dbp_simulator_dockwidget import dbpSimulatorDockWidget
# Import Epanet Files
from .importepanetinpfiles.main import ImpEpanet
# Initialize Qt resources from file resources.py
from .resources import *

try:
    from epyt import epanet
    import openpyxl
    import numpy
    import pandas as pd
except ImportError:
    subprocess.call(
        ['pip', 'install', 'numpy==1.22.4', 'epyt==1.2.2', 'xlsxwriter>=3.2.0', 'openpyxl>=3.1.0', 'pandas>=1.5.3'])
try:
    import matplotlib.pyplot as plt
    from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar
    from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
    from matplotlib.figure import Figure
except ImportError:
    subprocess.call(['pip', 'install', 'matplotlib>=3.7.2'])


def get_desktop_path():
    desktop_path = os.path.join(os.path.join(os.path.expanduser('~')), 'Desktop')
    if not os.path.exists(desktop_path):
        desktop_path = os.path.join(os.path.join(os.path.expanduser('~')), 'Onedrive', 'Desktop')
    return desktop_path

class PopulatePatternTask(QgsTask):
    def __init__(self, file_path, plugin_instance):
        super().__init__("Populate multiplication pattern", QgsTask.CanCancel)
        self.file_path = file_path
        self.plugin = plugin_instance
        self.df = None
        self.error = None

    def run(self):
        try:
            import pandas as pd
            self.df = pd.read_excel(self.file_path)
            return True
        except Exception as e:
            self.error = e
            return False

    def finished(self, result):
        # runs on main (GUI) thread
        iface = getattr(self.plugin, "iface", None)
        if not result:
            if iface:
                iface.messageBar().pushMessage("dbpRisk 2.0",
                                               f"Failed to load Excel: {self.error}",
                                               level=Qgis.Critical, duration=5)
            return

        try:
            df = self.df
            # basic column checks
            if 'ParameterName' not in df.columns or 'SensorLocation' not in df.columns:
                if iface:
                    iface.messageBar().pushMessage("dbpRisk 2.0",
                                                   "Excel missing required columns: ParameterName or SensorLocation",
                                                   level=Qgis.Warning, duration=5)
                return

            combo = self.plugin.dockwidget.multiplication_pattern_excel
            sensor_combo = self.plugin.dockwidget.sensor_location

            combo.blockSignals(True)
            sensor_combo.blockSignals(True)
            try:
                combo.clear()
                sensor_combo.clear()
                combo.addItem("")  # keep blank as first
                sensor_combo.addItem("")

                params = df['ParameterName'].dropna().astype(str).unique().tolist()
                sensors = df['SensorLocation'].dropna().astype(str).unique().tolist()

                combo.addItems(params)
                sensor_combo.addItems(sensors)
            finally:
                combo.blockSignals(False)
                sensor_combo.blockSignals(False)

            if iface:
                iface.messageBar().pushMessage("dbpRisk 2.0",
                                               f"Loaded {len(df)} rows from {os.path.basename(self.file_path)}",
                                               level=Qgis.Info, duration=5)
        except Exception as e:
            if iface:
                iface.messageBar().pushMessage("dbpRisk 2.0",
                                               f"Error updating UI: {e}",
                                               level=Qgis.Critical, duration=5)

class dbpSimulator:
    """QGIS Plugin Implementation."""

    def __init__(self, iface):
        """Constructor.

        :param iface: An interface instance that will be passed to this class
            which provides the hook by which you can manipulate the QGIS
            application at run time.
        :type iface: QgsInterface
        """

        # Initialize
        self.imported_excel_file = None
        self.import_excel_file_flag = False
        self.dock_plots = None
        self.species_names_function = None
        self.node_id = None
        self.MSXunits = None
        self.soeciesnamesmsx = None
        self.global_times = None
        self.MSX_comps = None
        self.dataframe = None
        self.dataframe_uncertainty = None
        self.mes = None
        Processing.initialize()

        # Save reference to the QGIS interface

        self.tm = QgsApplication.taskManager()
        self.scenario_input_type_list = []
        self.node_id_list = []
        self.injection_rate_list = []
        self.species_list = []
        self.species_type_list = []
        self.multiplication_pattern_list = []
        self.multiplication_sensor_location_list = []
        self.custom_pattern_file_path_list = []
        self.initial_concentration_list = []
        self.chemical_parameter_list = []
        self.chemical_parameter_value_list = []
        self.demand_uncertainty_list = []
        self.msx_uncertainty_list = []
        self.results = None
        self.sensor_id = None
        self.scenario_chemparam = None
        self.scenario_initconce = None
        self.scenario_injection = None
        self.scenario_hydrparam = None
        self.iface = iface
        self.model_file_path = None
        self.prev_plot_species_unc_state = False

        # initialize plugin directory
        self.plugin_dir = os.path.dirname(__file__)

        # initialize locale
        locale = QSettings().value('locale/userLocale')[0:2]
        locale_path = os.path.join(
            self.plugin_dir,
            'i18n',
            'dbpSimulator_{}.qm'.format(locale))

        if os.path.exists(locale_path):
            self.translator = QTranslator()
            self.translator.load(locale_path)
            QCoreApplication.installTranslator(self.translator)

        # Declare instance attributes
        self.actions = []
        self.menu = self.tr(u'&dbpRisk 2.0')
        # TODO: We are going to let the user set this up in a future iteration
        self.toolbar = self.iface.addToolBar(u'dbpRisk 2.0')
        self.toolbar.setObjectName(u'dbpRisk 2.0')

        # print "** INITIALIZING dbpSimulator"

        self.pluginIsActive = False
        self.dockwidget = None

    # noinspection PyMethodMayBeStatic
    def tr(self, message):
        """Get the translation for a string using Qt translation API.

        We implement this ourselves since we do not inherit QObject.

        :param message: String for translation.
        :type message: str, QString

        :returns: Translated version of message.
        :rtype: QString
        """
        # noinspection PyTypeChecker,PyArgumentList,PyCallByClass
        return QCoreApplication.translate('dbpRisk 2.0', message)

    def add_action(self, icon_path, text, callback, enabled_flag=True, add_to_menu=True, add_to_toolbar=True,
                   status_tip=None, whats_this=None, parent=None):
        """Add a toolbar icon to the toolbar.

        :param icon_path: Path to the icon for this action. Can be a resource
            path (e.g. ':/plugins/foo/bar.png') or a normal file system path.
        :type icon_path: str

        :param text: Text that should be shown in menu items for this action.
        :type text: str

        :param callback: Function to be called when the action is triggered.
        :type callback: function

        :param enabled_flag: A flag indicating if the action should be enabled
            by default. Defaults to True.
        :type enabled_flag: bool

        :param add_to_menu: Flag indicating whether the action should also
            be added to the menu. Defaults to True.
        :type add_to_menu: bool

        :param add_to_toolbar: Flag indicating whether the action should also
            be added to the toolbar. Defaults to True.
        :type add_to_toolbar: bool

        :param status_tip: Optional text to show in a popup when mouse pointer
            hovers over the action.
        :type status_tip: str

        :param parent: Parent widget for the new action. Defaults None.
        :type parent: QWidget

        :param whats_this: Optional text to show in the status bar when the
            mouse pointer hovers over the action.

        :returns: The action that was created. Note that the action is also
            added to self.actions list.
        :rtype: QAction
        """

        icon = QIcon(icon_path)
        action = QAction(icon, text, parent)
        action.triggered.connect(callback)
        action.setEnabled(enabled_flag)

        if 'Hide' in text:
            action.setCheckable(True)
            action.setText('Show/Hide')

        if 'Update' in text:
            action.setText('Update dbpRisk 2.0')

        if status_tip is not None:
            action.setStatusTip(status_tip)

        if whats_this is not None:
            action.setWhatsThis(whats_this)

        if add_to_toolbar:
            self.toolbar.addAction(action)

        if add_to_menu:
            self.iface.addPluginToMenu(
                self.menu,
                action)

        self.actions.append(action)

        return action

    def initGui(self):
        """Create the menu entries and toolbar icons inside the QGIS GUI."""
        self.main_window = self.iface.mainWindow()
        icon_path = os.path.join(self.plugin_dir, 'images', 'dbp.png')
        self.add_action(
            icon_path,
            text=self.tr(u'dbpRisk 2.0'),
            callback=self.run,
            parent=self.iface.mainWindow())

        self.hide_btn = self.add_action(
            icon_path=None,
            text=self.tr(u'Hide/Show panels and toolbars'),
            callback=self.hide_show_pantool,
            parent=self.iface.mainWindow())

        self.original_toolbar_states = {}
        self.original_panel_states = {}

        # Toolbars to keep visible (exact names)
        self.toolbars_to_keep = {
            'mAttributesToolBar',
            'mMapNavToolBar',
            'mFileToolBar',
            'mSelectionToolBar', 'Layers',
            'QRestart', 'mPluginToolBar', 'dbpsimulator_dockwidget', 'dbpRisk 2.0'
        }
        # -------------------  Toolbar 'Help' -------------------
        # Create ToolButton
        help_button = QToolButton()
        help_button.setText("Help")
        help_button.setPopupMode(QToolButton.MenuButtonPopup)  # Icon and arrow act separately
        help_button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)

        # Help icon
        help_icon = QIcon(os.path.join(self.plugin_dir, 'images', 'help.svg'))
        help_button.setIcon(help_icon)

        help_menu = QMenu(help_button)

        # Action 1 - Help
        action_help = QAction(help_icon, "Help", self.iface.mainWindow())
        action_help.triggered.connect(self.show_manual)
        help_menu.addAction(action_help)

        # Action 3 - About
        action_about = QAction(QIcon(os.path.join(self.plugin_dir, 'images', 'about.png')), "About",
                               self.iface.mainWindow())
        action_about.triggered.connect(self.show_about)
        help_menu.addAction(action_about)

        # Assign menu to button
        help_button.setMenu(help_menu)

        # Default action
        help_button.clicked.connect(self.show_manual)

        self.toolbar.addWidget(help_button)

    def update_plugin_dbp(self):
        self.show_message("Update", "Update functionality not implemented.", button="OK", icon="Info")

    #     import os
    #     import zipfile
    #     import tempfile
    #     import shutil
    #     import urllib.request
    #     from qgis.utils import plugins, reloadPlugin
    #     from qgis.core import QgsApplication
    #
    #     # Plugin download URL
    #     url = "https://www.dropbox.com/scl/fi/cpxm4jzkxvqq7g49s7k42/dbpRisk2.zip?rlkey=b5ke3h4nsaz8rfckokhb1ec4c&dl=1"
    #
    #     # Create temporary directory
    #     temp_dir = tempfile.mkdtemp()
    #     zip_path = os.path.join(temp_dir, "dbprisk2.zip")
    #     extracted_temp = os.path.join(temp_dir, "extracted")
    #
    #     # Download the ZIP file
    #     urllib.request.urlretrieve(url, zip_path)
    #
    #     # Extract the ZIP to a temporary folder
    #     with zipfile.ZipFile(zip_path, 'r') as zip_ref:
    #         zip_ref.extractall(extracted_temp)
    #
    #     # Define final plugin directory
    #     plugin_root = os.path.join(QgsApplication.qgisSettingsDirPath(), 'python', 'plugins')
    #     plugin_name = 'dbpRisk2'
    #     plugin_target = os.path.join(plugin_root, plugin_name)
    #
    #     # Remove old plugin directory if it exists
    #     if os.path.exists(plugin_target):
    #         shutil.rmtree(plugin_target)
    #
    #     # Find the correct folder in extracted files (flatten if needed)
    #     for item in os.listdir(extracted_temp):
    #         item_path = os.path.join(extracted_temp, item)
    #         if item.lower() == plugin_name.lower() and os.path.isdir(item_path):
    #             shutil.move(item_path, plugin_target)
    #             break
    #     else:
    #         # If the zip didn't have a top-level folder, move all contents into plugin_target
    #         os.makedirs(plugin_target)
    #         for item in os.listdir(extracted_temp):
    #             shutil.move(os.path.join(extracted_temp, item), plugin_target)
    #
    #     # Reload the plugin
    #     if plugin_name in plugins:
    #         reloadPlugin(plugin_name)
    #     else:
    #         print(f"Plugin '{plugin_name}' not found. Make sure it was extracted correctly.")
    #
    #     # Clean up temporary directory
    #     shutil.rmtree(temp_dir)
    #
    #     """Run method that performs all the real work"""
    #     if QgsProject.instance().isDirty():
    #         msg_box = QMessageBox()
    #         msg_box.setWindowTitle("Save project")
    #         msg_box.setText("Do you want to save the changes to the project?")
    #         msg_box.setStandardButtons(QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel)
    #         msg_box.setDefaultButton(QMessageBox.Save)
    #         msg_box.setIcon(QMessageBox.Information)
    #         button_clicked = msg_box.exec_()
    #         if button_clicked == QMessageBox.Save:
    #             self.iface.actionSaveProject().trigger()
    #         elif button_clicked == QMessageBox.Discard:
    #             QgsProject.instance().clear()
    #         else:
    #             return
    #     import subprocess
    #     self.iface.actionExit().trigger()
    #     subprocess.Popen(QgsApplication.applicationFilePath())

    def hide_show_pantool(self):
        if self.hide_btn.isChecked():
            self.hide_toolbars_and_panels()
        else:
            self.restore_toolbars_and_panels()

    def hide_toolbars_and_panels(self):

        # Hide all toolbars unless in keep list OR contains 'dbpRisk'
        for toolbar in self.main_window.findChildren(QToolBar):
            name = toolbar.objectName()
            self.original_toolbar_states[name] = toolbar.isVisible()

            if name in self.toolbars_to_keep or 'dbp' in name:
                continue
            toolbar.hide()

        # Hide all panels unless their name contains 'dbpRisk'
        for panel in self.main_window.findChildren(QDockWidget):
            if panel.objectName() == 'dbpsimulator_dockwidget':
                self.dbp_panel = panel
            name = panel.objectName()
            self.original_panel_states[name] = panel.isVisible()

            if 'dbp' in name:
                continue
            panel.hide()

    def restore_toolbars_and_panels(self):
        for toolbar in self.main_window.findChildren(QToolBar):
            name = toolbar.objectName()
            if name in self.original_toolbar_states:
                toolbar.setVisible(self.original_toolbar_states[name])

        for panel in self.main_window.findChildren(QDockWidget):
            name = panel.objectName()
            if name in self.original_panel_states:
                panel.setVisible(self.original_panel_states[name])

    def parse_requirements_file(self, file_path):
        packages = []
        with open(file_path, 'r') as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith('#'):
                    continue
                pkg_name = line.split('==')[0].split('>=')[0].strip()
                packages.append((line, pkg_name))
        return packages

    def check_requirements_file(self, file_path):
        if not os.path.exists(file_path):
            QMessageBox.warning(None, "Missing File", f"Could not find requirements file:\n{file_path}")
            return

        packages = self.parse_requirements_file(file_path)
        missing = []

        for full_spec, module_name in packages:
            try:
                importlib.import_module(module_name)
                if 'epyt' in module_name:
                    import epyt
                    if epyt.__version__ != '1.2.2':
                        missing.append(full_spec)
            except ImportError:
                missing.append(full_spec)

        if not missing:
            return

        msg = "The following packages are required:\n\n"
        msg += "\n".join(missing)
        msg += "\n\nDo you want to install them now?"

        reply = QMessageBox.question(None, 'Missing Dependencies', msg, QMessageBox.Yes | QMessageBox.No,
                                     QMessageBox.No)

        if reply == QMessageBox.Yes:
            pip = os.path.join(sys.prefix, 'scripts', 'pip.exe') if os.name == 'nt' else 'pip'
            for pkg in missing:
                subprocess.call([pip, 'install', pkg])
            QMessageBox.information(None, "Dependencies", "Installation complete. Please restart QGIS.")

    def onClosePlugin(self):
        """Cleanup necessary items here when plugin dockwidget is closed"""

        # print "** CLOSING dbpSimulator"

        # disconnects
        self.dockwidget.closingPlugin.disconnect(self.onClosePlugin)

        # remove this statement if dockwidget is to remain
        # for reuse if plugin is reopened
        # Commented next statement since it causes QGIS crashe
        # when closing the docked window:
        # self.dockwidget = None

        self.pluginIsActive = False

    def unload(self):
        """Removes the plugin menu item and icon from QGIS GUI."""

        # print "** UNLOAD dbpSimulator"

        for action in self.actions:
            self.iface.removePluginMenu(
                self.tr(u'&dbpRisk 2.0'),
                action)
            self.iface.removeToolBarIcon(action)
        if self.dockwidget:
            self.dockwidget.close()
        # remove the toolbar
        del self.toolbar

    def load_models(self):
        if self.model_file_path:
            model_file_name = os.path.basename(self.model_file_path)

            # Create project path based on filename
            self.model_prefix = os.path.splitext(model_file_name)[0]

            project_path = os.path.join(self.plugin_dir, "data", "project_data",
                                        f'dbpSimulator_{self.model_prefix}.qgz')
            project_path_tmp = os.path.join(self.plugin_dir, "data", "tmp_data",
                                            f'dbpSimulator_{self.model_prefix}.qgz')

            # If project doesn't exist, create it
            if not os.path.exists(project_path):
                # Run plugin to import layers (assumes it adds them to the project)
                ImpEpanet(self.iface).run([self.model_file_path])

                # Save the project under project_path
                QgsProject.instance().write(project_path)

            # Copy and load project
            copyfile(project_path, project_path_tmp)
            project = QgsProject.instance()
            project.read(project_path_tmp)

        # Get selected model names from dropdowns
        self.network_model = self.dockwidget.network_models.currentText()
        self.reaction_model = self.dockwidget.reaction_models.currentText()

        # Check if selections are valid
        if not self.network_model or not self.reaction_model:
            self.show_message("Warning", "Please select network and reaction models.", button="OK",
                              icon="Warning")
            return

        # Build full file paths
        self.network_model = os.path.join(self.plugin_dir, "data", "network_models", self.network_model)
        self.reaction_model = os.path.join(self.plugin_dir, "data", "reaction_models", self.reaction_model)

        # Get model prefix from the selected network model file
        self.model_prefix = os.path.splitext(os.path.basename(self.network_model))[0]
        # print(self.model_prefix)

        # Check if files exist
        if not os.path.exists(self.network_model):
            self.show_message("Warning", f"Network model not found:\n{self.network_model}.", button="OK",
                              icon="Warning")
            return

        if not os.path.exists(self.reaction_model):
            self.show_message("Warning", f"Reaction model not found:\n{self.reaction_model}.", button="OK",
                              icon="Warning")
            return

        project_path = os.path.join(self.plugin_dir, "data", "project_data", f'dbpSimulator_{self.model_prefix}.qgz')
        tmp_folder = os.path.join(self.plugin_dir, "data", "tmp_data")
        os.makedirs(tmp_folder, exist_ok=True)
        project_path_tmp = os.path.join(tmp_folder, f'dbpSimulator_{self.model_prefix}.qgz')

        copyfile(project_path, project_path_tmp)
        project = QgsProject.instance()
        project.read(project_path_tmp)

        # Load EPANET network model and MSX reaction model
        self.G = epanet(self.network_model)
        self.G.loadMSXFile(self.reaction_model)

        # Read default parameter values from MSX file
        self.parameter_defaults = {}  # Dictionary: param_name -> value

        # ToDo: need to change to self.parameter_defaults[param_name] = self.G.getMSXParametersTanksValue()
        with open(self.reaction_model, 'r') as msx_file:
            lines = msx_file.readlines()
            in_coeff_section = False
            for line in lines:
                line = line.strip()
                if line.startswith("[COEFFICIENTS]"):
                    in_coeff_section = True
                    continue
                if line.startswith("[") and in_coeff_section:
                    break  # Exit when we reach next section

                if in_coeff_section and line.startswith("PARAMETER"):
                    parts = line.split()
                    if len(parts) == 3:
                        _, param_name, param_value = parts
                        try:
                            self.parameter_defaults[param_name] = float(param_value)
                        except ValueError:
                            continue  # Skip invalid lines

        self.msx_species = self.G.getMSXSpeciesNameID()
        msx_parameters = self.G.getMSXParametersNameID()

        self.dockwidget.model_species_injection.clear()
        self.dockwidget.model_species_initial_conc.clear()
        self.dockwidget.select_species.clear()
        self.dockwidget.sim_multi_model_species.clear()
        # self.dockwidget.sim_multi_model_species_2.clear()
        self.dockwidget.chemical_parameters.clear()

        self.dockwidget.model_species_injection.addItems(self.msx_species)
        self.dockwidget.model_species_initial_conc.addItems(self.msx_species)

        self.dockwidget.select_species.addItems(self.msx_species)
        self.dockwidget.sim_multi_model_species.addItems(self.msx_species)
        # self.dockwidget.sim_multi_model_species_2.addItems(self.msx_species)
        self.dockwidget.chemical_parameters.addItems(msx_parameters)
        if hasattr(self, "iface"):
            self.iface.messageBar().clearWidgets()
        try:
            try:
                self.junctions_layer = QgsProject.instance().mapLayersByName(f'{self.model_prefix}_junctions')[0]
                self.reservoirs_layer = QgsProject.instance().mapLayersByName(f'{self.model_prefix}_reservoirs')[0]
                self.tanks_layer = QgsProject.instance().mapLayersByName(f'{self.model_prefix}_tanks')[0]
                self.iface.setActiveLayer(self.junctions_layer)
            except:
                pass
        finally:
            message = f'Models loaded successfully!'
            level = 3  # Qgis.Success
            self.iface.messageBar().pushMessage("dbpRisk 2.0", message, level, duration=4)

            # Enable selection
            self.iface.actionSelect().trigger()

            # Insert custom search widget
            junctions_layer = self.junctions_layer
            canvas = self.iface.mapCanvas()
            self.set_custom_widgets(junctions_layer, canvas)

        self.msx_units = self.G.getMSXSpeciesUnits()
        self.msx_rate_unit = self.G.getMSXRateUnits()

        self.dockwidget.injection_types.currentIndexChanged.connect(
            lambda: self.update_injection_label(self.dockwidget.injection_rate_lbl_4,
                                                self.dockwidget.model_species_injection.currentText(),
                                                self.dockwidget.injection_types.currentText(), 'Injection'))
        self.dockwidget.model_species_injection.currentIndexChanged.connect(
            lambda: self.update_injection_label(self.dockwidget.injection_rate_lbl_4,
                                                self.dockwidget.model_species_injection.currentText(),
                                                self.dockwidget.injection_types.currentText(), 'Injection'))
        self.dockwidget.model_species_initial_conc.currentIndexChanged.connect(
            lambda: self.update_initial_label(self.dockwidget.initconce_lbl_3,
                                              self.dockwidget.model_species_initial_conc.currentText(), 'Initial'))

        self.dockwidget.loadexcel.setEnabled(True)
        self.dockwidget.insert_action.setEnabled(True)
        self.dockwidget.scenario_table.setEnabled(True)
        self.dockwidget.import_scenario.setEnabled(True)
        # self.dockwidget.export_scenario.setEnabled(True)
        self.dockwidget.data_manager.setEnabled(True)
        #self.dockwidget.import_node_injection.setEnabled(True)

        self.G.unloadMSX()

    def set_custom_widgets(self, layer, canvas):
        from .custom_widgets.search_widget import SearchWidget
        # from .app.custom_widgets.selection_tool import SelectionTool

        widget_1 = self.dockwidget.injection_search_widget
        widget_2 = self.dockwidget.init_conc_search_widget
        # remove old placeholder widget if needed
        layout_1 = widget_1.layout()
        layout_2 = widget_2.layout()
        if layout_1 is not None:
            QWidget().setLayout(layout_1)  # clears old layout if exists
        if layout_2 is not None:
            QWidget().setLayout(layout_2)  # clears old layout if exists

        # Create custom widget
        self.search_widget_inj = SearchWidget(layer_name=layer.name(), canvas=canvas)
        self.search_widget_init_conc = SearchWidget(layer_name=layer.name(), canvas=canvas)

        # replace the placeholder with the custom widget
        dock_layout = QVBoxLayout(self.dockwidget.injection_search_widget)
        dock_layout.setContentsMargins(0, 0, 0, 0)
        dock_layout.addWidget(self.search_widget_inj)

        dock_layout = QVBoxLayout(self.dockwidget.init_conc_search_widget)
        dock_layout.setContentsMargins(0, 0, 0, 0)
        dock_layout.addWidget(self.search_widget_init_conc)

    def update_initial_label(self, lbl_widget, species, label):
        try:
            tmp = self.msx_species.index(species)
        except:
            return
        unit = self.msx_units[tmp].lower()
        lbl_widget.setText(f'{label} Value ({unit}/L):')

    def update_injection_label(self, lbl_widget, species, index_type, label):
        try:
            tmp = self.msx_species.index(species)
        except:
            return
        unit = self.msx_units[tmp].lower()
        if index_type.startswith('Inflow'):
            lbl_widget.setText(f'{label} Value ({unit}/L):')
        elif index_type.startswith('Mass'):
            lbl_widget.setText(f'{label} Value ({unit}/{self.msx_rate_unit}):')
        elif index_type.startswith('Set'):
            lbl_widget.setText(f'{label} Value ({unit}/L):')
        elif index_type.startswith('Flow'):
            lbl_widget.setText(f'{label} Value ({unit}/L):')

    def import_excel_file(self, lbl_browse):
        # Set initial directory to "ts_data" folder
        default_dir = os.path.join(self.plugin_dir, "data", "ts_data")
        options = QFileDialog.Options()

        fileName, _ = QFileDialog.getOpenFileName(
            None,
            "Select Excel File",
            default_dir,  # Start browsing here
            "Excel Files (*.xls *.xlsx)",
            options=options
        )

        if fileName:
            self.imported_excel_file = fileName
            lbl_browse.setText(fileName)

            # Read unique values from sensors data column 'ParameterName'
            self.import_excel_file_flag = False

    def valid_node_ids(self) -> set:
        """Return node IDs available in the loaded model (EPANET/MSX), fallback to layer features."""
        try:
            if hasattr(self, 'G') and self.G:
                ids = self.G.getNodeNameID()
                # epyt may return (names, ids) or just ids
                if isinstance(ids, tuple) and len(ids) == 2:
                    ids = ids[1]
                return set(map(str, ids))
        except Exception:
            pass

        out = set()
        for lyr in (
                getattr(self, 'junctions_layer', None),
                getattr(self, 'reservoirs_layer', None),
                getattr(self, 'tanks_layer', None),
        ):
            if lyr and lyr.isValid():
                out.update(str(f['id']) for f in lyr.getFeatures())
        return out

    def on_node_button_clicked(self, widget):
        # Collect selected node IDs from all relevant layers
        ids = []
        seen = set()
        for lyr in (
                getattr(self, 'junctions_layer', None),
                getattr(self, 'reservoirs_layer', None),
                getattr(self, 'tanks_layer', None),
        ):
            if lyr and lyr.isValid():
                for f in lyr.selectedFeatures():
                    nid = str(f['id'])
                    if nid and nid not in seen:
                        seen.add(nid)
                        ids.append(nid)

        if not ids:
            self.show_message("Warning", "Select one or more nodes on the map.", button="OK", icon="Warning")
            return

        allowed = self.valid_node_ids()
        accepted = [nid for nid in ids if nid in allowed] if allowed else ids
        skipped = [nid for nid in ids if nid not in allowed] if allowed else []

        if not accepted:
            self.show_message("Warning", "Selected nodes are not in the reaction model.", button="OK", icon="Warning")
            return

        # Update the Node ID input field as CSV
        widget.setText(", ".join(accepted))

        # Inform about any skipped IDs
        if skipped:
            preview = ", ".join(skipped[:10]) + (" ..." if len(skipped) > 10 else "")
            self.iface.messageBar().pushMessage("dbpRisk 2.0",
                                                f"Skipped {len(skipped)} node(s) not in reaction model: {preview}",
                                                level=1, duration=5)

    def filter_scenario_table(self, text):
        table = self.dockwidget.scenario_table
        search = text.lower()
        for row in range(table.rowCount()):
            match = False
            for col in range(table.columnCount()):
                item = table.item(row, col)
                if item and search in item.text().lower():
                    match = True
                    break
            table.setRowHidden(row, not match)

    def scenario_delete_accept(self):
        # Get all selected rows
        selected_items = self.dockwidget.scenario_table.selectedItems()
        selected_rows = sorted(set(item.row() for item in selected_items), reverse=True)

        for selected_row in selected_rows:
            self.dockwidget.scenario_table.removeRow(selected_row)

            try:
                self.scenario_input_type_list.pop(selected_row)
                self.node_id_list.pop(selected_row)
                self.injection_rate_list.pop(selected_row)
                self.species_list.pop(selected_row)
                self.species_type_list.pop(selected_row)
                self.multiplication_pattern_list.pop(selected_row)
                self.multiplication_sensor_location_list.pop(selected_row)
                self.custom_pattern_file_path_list.pop(selected_row)
                self.initial_concentration_list.pop(selected_row)
                self.chemical_parameter_list.pop(selected_row)
                self.chemical_parameter_value_list.pop(selected_row)
                self.demand_uncertainty_list.pop(selected_row)
                self.msx_uncertainty_list.pop(selected_row)

            except IndexError:
                print(f"Error: Tried to delete row {selected_row}, but one or more lists are out of sync.")

        self.update_scenario_buttons()

    def scenario_delete(self):
        selected_items = self.dockwidget.scenario_table.selectedItems()
        selected_texts = [item.text() for item in selected_items]
        if any("days" in text for text in selected_texts):
            self.tmp_td = None
        if selected_items:
            selected_rows = sorted(set(item.row() for item in selected_items))
            if len(selected_rows) == 1:
                selected_row = self.dockwidget.scenario_table.currentRow()
                self.showYesNoMessage(
                    "dbpRisk 2.0", f"Action {selected_row + 1} is going to be deleted?",
                    lambda: self.scenario_delete_accept(),
                    lambda: None, "Warning")
            else:
                self.showYesNoMessage(
                    "dbpRisk 2.0", "Selected actions are going to be deleted?",
                    lambda: self.scenario_delete_accept(),
                    lambda: None, "Warning")
        else:
            self.show_message(
                "dbpRisk 2.0", "Please select actions below to delete.", "OK", "Warning")

    # # Add the scenario string to QTableWidget
    # def add_scenario_to_table(self, scenario):
    #     # Split the scenario string into individual key-value pairs
    #     scenario_values = [value.strip() for value in scenario.split(',')]
    #
    #     # Ensure the table has enough columns
    #     if len(scenario_values) > self.dockwidget.scenario_table.columnCount():
    #         self.dockwidget.scenario_table.setColumnCount(len(scenario_values))
    #
    #     # Add a new row to the QTableWidget
    #     row_position = self.dockwidget.scenario_table.rowCount()
    #     self.dockwidget.scenario_table.insertRow(row_position)
    #
    #     # Populate each cell with the full key-value pair
    #     for col, value in enumerate(scenario_values):
    #         self.dockwidget.scenario_table.setItem(row_position, col, QTableWidgetItem(value))

    # Add the scenario string to a single column in the QTableWidget
    # def add_scenario_to_table(self, scenario):
    #     """
    #     Adds the entire scenario as a single entry in one column of the QTableWidget.
    #     """
    #     # Add a new row to the QTableWidget
    #     row_position = self.dockwidget.scenario_table.rowCount()
    #     self.dockwidget.scenario_table.insertRow(row_position)
    #
    #     # Insert the entire scenario string into the first column of the new row
    #     self.dockwidget.scenario_table.setColumnCount(1)  # Ensure only one column exists
    #     self.dockwidget.scenario_table.setItem(row_position, 0, QTableWidgetItem(scenario))

    def add_scenario_to_table(self, scenario):
        """
        Adds the entire scenario as a single entry in one column of the QTableWidget.
        Sets the row background color based on the source of the scenario.
        """
        # Determine the background color based on the active radio button
        # if self.dockwidget.radio_injection.isChecked():
        #     background_color = QColor(255, 255, 153)  # Light yellow
        # elif self.dockwidget.radio_initconce.isChecked():
        #     background_color = QColor(255, 204, 204)  # Light red
        # elif self.dockwidget.radio_chemparameters.isChecked():
        #     background_color = QColor(153, 255, 153)  # Light green
        # else:
        #     background_color = QColor(255, 255, 255)  # Default white

        # Add a new row to the QTableWidget
        row_position = self.dockwidget.scenario_table.rowCount()
        self.dockwidget.scenario_table.insertRow(row_position)

        # Insert the entire scenario string into the first column of the new row
        self.dockwidget.scenario_table.setColumnCount(1)  # Ensure only one column exists
        item = QTableWidgetItem(scenario)
        # item.setBackground(background_color)  # Set the background color
        self.dockwidget.scenario_table.setItem(row_position, 0, item)

        self.update_scenario_buttons()

    # def add_scenario_to_table(self, scenario):
    #     """
    #     Adds the entire scenario as a single entry in one column of the QTableWidget.
    #     Sets the row background color based on the source of the scenario and prevents color change on selection.
    #     """
    #     # Determine the background color based on the active radio button
    #     # if self.dockwidget.radio_injection.isChecked():
    #     #     background_color = QColor(255, 204, 204)  # Light red
    #     # elif self.dockwidget.radio_initconce.isChecked():
    #     #     background_color = QColor(153, 255, 153)  # Light green
    #     # elif self.dockwidget.radio_chemparameters.isChecked():
    #     #     background_color = QColor(153, 204, 255)  # Light blue
    #     # else:
    #     #     background_color = QColor(255, 255, 255)  # Default white
    #
    #     if self.dockwidget.radio_injection.isChecked():
    #         text_color = QColor(255, 0, 0)  # Red
    #     elif self.dockwidget.radio_initconce.isChecked():
    #         text_color = QColor(0, 102, 0)  # Green
    #     elif self.dockwidget.radio_chemparameters.isChecked():
    #         text_color = QColor(0, 0, 255)  # Blue
    #     else:
    #         text_color = QColor(0, 0, 0)  # Default black
    #
    #     # Add a new row to the QTableWidget
    #     row_position = self.dockwidget.scenario_table.rowCount()
    #     self.dockwidget.scenario_table.insertRow(row_position)
    #
    #     # Insert the entire scenario string into the first column of the new row
    #     self.dockwidget.scenario_table.setColumnCount(1)  # Ensure only one column exists
    #     item = QTableWidgetItem(scenario)
    #
    #     # Set the background color and ensure it doesn't change on selection
    #     # item.setBackground(background_color)
    #     # Set the text color explicitly
    #     item.setForeground(text_color)
    #
    #     self.dockwidget.scenario_table.setItem(row_position, 0, item)

    def clear_scenario_table(self):
        self.showYesNoMessage(
            "dbpRisk 2.0",
            "Are you sure you want to clear all actions?",
            lambda: self.clear_scenario_table_accept(),  # Pass the action as a lambda
            lambda: None,  # Do nothing if the user cancels
            "Warning"
        )

    def clear_scenario_table_accept(self):
        # Remove all rows and columns from the QTableWidget
        self.dockwidget.scenario_table.setRowCount(0)
        self.dockwidget.scenario_table.setColumnCount(0)

        # Clear previous entries
        self.scenario_input_type_list.clear()
        self.node_id_list.clear()
        self.injection_rate_list.clear()
        self.species_list.clear()
        self.species_type_list.clear()
        self.multiplication_pattern_list.clear()
        self.multiplication_sensor_location_list.clear()
        self.custom_pattern_file_path_list.clear()
        self.initial_concentration_list.clear()
        self.chemical_parameter_list.clear()
        self.chemical_parameter_value_list.clear()
        self.demand_uncertainty_list.clear()
        self.msx_uncertainty_list.clear()

        self.update_scenario_buttons()

        self.dockwidget.sim_multi_model_species.setEnabled(False)
        self.dockwidget.plot.setEnabled(False)
        self.dockwidget.plot_species_nodes.setEnabled(False)
        self.dockwidget.plot_species_locations.setEnabled(False)
        self.dockwidget.plot_species_unc.setEnabled(False)
        self.dockwidget.plot_species_unc.setEnabled(False)
        self.dockwidget.show_update_map.setEnabled(False)
        self.dockwidget.hour_min_max.setEnabled(False)
        self.dockwidget.upper_lowerbound.setEnabled(False)
        self.dockwidget.sim_hour.setEnabled(False)
        self.dockwidget.select_species.setEnabled(False)

    def save_scenarios_to_csv(self):
        """
        Saves all scenarios from the QTableWidget to a CSV file.
        """
        # Set default folder to scenarios_data directory
        default_dir = os.path.join(self.plugin_dir, "data", "scenarios_data")

        # Use Excel filename (without extension) as default
        default_name = "scenario"
        if hasattr(self, "imported_excel_file") and self.imported_excel_file:
            base_name = os.path.splitext(os.path.basename(self.imported_excel_file))[0]
            default_name = f"scenario_{base_name}"

        options = QFileDialog.Options()
        file_path, _ = QFileDialog.getSaveFileName(
            self.dockwidget,
            "Save Scenarios",
            os.path.join(default_dir, f"{default_name}.csv"),
            "CSV Files (*.csv);;All Files (*)",
            options=options
        )

        # If the user selects a file, proceed with saving
        if file_path:
            try:
                # Open the CSV file for writing
                with open(file_path, mode='w', newline='', encoding='utf-8') as file:
                    writer = csv.writer(file)

                    # Loop through all the rows and columns of the QTableWidget
                    for row in range(self.dockwidget.scenario_table.rowCount()):
                        row_data = []
                        for col in range(self.dockwidget.scenario_table.columnCount()):
                            item = self.dockwidget.scenario_table.item(row, col)
                            if item is not None:
                                row_data.append(item.text())  # Append the cell text
                            else:
                                row_data.append("")  # In case the cell is empty

                        # Write the row to the CSV file
                        writer.writerow(row_data)

                # Display success message after saving
                self.show_message("Success", "Scenario successfully saved to CSV.", button="OK",
                                  icon='Info')

            except Exception as e:
                # Handle any errors (e.g., file write errors)
                print(f"Error saving CSV: {e}")
                self.show_message("Error", "An error occurred while saving the scenario.", button="OK",
                                  icon='Warning')

    def show_message(self, title, msg, button, icon):
        msgBox = QMessageBox()
        if icon == 'Warning':
            msgBox.setIcon(QMessageBox.Warning)
        if icon == 'Info':
            msgBox.setIcon(QMessageBox.Information)
        msgBox.setWindowTitle(title)
        msgBox.setText(msg)
        msgBox.setStandardButtons(QMessageBox.Ok)
        font = QFont()
        font.setPointSize(9)
        msgBox.setFont(font)
        msgBox.setWindowFlags(Qt.CustomizeWindowHint | Qt.WindowStaysOnTopHint | Qt.WindowCloseButtonHint)
        buttonY = msgBox.button(QMessageBox.Ok)
        buttonY.setText(button)
        buttonY.setFont(font)
        msgBox.exec_()

    def load_scenarios_from_csv(self):
        """
        Loads scenarios from a CSV file into the QTableWidget.
        """
        # Set default folder to scenarios_data directory
        default_dir = os.path.join(self.plugin_dir, "data", "scenarios_data")
        options = QFileDialog.Options()

        file_path, _ = QFileDialog.getOpenFileName(
            self.dockwidget,
            "Open Scenarios CSV",
            default_dir,
            "CSV Files (*.csv);;All Files (*)",
            options=options
        )

        # If the user selects a file, proceed with loading
        if file_path:
            try:
                # Open the CSV file for reading
                with open(file_path, mode='r', newline='', encoding='utf-8') as file:
                    reader = csv.reader(file)

                    # Clear the table before loading new data
                    self.dockwidget.scenario_table.setRowCount(0)  # Clear existing rows
                    self.dockwidget.scenario_table.setColumnCount(0)  # Clear existing columns

                    # Read the rows from the CSV and add them to the QTableWidget
                    for row_data in reader:
                        if row_data:  # Ensure the row isn't empty
                            # Remove any trailing empty cells
                            row_data = [data for data in row_data if data.strip()]

                            # Skip empty rows after cleaning
                            if not row_data:
                                continue

                            row_position = self.dockwidget.scenario_table.rowCount()  # Get the current row count
                            self.dockwidget.scenario_table.insertRow(row_position)  # Insert a new row at the end

                            # If the row is longer than the current column count, adjust column count
                            if len(row_data) > self.dockwidget.scenario_table.columnCount():
                                self.dockwidget.scenario_table.setColumnCount(len(row_data))

                            # Populate each cell with the value from the CSV
                            for col, value in enumerate(row_data):
                                # Ensure there is a valid item at the cell and populate it
                                item = QTableWidgetItem(value)  # Create a table item with the value
                                self.dockwidget.scenario_table.setItem(row_position, col,
                                                                       item)  # Set the item at the correct cell

                # Display success message after loading
                self.show_message("Success", "Scenario successfully loaded from CSV file.", button="OK",
                                  icon='Info')

            except Exception as e:
                # Handle any errors (e.g., file read errors)
                print(f"Error loading CSV: {e}")
                self.show_message("Error", "An error occurred while loading the scenario.", button="OK",
                                  icon='Warning')

        # Populate internal scenario lists
        # Clear previous entries
        self.scenario_input_type_list.clear()
        self.node_id_list.clear()
        self.injection_rate_list.clear()
        self.species_list.clear()
        self.species_type_list.clear()
        self.multiplication_pattern_list.clear()
        self.multiplication_sensor_location_list.clear()
        self.custom_pattern_file_path_list.clear()
        self.initial_concentration_list.clear()
        self.chemical_parameter_list.clear()
        self.chemical_parameter_value_list.clear()
        self.demand_uncertainty_list.clear()
        self.msx_uncertainty_list.clear()

        # Parse each row and update lists
        row_count = self.dockwidget.scenario_table.rowCount()
        for row in range(row_count):
            item_text = self.dockwidget.scenario_table.item(row, 0).text()

            # Default values
            input_type = None
            node_id = None
            species = None
            injection_type = None
            injection_rate = None
            initial_conc = None
            chemical_param = None
            chemical_value = None
            demand_uncertainty = None
            msx_uncertainty = None
            multiplication_pattern = None
            multiplication_sensor_location = None
            custom_pattern_file_path = None

            if "Injection Rate" in item_text:
                input_type = 1
                try:
                    node_id = item_text.split("NodeID:")[1].split(",")[0].strip()
                    species = item_text.split("Species:")[1].split(",")[0].strip()
                    pattern_full = item_text.split("Pattern:")[1].split(",")[0].strip()

                    multiplication_pattern = ""
                    multiplication_sensor_location = ""
                    custom_pattern_file_path = ""

                    if pattern_full.lower() == "none":
                        # Option 1: Explicit None
                        pass
                    elif "(" in pattern_full and pattern_full.endswith(")"):
                        # Option 1: multiplication_pattern_excel(sensor_location)
                        multiplication_pattern = pattern_full.split("(")[0].strip()
                        multiplication_sensor_location = pattern_full.split("(")[1].rstrip(")").strip()
                    else:
                        # Option 3: custom_pattern_file
                        custom_pattern_file_path = os.path.join(
                            self.plugin_dir, "data", "pattern_data", pattern_full)

                    injection_type = item_text.split("Type:")[1].split(",")[0].strip()
                    injection_rate = float(item_text.split("Injection Rate:")[1].split(",")[0].strip())
                    msx_uncertainty = float(item_text.split("Uncertainty (%):")[1].strip())

                except Exception:
                    continue

            elif "Initial Concentration" in item_text:
                input_type = 2
                try:
                    node_id = item_text.split("NodeID:")[1].split(",")[0].strip()
                    species = item_text.split("Species:")[1].split(",")[0].strip()
                    initial_conc = float(item_text.split("Initial Concentration:")[1].split(",")[0].strip())
                    msx_uncertainty = float(item_text.split("Uncertainty (%):")[1].strip())
                except Exception:
                    continue

            elif "Chemical Parameter" in item_text:
                input_type = 3
                try:
                    chemical_param = item_text.split("Chemical Parameter:")[1].split(",")[0].strip()
                    chemical_value = float(item_text.split("Value:")[1].split(",")[0].strip())
                    msx_uncertainty = float(item_text.split("Uncertainty (%):")[1].strip())
                except Exception:
                    continue

            elif "Demands Uncertainty" in item_text:
                input_type = 4
                try:
                    demand_uncertainty = float(item_text.split("Demands Uncertainty (%):")[1].strip())
                except Exception:
                    continue

            elif "Simulation duration" in item_text:
                try:
                    duration_days = int(item_text.split(":")[-1].strip())
                    self.t_d = duration_days
                except Exception:
                    continue

            # Append to internal lists
            self.scenario_input_type_list.append(input_type)
            self.node_id_list.append(node_id)
            self.injection_rate_list.append(injection_rate)
            self.species_list.append(species)
            self.species_type_list.append(injection_type)
            self.multiplication_pattern_list.append(multiplication_pattern)
            self.multiplication_sensor_location_list.append(multiplication_sensor_location)
            self.custom_pattern_file_path_list.append(custom_pattern_file_path)
            self.initial_concentration_list.append(initial_conc)
            self.chemical_parameter_list.append(chemical_param)
            self.chemical_parameter_value_list.append(chemical_value)
            self.demand_uncertainty_list.append(demand_uncertainty)
            self.msx_uncertainty_list.append(msx_uncertainty)

        self.update_scenario_buttons()

    def update_scenario_list(self):
        self.scenario_input_type = self.dockwidget.scenarios_tabs.currentIndex() + 1

        # Initialize
        self.nodeID = None
        self.species = None
        self.injection_type = None
        self.injection_rate = None
        self.msx_uncertainty = None
        self.initial_concentration = None
        self.msx_uncertainty = None
        self.chemical_parameter = None
        self.parameter_value = None
        self.hydrparam_uncertainty = None
        self.multiplication_pattern = None
        self.multiplication_sensor_location = None
        self.custom_pattern_file_path = None

        # Action types
        skipped = []
        if self.scenario_input_type == 1:
            all_nodes = self.dockwidget.all_nodes_injection.isChecked()
            allowed = self.valid_node_ids() or set()
            if all_nodes:
                nodeIDs = "all_nodes_injection"
            else:
                import re
                raw = self.search_widget_inj.search_input.text()
                tokens = [t.strip() for t in re.split(r"[,\s;]+", raw) if t.strip()]
                # de-duplicate, preserve order
                seen, nodeIDs = set(), []
                for t in tokens:
                    if t not in seen:
                        seen.add(t)
                        nodeIDs.append(t)

            # Validate list upfront
            if not nodeIDs and not all_nodes:
                self.show_message("Warning", "Please enter at least one node ID.", button="OK", icon="Warning")
                return
            if not all_nodes:
                accepted = [nid for nid in nodeIDs if (not allowed or nid in allowed)]
                skipped = [nid for nid in nodeIDs if nid not in accepted]
                if not accepted:
                    self.show_message("Warning", "No valid node IDs found in the model.", button="OK", icon="Warning")
                    return
            else:
                accepted = [nodeIDs]

            insert_at = len(self.scenario_input_type_list)

            # Common UI fields
            self.species = self.dockwidget.model_species_injection.currentText()
            self.injection_type = self.dockwidget.injection_types.currentText()
            self.injection_rate = self.dockwidget.injection_rate.value()
            self.msx_uncertainty = self.dockwidget.uncertainty_injection.value()
            self.multiplication_pattern = self.dockwidget.multiplication_pattern_excel.currentText().strip()
            self.multiplication_sensor_location = self.dockwidget.sensor_location.currentText().strip()
            self.custom_pattern_file_path = self.dockwidget.custom_pattern_file.currentText().strip()

            if self.custom_pattern_file_path:
                pattern_str = os.path.basename(self.custom_pattern_file_path)
            elif self.multiplication_pattern and self.multiplication_pattern.lower() != "none":
                pattern_str = f"{self.multiplication_pattern}({self.multiplication_sensor_location})"
            else:
                pattern_str = "None"

            for nid in accepted:
                self.nodeID = nid  # set per-row
                scenario = (
                    f"NodeID: {self.nodeID}, "
                    f"Species: {self.species}, "
                    f"Pattern: {pattern_str}, "
                    f"Type: {self.injection_type}, "
                    f"Injection Rate: {self.injection_rate}, "
                    f"Uncertainty (%): {self.msx_uncertainty}"
                )
                self.add_scenario_to_table(scenario)

                # Append row-aligned parameters
                self.scenario_input_type_list.insert(insert_at, self.scenario_input_type)
                self.node_id_list.insert(insert_at, self.nodeID)
                self.injection_rate_list.insert(insert_at, self.injection_rate)
                self.species_list.insert(insert_at, self.species)
                self.species_type_list.insert(insert_at, self.injection_type)
                self.multiplication_pattern_list.insert(insert_at, self.multiplication_pattern)
                self.multiplication_sensor_location_list.insert(insert_at, self.multiplication_sensor_location)
                self.custom_pattern_file_path_list.insert(insert_at, self.custom_pattern_file_path)
                self.initial_concentration_list.insert(insert_at, self.initial_concentration)
                self.chemical_parameter_list.insert(insert_at, self.chemical_parameter)
                self.chemical_parameter_value_list.insert(insert_at, self.parameter_value)
                self.demand_uncertainty_list.insert(insert_at, self.hydrparam_uncertainty)
                self.msx_uncertainty_list.insert(insert_at, self.msx_uncertainty)
                insert_at += 1

            if skipped:
                preview = ", ".join(skipped[:10]) + (" ..." if len(skipped) > 10 else "")
                self.iface.messageBar().pushMessage("dbpRisk 2.0",
                                                    f"Skipped {len(skipped)} node(s): {preview}",
                                                    level=1, duration=5)
            self.update_scenario_buttons()
            return

        # Initial concentration
        elif self.scenario_input_type == 2:
            all_nodes = self.dockwidget.all_nodes_initial.isChecked()
            allowed = self.valid_node_ids() or set()
            if all_nodes:
                nodeIDs = "all_nodes_initial"
            else:
                import re
                raw = self.search_widget_init_conc.search_input.text()
                tokens = [t.strip() for t in re.split(r"[,\s;]+", raw) if t.strip()]
                seen, nodeIDs = set(), []
                for t in tokens:
                    if t not in seen:
                        seen.add(t)
                        nodeIDs.append(t)

            if not nodeIDs and not all_nodes:
                self.show_message("Warning", "Please enter at least one node ID.", button="OK", icon="Warning")
                return
            if not all_nodes:
                accepted = [nid for nid in nodeIDs if (not allowed or nid in allowed)]
                skipped = [nid for nid in nodeIDs if nid not in accepted]
                if not accepted:
                    self.show_message("Warning", "No valid node IDs found in the model.", button="OK", icon="Warning")
                    return
            else:
                accepted = [nodeIDs]

            insert_at = len(self.scenario_input_type_list)

            self.species = self.dockwidget.model_species_initial_conc.currentText()
            self.initial_concentration = self.dockwidget.initial_concentration.value()
            self.msx_uncertainty = self.dockwidget.uncertainty_initial_conc.value()

            for nid in accepted:
                self.nodeID = nid
                scenario = (
                    f"NodeID: {self.nodeID}, "
                    f"Species: {self.species}, "
                    f"Initial Concentration: {self.initial_concentration}, "
                    f"Uncertainty (%): {self.msx_uncertainty}"
                )
                self.add_scenario_to_table(scenario)

                # Input parameters
                self.scenario_input_type_list.insert(insert_at, self.scenario_input_type)
                self.node_id_list.insert(insert_at, self.nodeID)
                self.injection_rate_list.insert(insert_at, self.injection_rate)
                self.species_list.insert(insert_at, self.species)
                self.species_type_list.insert(insert_at, self.injection_type)
                self.multiplication_pattern_list.insert(insert_at, self.multiplication_pattern)
                self.multiplication_sensor_location_list.insert(insert_at, self.multiplication_sensor_location)
                self.custom_pattern_file_path_list.insert(insert_at, self.custom_pattern_file_path)
                self.initial_concentration_list.insert(insert_at, self.initial_concentration)
                self.chemical_parameter_list.insert(insert_at, self.chemical_parameter)
                self.chemical_parameter_value_list.insert(insert_at, self.parameter_value)
                self.demand_uncertainty_list.insert(insert_at, self.hydrparam_uncertainty)
                self.msx_uncertainty_list.insert(insert_at, self.msx_uncertainty)
                insert_at += 1

            if skipped:
                preview = ", ".join(skipped[:10]) + (" ..." if len(skipped) > 10 else "")
                self.iface.messageBar().pushMessage("dbpRisk 2.0",
                                                    f"Skipped {len(skipped)} node(s): {preview}",
                                                    level=1, duration=5)
            self.update_scenario_buttons()

            return



        elif self.scenario_input_type == 3:
            # elif self.dockwidget.scenarios_tabs.currentIndex() == 2:
            self.chemical_parameter = self.dockwidget.chemical_parameters.currentText()
            self.parameter_value = self.dockwidget.chem_parameter_value.value()
            self.msx_uncertainty = self.dockwidget.uncertainty_chem_parameter.value()

            scenario = (f'Chemical Parameter: {self.chemical_parameter}, '
                        f'Value: {self.parameter_value}, '
                        f'Uncertainty (%): {self.msx_uncertainty}')

        elif self.scenario_input_type == 4:
            self.hydrparam_uncertainty = self.dockwidget.uncertainty_hydr_parameter.value()
            scenario = f'Demands Uncertainty (%): {self.hydrparam_uncertainty}'

        elif self.scenario_input_type == 5:
            already_in_table = 0
            row_count = self.dockwidget.scenario_table.rowCount()
            for row in range(row_count):
                item_text = self.dockwidget.scenario_table.item(row, 0).text()
                if "Simulation duration" in item_text:
                    already_in_table = 1
            if already_in_table == 0:
                self.simulation_days = self.dockwidget.simulation_days.value()
                scenario = f'Simulation duration (days): {self.simulation_days}'
                self.t_d = self.simulation_days
                self.tmp_td = self.t_d
            else:
                self.show_message("Warning", "Please remove the existing Simulation duration before adding a new one.",
                                  button="OK", icon="Warning")
                return

        # Append single-row scenarios (3/4/5)
        insert_at = len(self.scenario_input_type_list)
        self.add_scenario_to_table(scenario)

        # Input parameters
        self.scenario_input_type_list.insert(insert_at, self.scenario_input_type)
        self.node_id_list.insert(insert_at, self.nodeID)
        self.injection_rate_list.insert(insert_at, self.injection_rate)
        self.species_list.insert(insert_at, self.species)
        self.species_type_list.insert(insert_at, self.injection_type)
        self.multiplication_pattern_list.insert(insert_at, self.multiplication_pattern)
        self.multiplication_sensor_location_list.insert(insert_at, self.multiplication_sensor_location)
        self.custom_pattern_file_path_list.insert(insert_at, self.custom_pattern_file_path)
        self.initial_concentration_list.insert(insert_at, self.initial_concentration)
        self.chemical_parameter_list.insert(insert_at, self.chemical_parameter)
        self.chemical_parameter_value_list.insert(insert_at, self.parameter_value)
        self.demand_uncertainty_list.insert(insert_at, self.hydrparam_uncertainty)
        self.msx_uncertainty_list.insert(insert_at, self.msx_uncertainty)

        self.update_scenario_buttons()

    def apply_graduated_symbology(self, column_name):
        """
        Apply graduated symbology to self.junctions_layer based on the specified column index.

        :param column_index: The index of the column to use for symbology.
        """
        # Check if the field exists in the layer
        if not self.junctions_layer.fields().indexOf(column_name) >= 0:
            raise ValueError(f"Field '{column_name}' does not exist in the layer.")

        # Create symbol ranges
        ranges = []

        # Define the ranges for graduated symbology
        # Example: Three ranges for demonstration purposes
        if 'CL2' in column_name or 'C_SRA' in column_name or 'C_FRA' in column_name:
            range_definitions = [
                # Updated colors based on the request
                (0, 0.02, "0-0.02", "#003f5c"),  # Blue for lower levels
                (0.02, 0.05, "0.02 - 0.05", "#2f95d9"),  # Cyan for intermediate low levels
                (0.05, 0.1, "0.05 - 0.1", "#81d450"),  # Light green for medium levels
                (0.1, 0.2, "0.1 - 0.2", "#f7a728"),  # Orange for higher levels
                (0.2, 0.3, "0.2 - 0.3", "#ff3333"),  # Even darker red for high levels
                (0.3, float('inf'), "Above 0.3", "#d7191c"),  # Deep red for highest levels
            ]
        elif 'THMs' in column_name:
            range_definitions = [
                # For THMs (Trihalomethanes)
                (0, 5.5, "0-5.5", "#003f5c"),  # Blue for lower levels
                (5.5, 11, "5.5-11", "#2f95d9"),  # Cyan for intermediate low levels
                (11, 16.5, "11-16.5", "#81d450"),  # Light green for medium levels
                (16.5, 22, "16.5-22", "#f7a728"),  # Orange for higher levels
                (22, float('inf'), "Above 22", "#d7191c"),  # Red for highest levels
            ]
        else:
            range_definitions = [
                # For HAAs (Haloacetic Acids)
                (0, 1, "0-1", "#003f5c"),  # Blue for lower levels
                (1, 2, "1-2", "#2f95d9"),  # Cyan for intermediate low levels
                (2, 3, "2-3", "#81d450"),  # Light green for medium levels
                (3, 4, "3-4", "#f7a728"),  # Orange for higher levels
                (4, float('inf'), "Above 4", "#d7191c"),  # Red for highest l
            ]

        for min_value, max_value, label, color in range_definitions:
            symbol = QgsSymbol.defaultSymbol(self.junctions_layer.geometryType())
            symbol.setColor(QColor(color))
            range = QgsRendererRange(min_value, max_value, symbol, label)
            ranges.append(range)

        # Create the renderer
        renderer = QgsGraduatedSymbolRenderer(column_name, ranges)

        # Set the mode to equal interval (can be changed to other modes if needed)
        renderer.setMode(QgsGraduatedSymbolRenderer.EqualInterval)

        # Apply the renderer to the layer
        self.junctions_layer.setRenderer(renderer)

        # Refresh the layer to update the rendering
        self.junctions_layer.triggerRepaint()

    def apply_join(self, target_layer, join_layer, join_field, target_field):
        # Check if the target layer already has the join applied
        existing_joins = target_layer.vectorJoins()
        for join in existing_joins:
            if join.joinLayerId() == join_layer.id():
                # If join exists, remove it
                target_layer.removeJoin(join.joinLayerId())

        # Create new join information
        join_info = QgsVectorLayerJoinInfo()
        join_info.setJoinLayerId(join_layer.id())
        join_info.setJoinLayer(join_layer)
        join_info.setJoinFieldName(join_field)
        join_info.setTargetFieldName(target_field)
        join_info.setUsingMemoryCache(True)

        # Reapply the join
        if target_layer.addJoin(join_info):
            pass
            # print("Join reapplied successfully.")

        # target_layer.reload()
        target_layer.triggerRepaint()
        self.iface.mapCanvas().refresh()

    def load_excel_layer(self, sheet_name, excel_file):
        layer_path = f"{excel_file}|layername={sheet_name}"
        layer = QgsVectorLayer(layer_path, sheet_name, "ogr")
        if not layer.isValid():
            raise ValueError(f"Failed to load layer: {sheet_name}")
        return layer

    def remove_all_joins(self):
        joins = self.junctions_layer.vectorJoins()
        for join in joins:
            self.junctions_layer.removeJoin(join.joinLayerId())

    def update_node_id_enable_initial(self):
        if self.dockwidget.all_nodes_initial.isChecked():
            self.dockwidget.init_conc_search_widget.setEnabled(False)
        else:
            self.dockwidget.init_conc_search_widget.setEnabled(True)

    def show_update_map_call(self):

        self.remove_all_joins()
        mode = self.dockwidget.hour_min_max.currentText().lower()  # 'hour', 'min', 'max', 'mean'
        species = self.dockwidget.select_species.currentText()
        hour = self.dockwidget.sim_hour.value()
        uncertainty_checkbox = self.dockwidget.uncertainty_checkbox.isChecked()
        bounds = self.dockwidget.upper_lowerbound.currentText().lower()

        if bounds == 'upper bound':
            upper_bound = True
            lower_bound = False
        else:
            if bounds == 'lower bound':
                lower_bound = True
                upper_bound = False

        # Min
        if mode == "min":
            column_name = f'{species}_stats_Min'

        # Max
        if mode == "max":
            column_name = f'{species}_stats_Max'

        # Mean
        if mode == "mean":
            column_name = f'{species}_stats_Mean'

        if mode in ['min', 'max', 'mean'] and not uncertainty_checkbox:
            summary_output_path = os.path.join(self.plugin_dir, "data", "tmp_data", "min_max_mean.xlsx")

            sheet_layer = self.load_excel_layer(species + '_stats', excel_file=summary_output_path)

            layer_name = 'min_max_mean'
            if not QgsProject.instance().mapLayersByName(layer_name):
                QgsProject.instance().addMapLayer(sheet_layer, False)

            self.apply_join(self.junctions_layer, sheet_layer, 'NodeID', "id")
            self.apply_graduated_symbology(column_name)
        else:
            if mode in ['min', 'max', 'mean'] and uncertainty_checkbox:
                summary_output_path = os.path.join(self.plugin_dir, "data", "tmp_data", "min_max_mean_uncertainty.xlsx")

                sheet_layer = self.load_excel_layer(species + '_stats', excel_file=summary_output_path)
                layer_name = 'min_max_mean_uncertainty'
                if not QgsProject.instance().mapLayersByName(layer_name):
                    QgsProject.instance().addMapLayer(sheet_layer, False)

                self.apply_join(self.junctions_layer, sheet_layer, 'NodeID', "id")
                self.apply_graduated_symbology(column_name)

        # Hour
        if mode == "hour" and not uncertainty_checkbox:
            self.export_species_hour_to_excel(species, hour)
            column_name = f'{species}_{hour}_Hour = {hour}'
            hour_excel = os.path.join(self.plugin_dir, "data", "tmp_data", 'species_hour_export.xlsx')
            sheet_layer = self.load_excel_layer(f"{species}_{hour}", excel_file=hour_excel)
            layer_name = 'species_hour_export'
            if not QgsProject.instance().mapLayersByName(layer_name):
                QgsProject.instance().addMapLayer(sheet_layer, False)

            self.apply_join(self.junctions_layer, sheet_layer, 'NODE ID', "id")
            self.apply_graduated_symbology(column_name)
        else:
            if mode == "hour" and uncertainty_checkbox:
                self.export_species_hour_to_excel_uncertainty(species, hour)

                hour_excel = os.path.join(self.plugin_dir, "data", "tmp_data", 'species_hour_export_uncertainty.xlsx')
                sheet_layer = self.load_excel_layer(f"{species}_{hour}", excel_file=hour_excel)
                layer_name = 'species_hour_export_uncertainty'
                if not QgsProject.instance().mapLayersByName(layer_name):
                    QgsProject.instance().addMapLayer(sheet_layer, False)
                if upper_bound:
                    column_name = f'{species}_{hour}_Hour = {hour} Upper_Bound'
                if lower_bound:
                    column_name = f'{species}_{hour}_Hour = {hour} Lower_Bound'
                self.apply_join(self.junctions_layer, sheet_layer, 'NODE ID', "id")
                self.apply_graduated_symbology(column_name)

        self.junctions_layer.setDisplayExpression(column_name)

        # Define your HTML map tip
        html_tip = f"""
        <table style="font-size:12px;">
        <tr><td><b>{species}:</b></td><td>[% format_number("{column_name}", 5) %]</td></tr>
        </table>
        """
        # Set the HTML map tip
        self.junctions_layer.setMapTipTemplate(html_tip)
        # Refresh the layer and map canvas
        self.junctions_layer.triggerRepaint()

        QgsSettings().setValue("/MapTips/MapTipsEnabled", True)

        # Show success message
        self.iface.messageBar().clearWidgets()
        self.iface.messageBar().pushMessage("dbpRisk 2.0", "Map updated!", level=3, duration=4)
        self.dockwidget.results_group.setEnabled(True)

    def showError(self):
        try:
            self.timer_watch.stop()
        except:
            pass
        self.iface.messageBar().clearWidgets()

        try:
            os.system(f'taskkill /f /im {self.app}')
            self.iface.messageBar().pushMessage("dbpRisk 2.0", "dbpSimulator successfully terminated.",
                                                level=0, duration=2)
        except:
            self.iface.messageBar().pushMessage("dbpRisk 2.0", "Error Encountered while running "
                                                               "script.", level=1, duration=2)

    def run_message(self):
        # Check if Excel path is empty
        if not self.dockwidget.excel_path.text().strip():
            self.show_message("Warning", "Please select data source.", button="OK",
                              icon="Warning")
            return

        QTimer.singleShot(100, self.run_app)  # Delay of 100 ms
        # self.update_plot_buttons()

    def run_app(self):
        # change current directory 
        os.chdir(self.plugin_dir)

        if not self.import_excel_file_flag:
            self.populate_multiplication_pattern(self.imported_excel_file)
            self.import_excel_file_flag = True
        # df = pd.read_excel(self.dockwidget.excel_path.text())
        self.sensor_id = ['dist412', 'dist1268', 'OutChl', 'WTP']

        # self.df = pd.read_excel(self.imported_excel_file)

        # Simulation duration
        # self.t_d = self.dockwidget.simulation_days.value()

        # Monte Carlo simulations
        monte_carlo_simulations = self.dockwidget.monte_carlo_simulations.value()

        self.populate_sim_hour()
        if not hasattr(self, "t_d"):
            self.t_d = self.dockwidget.simulation_days.value()
        input_parameters = {
            "inpname": self.network_model,  # input name epanet
            "msxname": self.reaction_model,  # msx file
            "excel_file": self.imported_excel_file,  # excel file
            "t_d": self.t_d,  # simulation days
            "monte_carlo_simulations": monte_carlo_simulations,  # monte carlo simulation
            "msx_timestep": 300,  # timestep in seconds 300 = 5 minutes
            "scenario_id": self.dockwidget.scenario_table.rowCount(),  # total number of actions
            "Input_Type": self.scenario_input_type_list,  # list of the scenario type
            "sensor_id": self.node_id_list,  # list of the nodes ids
            "injection_rate": self.injection_rate_list,  # list of the injection rates
            "multiplication_pattern": self.multiplication_pattern_list,  # list of multiplication patterns
            "multiplication_sensor_location": self.multiplication_sensor_location_list,  # list of sensor locations
            "custom_pattern_file_path": self.custom_pattern_file_path_list,  # list of custom patterns
            "species_names": self.species_list,  # list of the species names
            "species_types": self.species_type_list,  # list of the species types
            "initial_concentration": self.initial_concentration_list,  # list of the initial concentrations
            "chemical_param": self.chemical_parameter_list,  # list of the chemical parameters
            "chemical_value": self.chemical_parameter_value_list,  # list of the chemical parameters values
            # "Demand_Uncertainty": 0,  # list of the demand uncertainties
            "Demand_Uncertainty": self.demand_uncertainty_list,  # list of the demand uncertainties
            # "MSX_uncertainty": 0  # list of the msx uncertainties
            "MSX_uncertainty": self.msx_uncertainty_list  # list of the msx uncertainties
        }

        from ..dbpriskapp.processing_task import ProcessingTask
        parameters = {
            "INPUT": json.dumps(input_parameters)
        }

        from PyQt5.QtWidgets import QProgressBar
        from qgis.core import QgsProcessingFeedback

        self.iface.messageBar().clearWidgets()

        progressMessageBar = self.iface.messageBar()
        progressbar = QProgressBar()
        progressMessageBar.pushWidget(progressbar)
        progress_widget = progressMessageBar.pushWidget(progressbar)

        # Processing feedback
        def progress_changed(progress):
            progressbar.setValue(progress)
            if progress == 100:
                self.iface.messageBar().clearWidgets()

        f = QgsProcessingFeedback()
        f.progressChanged.connect(progress_changed)

        task = ProcessingTask(
            description="dbpRisk Algorithm",
            algorithm_id="dbpRiskApps:dbpRisk Algorithm",
            parameters=parameters,
            feedback=f
        )

        task.task_finished.connect(
            lambda result, exception: self.handleTaskFinished(result=result, exception=exception)
        )
        self.dockwidget.results_group.setEnabled(False)
        self.dockwidget.plot.setEnabled(False)
        self.dockwidget.plot_species_nodes.setEnabled(False)
        self.dockwidget.plot_species_locations.setEnabled(False)
        self.dockwidget.plot_species_unc.setEnabled(False)
        self.dockwidget.uncertainty_checkbox.setEnabled(False)
        self.dockwidget.show_update_map.setEnabled(False)
        self.dockwidget.hour_min_max.setEnabled(False)
        self.dockwidget.upper_lowerbound.setEnabled(False)
        self.dockwidget.sim_hour.setEnabled(False)
        self.dockwidget.select_species.setEnabled(False)
        self.tm.addTask(task, priority=1)

    def uncertainty_checkbox(self):
        all_uncertainties_zero = (
                all((val is None or val == 0) for val in self.msx_uncertainty_list) and
                all((val is None or val == 0) for val in self.demand_uncertainty_list)
        )
        if not all_uncertainties_zero:
            self.dockwidget.uncertainty_checkbox.setEnabled(True)
        else:
            self.dockwidget.uncertainty_checkbox.setEnabled(False)

    def update_plot_species_unc_state(self, checked):
        all_uncertainties_zero = (
                all((val is None or val == 0) for val in self.msx_uncertainty_list) and
                all((val is None or val == 0) for val in self.demand_uncertainty_list)
        )
        selected_species = self.dockwidget.sim_multi_model_species.checkedItems()
        selected_species = len(selected_species) == 1
        if checked and not all_uncertainties_zero and selected_species:
            self.dockwidget.plot_species_unc.setChecked(self.prev_plot_species_unc_state)
            self.dockwidget.plot_species_unc.setEnabled(True)
        else:
            self.prev_plot_species_unc_state = self.dockwidget.plot_species_unc.isChecked()
            self.dockwidget.plot_species_unc.setEnabled(False)
            self.dockwidget.plot_species_unc.setChecked(False)

    def plot_all(self):
        # if plot_species_nodes is checked
        if self.dockwidget.plot_species_nodes.isChecked():
            if self.dockwidget.plot_species_unc.isChecked():
                self.plot_uncertainties()
            else:
                self.plot_results()
        elif self.dockwidget.plot_species_locations.isChecked():
            if self.dockwidget.plot_species_unc.isChecked():
                self.plot_uncertainties()
            else:
                self.plot_sensorlocations()

    def plot_sensorlocations(self):

        mes = self.mes
        sensor_id = ['dist412', 'dist1268', 'OutChl', 'WTP']

        sensor_index = []
        for sen_id in sensor_id:
            sensor_index.append(self.node_id.index(sen_id) + 1)
        sensor_description = ['DMA_DP3', 'DMA_inlet', 'Tank_outlet', 'DWTP_outlet']
        selected_species = self.dockwidget.sim_multi_model_species.checkedItems()
        if selected_species:
            species_index = []
            for sen_id in selected_species:
                species_index.append(self.species_names_function.index(sen_id) + 1)
            canvas = self.plot_data(mes, [self.results], sensor_index, species_index, selected_species,
                                    sensor_description, f"")
            self.create_dock_widget_with_plot(canvas)

    def plot_uncertainties(self):
        mes = self.mes
        sensor_index = []
        sensor_ids = []
        sensor_description = None
        if self.dockwidget.plot_species_locations.isChecked():
            sensor_id = ['dist412', 'dist1268', 'OutChl', 'WTP']
            for sen_id in sensor_id:
                sensor_index.append(self.node_id.index(sen_id) + 1)
            sensor_description = ['DMA_DP3', 'DMA_inlet', 'Tank_outlet', 'DWTP_outlet']
        elif self.dockwidget.plot_species_nodes.isChecked():
            selected_features = self.junctions_layer.selectedFeatures()
            if not selected_features:
                self.show_message("Warning", "Please select nodes from the map to plot results.", button="OK",
                                  icon="Warning")
                return
            for i, feature in enumerate(selected_features):
                # Todo: Change for more plots
                sensor_ids.append(feature['id'])
                sensor_index.append(self.node_id.index(feature['id']) + 1)
            sensor_description = sensor_ids
        else:
            self.show_message("Warning", "Please select sensor locations or nodes to plot.", button="OK",
                              icon="Warning")
            return
        selected_species = [self.dockwidget.sim_multi_model_species.currentText()]
        if selected_species:
            species_index = []
            for sen_id in selected_species:
                species_index.append(self.species_names_function.index(sen_id) + 1)
            # canvas = self.sim.plot_data(mes, [self.results], sensor_index, species_index, selected_species, sensor_description, f"")
            canvas = self.plot_data_with_uncertainty(mes, sensor_index, species_index, selected_species,
                                                     sensor_description, f"")
            self.create_dock_widget_with_plot(canvas)

    def create_dock_widget_with_plot(self, canvas):
        self.dock_plots = uic.loadUi('dbp_plots_dockwidget.ui')
        try:
            self.dock_plots.close()
        except:
            pass

        self.dock_plots.setAllowedAreas(Qt.RightDockWidgetArea | Qt.LeftDockWidgetArea)
        # Add the matplotlib canvas and toolbar to the 'plots' QWidget using a grid layout

        canvas.figure.subplots_adjust(left=0.15, bottom=0.05)
        fig_h_px = int(canvas.figure.get_size_inches()[1] * canvas.figure.get_dpi())

        # Keep vertical size fixed and scroll instead of squishing
        canvas.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
        canvas.setMinimumWidth(450)
        canvas.setMinimumHeight(400)
        canvas.setMaximumHeight(fig_h_px)
        canvas.figure.tight_layout()
        nav_bar = NavigationToolbar(canvas, self.dock_plots)

        tb = nav_bar.findChild(QToolBar) or nav_bar
        tb.setToolButtonStyle(Qt.ToolButtonIconOnly)
        tb.setIconSize(QSize(20, 20))
        tb.setMovable(False)

        for btn in tb.findChildren(QToolButton):
            btn.setIconSize(QSize(20, 20))
            btn.setAutoRaise(True)  
            btn.setContentsMargins(0, 0, 0, 0)

        tb.setStyleSheet(
            "QToolBar{border:0; background:transparent; spacing:2px;}"
            "QToolBar QToolButton{margin:0; padding:0;}"
        )

        layout_plt = QGridLayout(self.dock_plots.plots)
        layout_plt.setContentsMargins(0, 0, 0, 0)
        layout_plt.addWidget(canvas, 0, 0)
        self.dock_plots.plots.setLayout(layout_plt)

        layout_nav_bar = QGridLayout(self.dock_plots.nav_bar)
        layout_nav_bar.setContentsMargins(0, 0, 0, 0)
        layout_nav_bar.addWidget(nav_bar, 0, 0)
        self.dock_plots.nav_bar.setLayout(layout_nav_bar)

        # Export button from UI
        export_btn = getattr(self.dock_plots, "export_pdf", None)
        if export_btn is None:
            export_btn = self.dock_plots.findChild(QPushButton, "export_pdf")

        def export_plot_to_pdf():
            file_path, _ = QFileDialog.getSaveFileName(
                self.dock_plots, "Export Plot to PDF", "", "PDF Files (*.pdf)"
            )
            if file_path:
                canvas.figure.savefig(file_path, format="pdf")
                # show success message with a link to open the file
                self.iface.messageBar().clearWidgets()
                self.iface.messageBar().pushMessage(
                    "Save as PDF: ",
                    f'Successfully saved to <a href="file:///{file_path}">{file_path}</a>',
                    level=3,
                    duration=5,
                )

        if export_btn:
            export_btn.clicked.connect(export_plot_to_pdf)

        # Add the dock widget to QGIS above the layers panel
        self.dock_plots.visibilityChanged.connect(lambda visible: self.adjust_canvas_layout(canvas, visible))
        self.iface.addDockWidget(Qt.RightDockWidgetArea, self.dock_plots)
        self.iface.mainWindow().tabifyDockWidget(self.layers_panel, self.dock_plots)

        # Ensure the dock widget is displayed
        self.dock_plots.raise_()

    def adjust_canvas_layout(self, canvas, visible):
        if visible:
            canvas.figure.tight_layout()
            canvas.draw()

    def plot_results(self):
        mes = self.mes
        selected_features = self.junctions_layer.selectedFeatures()
        # if no selected features, stop and prompt the user to select nodes
        if not selected_features:
            self.show_message("Warning", "Please select nodes from the map to plot results.", button="OK",
                              icon="Warning")
            return
        sensor_index = []
        sensor_ids = []
        for i, feature in enumerate(selected_features):
            # Todo: Change for more plots
            sensor_ids.append(feature['id'])
            sensor_index.append(self.node_id.index(feature['id']) + 1)

        # sensor_description = ['DMA_DP3', 'DMA_inlet', 'Tank_outlet', 'DWTP_outlet']
        sensor_description = sensor_ids
        selected_species = self.dockwidget.sim_multi_model_species.checkedItems()
        if selected_species:
            species_index = []
            for sen_id in selected_species:
                species_index.append(self.species_names_function.index(sen_id) + 1)
            canvas = self.plot_data(mes, [self.results], sensor_index, species_index, selected_species,
                                    sensor_description, "",
                                    show_measured=False)
            self.create_dock_widget_with_plot(canvas)

    def showYesNoMessage(self, title, msg, yesMethod, noMethod, icon):
        msgBox = QMessageBox()
        if icon == 'Warning':
            msgBox.setIcon(QMessageBox.Warning)
        if icon == 'Info':
            msgBox.setIcon(QMessageBox.Information)
        msgBox.setWindowTitle(title)
        msgBox.setText(msg)
        msgBox.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
        # msgBox.setWindowFlags(Qt.CustomizeWindowHint | Qt.WindowStaysOnTopHint | Qt.WindowCloseButtonHint)
        buttonY = msgBox.button(QMessageBox.Yes)
        buttonY.setText('OK')
        buttonY.clicked.connect(yesMethod)
        buttonNo = msgBox.button(QMessageBox.No)
        buttonNo.clicked.connect(noMethod)
        buttonNo.setText('Cancel')
        msgBox.exec_()

    def populate_model_files(self, subfolder, extension, exclude_suffix, target_combobox):
        folder_path = os.path.join(self.plugin_dir, "data", subfolder)

        if not os.path.exists(folder_path):
            return

        files = [
            f for f in os.listdir(folder_path) if f.endswith(extension) and not f.endswith(exclude_suffix)
        ]

        target_combobox.clear()

        # Add empty item
        target_combobox.addItem("")

        # Add Browse option with bold font
        browse_text = "IMPORT FILE"
        target_combobox.addItem(browse_text)
        browse_index = target_combobox.findText(browse_text)
        bold_font = QFont()
        bold_font.setBold(True)
        target_combobox.model().item(browse_index).setFont(bold_font)

        target_combobox.addItems(files)

    def ignore_hours(self):
        if self.dockwidget.hour_min_max.currentText() != 'hour':
            self.dockwidget.sim_hour.setEnabled(False)
            self.dockwidget.upper_lowerbound.setEnabled(False)
        else:
            self.dockwidget.sim_hour.setEnabled(True)

    def ignore_upper_lowerbound(self):
        self.dockwidget.upper_lowerbound.setEnabled(False)

        if self.dockwidget.hour_min_max.currentText() == "hour" and self.dockwidget.uncertainty_checkbox.isChecked():
            self.dockwidget.upper_lowerbound.setEnabled(True)

    def show_hydraulic_info(self):
        """Displays a summary of the selected hydraulic model using getCounts() from epanet."""

        # Get selected filename from the dropdown
        selected_model = self.dockwidget.network_models.currentText()

        if not selected_model:
            self.show_message("Warning", "Please select a hydraulic model.", button="OK", icon="Warning")
            return

        if selected_model == 'IMPORT FILE':
            self.show_message("Warning", "Please select a hydraulic model.", button="OK", icon="Warning")
            return

        # Build full path to the selected .inp file
        model_path = os.path.join(self.plugin_dir, "data", "network_models", selected_model)

        if not os.path.exists(model_path):
            self.show_message("Warning", f"Selected network model file not found:\n{model_path}", button="OK",
                              icon="Warning")
            return

        # Load the model using epanet
        try:
            self.G = epanet(model_path)
            counts = self.G.getCounts()

            msg = (
                f"Hydraulic Model: {selected_model}\n\n"
                f"Nodes: {counts.Nodes}\n"
                f"  ├─ Junctions: {counts.Junctions}\n"
                f"  ├─ Reservoirs: {counts.Reservoirs}\n"
                f"  └─ Tanks: {counts.Tanks}\n\n"
                f"Links: {counts.Links}\n"
                f"  ├─ Pipes: {counts.Pipes}\n"
                f"  ├─ Pumps: {counts.Pumps}\n"
                f"  └─ Valves: {counts.Valves}\n\n"
                f"Patterns: {counts.Patterns}\n\n"
                f"Curves: {counts.Curves}\n\n"
                f"Controls:\n"
                f"  ├─ Simple: {counts.SimpleControls}\n"
                f"  └─ Rule-Based: {counts.RuleBasedControls}"
            )

            self.show_message("Hydraulic Model", msg, button="OK", icon="Info")

        except Exception as e:
            self.show_message("Error", f"Failed to load the network model:\n{e}", button="OK",
                              icon="Warning")

    def show_reaction_info(self):
        """Displays the content of the selected reaction model (.msx file) in a scrollable text dialog."""

        selected_model = self.dockwidget.reaction_models.currentText()

        if not selected_model:
            self.show_message("Warning", "Please select a reaction model.", button="OK", icon="Warning")
            return

        if selected_model == 'IMPORT FILE':
            self.show_message("Warning", "Please select a reaction model.", button="OK", icon="Warning")
            return

        full_path = os.path.join(self.plugin_dir, "data", "reaction_models", selected_model)

        if not os.path.exists(full_path):
            self.show_message("Error", f"Selected reaction model file not found:\n{full_path}", button="OK",
                              icon="Warning")
            return

        try:
            with open(full_path, 'r', encoding='utf-8') as file:
                content = file.read()
        except Exception as e:
            self.show_message("Error", f"Failed to read file:\n{e}", button="OK", icon="Warning")
            return

        # Create and configure the dialog
        dialog = QDialog(self.dockwidget)
        dialog.setWindowTitle(f"Reaction Model: {selected_model}")
        dialog.setMinimumSize(600, 500)

        layout = QVBoxLayout(dialog)

        text_edit = QTextEdit()
        text_edit.setReadOnly(True)
        text_edit.setPlainText(content)
        layout.addWidget(text_edit)

        close_button = QPushButton("Close")
        close_button.clicked.connect(dialog.accept)
        layout.addWidget(close_button)

        dialog.exec_()

    # Deprecated  TODO: Remove in future versions
    # def open_data_folder(self):
    #     folder_path = os.path.join(self.plugin_dir, "data")
    #
    #     if not os.path.exists(folder_path):
    #         self.show_message("Error", f"The folder does not exist:\n{folder_path}", button="OK", icon="Warning")
    #         return
    #
    #     try:
    #         if platform.system() == 'Windows':
    #             os.startfile(folder_path)
    #         elif platform.system() == 'Darwin':  # macOS
    #             subprocess.run(['open', folder_path])
    #         else:  # Linux and others
    #             subprocess.run(['xdg-open', folder_path])
    #     except Exception as e:
    #         self.show_message("Error", f"Failed to open folder:\n{e}", button="OK", icon="Warning")

    def show_data_manager_dialog(self):
        from PyQt5 import QtWidgets, uic
        from PyQt5.QtWidgets import QDialog, QTreeView, QMessageBox, QFileSystemModel
        from PyQt5.QtCore import QDir
        import sys, os, subprocess

        dialog = QDialog(self.dockwidget)

        ui_path = os.path.join(self.plugin_dir, 'dbp_data_manager_dialog.ui')
        uic.loadUi(ui_path, dialog)

        file_model = QFileSystemModel()
        file_model.setRootPath(QDir.rootPath())
        file_model.setNameFilters(['*.msx', '*.inp', '*.xlsx', '*.xls', '*.csv', '*.txt'])
        file_model.setNameFilterDisables(False)

        tree_view = QTreeView()
        tree_view.setModel(file_model)
        tree_view.setRootIndex(file_model.index(os.path.join(self.plugin_dir, "data")))
        for col in range(1, file_model.columnCount()):
            tree_view.hideColumn(col)
        dialog.verticalLayout.addWidget(tree_view)

        def get_selected_file():
            indexes = tree_view.selectionModel().selectedIndexes()
            if indexes:
                index = indexes[0]
                if not file_model.isDir(index):
                    return file_model.filePath(index)
            return None

        def open_file():
            path = get_selected_file()
            if not path:
                QMessageBox.warning(dialog, 'No file', 'Please select a file.')
                return
            if sys.platform.startswith('linux'):
                subprocess.Popen(['xdg-open', path])
            elif sys.platform == 'darwin':
                subprocess.Popen(['open', path])
            elif sys.platform == 'win32':
                os.startfile(path)

        def delete_file():
            path = get_selected_file()
            if not path:
                QMessageBox.warning(dialog, 'No file', 'Please select a file.')
                return
            reply = QMessageBox.question(
                dialog,
                'Delete File',
                f'Are you sure you want to delete:\n{path}?',
                QMessageBox.Yes | QMessageBox.No,
                QMessageBox.No,
            )
            if reply == QMessageBox.Yes:
                try:
                    os.remove(path)
                except Exception as e:
                    QMessageBox.critical(dialog, 'Error', f'Failed to delete file:\n{e}')

        dialog.btnOpen.clicked.connect(open_file)
        dialog.btnDelete.clicked.connect(delete_file)

        dialog.show()

    def set_default_parameter_value(self):
        selected_param = self.dockwidget.chemical_parameters.currentText()
        if hasattr(self, 'parameter_defaults') and selected_param in self.parameter_defaults:
            default_value = self.parameter_defaults[selected_param]
            self.dockwidget.chem_parameter_value.setValue(default_value)

    def update_scenario_buttons(self):
        """
        Enables 'export_scenario' and 'run_simulation' buttons only if scenario_table has rows.
        """
        has_rows = self.dockwidget.scenario_table.rowCount() > 0
        self.dockwidget.export_scenario.setEnabled(has_rows)
        self.dockwidget.run_simulation.setEnabled(has_rows)
        self.dockwidget.label_sim_days.setEnabled(has_rows)
        self.dockwidget.simulation_days.setEnabled(has_rows)
        self.dockwidget.monte_carlo_simulations.setEnabled(has_rows)
        self.dockwidget.delete_action.setEnabled(has_rows)
        self.dockwidget.clear_scenario.setEnabled(has_rows)
        self.dockwidget.label_monte_carlo.setEnabled(has_rows)
        self.dockwidget.monte_carlo_simulations.setEnabled(has_rows)

    def update_plot_buttons(self):
        """
        Enables plot buttons only if at least one species is checked.
        """
        checked_items = self.dockwidget.sim_multi_model_species.checkedItems()
        enabled = bool(checked_items)

        self.dockwidget.plot.setEnabled(enabled)
        self.dockwidget.plot_species_nodes.setEnabled(enabled)
        self.dockwidget.plot_species_locations.setEnabled(enabled)
        # self.dockwidget.plot_species_unc.setEnabled(enabled)
        all_uncertainties_zero = (
                all((val is None or val == 0) for val in self.msx_uncertainty_list) and
                all((val is None or val == 0) for val in self.demand_uncertainty_list)
        )
        selected_species = self.dockwidget.sim_multi_model_species.checkedItems()
        selected_species = len(selected_species) == 1
        checked = self.dockwidget.plot_species_locations.isChecked() or self.dockwidget.plot_species_nodes.isChecked()
        if not all_uncertainties_zero and selected_species and checked:
            self.dockwidget.plot_species_unc.setChecked(self.prev_plot_species_unc_state)
            self.dockwidget.plot_species_unc.setEnabled(True)
        else:
            self.prev_plot_species_unc_state = self.dockwidget.plot_species_unc.isChecked()
            self.dockwidget.plot_species_unc.setEnabled(False)
            self.dockwidget.plot_species_unc.setChecked(False)

    def export_species_hour_to_excel(self, selected_species, hour):
        target_seconds = hour * 3600

        combined_df = self.dataframe

        if target_seconds not in combined_df.columns:
            raise ValueError(f"Time {target_seconds} seconds (from hour {hour}) not found in DataFrame.")

        filtered_df = combined_df[combined_df['SPECIES'] == selected_species]
        if filtered_df.empty:
            raise ValueError(f"No data found for species '{selected_species}'.")

        result_df = filtered_df[['NODE ID', target_seconds]].copy()
        time_label = f"Hour = {hour}"
        result_df.rename(columns={target_seconds: time_label}, inplace=True)

        self.output_file = os.path.join(self.plugin_dir, "data", "tmp_data", 'species_hour_export.xlsx')

        with pd.ExcelWriter(self.output_file, engine='xlsxwriter') as writer:
            sheet_name = f"{selected_species}_{hour}"
            result_df.to_excel(writer, sheet_name=sheet_name, index=False)

        return result_df

    def export_species_hour_to_excel_uncertainty(self, selected_species, hour):
        """
        Export lower and upper bounds of concentrations at a specific hour for a given species,
        aggregated across all simulation runs.

        Parameters:
            selected_species (str): The species to extract.
            hour (int or float): The hour at which to extract values.
        """
        target_seconds = hour * 3600
        combined_df = self.dataframe_uncertainty

        if target_seconds not in combined_df.columns:
            raise ValueError(f"Time {target_seconds} seconds (from hour {hour}) not found in DataFrame.")

        filtered_df = combined_df[combined_df['SPECIES'] == selected_species]
        if filtered_df.empty:
            raise ValueError(f"No data found for species '{selected_species}'.")

        grouped = filtered_df.groupby('NODE ID')[target_seconds]
        result_df = grouped.agg(Lower_Bound='min', Upper_Bound='max').reset_index()
        time_label = f"Hour = {hour}"
        result_df.rename(columns={target_seconds: time_label}, inplace=True)
        result_df.columns = ['NODE ID', f"Hour = {hour} Lower_Bound",
                             f"Hour = {hour} Upper_Bound"]

        self.output_file_uncertainty = os.path.join(self.plugin_dir, "data", "tmp_data",
                                                    'species_hour_export_uncertainty.xlsx')

        with pd.ExcelWriter(self.output_file_uncertainty, engine='xlsxwriter') as writer:
            sheet_name = f"{selected_species}_{hour}"
            result_df.to_excel(writer, sheet_name=sheet_name, index=False)

        return result_df

    def populate_sim_hour(self):
        t_d = self.dockwidget.simulation_days.value()
        total_hours = t_d * 24

        self.dockwidget.sim_hour.setValue(0)

        self.dockwidget.sim_hour.setMinimum(0)
        self.dockwidget.sim_hour.setMaximum(total_hours)

    def on_network_model_changed(self, index):
        combo = self.dockwidget.network_models
        browse_text = "IMPORT FILE"

        if combo.itemText(index) == browse_text:
            self.model_file_path, _ = QFileDialog.getOpenFileName(
                self.dockwidget, "Select Network Model (.inp)", "", "INP Files (*.inp);;All Files (*)"
            )

            # If user cancels
            if not self.model_file_path:
                combo.setCurrentIndex(0)  # Reset to first item (assumed to be blank)
                return

            file_name = os.path.basename(self.model_file_path)

            # Check if file already exists in combo box
            if combo.findText(file_name) != -1:
                self.show_message("Warning", f"'{file_name}' already exists in the model list.", button="OK",
                                  icon="Warning")
                combo.setCurrentIndex(0)  # Reset to blank if duplicate
                return  # Exit early

            internal_path = os.path.join(self.plugin_dir, 'data', 'network_models', file_name)

            # Copy to plugin directory
            shutil.copy(self.model_file_path, internal_path)

            # Insert and select
            combo.insertItem(combo.count() - 1, file_name)
            combo.setCurrentText(file_name)

    def on_reaction_model_changed(self, index):
        combo = self.dockwidget.reaction_models
        browse_text = "IMPORT FILE"

        if combo.itemText(index) == browse_text:
            self.model_file_path, _ = QFileDialog.getOpenFileName(
                self.dockwidget, "Select Reaction Model (.msx)", "", "MSX Files (*.msx);;All Files (*)"
            )

            # If user cancels
            if not self.model_file_path:
                combo.setCurrentIndex(0)  # Reset to first item (assumed to be blank)
                return

            file_name = os.path.basename(self.model_file_path)

            # Check if file already exists in combo box
            if combo.findText(file_name) != -1:
                self.show_message("Warning", f"'{file_name}' already exists in the model list.", button="OK",
                                  icon="Warning")
                combo.setCurrentIndex(0)  # Reset to blank if duplicate
                return  # Exit early

            internal_path = os.path.join(self.plugin_dir, 'data', 'reaction_models', file_name)

            # Copy to plugin directory
            shutil.copy(self.model_file_path, internal_path)

            # Insert and select
            combo.insertItem(combo.count() - 1, file_name)
            combo.setCurrentText(file_name)

    def populate_multiplication_pattern(self, file_path):
        combo = self.dockwidget.multiplication_pattern_excel
        combo.clear()

        try:
            df = pd.read_excel(file_path)

            if 'ParameterName' not in df.columns:
                self.show_message("Error", "'ParameterName' column not found in the Excel file.", button="OK",
                                  icon="Warning")
                return

            unique_params = sorted(df['ParameterName'].dropna().unique())

            combo.addItem("")  # Add empty item first
            combo.addItems(unique_params)

            # Populate sensor locations from same file
            self.populate_sensor_locations(file_path)

        except Exception as e:
            self.show_message("Error", f"Failed to read Excel file:\n{str(e)}", button="OK", icon="Critical")

    def populate_sensor_locations(self, file_path):
        combo = self.dockwidget.sensor_location
        combo.clear()

        try:
            df = pd.read_excel(file_path)

            if 'SensorLocation' not in df.columns:
                self.show_message("Error", "'SensorLocation' column not found in the Excel file.", button="OK",
                                  icon="Warning")
                return

            unique_locations = sorted(df['SensorLocation'].dropna().unique())
            combo.addItem("")  # Add first blank item
            combo.addItems(unique_locations)

        except Exception as e:
            self.show_message("Error", f"Failed to load sensor locations:\n{str(e)}", button="OK", icon="Critical")

    def on_multiplication_pattern_selected(self, index):
        combo = self.dockwidget.multiplication_pattern_excel
        is_custom_disabled = index > 0  # Disable custom if non-empty selection

        self.dockwidget.label_custom_pattern.setEnabled(not is_custom_disabled)
        self.dockwidget.custom_pattern_file.setEnabled(not is_custom_disabled)

        # If custom pattern text is filled, override and disable the other combo
        custom_path = self.dockwidget.custom_pattern_file.currentText().strip()
        disable_multiplication = bool(custom_path)

        self.dockwidget.label_multiplication_pattern.setEnabled(not disable_multiplication)
        self.dockwidget.multiplication_pattern_excel.setEnabled(not disable_multiplication)
        self.dockwidget.label_sensor_locations.setEnabled(not disable_multiplication)
        self.dockwidget.sensor_location.setEnabled(not disable_multiplication)

        # If empty is selected in multiplication_pattern_excel, also set sensor_location to its first item (blank)
        if combo.currentText().strip() == "":
            self.dockwidget.sensor_location.setCurrentIndex(0)

        self.on_custom_pattern_changed("")

    def on_sensor_location_selected(self, index):
        combo = self.dockwidget.sensor_location
        selected = combo.currentText().strip()

        disable_custom = bool(selected)

        self.dockwidget.label_custom_pattern.setEnabled(not disable_custom)
        self.dockwidget.custom_pattern_file.setEnabled(not disable_custom)

        self.on_custom_pattern_changed("")

    def on_custom_pattern_changed(self, text):
        # Determine if custom pattern should disable the multiplication inputs
        disable_multiplication = bool(text.strip())

        self.dockwidget.label_multiplication_pattern.setEnabled(not disable_multiplication)
        self.dockwidget.multiplication_pattern_excel.setEnabled(not disable_multiplication)
        self.dockwidget.label_sensor_locations.setEnabled(not disable_multiplication)
        self.dockwidget.sensor_location.setEnabled(not disable_multiplication)

        # Now control custom_pattern_file: only enable if both multiplications are empty
        pattern_selected = self.dockwidget.multiplication_pattern_excel.currentText().strip()
        sensor_selected = self.dockwidget.sensor_location.currentText().strip()

        enable_custom = not pattern_selected and not sensor_selected
        self.dockwidget.label_custom_pattern.setEnabled(enable_custom)
        self.dockwidget.custom_pattern_file.setEnabled(enable_custom)

    def open_network_model_in_notepad(self):
        selected_model = self.dockwidget.network_models.currentText()
        if not selected_model or selected_model.strip().upper() == "IMPORT FILE":
            self.show_message("Warning", "Please select a hydraulic model.", button="OK", icon="Warning")
            return

        model_path = os.path.join(self.plugin_dir, "data", "network_models", selected_model)

        if not os.path.exists(model_path):
            self.show_message("Warning", f"Hydraulic model file not found:\n{model_path}", button="OK", icon="Warning")
            return

        try:
            if platform.system() == 'Windows':
                subprocess.Popen(['notepad.exe', model_path])
            else:
                subprocess.Popen(['xdg-open', model_path])
        except Exception as e:
            self.show_message("Error", f"Could not open file in Notepad:\n{e}", button="OK", icon="Critical")

    def open_reaction_model_in_notepad(self):
        selected_model = self.dockwidget.reaction_models.currentText()
        if not selected_model or selected_model.strip().upper() == "IMPORT FILE":
            self.show_message("Warning", "Please select a reaction model.", button="OK", icon="Warning")
            return

        model_path = os.path.join(self.plugin_dir, "data", "reaction_models", selected_model)

        if not os.path.exists(model_path):
            self.show_message("Warning", f"Reaction model file not found:\n{model_path}", button="OK", icon="Warning")
            return

        try:
            if platform.system() == 'Windows':
                subprocess.Popen(['notepad.exe', model_path])
            else:
                subprocess.Popen(['xdg-open', model_path])
        except Exception as e:
            self.show_message("Error", f"Could not open file in Notepad:\n{e}", button="OK", icon="Critical")

    def open_network_model_in_epanet(self):
        selected_model = self.dockwidget.network_models.currentText()
        if not selected_model or selected_model.strip().upper() == "IMPORT FILE":
            self.show_message("Warning", "Please select a hydraulic model.", button="OK", icon="Warning")
            return

        model_path = os.path.join(self.plugin_dir, "data", "network_models", selected_model)

        if not os.path.exists(model_path):
            self.show_message("Warning", f"Hydraulic model file not found:\n{model_path}", button="OK", icon="Warning")
            return

        # Path to EPANET executable – update this if installed elsewhere
        epanet_exe = "C:\\Program Files (x86)\\EPANET 2.2\\Epanet2w.exe"

        if not os.path.exists(epanet_exe):
            self.show_message("Error", f"EPANET not found at:\n{epanet_exe}", button="OK", icon="Critical")
            return

        try:
            subprocess.Popen([epanet_exe, model_path])
        except Exception as e:
            self.show_message("Error", f"Could not open in EPANET:\n{e}", button="OK", icon="Critical")

    def override_timeduration(self):
        if self.dockwidget.override_time_checkbox.isChecked():
            self.t_d = self.dockwidget.simulation_days.value()
        else:
            if not hasattr(self, "tmp_td") or self.tmp_td is None:
                self.t_d = self.dockwidget.simulation_days.value()
            else:
                self.t_d = self.tmp_td

    def on_custom_pattern_file_changed(self, index):
        combo = self.dockwidget.custom_pattern_file
        browse_text = "IMPORT FILE"

        if combo.itemText(index) == browse_text:
            pattern_folder = os.path.join(self.plugin_dir, "data", "pattern_data")
            file_path, _ = QFileDialog.getOpenFileName(
                self.dockwidget,
                "Select Custom Pattern File",
                pattern_folder,
                "All Files (*);;CSV Files (*.csv);;Excel Files (*.xls *.xlsx)"
            )

            if not file_path:
                combo.setCurrentIndex(0)  # Reset to first blank
                return

            file_name = os.path.basename(file_path)

            # Check for duplicates
            if combo.findText(file_name) != -1:
                self.show_message("Warning", f"'{file_name}' already exists in the pattern list.", button="OK",
                                  icon="Warning")
                combo.setCurrentIndex(0)
                return

            internal_path = os.path.join(pattern_folder, file_name)
            shutil.copy(file_path, internal_path)

            # Insert new file
            combo.insertItem(combo.count() - 1, file_name)
            combo.setCurrentText(file_name)

        # Disable multiplication pattern Excel controls if any file is selected
        selected = combo.currentText().strip()
        if selected:
            self.dockwidget.label_multiplication_pattern.setEnabled(False)
            self.dockwidget.multiplication_pattern_excel.setEnabled(False)
            self.dockwidget.label_sensor_locations.setEnabled(False)
            self.dockwidget.sensor_location.setEnabled(False)
        else:
            self.dockwidget.label_multiplication_pattern.setEnabled(True)
            self.dockwidget.multiplication_pattern_excel.setEnabled(True)
            self.dockwidget.label_sensor_locations.setEnabled(True)
            self.dockwidget.sensor_location.setEnabled(True)

    def update_node_id_enable(self):
        if self.dockwidget.all_nodes_injection.isChecked():
            self.dockwidget.injection_search_widget.setEnabled(False)
        else:
            self.dockwidget.injection_search_widget.setEnabled(True)

    def monte_carlo_change(self):
        if self.dockwidget.uncertainty_hydr_parameter.value() > 0:
            if self.dockwidget.monte_carlo_simulations.value() == 0:
                self.dockwidget.monte_carlo_simulations.setValue(10)
            self.dockwidget.monte_carlo_simulations.setEnabled(True)
        else:
            self.dockwidget.monte_carlo_simulations.setValue(0)
            self.dockwidget.monte_carlo_simulations.setEnabled(False)

    def show_manual(self):
        pdf_path = os.path.join(self.plugin_dir, "help", "dbprisk2-readthedocs-io-en-latest.pdf")
        if os.path.exists(pdf_path):
            QDesktopServices.openUrl(QUrl.fromLocalFile(pdf_path))
        else:
            self.show_message("Error", "dbprisk2-readthedocs-io-en-latest.pdf not found in the help folder.", button="OK", icon="Warning")

    def show_about(self):
        """Displays a styled About dialog with clickable links."""
        about_path = os.path.join(self.plugin_dir, "help", "about.txt")

        if not os.path.exists(about_path):
            self.show_message("Error", f"'about.txt' not found in help folder:\n{about_path}",
                              button="OK", icon="Warning")
            return

        try:
            with open(about_path, 'r', encoding='utf-8') as f:
                raw = f.read()
        except Exception as e:
            self.show_message("Error", f"Failed to read about.txt:\n{e}",
                              button="OK", icon="Warning")
            return

        lines = [ln.strip() for ln in raw.splitlines() if ln.strip()]
        title = lines[0] if lines else "About dbpRisk 2.0"
        body = " ".join(lines[1:]) if len(lines) > 1 else ""

        body_html = html.escape(body)
        for kw in ["dbpRisk 2.0", "QGIS", "EPANET-MSX", "DBPs"]:
            body_html = re.sub(rf'\b({re.escape(kw)})\b', r'<b>\1</b>', body_html)

        html_doc = f"""
        <html>
          <head>
            <meta charset="utf-8">
            <style>
              body {{ font-family: Segoe UI, Arial, sans-serif; line-height: 1.45; padding: 8px 6px; }}
              h1   {{ font-size: 18px; margin: 0 0 10px; }}
              p    {{ margin: 0 0 10px; }}
              .links a {{ text-decoration: none; }}
              .links a:hover {{ text-decoration: underline; }}
            </style>
          </head>
          <body>
            <h1>{html.escape(title)}</h1>
            <p>{body_html}</p>
            <p class="links">
              Project site: <a href="https://intodbp.eu/" target="_blank">intodbp.eu</a><br>
              Documentation: <a href="https://dbprisk2.readthedocs.io/en/latest/" target="_blank">
                dbpRisk 2.0 Docs
              </a>
            </p>
          </body>
        </html>
        """

        dialog = QDialog(self.dockwidget)
        dialog.setWindowTitle("About dbpRisk 2.0")
        dialog.setMinimumSize(640, 420)

        layout = QVBoxLayout(dialog)

        browser = QTextBrowser()
        browser.setOpenExternalLinks(True)
        browser.setHtml(html_doc)
        layout.addWidget(browser)

        close_button = QPushButton("Close")
        close_button.clicked.connect(dialog.accept)
        layout.addWidget(close_button)

        dialog.exec_()

    # Python
    def populate_multiplication_pattern_async(self, file_path):
        """
        Launch background task to read `file_path` and populate:
          - `self.dockwidget.multiplication_pattern_excel`
          - `self.dockwidget.sensor_location`
        """
        # basic existence check
        if not os.path.exists(file_path):
            if hasattr(self, "iface"):
                self.iface.messageBar().pushMessage("dbpRisk 2.0",
                                                    f"File not found: {file_path}",
                                                    level=Qgis.Warning, duration=5)
            return

        task = PopulatePatternTask(file_path, self)
        QgsApplication.taskManager().addTask(task)

    def run(self):
        """Run method that loads and starts the plugin"""
        self.hide_toolbars_and_panels()
        self.hide_btn.setChecked(True)
        if not self.pluginIsActive:
            self.pluginIsActive = True

            # dockwidget may not exist if:
            # first run of plugin
            # removed on close (see self.onClosePlugin method)
            if self.dockwidget is None:
                requirements_path = os.path.join(self.plugin_dir, 'installpackages', 'requirements.txt')
                self.check_requirements_file(requirements_path)

                # Create the dockwidget (after translation) and keep reference
                self.dockwidget = dbpSimulatorDockWidget()
                self.imported_excel_file = os.path.join(self.plugin_dir, 'data', 'ts_data', 'sensors_data.xlsx')
                self.dockwidget.excel_path.setText(self.imported_excel_file)

                self.populate_model_files(
                    subfolder="network_models",
                    extension=".inp",
                    exclude_suffix="_temp.inp",
                    target_combobox=self.dockwidget.network_models
                )

                self.populate_model_files(
                    subfolder="reaction_models",
                    extension=".msx",
                    exclude_suffix="_temp.msx",
                    target_combobox=self.dockwidget.reaction_models
                )
                self.dockwidget.loadexcel.clicked.connect(lambda: self.import_excel_file(lbl_browse))
                self.dockwidget.loadmodels.clicked.connect(self.load_models)
                self.dockwidget.insert_action.clicked.connect(self.update_scenario_list)
                self.dockwidget.delete_action.clicked.connect(self.scenario_delete)
                self.dockwidget.run_simulation.clicked.connect(self.run_message)
                self.dockwidget.plot.clicked.connect(self.plot_all)
                self.dockwidget.plot_species_locations.toggled.connect(self.update_plot_species_unc_state)
                self.dockwidget.plot_species_nodes.clicked.connect(self.update_plot_species_unc_state)
                self.dockwidget.all_nodes_injection.clicked.connect(self.update_node_id_enable)
                self.dockwidget.override_time_checkbox.clicked.connect(self.override_timeduration)
                self.dockwidget.load_sensor_data.clicked.connect(self.import_sensor_data)
                self.dockwidget.simulation_days.valueChanged.connect(self.override_timeduration)
                self.dockwidget.uncertainty_hydr_parameter.valueChanged.connect(self.monte_carlo_change)
                self.dockwidget.all_nodes_initial.clicked.connect(self.update_node_id_enable_initial)
                # self.dockwidget.plot_species_nodes.clicked.connect(self.plot_results)
                # self.dockwidget.plot_species_locations.clicked.connect(self.plot_sensorlocations)
                # self.dockwidget.plot_species_unc.clicked.connect(self.plot_uncertainties)
                self.dockwidget.clear_scenario.clicked.connect(self.clear_scenario_table)
                self.dockwidget.scenario_search.textChanged.connect(self.filter_scenario_table)
                self.dockwidget.show_update_map.clicked.connect(self.show_update_map_call)
                self.dockwidget.export_scenario.clicked.connect(self.save_scenarios_to_csv)
                self.dockwidget.import_scenario.clicked.connect(self.load_scenarios_from_csv)
                self.dockwidget.data_manager.clicked.connect(self.show_data_manager_dialog)
                self.dockwidget.chemical_parameters.currentIndexChanged.connect(self.set_default_parameter_value)
                self.dockwidget.sim_multi_model_species.checkedItemsChanged.connect(self.update_plot_buttons)
                # self.dockwidget.samplings_checkbox.setEnabled(False)

                self.dockwidget.network_models.activated.connect(self.on_network_model_changed)
                self.dockwidget.reaction_models.activated.connect(self.on_reaction_model_changed)

                self.dockwidget.multiplication_pattern_excel.currentIndexChanged.connect(
                    self.on_multiplication_pattern_selected)

                self.dockwidget.sensor_location.currentIndexChanged.connect(self.on_sensor_location_selected)

                self.dockwidget.hour_min_max.currentIndexChanged.connect(self.ignore_hours)
                self.dockwidget.uncertainty_checkbox.clicked.connect(self.ignore_upper_lowerbound)

                # ----------------  Editable, searchable QComboBox for Hydraulic and Reaction Models ----------------
                # Make QComboBox editable
                self.dockwidget.network_models.setEditable(True)
                self.dockwidget.reaction_models.setEditable(True)

                # Get list of .inp files excluding *_temp.inp
                inp_folder = os.path.join(self.plugin_dir, "data", "network_models")
                inp_files = [f for f in os.listdir(inp_folder) if f.endswith('.inp') and not f.endswith('_temp.inp')]

                # Get list of .msx files excluding *_temp.msx
                msx_folder = os.path.join(self.plugin_dir, "data", "reaction_models")
                msx_files = [f for f in os.listdir(msx_folder) if f.endswith('.msx') and not f.endswith('_temp.msx')]

                completer = QCompleter(inp_files)
                completer.setCaseSensitivity(Qt.CaseInsensitive)

                msx_completer = QCompleter(msx_files)
                msx_completer.setCaseSensitivity(Qt.CaseInsensitive)

                self.dockwidget.network_models.setCompleter(completer)
                self.dockwidget.reaction_models.setCompleter(msx_completer)

                # Set placeholder and make it not read-only
                self.dockwidget.network_models.lineEdit().setPlaceholderText("Type to search...")
                self.dockwidget.reaction_models.lineEdit().setPlaceholderText("Type to search...")

                self.dockwidget.network_models.lineEdit().setReadOnly(False)
                self.dockwidget.reaction_models.lineEdit().setReadOnly(False)

                # ----------------  Editable, searchable QComboBox for Custom Pattern ----------------
                # --- Custom Pattern File Combo Setup ---
                self.dockwidget.custom_pattern_file.setEditable(True)

                # List files from pattern_data folder
                pattern_folder = os.path.join(self.plugin_dir, "data", "pattern_data")
                pattern_files = [
                    f for f in os.listdir(pattern_folder)
                    if f.endswith(('.csv', '.xls', '.xlsx')) and not f.endswith('_temp.csv')
                ]

                # Set completer for custom pattern
                pattern_completer = QCompleter(pattern_files)
                pattern_completer.setCaseSensitivity(Qt.CaseInsensitive)

                self.dockwidget.custom_pattern_file.setCompleter(pattern_completer)

                # Placeholder and edit settings
                self.dockwidget.custom_pattern_file.lineEdit().setPlaceholderText("Type to search...")

                self.dockwidget.custom_pattern_file.lineEdit().setReadOnly(False)

                # Populate items
                self.populate_model_files(
                    subfolder="pattern_data",
                    extension=".csv",  # First pattern
                    exclude_suffix="_temp.csv",
                    target_combobox=self.dockwidget.custom_pattern_file
                )
                # Also add Excel files
                excel_files = [
                    f for f in os.listdir(pattern_folder)
                    if f.endswith(('.xls', '.xlsx')) and not f.endswith('_temp.xlsx')
                ]
                for file in excel_files:
                    if self.dockwidget.custom_pattern_file.findText(file) == -1:
                        self.dockwidget.custom_pattern_file.addItem(file)

                # Handle selection change
                self.dockwidget.custom_pattern_file.activated.connect(self.on_custom_pattern_file_changed)

                # -------------------  QToolButton for Hydraulic Model -------------------
                # Reference to the tool button from UI
                tool_button = self.dockwidget.hydraulic_model_button

                # Set main icon (Information)
                info_icon = QIcon(os.path.join(self.plugin_dir, 'images', 'icons8-info-50.png'))
                tool_button.setIcon(info_icon)
                tool_button.setPopupMode(QToolButton.MenuButtonPopup)  # Makes icon and arrow clickable separately
                tool_button.setToolButtonStyle(Qt.ToolButtonIconOnly)  # Show only icon

                injection_help_button = self.dockwidget.type_of_injection_help
                injection_help_button.setIcon(info_icon)
                injection_help_button.clicked.connect(self.show_injection_info)

                # Create the dropdown menu
                menu = QMenu(tool_button)

                # Action 1 - Information (this will be in the dropdown too, for consistency)
                action_info = QAction(info_icon, "Information", self.iface.mainWindow())
                action_info.triggered.connect(self.show_hydraulic_info)
                menu.addAction(action_info)

                # Action 2 - EPANET
                action_epanet = QAction(QIcon(os.path.join(self.plugin_dir, 'images', 'logo_epanet.jpg')), "EPANET",
                                        self.iface.mainWindow())
                action_epanet.triggered.connect(self.open_network_model_in_epanet)
                menu.addAction(action_epanet)

                # Action 3 - Notepad
                action_notepad = QAction(QIcon(os.path.join(self.plugin_dir, 'images', 'notepad-icon.png')), "Notepad",
                                         self.iface.mainWindow())
                action_notepad.triggered.connect(self.open_network_model_in_notepad)
                menu.addAction(action_notepad)

                excel_showcase_button = self.dockwidget.excel_showcase_button
                excel_showcase_button.setIcon(info_icon)
                excel_showcase_button.clicked.connect(self.show_excel_info)
                # Assign the menu
                tool_button.setMenu(menu)

                # Connect the main icon click to show_hydraulic_info
                tool_button.clicked.connect(self.show_hydraulic_info)

                # -------------------  QToolButton for Reaction Model -------------------
                # Reference to the tool button from UI
                reaction_button = self.dockwidget.reaction_model_button

                # Set main icon (Information)
                info_icon = QIcon(os.path.join(self.plugin_dir, 'images', 'icons8-info-50.png'))
                reaction_button.setIcon(info_icon)
                reaction_button.setPopupMode(QToolButton.MenuButtonPopup)
                reaction_button.setToolButtonStyle(Qt.ToolButtonIconOnly)

                # Create the dropdown menu
                reaction_menu = QMenu(reaction_button)

                # Action 1 - Information
                action_info = QAction(info_icon, "Information", self.iface.mainWindow())
                action_info.triggered.connect(self.show_reaction_info)
                reaction_menu.addAction(action_info)

                # Action 2 - Notepad
                action_notepad = QAction(QIcon(os.path.join(self.plugin_dir, 'images', 'notepad-icon.png')), "Notepad",
                                         self.iface.mainWindow())
                action_notepad.triggered.connect(self.open_reaction_model_in_notepad)
                reaction_menu.addAction(action_notepad)

                # Assign the menu
                reaction_button.setMenu(reaction_menu)

                # Connect main icon click to show_reaction_info
                reaction_button.clicked.connect(self.show_reaction_info)


            else:
                self.dockwidget.loadexcel.setEnabled(False)
                self.dockwidget.insert_action.setEnabled(False)
                self.dockwidget.scenario_table.setEnabled(False)
                self.dockwidget.import_scenario.setEnabled(False)
                self.dockwidget.export_scenario.setEnabled(False)
                self.dockwidget.data_manager.setEnabled(False)
                # connect to provide cleanup on closing of dockwidget
            self.dockwidget.closingPlugin.connect(self.onClosePlugin)

            # show the dockwidget
            # TODO: fix to allow choice of dock location
            self.iface.addDockWidget(Qt.LeftDockWidgetArea, self.dockwidget)

            lbl_browse = self.dockwidget.excel_path

            self.dockwidget.show()

        self.layers_panel = self.iface.mainWindow().findChild(QDockWidget, "Layers")
        self.iface.addDockWidget(Qt.RightDockWidgetArea, self.layers_panel)
        self.layers_panel.show()
        try:
            self.dockwidget.show()
        except:
            pass
        # self.iface.mainWindow().menuBar().setVisible(False)
    def import_sensor_data(self):
        self.populate_multiplication_pattern_async(self.imported_excel_file)
        self.import_excel_file_flag = True

    def show_excel_info(self):

        # Direct path to the Excel file
        xlsx_path = os.path.join(self.plugin_dir, "help", "sensor_data_template.xlsx")
        df = pd.read_excel(xlsx_path, dtype=str)

        dialog = QDialog()
        dialog.setWindowTitle("Sensor Data Preview")
        dialog.resize(650, 310)
        dialog.setMaximumSize(700, 400)
        layout = QVBoxLayout(dialog)

        headers = df.keys().tolist()
        table = QTableWidget()
        table.setColumnCount(len(headers))
        table.setRowCount(len(df))
        table.setHorizontalHeaderLabels(headers)

        # Fill cells
        for r, row in enumerate(df.itertuples(index=False)):
            values = [getattr(row, h) for h in headers]
            for c, val in enumerate(values):
                item = QTableWidgetItem("" if val is None else str(val))
                item.setFlags(item.flags() & ~Qt.ItemIsEditable)  # read-only cell
                table.setItem(r, c, item)

        # Table appearance/behavior
        table.setAlternatingRowColors(True)
        table.setEditTriggers(QAbstractItemView.NoEditTriggers)
        table.setSelectionBehavior(QAbstractItemView.SelectRows)
        table.setSortingEnabled(True)
        table.horizontalHeader().setStretchLastSection(True)
        table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
        layout.addWidget(table, 1)
        button_box = QDialogButtonBox(QDialogButtonBox.Ok)
        button_box.accepted.connect(dialog.accept)
        layout.addWidget(button_box, 0, alignment=Qt.AlignRight)

        dialog.setLayout(layout)
        dialog.exec_()

    def plot_data(self, measured_data, simulated_data, sensor_index, species_index, species_names,
                  sensor_description, subtitle=None, show_measured=True):
        # Move these before first use of Figure/FigureCanvas
        from matplotlib.figure import Figure
        from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas

        from matplotlib import cm
        import matplotlib.colors as mcolors
        from itertools import cycle
        import matplotlib.dates as mdates
        from matplotlib.ticker import MaxNLocator
        from datetime import datetime, timedelta

        base_pool = list(plt.rcParams['axes.prop_cycle'].by_key()['color'])
        tab20_pool = [mcolors.to_hex(cm.get_cmap('tab20')(i)) for i in range(cm.get_cmap('tab20').N)]
        colour_pool = [c for c in base_pool if c != "blue"] + [c for c in tab20_pool if c != "blue"]
        colour_cycle = cycle(colour_pool)

        species_colour_map = {}

        def colour_for_species(name):
            """Return the consistent colour assigned to *name*, assigning a new one if necessary."""
            if name not in species_colour_map:
                _ = next(colour_cycle)  # discard
                assigned = next(colour_cycle)
                species_colour_map[name] = assigned
            return species_colour_map[name]

            # Number of subplots equals the number of sensors

        # Fixed height per subplot (inches)

        # Figure sizing
        FIG_DPI = 96  # explicit DPI for predictable pixel math
        MAX_FIGURE_PX = 32768  # must be < 2^15
        per_subplot_h = 2.6
        extra_margins_h = 0.4
        fig_w = 10

        max_plots = max(1, int((MAX_FIGURE_PX / FIG_DPI - extra_margins_h) // per_subplot_h))
        nplots_requested = max(1, len(sensor_index))
        if nplots_requested > max_plots:
            self.show_message(
                "Warning",
                f"Too many subplots ({nplots_requested}). Showing first {max_plots} to avoid backend limits.",
                button="OK",
                icon="Warning"
            )
            sensor_index = sensor_index[:max_plots]
            sensor_description = sensor_description[:max_plots]

        nplots = max(1, len(sensor_index))
        fig_h = per_subplot_h * nplots + extra_margins_h

        figure = Figure(figsize=(fig_w, fig_h), dpi=FIG_DPI, constrained_layout=True)
        figure.set_constrained_layout_pads(w_pad=0.02, h_pad=0.02, wspace=0.05, hspace=0.05)
        canvas = FigureCanvas(figure)
        duration = self.t_d * 288

        duration_hours = self.t_d * 24
        if duration_hours <= 24 * 1:
            interval = 2
        elif duration_hours <= 24 * 2:
            interval = 4
        elif duration_hours <= 24 * 3:
            interval = 6
        elif duration_hours <= 24 * 4:
            interval = 8
        elif duration_hours <= 24 * 5:
            interval = 10
        elif duration_hours <= 24 * 6:
            interval = 12
        elif duration_hours <= 24 * 7:
            interval = 14

        ymax_left = 0
        ymax_right = 0

        for k, (i, sensor_name) in enumerate(zip(sensor_index, sensor_description), start=1):
            ax_left = figure.add_subplot(nplots, 1, k)
            left_counter = 0
            right_counter = 0

            ax_right = None
            #  Measured chlorine
            measured_array = measured_data.get(sensor_name)
            times = self.global_times
            ax_left.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))

            ax_left.xaxis.set_major_locator(MaxNLocator(nbins=13))

            for label in ax_left.get_xticklabels():
                label.set_rotation(30)
                label.set_horizontalalignment('right')
                label.set_fontsize(8)

            if any("CL2" == name for name in species_names):
                show_measured = True
            else:
                show_measured = False

            if measured_array is not None and show_measured:
                ax_left.plot(times[:duration], measured_array[:duration], label="CL2 measured", color="blue")
                ax_left.set_ylabel("(mg/L)", color="blue")
                ax_left.tick_params(axis="y", labelcolor="blue")
                ax_left.set_ylim(bottom=0, top=max(0.01, measured_array[:duration].max() * 1.2))  # Add headroom
                ax_left.xaxis.set_major_locator(mdates.HourLocator(interval=interval))
                ax_left.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))
                ymax_left = max(ymax_left, measured_array[:duration].max())

            #  Simulated species
            for idx, sp_ind in enumerate(species_index):
                if sp_ind is None or i == 0:
                    continue

                quality_data = simulated_data[0].Quality[i][:, sp_ind - 1]
                species_name = species_names[idx]
                unit_idx = self.soeciesnamesmsx.index(species_name)
                unit = self.MSXunits[unit_idx].lower()

                colour = colour_for_species(species_name)
                if "mg" in unit:
                    ax_left.plot(times[:duration], quality_data[:duration], label=species_name, color=colour)
                    ax_left.set_ylabel("(mg/L)", color="blue")
                    ax_left.tick_params(axis="y", labelcolor="blue")
                    ymax_left = max(ymax_left, quality_data[:duration].max())
                    ax_left.xaxis.set_major_locator(mdates.HourLocator(interval=interval))
                    ax_left.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))
                    left_counter += 1
                elif any(u in unit for u in ("ug", "µg", "μg")):
                    if ax_right is None:
                        ax_right = ax_left.twinx()
                    ax_right.plot(times[:duration], quality_data[:duration], label=species_name, color=colour)
                    ax_right.set_ylabel("(ug/L)", color="red")
                    ax_right.tick_params(axis="y", labelcolor="red")
                    ymax_right = max(ymax_right, quality_data[:duration].max())
                    ax_right.set_ylim(bottom=0, top=max(0.01, quality_data[:duration].max() * 1.2))  # Add headroom
                    right_counter += 1
                else:
                    ax_left.plot(times[:duration], quality_data[:duration], label=species_name, color=colour)

            start_date = self.global_times[0].astype('M8[D]').astype(str)  # 'YYYY-MM-DD'

            start_dt = datetime.strptime(start_date, "%Y-%m-%d")
            end_dt = start_dt + timedelta(days=self.t_d)
            start_str = start_dt.strftime("%d/%m/%Y")
            end_str = end_dt.strftime("%d/%m/%Y")
            ax_left.set_xlim(times[0], times[duration - 1])
            if left_counter > 0:
                ax_left.set_ylim(bottom=0, top=max(0.01, ymax_left * 1.2))
            if right_counter > 0:
                ax_right.set_ylim(bottom=0, top=max(0.01, ymax_right * 1.2))

            title = f"{sensor_name} ({start_str}-{end_str})"
            ax_left.set_title(title, fontsize=9, fontweight="bold")
            ax_left.grid(True)
            # if k == len(sensor_index):
            # ax_left.set_xlabel("Time of Day", fontsize = 8)
            if left_counter == 0 and ax_left is not None:
                ax_left.set_yticks([])
                ax_left.set_ylabel("")
            if right_counter == 0 and ax_right is not None:
                ax_right.set_yticks([])
                ax_right.set_ylabel("")
            handles_l, labels_l = ax_left.get_legend_handles_labels()
            if labels_l:
                ax_left.legend(handles_l, labels_l, loc="upper left",
                               fontsize="x-small", frameon=True, framealpha=0.5, facecolor='white')
            else:
                leg_l = ax_left.get_legend()
                if leg_l:
                    leg_l.remove()
            if ax_right is not None:
                ax_right.tick_params(axis="y")
                handles_r, labels_r = ax_right.get_legend_handles_labels()
                if labels_r:
                    ax_right.legend(handles_r, labels_r, loc="upper right",
                                    fontsize="x-small", frameon=True, framealpha=0.5, facecolor='white')
                else:
                    leg_r = ax_right.get_legend()
                    if leg_r:
                        leg_r.remove()

        if subtitle:
            figure.suptitle(subtitle, fontsize=12)

        figure.tight_layout()
        canvas.draw()
        return canvas

    def plot_data_with_uncertainty(self, measured_data,
                                   sensor_index, species_index, species_names,
                                   sensor_description, subtitle=None, show_measured=False):
        from matplotlib import cm
        import matplotlib.colors as mcolors
        import matplotlib.pyplot as plt
        from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
        from matplotlib.figure import Figure
        import matplotlib.dates as mdates
        from matplotlib.ticker import MaxNLocator
        from datetime import datetime, timedelta

        # Figure sizing
        FIG_DPI = 96
        MAX_FIGURE_PX = 32768
        per_subplot_h = 2.6
        extra_margins_h = 0.4
        fig_w = 10

        max_plots = max(1, int((MAX_FIGURE_PX / FIG_DPI - extra_margins_h) // per_subplot_h))

        nplots_requested = max(1, len(sensor_index))
        if nplots_requested > max_plots:
            self.show_message(
                "Warning",
                f"Too many subplots ({nplots_requested}). Showing first {max_plots} to avoid backend limits.",
                button="OK",
                icon="Warning"
            )
            sensor_index = sensor_index[:max_plots]
            sensor_description = sensor_description[:max_plots]

        nplots = max(1, len(sensor_index))
        fig_h = per_subplot_h * nplots + extra_margins_h

        figure = Figure(figsize=(fig_w, fig_h), dpi=FIG_DPI, constrained_layout=True)
        figure.set_constrained_layout_pads(w_pad=0.02, h_pad=0.02, wspace=0.05, hspace=0.05)
        canvas = FigureCanvas(figure)

        ymax_left = 0
        duration = self.t_d * 288

        duration_hours = self.t_d * 24
        if duration_hours <= 24 * 1:
            interval = 2
        elif duration_hours <= 24 * 2:
            interval = 4
        elif duration_hours <= 24 * 3:
            interval = 6
        elif duration_hours <= 24 * 4:
            interval = 8
        elif duration_hours <= 24 * 5:
            interval = 10
        elif duration_hours <= 24 * 6:
            interval = 12
        elif duration_hours <= 24 * 7:
            interval = 14

        if any("CL2" in name for name in species_names):
            show_measured = True
        else:
            show_measured = False

        for k, (i, sensor_name) in enumerate(zip(sensor_index, sensor_description), start=1):
            ax_left = figure.add_subplot(nplots, 1, k)
            ax_right = None

            times = self.global_times
            measured_array = measured_data.get(sensor_name)
            ax_left.xaxis.set_major_formatter(mdates.DateFormatter('%d %H:%M'))
            ax_left.xaxis.set_major_locator(MaxNLocator(nbins=13))
            for label in ax_left.get_xticklabels():
                label.set_rotation(30)
                label.set_horizontalalignment('right')
                label.set_fontsize(8)

            if measured_array is not None and show_measured:
                ax_left.plot(times[:duration], measured_array[:duration], label="CL2 measured", color="blue")
                ax_left.set_ylabel("(mg/L)", color="blue")
                ax_left.tick_params(axis="y", labelcolor="blue")
                ax_left.xaxis.set_major_locator(mdates.HourLocator(interval=interval))
                ax_left.xaxis.set_major_formatter(mdates.DateFormatter('%d %H:%M'))
                ymax_left = max(ymax_left, measured_array[:duration].max())

            # Only the first species will be plotted
            sp_ind = species_index[0]
            species_name = species_names[0]

            unit_idx = self.soeciesnamesmsx.index(species_name)
            unit = self.MSXunits[unit_idx].lower()

            mean_vals = self.MSX_comps[0].Quality[i][:, sp_ind - 1]

            # Determine axis (left/right)
            is_micro = any(u in unit for u in ("ug", "µg", "μg"))
            plot_ax = ax_left if not is_micro else ax_left.twinx()
            if is_micro:
                ax_right = plot_ax

            # Aggregate uncertainty range across all MSX_comps[1:]
            if len(self.MSX_comps) > 1:
                uncertainty_arrays = [
                    comp.Quality[i][:, sp_ind - 1] for comp in self.MSX_comps[1:]
                ]
                uncertainty_stack = numpy.stack(uncertainty_arrays)  # shape: (n_comps, n_times)
                min_vals = numpy.min(uncertainty_stack, axis=0)
                max_vals = numpy.max(uncertainty_stack, axis=0)
                ymax_left = max(ymax_left, max_vals[:duration].max())
                ymax_left = max(ymax_left, min_vals[:duration].max())
                ymax_left = max(ymax_left, mean_vals[:duration].max())

                plot_ax.fill_between(times[:duration], min_vals[:duration], max_vals[:duration],
                                     color="orange", alpha=0.3, label=f"{species_name} range")

            # Plot mean
            plot_ax.plot(times[:duration], mean_vals[:duration], color="red", linewidth=0.8,
                         label=f"{species_name} with no uncertainty")

            # Axis labeling
            ylabel = "(mg/L)" if not is_micro else "(ug/L)"
            plot_ax.set_ylabel(ylabel, color="blue" if not is_micro else "red")
            plot_ax.tick_params(axis="y", labelcolor="blue" if not is_micro else "red")

            # X-axis and title formatting
            start_date = self.global_times[0].astype('M8[D]').astype(str)
            start_dt = datetime.strptime(start_date, "%Y-%m-%d")
            end_dt = start_dt + timedelta(days=self.t_d)
            start_str = start_dt.strftime("%d/%m/%Y")
            end_str = end_dt.strftime("%d/%m/%Y")
            ax_left.set_ylim(bottom=0, top=max(0.01, ymax_left * 1.2))
            ax_left.set_xlim(times[0], times[duration - 1])
            ax_left.set_title(f"{sensor_name} ({start_str}-{end_str})", fontsize=9, fontweight="bold")
            ax_left.grid(True)
            ax_left.xaxis.set_major_locator(mdates.HourLocator(interval=interval))
            ax_left.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))
            # Legends
            if is_micro:
                ax_left.set_yticks([])
                ax_left.set_ylabel("")
                plot_ax.legend(loc="upper left",
                               fontsize="x-small", frameon=True, framealpha=0.5, facecolor='white')
            else:
                if ax_right is not None:
                    ax_right.set_yticks([])
                    ax_right.set_ylabel("")
                    plot_ax.legend(loc="upper right",
                                   fontsize="x-small", frameon=True, framealpha=0.5, facecolor='white')

        if subtitle:
            figure.suptitle(subtitle, fontsize=12)

        figure.tight_layout()
        canvas.draw()
        return canvas

    def handleTaskFinished(self, result, exception):

        if exception:
            message = f"dbp algo failed to run. See Expetion  : {exception}"
            self.iface.messageBar().pushMessage('Error', message, level=2, duration=4)
            print(exception)

        else:
            # message = "Great Success"
            # self.iface.messageBar().pushMessage('Success',message, level = 0 , duration = 4)

            self.dockwidget.results_group.setEnabled(True)
            self.dockwidget.sim_multi_model_species.setEnabled(True)
            self.dockwidget.plot.setEnabled(True)
            self.dockwidget.plot_species_nodes.setEnabled(True)
            self.dockwidget.plot_species_locations.setEnabled(True)
            self.dockwidget.show_update_map.setEnabled(True)
            self.dockwidget.hour_min_max.setEnabled(True)
            self.dockwidget.sim_hour.setEnabled(True)
            self.dockwidget.select_species.setEnabled(True)
            self.uncertainty_checkbox()
            # Disable the plot button if no species are selected
            if len(self.dockwidget.sim_multi_model_species.checkedItems()) == 0:
                self.dockwidget.plot.setEnabled(False)
            checked = self.dockwidget.plot_species_locations.isChecked()
            self.update_plot_species_unc_state(checked)

            # # Disable uncertainty button if all uncertainty values are 0
            # all_uncertainties_zero = (
            #         all((val is None or val == 0) for val in self.msx_uncertainty_list) and
            #         all((val is None or val == 0) for val in self.demand_uncertainty_list)
            # )

            # checked = self.dockwidget.plot_species_locations.isChecked()
            # selected_species = self.dockwidget.sim_multi_model_species.checkedItems()
            # selected_species = len(selected_species) == 1
            # self.dockwidget.plot_species_unc.setEnabled(not all_uncertainties_zero and checked and selected_species)

            # combo box for plotting species check everything
            # combo = self.dockwidget.sim_multi_model_species
            # for i in range(combo.count()):
            #     item = combo.model().item(i)
            #     if item is not None:
            #         item.setCheckState(Qt.Checked)

            self.mes = result.get("Measured_Chlorine")
            self.dataframe = result.get("Dataframe")
            self.dataframe_uncertainty = result.get("Dataframes")
            self.dataf = result.get("Dataf")
            self.MSX_comps = result.get("MSX_comps")
            self.global_times = result.get("global_times")
            self.soeciesnamesmsx = result.get("soeciesnamesmsx")
            self.MSXunits = result.get("MSXunits")
            self.node_id = result.get("node_id")
            self.species_names_function = result.get("species_names_function")
            self.results = result.get("results")
            # reload
            self.load_models()

    def show_injection_info(self):
        txt_path = os.path.join(self.plugin_dir, 'help', 'injection_types.txt')

        try:
            with open(txt_path, 'r') as file:
                injection_text = file.read()
        except FileNotFoundError:
            injection_text = "Injection information file not found."

        # Create dialog
        dialog = QDialog()
        dialog.setWindowTitle("Type of Injection")
        dialog.resize(450, 350)

        layout = QVBoxLayout(dialog)

        link_label = QLabel('<a href="http://wateranalytics.org/EPANET/_sources_page.html">'
                            'EPANET Injection Types Guide</a>')
        link_label.setTextFormat(Qt.RichText)
        link_label.setTextInteractionFlags(Qt.TextBrowserInteraction)
        link_label.setOpenExternalLinks(True)
        layout.addWidget(link_label)

        text_edit = QTextEdit()
        text_edit.setReadOnly(True)
        text_edit.setText(injection_text)
        layout.addWidget(text_edit)

        button_box = QDialogButtonBox(QDialogButtonBox.Ok)
        button_box.accepted.connect(dialog.accept)
        layout.addWidget(button_box)

        dialog.setLayout(layout)
        dialog.exec_()
