# -*- coding: utf-8 -*-
import os
from qgis.PyQt.QtCore import Qt, pyqtSlot, pyqtSignal, QEvent, QTimer
from qgis.PyQt.QtGui import QIcon, QFont, QColor, QBrush
from qgis.PyQt.QtWidgets import QDockWidget, QWidget, QMessageBox, QLineEdit, QListWidgetItem, QTableWidgetItem, QHeaderView, QAbstractItemView, QFrame, QToolButton, QHBoxLayout
from qgis.PyQt import uic
from qgis.core import QgsProject, QgsVectorLayer, QgsSettings, QgsGeometry, QgsPointXY, QgsRectangle, QgsFeature, QgsLayerMetadata, QgsSpatialIndex, Qgis
from qgis.utils import iface
from qgis.gui import QgsHighlight
from ...tools.utils.qgisred_field_utils import QGISRedFieldUtils
from ...tools.utils.qgisred_layer_utils import QGISRedLayerUtils
from ..analysis.qgisred_results_dock import QGISRedResultsDock
from ...compat import LINEEDIT_LEADING_POSITION

FORM_CLASS, _ = uic.loadUiType(os.path.join(os.path.dirname(__file__), "qgisred_element_explorer_dock.ui"))

# Language-independence rules for this dock:
# - Element types are identified by qgisred_identifier (qgisred_pipes, ...).
#   Display names come from self.identifierSingulars; never from layer.name().
# - Property names are raw English from the layer schema; translate only via
#   QGISRedFieldUtils.getFieldPrettyName for display, never compare to literals.
# - Units come from QGISRedFieldUtils.getFieldUnit / getResultPropertyUnit and
#   may be translated; never compare unit strings to literals.
# - List items carry identifier (UserRole), raw element id (UserRole+1) and
#   suffix kinds (UserRole+2) so reverse parsing of translated text is avoided.

class QGISRedElementExplorerDock(QDockWidget, FORM_CLASS):
    _instance = None
    dockVisibilityChanged = pyqtSignal(bool)
    findElementsDockVisibilityChanged = pyqtSignal(bool)
    elementPropertiesDockVisibilityChanged = pyqtSignal(bool)
    dockFocusChanged = pyqtSignal(bool)
    dockClosed = pyqtSignal(bool)

    # ------------------------------
    # Initialization and Setup Methods
    # ------------------------------
    @classmethod
    def getInstance(cls, canvas, parent=None, showFindElements=True, showElementProperties=True):
        if cls._instance is None or not cls._instance.isVisible():
            if cls._instance is not None:
                try:
                    cls._instance.deleteLater()
                except RuntimeError:
                    pass
            cls._instance = cls(canvas, parent, showFindElements, showElementProperties)
        return cls._instance

    def __init__(self, canvas, parent=None, showFindElements=True, showElementProperties=True):
        if self._instance is not None:
            raise Exception(f"{self.__class__.__name__} is a singleton! Use getInstance() instead.")
        super(self.__class__, self).__init__(parent)
        self.setupUi(self)
        self.initTableWidgets()
        self.mElementPropertiesGroupBox.setCollapsed(True)
        self.setupEventFilters()
        self.setObjectName(self.__class__.__name__)
        self.setFloating(False)
        iface.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self)

        self.canvas = canvas
        self.findElementsVisible = showFindElements
        self.elementPropertiesVisible = showElementProperties

        # Identifier → English plural layer name as created by QGISRed.
        # Used only at first project load to set the qgisred_identifier custom
        # property on layers; after that all lookups go via customProperty.
        self.identifierToLayerName = {
            'qgisred_pipes':              'Pipes',
            'qgisred_junctions':          'Junctions',
            'qgisred_demands':            'Demands',
            'qgisred_reservoirs':         'Reservoirs',
            'qgisred_tanks':              'Tanks',
            'qgisred_pumps':              'Pumps',
            'qgisred_valves':             'Valves',
            'qgisred_sources':            'Sources',
            'qgisred_serviceconnections': 'Service Connections',
            'qgisred_isolationvalves':    'Isolation Valves',
            'qgisred_meters':             'Meters',
        }

        # Identifier → translated singular display name. Used for ALL display.
        # Iteration order also defines the cbElementType combobox order.
        self.identifierSingulars = {
            'qgisred_pipes':              self.tr('Pipe'),
            'qgisred_junctions':          self.tr('Junction'),
            'qgisred_demands':            self.tr('Multiple Demand'),
            'qgisred_reservoirs':         self.tr('Reservoir'),
            'qgisred_tanks':              self.tr('Tank'),
            'qgisred_pumps':              self.tr('Pump'),
            'qgisred_valves':             self.tr('Valve'),
            'qgisred_sources':            self.tr('Source'),
            'qgisred_serviceconnections': self.tr('Service Connection'),
            'qgisred_isolationvalves':    self.tr('Isolation Valve'),
            'qgisred_meters':             self.tr('Meter'),
        }
        
        self.originalIds = []
        self.adjacentHighlights = []
        self.mainHighlight = None
        self.currentSelectedHighlight = None
        self.dictOfElementIds = {}
        self.currentLayer = None
        self.currentFeature = None

        # Add spatial index cache for node layers
        self.nodeLayerSpatialIndices = {}  # {layer_id: (QgsSpatialIndex, layer)}
        self.sourcesDemandToNodeCache = {}  # {(layer_identifier, feature_id): (node_id, node_layer_identifier)}
        # Spatial indices for source and demand layers (used by extractLayerIdsDirect)
        self.sourceSpatialIndex = None  # QgsSpatialIndex for source layer
        self.sourceSpatialLayer = None  # source layer reference
        self.demandSpatialIndex = None  # QgsSpatialIndex for demand layer
        self.demandSpatialLayer = None  # demand layer reference

        self.spoilerElementProperties = None 
        self.spoilerFindElements = None

        self.linkLayers = ["qgisred_pipes", "qgisred_pumps", "qgisred_valves"]
        self.nodeLayers = ["qgisred_reservoirs", "qgisred_tanks", "qgisred_junctions",
                           "qgisred_sources", "qgisred_demands", "qgisred_meters", "qgisred_isolationvalves"]
        self.specialLayers = ["qgisred_serviceconnections"]
        self.sourcesAndDemands = ["qgisred_sources", "qgisred_demands"]
        self.connectedLayerNodes = []
        self.connectedGroups = []

        # Results tab state
        self.resultsDock = None
        self.resultsCurrentTimeText = ""
        self.resultsCurrentStat = ""

        self.demandPageIndex = 0
        self.demandsPerPage = 1

        self.resultsDockVisibilityTimer = QTimer()
        self.resultsDockVisibilityTimer.setSingleShot(True)
        self.resultsDockVisibilityTimer.setInterval(150)
        self.resultsDockVisibilityTimer.timeout.connect(self.checkResultsDockClosed)

        self.layerTreeChangeTimer = QTimer()
        self.layerTreeChangeTimer.setSingleShot(True)
        self.layerTreeChangeTimer.setInterval(100)
        self.layerTreeChangeTimer.timeout.connect(self.doLayerTreeChanged)

        if hasattr(self, 'listWidget'):
            self.listWidget.installEventFilter(self)
        
        if hasattr(self, 'labelFoundElement'):
            font = QFont()
            font.setPointSize(12)
            font.setBold(True)
            self.labelFoundElement.setFont(font)
            self.labelFoundElement.setWordWrap(True)
            self.labelFoundElement.setText("")

        if hasattr(self, 'labelFoundElementTag'):
            self.labelFoundElementTag.setWordWrap(True)
            self.labelFoundElementTag.setText("")
            self.labelFoundElementTag.hide()
            self.isTagVisible = False

        if hasattr(self, 'labelFoundElementDescription'):
            self.labelFoundElementDescription.setWordWrap(True)
            self.labelFoundElementDescription.setText("")
            self.labelFoundElementDescription.hide()
            self.isDescVisible = False

        self.setDockStyle()
        self.setupConnections()
        
        if hasattr(self, 'initializeCustomLayerProperties'):
            self.initializeCustomLayerProperties()

        if hasattr(self, 'initializeElementTypes'):
            self.initializeElementTypes()

        self.mElementPropertiesGroupBox.setCollapsed(False)
        self.mFindElementsGroupBox.setCollapsed(False)
        self.mConnectedElementsGroupBox.setCollapsed(True)
        self.trackCollapsibleWidgetsEvents()
        
        self.topLevelChanged.connect(self.onTopLevelChanged)

    def trackCollapsibleWidgetsEvents(self):
        self.mElementPropertiesGroupBox.collapsedStateChanged.connect(self.onElementPropertiesToggled)
        self.mFindElementsGroupBox.collapsedStateChanged.connect(self.onFindElementsToggled)
        self.labelFoundElementTag.installEventFilter(self)
        self.labelFoundElementDescription.installEventFilter(self)

    def updateCollapsibleWidgetsState(self, collapseElementProperties=None, collapseFindElements=None, collapseConnectedElements=None):
        self.mElementPropertiesGroupBox.blockSignals(True)
        self.mFindElementsGroupBox.blockSignals(True)
        self.mConnectedElementsGroupBox.blockSignals(True)

        if collapseElementProperties is not None:
            self.mElementPropertiesGroupBox.setCollapsed(collapseElementProperties)

        if collapseFindElements is not None:
            self.mFindElementsGroupBox.setCollapsed(collapseFindElements)

        if collapseConnectedElements is not None:
            self.mConnectedElementsGroupBox.setCollapsed(collapseConnectedElements)

        ep_collapsed = self.mElementPropertiesGroupBox.isCollapsed()
        fe_collapsed = self.mFindElementsGroupBox.isCollapsed()

        # Panel should NOT close automatically when frames collapse
        # It should only close when the X button is clicked


        self.mElementPropertiesGroupBox.blockSignals(False)
        self.mFindElementsGroupBox.blockSignals(False)
        self.mConnectedElementsGroupBox.blockSignals(False)

    def onTopLevelChanged(self, floating):
        if floating:
            QTimer.singleShot(50, self.resizeToMinimumHeight)

    # ------------------------------
    # Collapsible Widgets Handlers
    # ------------------------------
    def onElementPropertiesToggled(self, collapsed):
        pass  # Handled via event filters on individual labels        
    def onFindElementsToggled(self, collapsed):
        pass

    def resetScrollPosition(self):
        # Reset the scroll area position to the top
        if hasattr(self, 'scrollArea'):
            self.scrollArea.ensureVisible(0, 0, 0, 0)

    def scrollToTop(self):
        """Scroll the panel to show the top (Find Elements section)"""
        if hasattr(self, 'scrollArea'):
            self.scrollArea.verticalScrollBar().setValue(0)

    def scrollToElementProperties(self):
        """Scroll the panel to make the Element Properties section visible"""
        if hasattr(self, 'scrollArea') and hasattr(self, 'mElementPropertiesGroupBox'):
            # Ensure the element properties group box is visible
            self.scrollArea.ensureWidgetVisible(self.mElementPropertiesGroupBox)

    def reHighlightCurrentElement(self):
        """Re-apply the red highlight and selection on the current element, if any."""
        if not self.isLayerValid(self.currentLayer) or not self.currentFeature:
            return False
        try:
            self.clearHighlights()
            highlight = QgsHighlight(iface.mapCanvas(), self.currentFeature.geometry(), self.currentLayer)
            highlight.setColor(QColor("red"))
            highlight.setWidth(5)
            highlight.show()
            self.mainHighlight = highlight
            self.currentLayer.selectByIds([self.currentFeature.id()])
            return True
        except Exception:
            return False

    def refreshCurrentElement(self):
        """Refresh the current element's data without changing collapsed state or scroll position.
        This is useful after C# library operations that may have changed the element's data.
        """
        if not self.isLayerValid(self.currentLayer) or not self.currentFeature:
            return False

        # Save scroll position
        scrollValue = self.scrollArea.verticalScrollBar().value() if hasattr(self, 'scrollArea') else 0

        # Re-query the feature from the layer to get fresh data
        try:
            featureId = self.currentFeature.id()
            freshFeature = self.currentLayer.getFeature(featureId)
            if not freshFeature.isValid():
                return False

            # Update the feature reference
            self.currentFeature = freshFeature

            # Re-populate the data table
            self.populateDataTableWidget()

            # Update labels if they exist
            if hasattr(self, 'labelFoundElementTag') and freshFeature.fields().indexFromName("Tag") != -1:
                featureTag = freshFeature.attribute("Tag")
                if featureTag and str(featureTag).strip() != "":
                    self.labelFoundElementTag.setText(str(featureTag))
                    self.labelFoundElementTag.show()
                    self.isTagVisible = True
                else:
                    self.labelFoundElementTag.hide()
                    self.isTagVisible = False

            if hasattr(self, 'labelFoundElementDescription') and freshFeature.fields().indexFromName("Descrip") != -1:
                featureDescription = freshFeature.attribute("Descrip")
                if featureDescription and str(featureDescription).strip() != "":
                    self.labelFoundElementDescription.setText(str(featureDescription))
                    self.labelFoundElementDescription.show()
                    self.isDescVisible = True
                else:
                    self.labelFoundElementDescription.hide()
                    self.isDescVisible = False

            # Restore scroll position
            if hasattr(self, 'scrollArea'):
                self.scrollArea.verticalScrollBar().setValue(scrollValue)

            return True
        except Exception:
            return False

    # ------------------------------
    # Event Filter Setup
    # ------------------------------
    def setupEventFilters(self):
        mainWidget = self.widget()
        self.installEventFilterRecursive(mainWidget)

    def installEventFilterRecursive(self, widget):
        if widget:
            widget.installEventFilter(self)
            for child in widget.children():
                if isinstance(child, QWidget):
                    self.installEventFilterRecursive(child)

    def eventFilter(self, obj, event):
        # Intercept Show events on conditional labels to keep them hidden if empty
        if event.type() == QEvent.Type.Show and obj in (self.labelFoundElementTag, self.labelFoundElementDescription):
            text = obj.text().strip()
            if not text or text == "-":
                obj.hide()
                return True

        if event.type() == QEvent.Type.FocusIn:
            if obj != self and self.isAncestorOf(obj):
                self.reestablishIdentifyTool()
        return super(QGISRedElementExplorerDock, self).eventFilter(obj, event)

    def reestablishIdentifyTool(self):
        from ...tools.map_tools.qgisred_identifyFeature import QGISRedIdentifyFeature
        currentTool = self.canvas.mapTool()
        if not isinstance(currentTool, QGISRedIdentifyFeature):
            self.dockFocusChanged.emit(True)

    # ------------------------------
    # UI and Dock Styling Methods
    # ------------------------------
    def resizeToMinimumHeight(self):
        self.layout().activate()
        self.adjustSize()

    def setDockStyle(self):
        iconName = 'iconFindElements.svg' if 'Find' in self.__class__.__name__ else 'iconElementProperties.svg'
        iconPath = os.path.join(os.path.dirname(__file__), '..', '..', 'images', iconName)
        self.setWindowIcon(QIcon(iconPath))
        
        if hasattr(self, 'leElementMask'):
            searchIcon = QIcon(":/images/iconFilter.svg")
            self.leElementMask.addAction(searchIcon, LINEEDIT_LEADING_POSITION)
        
        comboStyle = """
            QComboBox { 
                background-color: white; 
                combobox-popup: 0;
            }
            QComboBox QAbstractItemView {
                selection-background-color: #3574F0;
                selection-color: white;
                outline: none;
                max-height: 250px;
                qproperty-verticalScrollBarPolicy: ScrollBarAsNeeded;
            }
        """
        if hasattr(self, 'cbElementType'):
            self.cbElementType.setStyleSheet(comboStyle)
        if hasattr(self, 'cbElementId'):
            self.cbElementId.setStyleSheet(comboStyle)

        self.tempHideOtherTabs()
        self.clearResultsTable()

    def tempHideOtherTabs(self):
        self.tabWidget.setTabVisible(1, False)
        self.tabWidget.setTabVisible(2, False)
        self.tabWidget.setTabVisible(3, False)
        self.tabWidget.setTabVisible(4, False)
        # Add padding spaces so tab text is never clipped
        for i in range(self.tabWidget.count()):
            text = self.tabWidget.tabText(i)
            if text and not text.startswith(" "):
                self.tabWidget.setTabText(i, f" {text} ")

    def setComponentVisibility(self, showFindElements, showElementProperties):
        self.findElementsVisible = showFindElements
        self.elementPropertiesVisible = showElementProperties
        
        self.findElementsDockVisibilityChanged.emit(showFindElements)
        self.elementPropertiesDockVisibilityChanged.emit(showElementProperties)
        
        if not showFindElements and not showElementProperties and not self.isFloating():
            self.close()

    def openIdentifyForFindDock(self):
        from ...tools.map_tools.qgisred_identifyFeature import QGISRedIdentifyFeature
        self.identifyTool = QGISRedIdentifyFeature(self.canvas, useFindDock=True)
        self.canvas.setMapTool(self.identifyTool)

    @pyqtSlot()
    def toggleFloating(self):
        self.setFloating(not self.isFloating())

    def onDockVisibilityChanged(self, visible):
        if self.isFloating():
            return
        
        findDockVisible = self.frameFindElements.isVisible() 
        elementPropertiesVisible = self.frameElementProperties.isVisible()

        if not findDockVisible and not elementPropertiesVisible:
            self.close()
            return
        
        self.setComponentVisibility(findDockVisible, elementPropertiesVisible)
        self.findElementsDockVisibilityChanged.emit(findDockVisible)
        self.elementPropertiesDockVisibilityChanged.emit(elementPropertiesVisible)
        self.resizeToMinimumHeight()

    # ------------------------------
    # Connection Setup Methods
    # ------------------------------
    def setupConnections(self):
        self.cbElementType.currentIndexChanged.connect(self.updateElementIds)
        self.leElementMask.textChanged.connect(self.filterElementIds)
        self.listWidget.itemClicked.connect(self.onListItemSingleClicked)
        self.listWidget.itemDoubleClicked.connect(self.onListItemDoubleClicked)
        self.cbElementId.currentIndexChanged.connect(self.onElementIdChanged)           


        project = QgsProject.instance()
        project.layersAdded.connect(self.onLayerTreeChanged)
        project.layersRemoved.connect(self.onLayerTreeChanged)
        project.readProject.connect(self.onProjectChanged)
        project.cleared.connect(self.onProjectChanged)

        iface.mapCanvas().mapToolSet.connect(self.onMapToolSet)

        for identifier in ("qgisred_inputs", "qgisred_results"):
            group = QGISRedLayerUtils.findGroupByIdentifier(identifier)
            if group:
                self.connectGroupSignals(group)
                for layerNode in group.findLayers():
                    self.connectLayerSignals(layerNode)

    def connectGroupSignals(self, group):
        try:
            group.addedChildren.connect(self.onLayerTreeChanged)
            group.removedChildren.connect(self.onLayerTreeChanged)
            self.connectedGroups.append(group)
        except Exception:
            pass

    def disconnectGroupSignals(self, group):
        try:
            self.safeDisconnect(group.addedChildren, self.onLayerTreeChanged)
            self.safeDisconnect(group.removedChildren, self.onLayerTreeChanged)
        except (RuntimeError, TypeError):
            pass

    def connectLayerSignals(self, layerNode):
        try:
            layerNode.nameChanged.connect(self.onLayerTreeChanged)
            if layerNode.layer():
                layer = layerNode.layer()
                layer.dataChanged.connect(self.onLayerTreeChanged)
                if not self.isResultsLayer(layer):
                    layer.featureAdded.connect(self.updateElementIds)
                    layer.featureDeleted.connect(self.updateElementIds)
            self.connectedLayerNodes.append(layerNode)
        except Exception:
            pass

    def disconnectLayerNode(self, layerNode):
        try:
            self.safeDisconnect(layerNode.nameChanged, self.onLayerTreeChanged)
            layer = layerNode.layer()
            if layer:
                self.safeDisconnect(layer.dataChanged, self.onLayerTreeChanged)
                if not self.isResultsLayer(layer):
                    self.safeDisconnect(layer.featureAdded, self.updateElementIds)
                    self.safeDisconnect(layer.featureDeleted, self.updateElementIds)
        except (RuntimeError, TypeError):
            pass

    def isResultsLayer(self, layer):
        identifier = layer.customProperty("qgisred_identifier", "") if layer else ""
        return identifier.startswith("qgisred_node") or identifier.startswith("qgisred_link")

    def disconnectLayerSignals(self, layer):
        try:
            if hasattr(layer, 'nameChanged'):
                try:
                    layer.nameChanged.disconnect(self.onLayerTreeChanged)
                except Exception:
                    pass
            if hasattr(layer, 'dataChanged'):
                try:
                    layer.dataChanged.disconnect(self.onLayerTreeChanged)
                except Exception:
                    pass
        except Exception:
            pass

    def reconnectLayerSignals(self):
        # Disconnect all previously connected layer nodes and groups
        for layerNode in self.connectedLayerNodes:
            self.disconnectLayerNode(layerNode)
        self.connectedLayerNodes.clear()
        for group in self.connectedGroups:
            self.disconnectGroupSignals(group)
        self.connectedGroups.clear()

        # Reconnect to current Inputs and Results group layers
        for identifier in ("qgisred_inputs", "qgisred_results"):
            group = QGISRedLayerUtils.findGroupByIdentifier(identifier)
            if group:
                self.connectGroupSignals(group)
                for layerNode in group.findLayers():
                    self.connectLayerSignals(layerNode)

    # ------------------------------
    # Clear and Reset Methods
    # ------------------------------
    def deselectElement(self):
        """Deselect the current element and reset the ID combobox."""
        self.cbElementId.setCurrentIndex(-1)

    def clearAll(self):
        self.clearHighlights()
        self.clearAllLayerSelections()
        self.clearAllCaches()

        if hasattr(self, 'leElementMask'):
            self.leElementMask.clear()
        if hasattr(self, 'cbElementId') and self.cbElementId.count() > 0:
            self.cbElementId.setCurrentIndex(0)
        if hasattr(self, 'labelFoundElement'):
            self.labelFoundElement.setText("")
        if hasattr(self, 'labelFoundElementTag'):
            self.labelFoundElementTag.setText("")
        if hasattr(self, 'labelFoundElementDescription'):
            self.labelFoundElementDescription.setText("")
        if hasattr(self, 'listWidget'):
            self.listWidget.clear()
        if hasattr(self, 'dataTableWidget'):
            self.dataTableWidget.clearContents()
            self.dataTableWidget.setRowCount(0)

    def clearAllLayerSelections(self):
        for lyr in QgsProject.instance().mapLayers().values():
            if isinstance(lyr, QgsVectorLayer):
                lyr.removeSelection()

    def clearHighlights(self):
        if self.mainHighlight:
            self.mainHighlight.hide()
            self.mainHighlight = None
        for h in self.adjacentHighlights:
            h.hide()
        self.adjacentHighlights.clear()
        if self.currentSelectedHighlight:
            self.currentSelectedHighlight.hide()
            self.currentSelectedHighlight = None

        canvas = iface.mapCanvas()
        scene = canvas.scene()
        for item in scene.items():
            if isinstance(item, QgsHighlight):
                item.hide()
                scene.removeItem(item)
                del item
        canvas.refresh()

    def closeEvent(self, event):
        try:
            self.dockVisibilityChanged.emit(False)
            self.dockClosed.emit(True)
            
            settings = QgsSettings()
            settings.setValue("QGISRed/ElementsExplorer/geometry", self.saveGeometry())
            settings.setValue("QGISRed/ElementsExplorer/floating", self.isFloating())

            # ----- Disconnect all signals -----
            # Project signals
            project = QgsProject.instance()
            self.safeDisconnect(project.layersAdded, self.onLayerTreeChanged)
            self.safeDisconnect(project.layersRemoved, self.onLayerTreeChanged)
            self.safeDisconnect(project.readProject, self.onProjectChanged)
            self.safeDisconnect(project.cleared, self.onProjectChanged)

            self.safeDisconnect(iface.mapCanvas().mapToolSet, self.onMapToolSet)

            # Layer tree signals
            for layerNode in self.connectedLayerNodes:
                self.disconnectLayerNode(layerNode)
            self.connectedLayerNodes.clear()

            for group in self.connectedGroups:
                self.disconnectGroupSignals(group)
            self.connectedGroups.clear()
            
            self.safeDisconnect(self.cbElementType.currentIndexChanged, self.updateElementIds)
            self.safeDisconnect(self.leElementMask.textChanged, self.filterElementIds)
            self.safeDisconnect(self.listWidget.itemClicked, self.onListItemSingleClicked)
            self.safeDisconnect(self.listWidget.itemDoubleClicked, self.onListItemDoubleClicked)
            self.safeDisconnect(self.cbElementId.currentIndexChanged, self.onElementIdChanged)

            
            self.safeDisconnect(self.mElementPropertiesGroupBox.collapsedStateChanged, self.onElementPropertiesToggled)
            self.safeDisconnect(self.mFindElementsGroupBox.collapsedStateChanged, self.onFindElementsToggled)

            if self.resultsDock is not None:
                self.disconnectResultsDock()

            self.layerTreeChangeTimer.stop()
            self.resultsDockVisibilityTimer.stop()

            self.removeEventFiltersRecursive(self.widget())
            self.clearHighlights()
            self.clearAllLayerSelections()
            
            self.canvas = None
            self.identifyTool = None
            self.currentLayer = None
            self.currentFeature = None
            self.mainHighlight = None
            self.adjacentHighlights = []
            self.currentSelectedHighlight = None
            self.dictOfElementIds = {}

            self.__class__._instance = None
            super(QDockWidget, self).closeEvent(event)
            self.deleteLater()
        except Exception as e:
            self.__class__._instance = None
            super(QDockWidget, self).closeEvent(event)

    def safeDisconnect(self, signal, slot):
        try:
            signal.disconnect(slot)
        except (TypeError, RuntimeError):
            pass

    def removeEventFiltersRecursive(self, widget):
        if widget:
            widget.removeEventFilter(self)
            for child in widget.children():
                if isinstance(child, QWidget):
                    self.removeEventFiltersRecursive(child)

    # ------------------------------
    # Layer and Project Event Methods
    # ------------------------------
    def onLayerTreeChanged(self):
        # Debounce: restart the timer on each call so rapid-fire signals
        # are coalesced into a single deferred execution.
        self.layerTreeChangeTimer.start()

    def doLayerTreeChanged(self):
        self.clearAllCaches()
        self.reconnectLayerSignals()

        prevState = self.saveCurrentElementState()

        # Suppress UI repaints during bulk rebuild
        self.setUpdatesEnabled(False)
        try:
            # Block combobox signals to avoid triggering onElementIdChanged multiple times
            # while rebuilding the lists and restoring the selection
            self.cbElementId.blockSignals(True)
            self.cbElementType.blockSignals(True)

            self.initializeCustomLayerProperties()
            self.initializeElementTypes()
            self.restoreComboBoxSelections(prevState['currentType'], prevState['currentId'])

            self.cbElementType.blockSignals(False)
            self.cbElementId.blockSignals(False)

            hasSelection = self.cbElementId.currentIndex() >= 0 and bool(self.cbElementId.currentText())
            self.mElementPropertiesGroupBox.setCollapsed(not hasSelection)
            if not hasSelection:
                self.mConnectedElementsGroupBox.setVisible(False)

            self.reacquireCurrentElement(prevState)
            self.updateResultsTabVisibility()
            self.populateResultsTable()
        finally:
            self.setUpdatesEnabled(True)

    def clearAllCaches(self):
        self.nodeLayerSpatialIndices.clear()
        self.sourcesDemandToNodeCache.clear()
        self.sourceSpatialIndex = None
        self.sourceSpatialLayer = None
        self.demandSpatialIndex = None
        self.demandSpatialLayer = None
        if hasattr(self, 'sourceDemandIdCache'):
            self.sourceDemandIdCache.clear()

    def saveCurrentElementState(self):
        currentIdentifier = self.cbElementType.currentData()
        currentId = self.extractNodeId(self.cbElementId.currentText())
        return {
            'currentType': currentIdentifier,
            'currentId': currentId,
            'identifier': currentIdentifier,
            'featureIdAttr': currentId if currentId else None,
            'labelText': self.labelFoundElement.text(),
            'tagText': self.labelFoundElementTag.text(),
            'tagVisible': self.isTagVisible,
            'descText': self.labelFoundElementDescription.text(),
            'descVisible': self.isDescVisible,
        }

    def restoreComboBoxSelections(self, currentType, currentId):
        typeIndex = self.cbElementType.findData(currentType)
        if typeIndex >= 0:
            self.cbElementType.setCurrentIndex(typeIndex)
            idIndex = self.cbElementId.findText(currentId)
            if idIndex >= 0:
                self.cbElementId.setCurrentIndex(idIndex)

    def reacquireCurrentElement(self, prevState):
        prevIdentifier = prevState['identifier']
        prevFeatureIdAttr = prevState['featureIdAttr']

        if not prevIdentifier or prevFeatureIdAttr is None:
            return

        newLayer = self.getLayerByIdentifier(prevIdentifier)
        if not newLayer:
            self.currentLayer = None
            self.currentFeature = None
            self.clearHighlights()
            return

        newFeature = None
        for feat in newLayer.getFeatures():
            if feat.attribute("Id") == prevFeatureIdAttr:
                newFeature = feat
                break

        if newFeature:
            self.currentLayer = newLayer
            self.currentFeature = newFeature
            self.refreshCurrentElement()
            if type(self.canvas.mapTool()).__name__ == "QGISRedIdentifyFeature":
                self.reHighlightCurrentElement()
            self.refreshConnectedElements(newFeature, newLayer)
            self.restoreLabels(prevState['labelText'], prevState['tagText'], prevState['tagVisible'],
                               prevState['descText'], prevState['descVisible'])
        else:
            self.currentLayer = None
            self.currentFeature = None
            self.clearHighlights()

    def restoreLabels(self, labelText, tagText, tagVisible, descText, descVisible):
        self.labelFoundElement.setText(labelText)
        self.labelFoundElementTag.setText(tagText)
        self.labelFoundElementTag.setVisible(tagVisible)
        self.isTagVisible = tagVisible
        self.labelFoundElementDescription.setText(descText)
        self.labelFoundElementDescription.setVisible(descVisible)
        self.isDescVisible = descVisible

    def onProjectClosed(self):
        self.clearHighlights()
        self.clearAllLayerSelections()
    
    def onProjectChanged(self):
        self.clearAllCaches()
        self.clearAll()
        self.onLayerTreeChanged()

    def onMapToolSet(self, tool):
        if not self.isVisible():
            return
        if type(tool).__name__ == "QGISRedIdentifyFeature":
            if self.isLayerValid(self.currentLayer) and self.currentFeature:
                self.findElement()
        else:
            self.clearHighlights()

    # ------------------------------
    # Element Type and Identifier Initialization
    # ------------------------------
    def initializeCustomLayerProperties(self):
        inputsGroup = QGISRedLayerUtils.findGroupByIdentifier("qgisred_inputs")
        if not inputsGroup:
            return
        layerNameToIdentifier = {v: k for k, v in self.identifierToLayerName.items()}
        for layerNode in inputsGroup.findLayers():
            identifier = layerNameToIdentifier.get(layerNode.name())
            if not identifier:
                continue
            layerObj = layerNode.layer()
            if not layerObj:
                continue
            layerObj.setCustomProperty("qgisred_identifier", identifier)
            layerMetadata = QgsLayerMetadata()
            layerMetadata.setIdentifier(identifier)
            layerObj.setMetadata(layerMetadata)

    def initializeElementTypes(self):
        self.cbElementType.clear()
        for identifier in self.getAvailableElementIdentifiers():
            self.cbElementType.addItem(self.identifierSingulars[identifier], identifier)
        self.initializeElementIdsCache()

    def initializeElementIdsCache(self):
        """Initialize element IDs cache with optimized batch processing"""
        self.buildNodeLayerSpatialIndices()
        self.buildSourceDemandCacheBatch()

        for identifier in self.getAvailableElementIdentifiers():
            layer = self.getLayerByIdentifier(identifier)
            if layer:
                if identifier in self.sourcesAndDemands:
                    # Use pre-built cache for sources/demands
                    ids = self.getCachedSourceDemandIds(identifier)
                else:
                    # Regular features - extract IDs directly without special naming
                    ids = self.extractLayerIdsDirect(layer, identifier)

                self.dictOfElementIds[identifier] = sorted(set(ids))
            else:
                self.dictOfElementIds[identifier] = []

    def buildSourceDemandCacheBatch(self):
        """Pre-compute all source/demand to node relationships in batch.
        Uses coordinate-based deduplication to avoid redundant spatial lookups
        when many features share the same location (common with multiple demands)."""
        self.sourcesDemandToNodeCache.clear()
        self.sourceDemandIdCache = {'qgisred_sources': [], 'qgisred_demands': []}

        sourceLayer = self.getLayerByIdentifier("qgisred_sources")
        demandLayer = self.getLayerByIdentifier("qgisred_demands")

        for layerIdentifier, layer, suffixKind in [
            ("qgisred_sources", sourceLayer, "source"),
            ("qgisred_demands", demandLayer, "mult_dem")
        ]:
            if not layer:
                continue

            # Cache spatial lookups by rounded coordinates to avoid redundant lookups
            # for features at the same location (e.g., multiple demands on one junction)
            coordCache = {}  # {(rounded_x, rounded_y): (nodeId, nodeLayer)}

            for feature in layer.getFeatures():
                cacheKey = (layerIdentifier, feature.id())
                geom = feature.geometry()
                if geom.isEmpty():
                    self.sourcesDemandToNodeCache[cacheKey] = (None, None)
                    continue

                point = geom.asPoint()
                coordKey = (round(point.x(), 4), round(point.y(), 4))

                if coordKey in coordCache:
                    nodeId, nodeLayer = coordCache[coordKey]
                else:
                    nodeFeature, nodeLayer = self.findOverlappingNodeOptimized(geom)
                    if nodeFeature and nodeLayer:
                        nodeId = self.extractNodeId(nodeFeature.attribute("Id"))
                    else:
                        nodeId, nodeLayer = None, None
                    coordCache[coordKey] = (nodeId, nodeLayer)

                nodeIdentifier = nodeLayer.customProperty("qgisred_identifier", "") if nodeLayer else None
                self.sourcesDemandToNodeCache[cacheKey] = (nodeId, nodeIdentifier)

                if nodeId and nodeLayer:
                    singular = self.getSingularForLayer(nodeLayer)
                    displayId = f"{singular} {nodeId} {self.getSuffixForKind(suffixKind)}"
                    self.sourceDemandIdCache[layerIdentifier].append(displayId)

    def getCachedSourceDemandIds(self, identifier):
        """Get cached IDs for sources/demands without recomputing"""
        return self.sourceDemandIdCache.get(identifier, [])

    def extractLayerIdsDirect(self, layer, identifier):
        """Extract IDs directly from layer without special naming logic.
        Uses pre-built spatial indices for source/demand overlap checks."""
        ids = []

        # For node layers that might have source/demand suffixes
        if identifier in ["qgisred_junctions", "qgisred_reservoirs", "qgisred_tanks"]:
            hasSourceIndex = self.sourceSpatialIndex is not None and self.sourceSpatialLayer is not None
            hasDemandIndex = (identifier == "qgisred_junctions" and
                              self.demandSpatialIndex is not None and self.demandSpatialLayer is not None)

            for feature in layer.getFeatures():
                value = feature.attribute("Id")
                idStr = str(value) if value is not None else ""

                featGeom = feature.geometry()
                if not featGeom.isEmpty() and (hasSourceIndex or hasDemandIndex):
                    point = featGeom.asPoint()
                    searchRect = QgsRectangle(point.x() - 1e-6, point.y() - 1e-6,
                                              point.x() + 1e-6, point.y() + 1e-6)
                    suffixes = []

                    if hasSourceIndex:
                        candidateIds = self.sourceSpatialIndex.intersects(searchRect)
                        for fid in candidateIds:
                            srcFeat = self.sourceSpatialLayer.getFeature(fid)
                            if not srcFeat.geometry().isEmpty() and self.areOverlappedPoints(featGeom, srcFeat.geometry()):
                                suffixes.append("source")
                                break

                    if hasDemandIndex:
                        candidateIds = self.demandSpatialIndex.intersects(searchRect)
                        for fid in candidateIds:
                            dmndFeat = self.demandSpatialLayer.getFeature(fid)
                            if not dmndFeat.geometry().isEmpty() and self.areOverlappedPoints(featGeom, dmndFeat.geometry()):
                                suffixes.append("mult_dem")
                                break

                    if suffixes:
                        idStr += " " + self.composeSuffixText(suffixes)

                ids.append(idStr)
        else:
            # Simple ID extraction for other layers
            for feature in layer.getFeatures():
                value = feature.attribute("Id")
                if value is not None:
                    ids.append(str(value))

        return ids

    def setDefaultValue(self):
        self.clearAll()

        pipesLayer = self.getLayerByIdentifier("qgisred_pipes")
        if not pipesLayer:
            return

        index = self.cbElementType.findData("qgisred_pipes")
        if index >= 0:
            self.cbElementType.setCurrentIndex(index)
        self.updateElementIds()

    # ------------------------------
    # Element Finding and Updating Methods
    # ------------------------------
    @pyqtSlot(int)
    def onElementIdChanged(self, index):
        self.labelFoundElement.setText("")
        self.labelFoundElementTag.setText("")
        self.labelFoundElementTag.hide()
        self.isTagVisible = False
        self.labelFoundElementDescription.setText("")
        self.labelFoundElementDescription.hide()
        self.isDescVisible = False
        self.listWidget.clear()
        self.dataTableWidget.clearContents()
        self.dataTableWidget.setRowCount(0)
        self.setUpdatesEnabled(False)
        try:
            if index >= 0 and self.cbElementId.currentText():
                self.findElement()
                self.mElementPropertiesGroupBox.setCollapsed(False)
            else:
                self.mElementPropertiesGroupBox.setCollapsed(True)
                self.mConnectedElementsGroupBox.setVisible(False)
                self.clearHighlights()
                self.clearAllLayerSelections()
        finally:
            self.setUpdatesEnabled(True)

    @pyqtSlot()
    def findElement(self):
        self.clearHighlights()
        self.clearAllLayerSelections()
        self.listWidget.clear()

        # Check if any layers are available
        availableLayers, sourceGroup = self.getAvailableLayersForExplorer()
        if not availableLayers:
            self.showLayerVisibilityWarning()
            return

        elementIdentifier = self.cbElementType.currentData()
        selectedId = self.extractNodeId(self.cbElementId.currentText())

        if not selectedId:
            self.labelFoundElement.setText("")
            return

        layer = self.getLayerByIdentifier(elementIdentifier) if elementIdentifier else None
        foundFeature = None
        foundFeatureLayer = None

        if layer:
            if layer.customProperty("qgisred_identifier") in self.sourcesAndDemands:
                foundFeatureLayer, foundFeature = self.findNodeLayer(selectedId)
            else:
                for feature in layer.getFeatures():
                    if self.getFeatureIdValue(feature, layer) == selectedId:
                        foundFeature = feature
                        foundFeatureLayer = layer
                        break

        # If not found in Inputs, try Results group
        if not foundFeature and sourceGroup == "Inputs":
            foundFeature, foundFeatureLayer = self.findElementInResultsGroup(selectedId)

        if not foundFeature:
            QMessageBox.information(self, self.tr("Info"), self.tr("Feature not found"))
            return

        self.currentLayer = foundFeatureLayer
        self.currentFeature = foundFeature

        finalTitleText = self.updateFoundElementLabel(selectedId, foundFeatureLayer)
        highlight = QgsHighlight(iface.mapCanvas(), foundFeature.geometry(), foundFeatureLayer)
        highlight.setColor(QColor("red"))
        highlight.setWidth(5)
        highlight.show()
        self.mainHighlight = highlight
        self.adjustMapView(foundFeature)

        self.refreshConnectedElements(foundFeature, foundFeatureLayer)
        self.loadFeature(foundFeatureLayer, foundFeature, finalTitleText)

    @pyqtSlot()
    def updateElementIds(self):
        self.cbElementId.setUpdatesEnabled(False)
        try:
            self.cbElementId.clear()
            self.labelFoundElement.setText("")

            selectedIdentifier = self.cbElementType.currentData()
            ids = self.dictOfElementIds.get(selectedIdentifier, [])

            mask = self.leElementMask.text().strip()
            if mask:
                filteredIds = [id for id in ids if mask.lower() in id.lower()]
                self.cbElementId.addItems(filteredIds)
            else:
                self.cbElementId.addItems(ids)
        finally:
            self.cbElementId.setUpdatesEnabled(True)

    @pyqtSlot()
    def filterElementIds(self):
        self.cbElementId.setUpdatesEnabled(False)
        try:
            mask = self.leElementMask.text().strip()
            self.cbElementId.clear()
            selectedIdentifier = self.cbElementType.currentData()
            ids = self.dictOfElementIds.get(selectedIdentifier, [])
            if mask:
                filteredIds = [id for id in ids if mask.lower() in id.lower()]
            else:
                filteredIds = ids
            self.cbElementId.addItems(filteredIds)
        finally:
            self.cbElementId.setUpdatesEnabled(True)

    def onListItemSingleClicked(self, item):
        if self.currentSelectedHighlight:
            self.currentSelectedHighlight.hide()
            self.currentSelectedHighlight = None

        elementIdentifier, selectedId = self.extractIdentifierAndIdFromItem(item)
        if not elementIdentifier or not selectedId:
            return

        matchingLayers = [
            layer for layer in self.getAllInputGroupLayers()
            if layer.customProperty("qgisred_identifier") == elementIdentifier
        ]
        for layer in matchingLayers:
            for feature in layer.getFeatures():
                if self.getFeatureIdValue(feature, layer) == selectedId:
                    highlight = QgsHighlight(iface.mapCanvas(), feature.geometry(), layer)
                    highlight.setColor(QColor("orange"))
                    highlight.setWidth(5)
                    highlight.show()
                    self.currentSelectedHighlight = highlight
                    return

    def onListItemDoubleClicked(self, item):
        self.leElementMask.clear()
        elementIdentifier, selectedId = self.extractIdentifierAndIdFromItem(item)
        if not elementIdentifier:
            return

        # Read suffix kinds before setCurrentIndex, which may rebuild the list and delete the item
        suffixKinds = item.data(Qt.ItemDataRole.UserRole + 2) or []

        index = self.cbElementType.findData(elementIdentifier)
        if index >= 0:
            self.cbElementType.setCurrentIndex(index)
        if selectedId:
            suffixText = self.composeSuffixText(suffixKinds)
            fullId = f"{selectedId} {suffixText}".strip() if suffixText else selectedId
            idIndex = self.cbElementId.findText(fullId)
            if idIndex >= 0:
                self.cbElementId.setCurrentIndex(idIndex)

    # ------------------------------
    # Map and Feature Display Methods
    # ------------------------------
    def populateDataTableWidget(self):
        self.dataTableWidget.setRowCount(0)
        self.dataTableWidget.setColumnWidth(2, 44)

        fields = self.currentLayer.fields()
        attributes = self.currentFeature.attributes()

        # Skip fields already shown in the header labels
        skipFields = {"Id", "Tag", "Descrip"}

        # Get the layer identifier for pretty name lookup
        layerIdentifier = self.currentLayer.customProperty("qgisred_identifier") if self.currentLayer else None
        utils = QGISRedFieldUtils()

        # Hide quality-related fields when the quality model is None, Age, or Trace
        qualityModel = utils.getQualityModel().upper()
        if qualityModel in ("NONE", "AGE", "TRACE"):
            nonChemicalFields = {
                "qgisred_pipes":      {"BulkCoeff", "WallCoeff"},
                "qgisred_tanks":      {"ReactCoef", "IniQuality"},
                "qgisred_reservoirs": {"IniQuality"},
                "qgisred_junctions":  {"IniQuality"},
            }.get(layerIdentifier, set())
            skipFields = skipFields | nonChemicalFields

        # For node layers, collect attached demands and source
        isNodeLayer = layerIdentifier in ["qgisred_junctions", "qgisred_reservoirs", "qgisred_tanks"]
        nodeDemands = []
        demandLayer = None
        nodeSource = None
        sourceLayer = None
        if isNodeLayer:
            nodeGeom = self.currentFeature.geometry()
            nodeDemands, demandLayer = self.getAllDemandsForNode(nodeGeom)
            nodeSource, sourceLayer = self.getSourceForNode(nodeGeom)

        totalDemands = len(nodeDemands)
        paginated = totalDemands > self.demandsPerPage
        if paginated:
            pageCount = (totalDemands + self.demandsPerPage - 1) // self.demandsPerPage
            if self.demandPageIndex < 0:
                self.demandPageIndex = 0
            elif self.demandPageIndex >= pageCount:
                self.demandPageIndex = pageCount - 1
            pageStart = self.demandPageIndex * self.demandsPerPage
            pageEnd = min(pageStart + self.demandsPerPage, totalDemands)
        else:
            self.demandPageIndex = 0
            pageStart = 0
            pageEnd = totalDemands
        visibleDemands = nodeDemands[pageStart:pageEnd]

        hasTotalDemandsRow = totalDemands > 1
        isJunction = layerIdentifier == "qgisred_junctions"
        hasMultipleDemands = isJunction and hasTotalDemandsRow
        hasSource = isJunction and nodeSource is not None
        if hasMultipleDemands:
            skipFields = skipFields | {"BaseDem", "IdPattDem"}
        preDemandFieldNames = {"Elevation"} if hasMultipleDemands else set()
        preSourceFieldNames = {"EmittCoef", "IniQuality"} if hasSource else set()
        middleExcludeFieldNames = preDemandFieldNames | preSourceFieldNames

        numDisplayFields = sum(1 for f in fields if f.name() not in skipFields)
        demandRowsCount = 3 * len(visibleDemands)
        sourceRowsCount = 3 if nodeSource else 0
        totalRows = (1 if hasTotalDemandsRow else 0) + numDisplayFields + demandRowsCount + sourceRowsCount
        self.dataTableWidget.setRowCount(totalRows)

        displayRow = 0
        showDemandIndex = totalDemands > 1

        if hasMultipleDemands:
            displayRow = self.emitNodeFields(displayRow, fields, attributes, skipFields, layerIdentifier, utils,
                                             onlyFieldNames=preDemandFieldNames)

        if hasTotalDemandsRow:
            totalDemandsRow = displayRow
            self.setDataRow(displayRow, self.tr("Total Demands"), str(totalDemands), "",
                            backgroundBrush=self.totalDemandsBrush)
            displayRow += 1
            if paginated:
                self.installDemandPaginationButtons(totalDemandsRow, totalDemands)

        if hasMultipleDemands and visibleDemands and demandLayer:
            displayRow = self.appendDemandRows(displayRow, visibleDemands, demandLayer, utils,
                                               startIndex=pageStart + 1, showIndex=showDemandIndex)

        displayRow = self.emitNodeFields(displayRow, fields, attributes, skipFields, layerIdentifier, utils,
                                         excludeFieldNames=middleExcludeFieldNames)

        # Append one triplet per attached demand (non-junction or single-demand case)
        if not hasMultipleDemands and visibleDemands and demandLayer:
            displayRow = self.appendDemandRows(displayRow, visibleDemands, demandLayer, utils,
                                               startIndex=pageStart + 1, showIndex=showDemandIndex)

        if hasSource:
            displayRow = self.emitNodeFields(displayRow, fields, attributes, skipFields, layerIdentifier, utils,
                                             onlyFieldNames=preSourceFieldNames)

        # Append source triplet
        if nodeSource and sourceLayer:
            displayRow = self.appendSourceRows(displayRow, nodeSource, sourceLayer, utils)

    def emitNodeFields(self, startRow, fields, attributes, skipFields, layerIdentifier, utils,
                       onlyFieldNames=None, excludeFieldNames=None):
        qualityFieldNames = {"MixingMod", "MixingFrac", "ReactCoef", "IniQuality", "BulkCoeff", "WallCoeff"}
        demandFieldNames = {"BaseDem", "IdPattDem"} if layerIdentifier == "qgisred_junctions" else set()
        row = startRow
        for fieldIdx, field in enumerate(fields):
            fieldName = field.name()
            if fieldName in skipFields:
                continue
            if onlyFieldNames is not None and fieldName not in onlyFieldNames:
                continue
            if excludeFieldNames and fieldName in excludeFieldNames:
                continue

            prettyName = utils.getFieldPrettyName(layerIdentifier, fieldName)
            rawValue = attributes[fieldIdx]
            displayValue = self.formatFieldValue(rawValue, layerIdentifier, fieldName, utils)

            if fieldName in ["EnergyPric", "EnergyPrice"] and displayValue:
                displayValue = f"${displayValue}"

            fieldUnit = utils.getFieldUnit(layerIdentifier, fieldName)
            unitDisplay = fieldUnit if fieldUnit and fieldUnit != "-" else ""

            if fieldName in qualityFieldNames:
                backgroundBrush = self.sourceBrush
            elif fieldName in demandFieldNames:
                backgroundBrush = self.totalDemandsBrush
            else:
                backgroundBrush = None

            self.setDataRow(row, prettyName, displayValue, unitDisplay, backgroundBrush=backgroundBrush)
            row += 1
        return row

    def setDataRow(self, row, propertyName, value, unit, backgroundBrush=None):
        fieldItem = QTableWidgetItem(propertyName)
        fieldItem.setToolTip(propertyName)
        valueItem = QTableWidgetItem(value)
        valueItem.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
        valueItem.setToolTip(value)
        unitItem = QTableWidgetItem(unit)
        unitItem.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
        if unit:
            unitItem.setToolTip(unit)
        if backgroundBrush:
            fieldItem.setBackground(backgroundBrush)
            valueItem.setBackground(backgroundBrush)
            unitItem.setBackground(backgroundBrush)
        self.dataTableWidget.setItem(row, 0, fieldItem)
        self.dataTableWidget.setItem(row, 1, valueItem)
        self.dataTableWidget.setItem(row, 2, unitItem)

    def formatFieldValue(self, rawValue, layerIdentifier, fieldName, utils):
        if rawValue is None or str(rawValue) == "NULL" or str(rawValue).strip() == "":
            return ""
        if utils.isDateField(layerIdentifier, fieldName):
            return self.formatDateValue(rawValue)
        if utils.isTextField(layerIdentifier, fieldName):
            return str(rawValue)
        try:
            numeric = float(rawValue)
            decimals = utils.getFieldDecimals(layerIdentifier, fieldName, default=2)
            return f"{numeric:.{decimals}f}"
        except (ValueError, TypeError):
            return str(rawValue)

    def formatDateValue(self, rawValue):
        try:
            digits = str(int(float(rawValue)))
        except (ValueError, TypeError):
            return str(rawValue)
        if len(digits) == 8:
            year, month, day = digits[0:4], digits[4:6], digits[6:8]
            if 1 <= int(month) <= 12 and 1 <= int(day) <= 31:
                return f"{year}-{month}-{day}"
        return digits

    def appendDemandRows(self, startRow, demandFeatures, demandLayer, utils, startIndex=1, showIndex=None):
        layerIdentifier = demandLayer.customProperty("qgisred_identifier") or "qgisred_demands"
        skipFields = {"Id"}
        row = startRow
        if showIndex is None:
            showIndex = len(demandFeatures) > 1
        for idx, demandFeat in enumerate(demandFeatures, start=startIndex):
            fields = demandFeat.fields()
            attributes = demandFeat.attributes()
            for fieldIndex, field in enumerate(fields):
                fieldName = field.name()
                if fieldName in skipFields:
                    continue
                prettyName = utils.getFieldPrettyName(layerIdentifier, fieldName)
                if showIndex:
                    prettyName = f"{prettyName} {idx}"
                displayValue = self.formatFieldValue(attributes[fieldIndex], layerIdentifier, fieldName, utils)
                fieldUnit = utils.getFieldUnit(layerIdentifier, fieldName)
                unitDisplay = fieldUnit if fieldUnit and fieldUnit != "-" else ""
                self.setDataRow(row, prettyName, displayValue, unitDisplay, backgroundBrush=self.demandBrush)
                row += 1
        return row

    def installDemandPaginationButtons(self, row, totalDemands):
        pageCount = (totalDemands + self.demandsPerPage - 1) // self.demandsPerPage
        container = QWidget()
        layout = QHBoxLayout(container)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(0)

        prevButton = QToolButton(container)
        prevButton.setText("◀")
        prevButton.setFixedSize(18, 18)
        prevButton.setToolTip(self.tr("Previous demand"))
        prevButton.setEnabled(self.demandPageIndex > 0)
        prevButton.clicked.connect(self.onDemandPrevClicked)

        nextButton = QToolButton(container)
        nextButton.setText("▶")
        nextButton.setFixedSize(18, 18)
        nextButton.setToolTip(self.tr("Next demand"))
        nextButton.setEnabled(self.demandPageIndex < pageCount - 1)
        nextButton.clicked.connect(self.onDemandNextClicked)

        layout.addWidget(prevButton)
        layout.addWidget(nextButton)
        self.dataTableWidget.setColumnWidth(2, 44)
        self.dataTableWidget.setCellWidget(row, 2, container)

    @pyqtSlot()
    def onDemandPrevClicked(self):
        if self.demandPageIndex > 0:
            self.demandPageIndex -= 1
            self.populateDataTableWidget()

    @pyqtSlot()
    def onDemandNextClicked(self):
        self.demandPageIndex += 1
        self.populateDataTableWidget()

    def appendSourceRows(self, startRow, sourceFeature, sourceLayer, utils):
        layerIdentifier = sourceLayer.customProperty("qgisred_identifier") or "qgisred_sources"
        skipFields = {"Id"}
        row = startRow
        fields = sourceFeature.fields()
        attributes = sourceFeature.attributes()
        for fieldIndex, field in enumerate(fields):
            fieldName = field.name()
            if fieldName in skipFields:
                continue
            prettyName = utils.getFieldPrettyName(layerIdentifier, fieldName)
            displayValue = self.formatFieldValue(attributes[fieldIndex], layerIdentifier, fieldName, utils)
            fieldUnit = utils.getFieldUnit(layerIdentifier, fieldName)
            unitDisplay = fieldUnit if fieldUnit and fieldUnit != "-" else ""
            self.setDataRow(row, prettyName, displayValue, unitDisplay, backgroundBrush=self.sourceBrush)
            row += 1
        return row

    def getAllDemandsForNode(self, nodeGeom):
        if nodeGeom.isEmpty():
            return [], None
        if self.demandSpatialIndex is None or self.demandSpatialLayer is None:
            return [], None
        point = nodeGeom.asPoint()
        searchRect = QgsRectangle(point.x() - 1e-6, point.y() - 1e-6,
                                  point.x() + 1e-6, point.y() + 1e-6)
        candidateIds = self.demandSpatialIndex.intersects(searchRect)
        demands = []
        for fid in candidateIds:
            feat = self.demandSpatialLayer.getFeature(fid)
            if not feat.geometry().isEmpty() and self.areOverlappedPoints(nodeGeom, feat.geometry()):
                demands.append(feat)
        return demands, self.demandSpatialLayer

    def getSourceForNode(self, nodeGeom):
        if nodeGeom.isEmpty():
            return None, None
        if self.sourceSpatialIndex is None or self.sourceSpatialLayer is None:
            return None, None
        point = nodeGeom.asPoint()
        searchRect = QgsRectangle(point.x() - 1e-6, point.y() - 1e-6,
                                  point.x() + 1e-6, point.y() + 1e-6)
        candidateIds = self.sourceSpatialIndex.intersects(searchRect)
        for fid in candidateIds:
            feat = self.sourceSpatialLayer.getFeature(fid)
            if not feat.geometry().isEmpty() and self.areOverlappedPoints(nodeGeom, feat.geometry()):
                return feat, self.sourceSpatialLayer
        return None, None

    def initTableWidgets(self):
        """One-time setup for dataTableWidget and tableResults. Called once from __init__."""
        self.sourceBrush = QBrush(QColor("#FFE0FF"))
        self.demandBrush = QBrush(QColor("#FFE4CC"))
        self.totalDemandsBrush = QBrush(QColor("#FFBD80"))

        self.setDataTableWidgetColumns()
        self.dataTableWidget.setShowGrid(True)
        self.dataTableWidget.setStyleSheet("QTableWidget { gridline-color: #E0E0E0; } QTableWidget::item { padding: 1px; }")
        self.dataTableWidget.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        self.dataTableWidget.verticalHeader().setDefaultSectionSize(20)
        self.dataTableWidget.verticalHeader().setVisible(False)

        self.setResultsTableColumns()
        self.tableResults.setShowGrid(True)
        self.tableResults.setStyleSheet("QTableWidget { gridline-color: #E0E0E0; } QTableWidget::item { padding: 1px; }")
        self.tableResults.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        self.tableResults.verticalHeader().setDefaultSectionSize(20)
        self.tableResults.verticalHeader().setVisible(False)

    def setDataTableWidgetColumns(self):
        self.dataTableWidget.setColumnCount(3)
        self.dataTableWidget.setHorizontalHeaderLabels([self.tr("Property"), self.tr("Value"), self.tr("Units")])

        header = self.dataTableWidget.horizontalHeader()
        header.setStyleSheet("QHeaderView::section { font-weight: normal; }")
        # Allow smaller column sizes (default minimum is ~20px)
        header.setMinimumSectionSize(10)
        header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
        header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
        header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
        header.setStretchLastSection(False)
        self.dataTableWidget.setColumnWidth(1, 80)
        self.dataTableWidget.setColumnWidth(2, 44)

    def loadFeature(self, layer, feature, featureIdText=""):
        if not layer or not feature:
            return

        # Save active tab so switching elements doesn't reset it
        currentTabIndex = self.tabWidget.currentIndex()

        self.currentLayer = layer
        self.currentFeature = feature
        self.demandPageIndex = 0
        layer.selectByIds([feature.id()])
        self.populateDataTableWidget()
        self.updateResultsTabVisibility()
        self.populateResultsTable()

        if feature.fields().indexFromName("Tag") != -1:
            featureTag = feature.attribute("Tag")
        else:
            featureTag = ""

        if feature.fields().indexFromName("Descrip") != -1:
            featureDescription = feature.attribute("Descrip")
        else:
            featureDescription = "" 

        self.labelFoundElement.setText(f"{featureIdText}")
        self.labelFoundElement.setStyleSheet("font-weight: bold; font-size: 12pt;")

        if featureTag and str(featureTag).strip() != "":
            self.labelFoundElementTag.setText(str(featureTag))
            self.labelFoundElementTag.show()
            self.isTagVisible = True
        else:
            self.labelFoundElementTag.hide()
            self.isTagVisible = False

        if featureDescription and str(featureDescription).strip() != "":
            self.labelFoundElementDescription.setText(str(featureDescription))
            self.labelFoundElementDescription.show()
            self.isDescVisible = True
        else:
            self.labelFoundElementDescription.hide()
            self.isDescVisible = False

        # Restore previously active tab, falling back to Data if it was hidden
        if self.tabWidget.isTabVisible(currentTabIndex):
            self.tabWidget.setCurrentIndex(currentTabIndex)
        else:
            self.tabWidget.setCurrentIndex(0)

    def appendFeatureProperties(self, feature, labelSuffix=""):
        if not hasattr(self, 'dataTableWidget'):
            return
        fields = feature.fields()
        attributes = feature.attributes()
        currentRowCount = self.dataTableWidget.rowCount()
        newRowCount = currentRowCount + len(fields)
        self.dataTableWidget.setRowCount(newRowCount)
        for i, field in enumerate(fields):
            fieldName = f"{field.name()} ({labelSuffix})"
            fieldItem = QTableWidgetItem(fieldName)
            valueItem = QTableWidgetItem(str(attributes[i]))
            self.dataTableWidget.setItem(currentRowCount + i, 0, fieldItem)
            self.dataTableWidget.setItem(currentRowCount + i, 1, valueItem)

    def handleJunctions(self, layer, feature, tabs):
        self.setupTabs(tabs)
        self.loadFeature(layer, feature)

    def handlePipes(self, layer, feature, tabs):
        self.setupTabs(tabs)
        self.loadFeature(layer, feature)

    def handlePumps(self, layer, feature, tabs):
        self.setupTabs(tabs)
        self.loadFeature(layer, feature)

    def handleReservoirs(self, layer, feature, tabs):
        self.setupTabs(tabs)
        self.loadFeature(layer, feature)

    def handleTanks(self, layer, feature, tabs):
        self.setupTabs(tabs)
        self.loadFeature(layer, feature)

    def handleValves(self, layer, feature, tabs):
        self.setupTabs(tabs)
        self.loadFeature(layer, feature)
    
    def handleMeters(self, layer, feature, tabs):
        self.setupTabs(tabs)
        self.loadFeature(layer, feature)

    def handleIsolationValves(self, layer, feature, tabs):
        self.setupTabs(tabs)
        self.loadFeature(layer, feature)

    def handleServiceConnections(self, layer, feature, tabs):
        self.setupTabs(tabs)
        self.loadFeature(layer, feature)
    
    def setupTabs(self, visibleTabs):
        ...

    def adjustMapView(self, feature):
        canvas = iface.mapCanvas()
        currentExtent = canvas.extent()
        geom = feature.geometry()
        featureExtent = geom.boundingBox()
        mapWidth = currentExtent.width()
        mapHeight = currentExtent.height()
        featWidth = featureExtent.width()
        featHeight = featureExtent.height()
        isPoint = (featWidth == 0 and featHeight == 0)
        featLargestDim = max(featWidth, featHeight)
        mapLargestDim = max(mapWidth, mapHeight)
        ratio = featLargestDim / mapLargestDim if mapLargestDim != 0 else 1

        # If the element is already visible within the current extent, do nothing
        if currentExtent.contains(featureExtent):
            return

        center = featureExtent.center()
        if not isPoint:
            if ratio > 0.25:
                factor = ratio / 0.25
                newWidth = mapWidth * factor
                newHeight = mapHeight * factor
                newExtent = self.recenterExtent(newWidth, newHeight, center.x(), center.y())
            elif ratio < 0.05:
                factor = 0.05 / ratio
                newWidth = mapWidth / factor
                newHeight = mapHeight / factor
                newExtent = self.recenterExtent(newWidth, newHeight, center.x(), center.y())
            else:
                newExtent = QgsRectangle(currentExtent)
        else:
            newExtent = QgsRectangle(currentExtent)
        newExtent = self.applyMinimalPan(newExtent, featureExtent)
        canvas.setExtent(newExtent)
        canvas.refresh()

    def recenterExtent(self, newWidth, newHeight, centerX, centerY):
        halfW = newWidth / 2.0
        halfH = newHeight / 2.0
        rect = QgsRectangle(centerX - halfW, centerY - halfH, centerX + halfW, centerY + halfH)
        return rect

    def applyMinimalPan(self, currentExtent, featureExtent):
        marginX = currentExtent.width() * 0.1
        marginY = currentExtent.height() * 0.1
        leftDist = featureExtent.xMinimum() - currentExtent.xMinimum()
        rightDist = currentExtent.xMaximum() - featureExtent.xMaximum()
        topDist = currentExtent.yMaximum() - featureExtent.yMaximum()
        bottomDist = featureExtent.yMinimum() - currentExtent.yMinimum()
        newExtent = QgsRectangle(currentExtent)
        if leftDist < marginX:
            shift = marginX - leftDist
            newExtent.setXMinimum(newExtent.xMinimum() - shift)
            newExtent.setXMaximum(newExtent.xMaximum() - shift)
        if rightDist < marginX:
            shift = marginX - rightDist
            newExtent.setXMinimum(newExtent.xMinimum() + shift)
            newExtent.setXMaximum(newExtent.xMaximum() + shift)
        if topDist < marginY:
            shift = marginY - topDist
            newExtent.setYMinimum(newExtent.yMinimum() + shift)
            newExtent.setYMaximum(newExtent.yMaximum() + shift)
        if bottomDist < marginY:
            shift = marginY - bottomDist
            newExtent.setYMinimum(newExtent.yMinimum() - shift)
            newExtent.setYMaximum(newExtent.yMaximum() - shift)
        return newExtent

    # ------------------------------
    # Utility Functions
    # ------------------------------
    def buildNodeLayerSpatialIndices(self):
        """Build spatial indices for all node layers and source/demand layers for fast lookup"""
        self.nodeLayerSpatialIndices.clear()
        self.sourceSpatialIndex = None
        self.sourceSpatialLayer = None
        self.demandSpatialIndex = None
        self.demandSpatialLayer = None
        supportedIds = ["qgisred_junctions", "qgisred_reservoirs", "qgisred_tanks"]

        for layer in self.getAllInputGroupLayers():
            identifier = layer.customProperty("qgisred_identifier", "")
            if identifier in supportedIds:
                spatialIndex = QgsSpatialIndex()
                for feature in layer.getFeatures():
                    if not feature.geometry().isEmpty():
                        spatialIndex.addFeature(feature)
                self.nodeLayerSpatialIndices[layer.id()] = (spatialIndex, layer)
            elif identifier == "qgisred_sources":
                self.sourceSpatialIndex = QgsSpatialIndex()
                self.sourceSpatialLayer = layer
                for feature in layer.getFeatures():
                    if not feature.geometry().isEmpty():
                        self.sourceSpatialIndex.addFeature(feature)
            elif identifier == "qgisred_demands":
                self.demandSpatialIndex = QgsSpatialIndex()
                self.demandSpatialLayer = layer
                for feature in layer.getFeatures():
                    if not feature.geometry().isEmpty():
                        self.demandSpatialIndex.addFeature(feature)

    def findOverlappingNodeOptimized(self, pointGeom, excludeLayer=None):
        """Find node that overlaps with given point geometry using spatial index"""
        if pointGeom.isEmpty():
            return None, None

        point = pointGeom.asPoint()
        searchRect = QgsRectangle(point.x() - 1e-6, point.y() - 1e-6,
                                  point.x() + 1e-6, point.y() + 1e-6)

        for layerId, (spatialIndex, layer) in self.nodeLayerSpatialIndices.items():
            if excludeLayer and layer == excludeLayer:
                continue

            # Get candidate features from spatial index
            candidateIds = spatialIndex.intersects(searchRect)

            for fid in candidateIds:
                feature = layer.getFeature(fid)
                if not feature.geometry().isEmpty():
                    if self.areOverlappedPoints(pointGeom, feature.geometry()):
                        return feature, layer

        return None, None

    def getAvailableElementIdentifiers(self):
        availableIdentifiers = []
        for identifier in self.identifierSingulars.keys():
            for layer in self.getAllInputGroupLayers():
                if layer.customProperty("qgisred_identifier") == identifier:
                    availableIdentifiers.append(identifier)
                    break
        return availableIdentifiers

    def getLayerByIdentifier(self, identifier):
        for layer in self.getAllInputGroupLayers():
            if layer.customProperty("qgisred_identifier") == identifier:
                return layer
        return None

    def getFeatureIdValue(self, feature, layer, specialNaming=False):
        if not layer:
            return "Id"

        identifier = layer.customProperty("qgisred_identifier")

        if identifier in self.sourcesAndDemands:
            # Check cache first
            cacheKey = (identifier, feature.id())

            if cacheKey not in self.sourcesDemandToNodeCache:
                # Not in cache, compute it
                # Ensure spatial indices are built
                if not self.nodeLayerSpatialIndices:
                    self.buildNodeLayerSpatialIndices()

                # Use optimized spatial search
                nodeFeature, nodeLayer = self.findOverlappingNodeOptimized(feature.geometry())

                if nodeFeature and nodeLayer:
                    nodeId = self.extractNodeId(nodeFeature.attribute("Id"))
                    nodeIdentifier = nodeLayer.customProperty("qgisred_identifier", "")
                    self.sourcesDemandToNodeCache[cacheKey] = (nodeId, nodeIdentifier)
                else:
                    self.sourcesDemandToNodeCache[cacheKey] = (None, None)

            # Get from cache
            nodeId, nodeIdentifier = self.sourcesDemandToNodeCache[cacheKey]

            if nodeId:
                if specialNaming:
                    singular = self.identifierSingulars.get(nodeIdentifier, nodeIdentifier or "")
                    suffixKind = "source" if identifier == "qgisred_sources" else "mult_dem"
                    return f"{singular} {nodeId} {self.getSuffixForKind(suffixKind)}"
                return str(nodeId)
            return ""
        else:
            # Original code for non-source/demand features
            value = feature.attribute("Id")
            idStr = str(value) if value is not None else ""

            if specialNaming and identifier in ["qgisred_junctions", "qgisred_reservoirs", "qgisred_tanks"]:
                # Build spatial indices if needed for source/demand overlap check
                if not self.nodeLayerSpatialIndices:
                    self.buildNodeLayerSpatialIndices()

                suffixes = self.getNodeSuffixes(feature.geometry(), identifier)
                if suffixes:
                    idStr += " " + self.composeSuffixText(suffixes)

            return idStr

    def extractNodeId(self, text):
        for kind in ("source", "mult_dem"):
            text = text.replace(" " + self.getSuffixForKind(kind), "")
        parts = text.strip().split()
        result = parts[-1] if len(parts) > 1 else text
        return result

    def extractIdentifierAndIdFromItem(self, item):
        identifier = item.data(Qt.ItemDataRole.UserRole)
        rawId = item.data(Qt.ItemDataRole.UserRole + 1) or ""
        return identifier, rawId

    def isLayerValid(self, layer):
        """Check if a QgsVectorLayer reference is still alive (C++ object not deleted)."""
        if layer is None:
            return False
        try:
            layer.id()
            return True
        except RuntimeError:
            return False

    def areOverlappedPoints(self, point1, point2, tolerance=1e-9):
        return point1.distance(point2) < tolerance

    def getNodeSuffixes(self, nodeGeom, nodeIdentifier):
        """Get source/demand suffix kinds for a node using spatial indices.
        Returns a list of kinds like ["source"] or ["source", "mult_dem"] or []."""
        if nodeGeom.isEmpty():
            return []
        if nodeIdentifier not in ["qgisred_junctions", "qgisred_reservoirs", "qgisred_tanks"]:
            return []

        point = nodeGeom.asPoint()
        searchRect = QgsRectangle(point.x() - 1e-6, point.y() - 1e-6,
                                  point.x() + 1e-6, point.y() + 1e-6)
        suffixes = []

        if self.sourceSpatialIndex is not None and self.sourceSpatialLayer is not None:
            candidateIds = self.sourceSpatialIndex.intersects(searchRect)
            for fid in candidateIds:
                srcFeat = self.sourceSpatialLayer.getFeature(fid)
                if not srcFeat.geometry().isEmpty() and self.areOverlappedPoints(nodeGeom, srcFeat.geometry()):
                    suffixes.append("source")
                    break

        if nodeIdentifier == "qgisred_junctions" and self.demandSpatialIndex is not None and self.demandSpatialLayer is not None:
            candidateIds = self.demandSpatialIndex.intersects(searchRect)
            for fid in candidateIds:
                dmndFeat = self.demandSpatialLayer.getFeature(fid)
                if not dmndFeat.geometry().isEmpty() and self.areOverlappedPoints(nodeGeom, dmndFeat.geometry()):
                    suffixes.append("mult_dem")
                    break

        return suffixes

    def updateFoundElementLabel(self, selectedId, layer=None):
        if not selectedId:
            self.labelFoundElement.setText("")
            return

        if layer and layer.customProperty("qgisred_identifier") in self.sourcesAndDemands:
            nodeLayer, nodeFeat = self.findNodeLayer(selectedId)
        elif layer:
            nodeFeat = None
            for feat in layer.getFeatures():
                if self.getFeatureIdValue(feat, layer) == selectedId:
                    nodeFeat = feat
                    break
            nodeLayer = layer if nodeFeat else None
        else:
            nodeLayer, nodeFeat = self.findNodeLayer(selectedId)

        if nodeLayer and nodeFeat:
            nodeIdentifier = nodeLayer.customProperty("qgisred_identifier", "")
            suffixKinds = self.getNodeSuffixes(nodeFeat.geometry(), nodeIdentifier)
            singularNodeType = self.getSingularForLayer(nodeLayer)
            suffixStr = self.composeSuffixText(suffixKinds)
            finalText = f"{singularNodeType} {selectedId} {suffixStr}".strip()
            self.labelFoundElement.setText(finalText)
        else:
            elementIdentifier = self.cbElementType.currentData()
            singularElementType = self.identifierSingulars.get(elementIdentifier, self.cbElementType.currentText())
            finalText = f"{singularElementType} {selectedId}"
            self.labelFoundElement.setText(finalText)

        return finalText

    def findNodeLayer(self, nodeId):
        for layer in self.getAllInputGroupLayers():
            identifier = layer.customProperty("qgisred_identifier", "")
            if identifier in self.nodeLayers:
                if identifier in self.sourcesAndDemands:
                    continue
                for feature in layer.getFeatures():
                    if self.getFeatureIdValue(feature, layer) == nodeId:
                        return layer, feature
        return None, None

    def findOverlappedNode(self, pointFeature, currentLayer, supportedOnly=False):
        featureGeom = pointFeature.geometry()
        if featureGeom.isEmpty():
            return None, None
        featurePoint = featureGeom.asPoint()
        featurePointGeom = QgsGeometry.fromPointXY(featurePoint)
        tolerance = 1e-6
        if supportedOnly:
            supportedIds = ["qgisred_junctions", "qgisred_reservoirs", "qgisred_tanks"]
            for nodeLayer in self.getAllInputGroupLayers():
                if nodeLayer == currentLayer:
                    continue
                nodeIdentifier = nodeLayer.customProperty("qgisred_identifier", "")
                if nodeIdentifier not in supportedIds:
                    continue
                for nodeFeature in nodeLayer.getFeatures():
                    nodeGeom = nodeFeature.geometry()
                    if nodeGeom.isEmpty():
                        continue
                    nodePointGeom = QgsGeometry.fromPointXY(nodeGeom.asPoint())
                    if self.areOverlappedPoints(featurePointGeom, nodePointGeom):
                        return nodeFeature, nodeLayer
            return None, None
        else:
            for nodeLayer in self.getAllInputGroupLayers():
                if nodeLayer == currentLayer:
                    continue
                nodeIdentifier = nodeLayer.customProperty("qgisred_identifier", "")
                if nodeIdentifier in self.nodeLayers and nodeIdentifier not in self.sourcesAndDemands:
                    for nodeFeature in nodeLayer.getFeatures():
                        nodeGeom = nodeFeature.geometry()
                        if nodeGeom.isEmpty():
                            continue
                        nodePointGeom = QgsGeometry.fromPointXY(nodeGeom.asPoint())
                        if self.areOverlappedPoints(featurePointGeom, nodePointGeom):
                            return nodeFeature, nodeLayer
            return None, None

    def findSourceOrDemandForNodeId(self, nodeId):
        nodeLayer, nodeFeat = self.findNodeLayer(nodeId)
        if not nodeLayer or not nodeFeat:
            return None, None
        nodeGeom = nodeFeat.geometry()
        if nodeGeom.isEmpty():
            return None, None
        for layer in self.getAllInputGroupLayers():
            identifier = layer.customProperty("qgisred_identifier", "")
            if identifier in self.sourcesAndDemands:
                for feat in layer.getFeatures():
                    featGeom = feat.geometry()
                    if featGeom.isEmpty():
                        continue
                    if self.areOverlappedPoints(nodeGeom, featGeom):
                        return feat, layer
        return None, None

    def findElementInResultsGroup(self, elementId):
        """
        Search for an element by ID in the Results group layers.
        Results group typically contains nodes and lines layers without type separation.

        Args:
            elementId: The ID of the element to find.

        Returns:
            tuple: (feature, layer) if found, (None, None) otherwise.
        """
        resultsLayers = self.getAllResultsGroupLayers()
        if not resultsLayers:
            return None, None

        for layer in resultsLayers:
            # Results layers may have different field names for ID
            idFieldNames = ["Id", "ID", "id", "fid", "FID"]
            idFieldIndex = -1

            for fieldName in idFieldNames:
                idx = layer.fields().indexFromName(fieldName)
                if idx >= 0:
                    idFieldIndex = idx
                    break

            if idFieldIndex < 0:
                continue

            for feature in layer.getFeatures():
                featureId = feature.attribute(idFieldIndex)
                if featureId is not None and str(featureId) == str(elementId):
                    return feature, layer

        return None, None

    def isLineElement(self, layer):
        result = layer.customProperty("qgisred_identifier") in self.linkLayers
        return result

    def getSingularForLayer(self, layer):
        identifier = layer.customProperty("qgisred_identifier", "") if layer else ""
        return self.identifierSingulars.get(identifier, layer.name() if layer else "")

    def getSuffixForKind(self, kind):
        if kind == "source":
            return self.tr("(Source)")
        if kind == "mult_dem":
            return self.tr("(Mult.Dem)")
        return ""

    def composeSuffixText(self, suffixKinds):
        if not suffixKinds:
            return ""
        return " ".join(self.getSuffixForKind(k) for k in suffixKinds)

    def addAdjacencyItem(self, itemText, identifier, rawId="", suffixKinds=None):
        newItem = QListWidgetItem(itemText)
        newItem.setData(Qt.ItemDataRole.UserRole, identifier)
        newItem.setData(Qt.ItemDataRole.UserRole + 1, rawId)
        newItem.setData(Qt.ItemDataRole.UserRole + 2, list(suffixKinds) if suffixKinds else [])
        self.listWidget.addItem(newItem)

    def sortListWidgetItems(self):
        items = []
        for i in range(self.listWidget.count()):
            item = self.listWidget.item(i)
            items.append((
                item.text(),
                item.data(Qt.ItemDataRole.UserRole),
                item.data(Qt.ItemDataRole.UserRole + 1),
                item.data(Qt.ItemDataRole.UserRole + 2),
            ))
        self.listWidget.clear()

        identifierOrder = list(self.identifierSingulars.keys())
        def sortKey(entry):
            iden = entry[1]
            try:
                return identifierOrder.index(iden)
            except ValueError:
                return len(identifierOrder)
        for text, identifier, rawId, suffixKinds in sorted(items, key=sortKey):
            newItem = QListWidgetItem(text)
            newItem.setData(Qt.ItemDataRole.UserRole, identifier)
            newItem.setData(Qt.ItemDataRole.UserRole + 1, rawId or "")
            newItem.setData(Qt.ItemDataRole.UserRole + 2, list(suffixKinds) if suffixKinds else [])
            self.listWidget.addItem(newItem)
        self.adjustConnectedElementsHeight()

    def adjustConnectedElementsHeight(self):
        maxVisibleRows = 4
        count = self.listWidget.count()
        rowHeight = self.listWidget.sizeHintForRow(0) if count > 0 else 16
        if rowHeight <= 0:
            rowHeight = 16
        visibleRows = min(max(count, 1), maxVisibleRows)
        frameBorder = 2 * self.listWidget.frameWidth()
        self.listWidget.setFixedHeight(rowHeight * visibleRows + frameBorder)

    def addServiceConnectionAdjacencies(self, currentGeom, tolerance):
        serviceLayers = [
            layer for layer in self.getAllInputGroupLayers()
            if layer.customProperty("qgisred_identifier") == "qgisred_serviceconnections"
        ]
        for layer in serviceLayers:
            for feat in layer.getFeatures():
                serviceGeom = feat.geometry()
                if serviceGeom.isEmpty():
                    continue
                if currentGeom.intersects(serviceGeom) or currentGeom.distance(serviceGeom) < tolerance:
                    serviceId = self.getFeatureIdValue(feat, layer)
                    singular = self.getSingularForLayer(layer)
                    self.addAdjacencyItem(f"{singular} {serviceId}",
                                          layer.customProperty("qgisred_identifier"), serviceId)

    def addIsolationValveAdjacencies(self, currentGeom, tolerance):
        isolationLayers = [
            layer for layer in self.getAllInputGroupLayers()
            if layer.customProperty("qgisred_identifier") == "qgisred_isolationvalves"
        ]
        for layer in isolationLayers:
            for feat in layer.getFeatures():
                isoGeom = feat.geometry()
                if isoGeom.isEmpty():
                    continue
                if currentGeom.intersects(isoGeom) or currentGeom.distance(isoGeom) < tolerance:
                    isoId = self.getFeatureIdValue(feat, layer)
                    singular = self.getSingularForLayer(layer)
                    self.addAdjacencyItem(f"{singular} {isoId}",
                                          layer.customProperty("qgisred_identifier"), isoId)

    def refreshConnectedElements(self, feature, layer):
        self.listWidget.clear()
        identifier = layer.customProperty("qgisred_identifier")
        if self.isLineElement(layer):
            self.findAdjacentNodesByGeometry(feature)
        elif identifier == "qgisred_meters":
            self.findMeterAdjacency(feature, layer)
        elif identifier == "qgisred_isolationvalves":
            self.findIsolationValveAdjacency(feature, layer)
        elif identifier == "qgisred_serviceconnections":
            self.findServiceConnectionAdjacency(feature, layer)
        else:
            self.findAdjacentLinksByGeometry(feature, layer)
        self.sortListWidgetItems()
        hasConnected = self.listWidget.count() > 0
        self.mConnectedElementsGroupBox.setVisible(hasConnected)

    def findAdjacentNodesByGeometry(self, lineFeature):
        geom = lineFeature.geometry()
        if geom.isEmpty():
            return
        if geom.isMultipart():
            parts = geom.asMultiPolyline()
            linePoints = parts[0] if parts else []
        else:
            linePoints = geom.asPolyline()
        if not linePoints:
            return
        lineGeom = QgsGeometry.fromPolylineXY(linePoints)
        tolerance = 1e-6
        foundNodes = []
        nodeLayers = [
            layer for layer in self.getAllInputGroupLayers()
            if layer.customProperty("qgisred_identifier") in (self.nodeLayers + self.specialLayers)
        ]
        for nodeLayer in nodeLayers:
            if nodeLayer.geometryType() != 0:
                continue
            identifier = nodeLayer.customProperty("qgisred_identifier", "")
            if identifier in self.sourcesAndDemands:
                continue
            for f in nodeLayer.getFeatures():
                nodeGeom = f.geometry()
                if nodeGeom.isEmpty():
                    continue
                if lineGeom.distance(nodeGeom) < tolerance:
                    nodeId = self.getFeatureIdValue(f, nodeLayer)
                    singular = self.getSingularForLayer(nodeLayer)
                    nodeSuffixes = self.getNodeSuffixes(nodeGeom, identifier)
                    suffixText = self.composeSuffixText(nodeSuffixes)
                    nodeInfo = f"{singular} {nodeId} {suffixText}".strip() if suffixText else f"{singular} {nodeId}"
                    foundNodes.append((nodeLayer, f, nodeInfo, nodeId, nodeSuffixes))
        for nodeLayer, f, nodeInfo, nodeId, nodeSuffixes in foundNodes:
            self.addAdjacencyItem(nodeInfo, nodeLayer.customProperty("qgisred_identifier", ""),
                                  nodeId, nodeSuffixes)
        self.addServiceConnectionAdjacencies(lineGeom, tolerance)

    def findAdjacentLinksByGeometry(self, nodeFeature, layer):
        nodeGeom = nodeFeature.geometry()
        if nodeGeom.isEmpty():
            return
        # Check if the geometry is a point type (geometryType() == 0 for Point)
        if nodeGeom.type() != 0:  # Not a point geometry
            return
        nodePoint = QgsPointXY(nodeGeom.asPoint())
        nodeG = QgsGeometry.fromPointXY(nodePoint)
        tolerance = 1e-9
        foundLinks = []
        linkLayers = [
            lyr for lyr in self.getAllInputGroupLayers()
            if lyr.customProperty("qgisred_identifier") in (self.linkLayers + ["qgisred_meters"])
        ]
        for linkLayer in linkLayers:
            ident = linkLayer.customProperty("qgisred_identifier", "")
            if ident in self.linkLayers:
                if linkLayer.geometryType() != 1:
                    continue
                for f in linkLayer.getFeatures():
                    linkGeom = f.geometry()
                    if linkGeom.isMultipart():
                        parts = linkGeom.asMultiPolyline()
                        linePoints = parts[0] if parts else []
                    else:
                        linePoints = linkGeom.asPolyline()
                    if not linePoints:
                        continue
                    if (nodeG.distance(linkGeom) < tolerance or
                        self.areOverlappedPoints(nodeG, QgsGeometry.fromPointXY(linePoints[0])) or
                        self.areOverlappedPoints(nodeG, QgsGeometry.fromPointXY(linePoints[-1]))):
                        linkId = self.getFeatureIdValue(f, linkLayer)
                        singular = self.getSingularForLayer(linkLayer)
                        foundLinks.append((linkLayer, f, f"{singular} {linkId}", linkId))
            elif ident == "qgisred_meters":
                if linkLayer.geometryType() != 0:
                    continue
                for f in linkLayer.getFeatures():
                    meterGeom = f.geometry()
                    if meterGeom.isEmpty():
                        continue
                    if nodeG.distance(meterGeom) < tolerance:
                        meterId = self.getFeatureIdValue(f, linkLayer)
                        singular = self.getSingularForLayer(linkLayer)
                        foundLinks.append((linkLayer, f, f"{singular} {meterId}", meterId))
        for linkLayer, f, linkInfo, linkRawId in foundLinks:
            self.addAdjacencyItem(linkInfo, linkLayer.customProperty("qgisred_identifier"), linkRawId)
        self.addServiceConnectionAdjacencies(nodeG, tolerance)
        if layer.customProperty("qgisred_identifier") == "qgisred_junctions":
            self.addIsolationValveAdjacencies(nodeG, tolerance)

    def findServiceConnectionAdjacency(self, feature, currentLayer):
        geom = feature.geometry()
        if geom.isEmpty():
            return
        if geom.isMultipart():
            parts = geom.asMultiPolyline()
            if not parts or not parts[0]:
                return
            linePoints = parts[0]
        else:
            linePoints = geom.asPolyline()
        if not linePoints:
            return
        endpoints = [QgsPointXY(linePoints[0]), QgsPointXY(linePoints[-1])]
        tolerance = 1e-6
        for pt in endpoints:
            dummyFeature = QgsFeature()
            dummyFeature.setGeometry(QgsGeometry.fromPointXY(pt))
            nodeFeature, nodeLayer = self.findOverlappedNode(dummyFeature, currentLayer)
            if nodeFeature and nodeLayer.customProperty("qgisred_identifier") == "qgisred_junctions":
                rawId = str(nodeFeature.attribute("Id"))
                suffixKinds = self.getNodeSuffixes(nodeFeature.geometry(), "qgisred_junctions")
                singularName = self.getSingularForLayer(nodeLayer)
                suffixText = self.composeSuffixText(suffixKinds)
                fullName = f"{singularName} {rawId} {suffixText}".strip() if suffixText else f"{singularName} {rawId}"
                self.addAdjacencyItem(fullName, nodeLayer.customProperty("qgisred_identifier"), rawId, suffixKinds)
                return
        for pt in endpoints:
            ptGeom = QgsGeometry.fromPointXY(pt)
            for lyr in self.getAllInputGroupLayers():
                if lyr.customProperty("qgisred_identifier") == "qgisred_pipes":
                    for f in lyr.getFeatures():
                        pipeGeom = f.geometry()
                        if pipeGeom.isEmpty():
                            continue
                        if ptGeom.distance(pipeGeom) < tolerance:
                            pipeId = self.getFeatureIdValue(f, lyr)
                            singular = self.getSingularForLayer(lyr)
                            fullName = f"{singular} {pipeId}"
                            self.addAdjacencyItem(fullName, lyr.customProperty("qgisred_identifier"), pipeId)
                            return

    def findIsolationValveAdjacency(self, feature, currentLayer):
        geom = feature.geometry()
        if geom.isEmpty():
            return
        tolerance = 1e-6
        nodeFeature, nodeLayer = self.findOverlappedNode(feature, currentLayer)
        if nodeFeature and nodeLayer.customProperty("qgisred_identifier") == "qgisred_junctions":
            rawId = str(nodeFeature.attribute("Id"))
            suffixKinds = self.getNodeSuffixes(nodeFeature.geometry(), "qgisred_junctions")
            singularName = self.getSingularForLayer(nodeLayer)
            suffixText = self.composeSuffixText(suffixKinds)
            nodeFullName = f"{singularName} {rawId} {suffixText}".strip() if suffixText else f"{singularName} {rawId}"
            self.addAdjacencyItem(nodeFullName, nodeLayer.customProperty("qgisred_identifier"), rawId, suffixKinds)
            return
        for lyr in self.getAllInputGroupLayers():
            if lyr.customProperty("qgisred_identifier") == "qgisred_pipes":
                for f in lyr.getFeatures():
                    pipeGeom = f.geometry()
                    if pipeGeom.isEmpty():
                        continue
                    if geom.distance(pipeGeom) < tolerance:
                        pipeId = self.getFeatureIdValue(f, lyr)
                        singular = self.getSingularForLayer(lyr)
                        fullName = f"{singular} {pipeId}"
                        self.addAdjacencyItem(fullName, lyr.customProperty("qgisred_identifier"), pipeId)
                        return

    def findMeterAdjacency(self, feature, currentLayer):
        geom = feature.geometry()
        if geom.isEmpty():
            return
        tolerance = 1e-6
        nodeFeature, nodeLayer = self.findOverlappedNode(feature, currentLayer)
        if nodeFeature:
            rawId = str(nodeFeature.attribute("Id"))
            nodeIdentifier = nodeLayer.customProperty("qgisred_identifier", "")
            suffixKinds = self.getNodeSuffixes(nodeFeature.geometry(), nodeIdentifier)
            singularName = self.getSingularForLayer(nodeLayer)
            suffixText = self.composeSuffixText(suffixKinds)
            nodeItemText = f"{singularName} {rawId} {suffixText}".strip() if suffixText else f"{singularName} {rawId}"
            self.addAdjacencyItem(nodeItemText, nodeIdentifier, rawId, suffixKinds)
            return
        for lyr in self.getAllInputGroupLayers():
            if lyr.customProperty("qgisred_identifier") != "qgisred_meters":
                for f in lyr.getFeatures():
                    linkGeom = f.geometry()
                    if linkGeom.isEmpty():
                        continue
                    if geom.distance(linkGeom) < tolerance:
                        adjId = self.getFeatureIdValue(f, lyr)
                        singular = self.getSingularForLayer(lyr)
                        fullName = f"{singular} {adjId}"
                        self.addAdjacencyItem(fullName, lyr.customProperty("qgisred_identifier"), adjId)
                        return

    def resolveToInputElement(self, layer, feature):
        """If layer is from Results or Queries group, find the corresponding Inputs element by Id."""
        identifier = layer.customProperty("qgisred_identifier", "")

        # Already an Inputs layer
        if identifier in self.identifierSingulars:
            return layer, feature

        # Determine which Inputs identifiers to search
        searchIdentifiers = None
        if identifier == "qgisred_link" or identifier.startswith("qgisred_link_"):
            searchIdentifiers = ["qgisred_pipes", "qgisred_pumps", "qgisred_valves"]
        elif identifier == "qgisred_node" or identifier.startswith("qgisred_node_"):
            searchIdentifiers = ["qgisred_junctions", "qgisred_reservoirs", "qgisred_tanks"]
        elif identifier.startswith("qgisred_query_"):
            # Pattern: qgisred_query_{layerType}_{field} e.g. qgisred_query_pipes_diameter
            parts = identifier.split("_", 3)  # ['qgisred', 'query', 'pipes', 'diameter']
            if len(parts) >= 3:
                layerType = parts[2]
                inputIdentifier = f"qgisred_{layerType}"
                if inputIdentifier in self.identifierSingulars:
                    searchIdentifiers = [inputIdentifier]

        if not searchIdentifiers:
            return layer, feature

        # Match by Id field
        featureId = feature.attribute("Id")
        if featureId is None:
            return layer, feature
        featureIdStr = str(featureId)

        for inputLayer in self.getAllInputGroupLayers():
            if inputLayer.customProperty("qgisred_identifier") in searchIdentifiers:
                for feat in inputLayer.getFeatures():
                    if str(feat.attribute("Id")) == featureIdStr:
                        return inputLayer, feat

        return layer, feature

    def findFeature(self, layer, feature):
        # Safety check - verify layer and feature are valid
        if not layer or not feature:
            self.showLayerVisibilityWarning()
            return

        # Check if layers are available
        availableLayers, _ = self.getAvailableLayersForExplorer()
        if not availableLayers:
            self.showLayerVisibilityWarning()
            return

        # Resolve Results/Queries layer to its Inputs counterpart
        layer, feature = self.resolveToInputElement(layer, feature)

        layerIdentifier = layer.customProperty("qgisred_identifier", "")
        index = self.cbElementType.findData(layerIdentifier)
        if index >= 0:
            self.cbElementType.setCurrentIndex(index)

        self.updateElementIds()

        featureIdText = self.getFeatureIdValue(feature, layer, specialNaming=True)

        index = self.cbElementId.findText(featureIdText)
        if index >= 0:
            self.cbElementId.setCurrentIndex(index)

        self.clearHighlights()
        self.clearAllLayerSelections()
        self.listWidget.clear()

        finalTitleText = self.updateFoundElementLabel(featureIdText, layer)

        highlight = QgsHighlight(iface.mapCanvas(), feature.geometry(), layer)
        highlight.setColor(QColor("red"))
        highlight.setWidth(5)
        highlight.show()
        self.mainHighlight = highlight

        self.adjustMapView(feature)
        
        identifier = layer.customProperty("qgisred_identifier")
        if self.isLineElement(layer):
            self.findAdjacentNodesByGeometry(feature)
        elif identifier == "qgisred_meters":
            self.findMeterAdjacency(feature, layer)
        elif identifier == "qgisred_isolationvalves":
            self.findIsolationValveAdjacency(feature, layer)
        elif identifier == "qgisred_serviceconnections":
            self.findServiceConnectionAdjacency(feature, layer)
        else:
            self.findAdjacentLinksByGeometry(feature, layer)
        
        self.sortListWidgetItems()

        self.loadFeature(layer, feature, finalTitleText)

    def getCheckedInputGroupLayers(self):
        inputsGroup = QGISRedLayerUtils.findGroupByIdentifier("qgisred_inputs")
        if not inputsGroup:
            return []
        return inputsGroup.checkedLayers()

    def getAllInputGroupLayers(self):
        """Get all layers from Inputs group regardless of visibility/checked state."""
        inputsGroup = QGISRedLayerUtils.findGroupByIdentifier("qgisred_inputs")
        if not inputsGroup:
            return []
        layers = []
        for layerNode in inputsGroup.findLayers():
            layer = layerNode.layer()
            if layer and isinstance(layer, QgsVectorLayer):
                layers.append(layer)
        return layers

    def getAllResultsGroupLayers(self):
        """Get all layers from Results group regardless of visibility/checked state."""
        resultsGroup = QGISRedLayerUtils.findGroupByIdentifier("qgisred_results")
        if not resultsGroup:
            return []
        layers = []
        for layerNode in resultsGroup.findLayers():
            layer = layerNode.layer()
            if layer and isinstance(layer, QgsVectorLayer):
                layers.append(layer)
        return layers

    def getCheckedResultsGroupLayers(self):
        """Get checked/visible layers from Results group."""
        resultsGroup = QGISRedLayerUtils.findGroupByIdentifier("qgisred_results")
        if not resultsGroup:
            return []
        return resultsGroup.checkedLayers()

    def isResultsDataAvailable(self):
        """Check if results subfolder and results data block exist."""
        projectPath = QgsProject.instance().readPath("./")
        if not projectPath or projectPath == "./":
            return False

        resultsPath = os.path.join(projectPath, "Results")
        if not os.path.exists(resultsPath):
            return False

        # Check if Results group has any layers
        resultsLayers = self.getAllResultsGroupLayers()
        return len(resultsLayers) > 0

    def getAvailableLayersForExplorer(self):
        """
        Get available layers for the Element Explorer.
        First tries Inputs group, falls back to Results group.

        Returns:
            tuple: (layers_list, source_group_name) or ([], None) if no layers available
        """
        # First, try Inputs group (regardless of visibility)
        if QGISRedLayerUtils.findGroupByIdentifier("qgisred_inputs"):
            inputLayers = self.getAllInputGroupLayers()
            if inputLayers:
                return inputLayers, "Inputs"

        # If Inputs not available, try Results group
        if QGISRedLayerUtils.findGroupByIdentifier("qgisred_results"):
            resultsLayers = self.getAllResultsGroupLayers()
            if resultsLayers:
                return resultsLayers, "Results"

        return [], None

    def showLayerVisibilityWarning(self):
        """Show warning message when no layers are found in either Inputs or Results groups."""
        QMessageBox.warning(
            self,
            self.tr("No Layers Found"),
            self.tr("No layers found in the Inputs or Results group.")
        )

    # ------------------------------
    # Results Tab
    # ------------------------------
    def connectResultsDock(self, resultsDock=None):
        """Connect to a QGISRedResultsDock to sync results data.
        If no resultsDock is provided, attempts to find one automatically."""
        if resultsDock is None:
            resultsDock = self.findResultsDock()
        if resultsDock is None:
            return
        if self.resultsDock is resultsDock:
            return
        if self.resultsDock is not None:
            self.disconnectResultsDock()
        self.resultsDock = resultsDock
        resultsDock.timeTextChanged.connect(self.onResultsTimeChanged)
        resultsDock.statisticsModeChanged.connect(self.onResultsStatisticsChanged)
        resultsDock.visibilityChanged.connect(self.onResultsDockVisibilityChanged)
        resultsDock.simulationFinished.connect(self.onResultsSimulationFinished)
        resultsDock.resultPropertyChanged.connect(self.onResultsSimulationFinished)
        # Initial sync
        if resultsDock._statsMode:
            self.onResultsStatisticsChanged(resultsDock._currentStat)
        else:
            timeText = resultsDock.lbTime.text()
            if timeText:
                self.onResultsTimeChanged(timeText)
        self.updateResultsTabVisibility()

    def findResultsDock(self):
        """Find an existing visible QGISRedResultsDock among main window dock widgets."""
        for widget in iface.mainWindow().findChildren(QDockWidget):
            if isinstance(widget, QGISRedResultsDock) and widget.isVisible():
                return widget
        return None

    def disconnectResultsDock(self):
        """Disconnect from the results dock and show placeholder."""
        if self.resultsDock is not None:
            try:
                self.resultsDock.timeTextChanged.disconnect(self.onResultsTimeChanged)
            except (TypeError, RuntimeError):
                pass
            try:
                self.resultsDock.statisticsModeChanged.disconnect(self.onResultsStatisticsChanged)
            except (TypeError, RuntimeError):
                pass
            try:
                self.resultsDock.visibilityChanged.disconnect(self.onResultsDockVisibilityChanged)
            except (TypeError, RuntimeError):
                pass
            try:
                self.resultsDock.simulationFinished.disconnect(self.onResultsSimulationFinished)
            except (TypeError, RuntimeError):
                pass
            try:
                self.resultsDock.resultPropertyChanged.disconnect(self.onResultsSimulationFinished)
            except (TypeError, RuntimeError):
                pass
            self.resultsDock = None
        self.resultsCurrentTimeText = ""
        self.resultsCurrentStat = ""
        self.labelResultsTime.setText("00:00:00")
        self.populateResultsTable()
        self.updateResultsTabVisibility()

    def onResultsDockVisibilityChanged(self, visible):
        if not visible:
            # Delay disconnect to avoid false triggers when RD is detached to float
            self.resultsDockVisibilityTimer.start()
            return
        # RD became visible again — cancel any pending disconnect
        self.resultsDockVisibilityTimer.stop()
        if self.resultsDock:
            timeText = self.resultsDock.lbTime.text()
            if timeText:
                self.onResultsTimeChanged(timeText)
        self.updateResultsTabVisibility()

    def checkResultsDockClosed(self):
        """Called after a short delay to verify the RD is truly closed, not just floating."""
        if self.resultsDock is not None and not self.resultsDock.isVisible():
            self.disconnectResultsDock()

    def onResultsTimeChanged(self, timeText):
        """Handle time changes from the results dock."""
        if timeText == "NULL":
            timeText = "N/A"
        self.resultsCurrentTimeText = timeText
        self.labelResultsTime.show()
        self.labelResultsTime.setText(timeText)
        self.updateResultsTabVisibility()
        self.populateResultsTable()

    def onResultsStatisticsChanged(self, statName):
        """Handle statistics mode changes from the results dock."""
        self.resultsCurrentStat = statName
        self.labelResultsTime.show()
        if statName:
            self.labelResultsTime.setText(self.formatStatsLabelText(statName))
        else:
            self.labelResultsTime.setText(self.resultsCurrentTimeText)
        self.updateResultsTabVisibility()
        self.populateResultsTable()

    def onResultsSimulationFinished(self):
        self.updateResultsTabVisibility()
        self.populateResultsTable()

    def formatStatsLabelText(self, statName):
        """Format statistics label as 'StatName for report times'."""
        return f"{statName} {self.tr('values for report times')}"

    def clearResultsTable(self):
        """Clear the results table contents."""
        self.labelResultsTime.hide()
        self.labelResultsTime.setText("")
        self.tableResults.clearContents()
        self.tableResults.setRowCount(0)

    def setResultsTableColumns(self):
        """Configure tableResults with the same 3-column structure as dataTableWidget."""
        self.tableResults.setColumnCount(3)
        self.tableResults.setHorizontalHeaderLabels([self.tr("Property"), self.tr("Value"), self.tr("Units")])
        self.tableResults.horizontalHeader().setVisible(True)

        header = self.tableResults.horizontalHeader()
        header.setStyleSheet("QHeaderView::section { font-weight: normal; }")
        header.setMinimumSectionSize(10)
        header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
        header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
        header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
        header.setStretchLastSection(False)
        self.tableResults.setColumnWidth(1, 80)
        self.tableResults.setColumnWidth(2, 50)

    def findResultsLayerForElement(self, isNode):
        """Find the Results group layer matching the element type (node or link).
        Matches both the base identifier (qgisred_node/qgisred_link) set on layer creation
        and the variable-specific identifier (qgisred_node_pressure etc.) set after QML styling."""
        base = "qgisred_node" if isNode else "qgisred_link"
        for layer in self.getAllResultsGroupLayers():
            identifier = layer.customProperty("qgisred_identifier", "")
            if identifier == base or identifier.startswith(base + "_"):
                return layer
        return None

    def getResultsFieldOrder(self, isNode):
        fu = QGISRedFieldUtils()
        isQuality = fu.getQualityModel().upper() != "NONE"
        if isNode:
            fields = ["Pressure", "Head", "Demand"]
            if isQuality:
                fields.append("Quality")
            return fields
        fields = ["Status", "Flow", "Velocity", "HeadLoss", "UnitHdLoss", "FricFactor"]
        if isQuality:
            fields.append("ReactRate")
            fields.append("Quality")
        return fields

    def populateResultsTable(self):
        """Populate tableResults with per-element results from the Results group layer attribute table."""
        if not self.isLayerValid(self.currentLayer) or not self.currentFeature:
            self.clearResultsTable()
            return

        identifier = self.currentLayer.customProperty("qgisred_identifier", "")
        isNode = identifier in self.nodeLayers
        isLink = identifier in self.linkLayers

        if not isNode and not isLink:
            self.clearResultsTable()
            return

        try:
            # Get element Id
            elementId = str(self.currentFeature.attribute("Id"))

            # Find the Results group layer for this element type
            resultsLayer = self.findResultsLayerForElement(isNode)
            if not resultsLayer:
                self.clearResultsTable()
                return

            # Determine query mode and find matching feature
            isStatsMode = bool(self.resultsCurrentStat)
            matchedFeature = None

            if isStatsMode:
                # Statistics mode: one row per element, no Time column
                expr = f"\"Id\" = '{elementId}'"
                for feat in resultsLayer.getFeatures(expr):
                    matchedFeature = feat
                    break
                labelText = self.formatStatsLabelText(self.resultsCurrentStat)
            else:
                # Time mode: filter by Id + Time
                timeText = self.resultsCurrentTimeText
                if timeText:
                    # Results dock connected — filter by Id and Time
                    expr = f"\"Id\" = '{elementId}' AND \"Time\" = '{timeText}'"
                    for feat in resultsLayer.getFeatures(expr):
                        matchedFeature = feat
                        break
                else:
                    # No results dock — query by Id only, read time from the feature
                    expr = f"\"Id\" = '{elementId}'"
                    for feat in resultsLayer.getFeatures(expr):
                        matchedFeature = feat
                        if feat.fields().indexFromName("Time") >= 0:
                            timeText = str(feat.attribute("Time"))
                            if timeText == "NULL":
                                timeText = "N/A"
                        break
                    if not timeText:
                        timeText = "00:00:00"
                labelText = timeText

            # Show the time/statistics label
            self.labelResultsTime.show()
            self.labelResultsTime.setText(labelText)

            if not matchedFeature:
                self.clearResultsTable()
                return

            elementCategory = "Nodes" if isNode else "Links"
            resultCategory = "Node" if isNode else "Link"

            # Get field order and filter to fields that exist in the layer
            fieldOrder = self.getResultsFieldOrder(isNode)
            layerFieldNames = [f.name() for f in resultsLayer.fields()]
            displayFields = [f for f in fieldOrder if f in layerFieldNames]

            if not displayFields:
                self.clearResultsTable()
                return

            self.tableResults.setRowCount(len(displayFields))

            utils = QGISRedFieldUtils()
            resultsBrush = QBrush(QColor("#FFF8DC"))
            for row, fieldName in enumerate(displayFields):
                value = matchedFeature.attribute(fieldName)

                # Property name
                prettyName = utils.getFieldPrettyName(elementCategory, fieldName)
                propertyItem = QTableWidgetItem(prettyName)
                propertyItem.setToolTip(prettyName)

                # Value
                if value is None or str(value) == "NULL" or str(value).strip() == "":
                    displayValue = ""
                else:
                    try:
                        numValue = float(value)
                        if fieldName == "Flow":
                            numValue = abs(numValue)
                        decimals = utils.getFieldDecimals(elementCategory, fieldName, default=2)
                        displayValue = f"{numValue:.{decimals}f}"
                    except (ValueError, TypeError):
                        displayValue = str(value)
                valueItem = QTableWidgetItem(displayValue)
                valueItem.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
                valueItem.setToolTip(displayValue)

                # Units
                fieldUnit = utils.getResultPropertyUnit(resultCategory, fieldName)
                unitDisplay = fieldUnit if fieldUnit and fieldUnit != "-" else ""
                unitItem = QTableWidgetItem(unitDisplay)
                unitItem.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
                if unitDisplay:
                    unitItem.setToolTip(unitDisplay)

                # Apply yellow background to Value/Unit columns for results data
                valueItem.setBackground(resultsBrush)
                unitItem.setBackground(resultsBrush)

                self.tableResults.setItem(row, 0, propertyItem)
                self.tableResults.setItem(row, 1, valueItem)
                self.tableResults.setItem(row, 2, unitItem)

        except Exception:
            self.clearResultsTable()

    def updateResultsTabVisibility(self):
        """Show or hide the Results tab based on Results group layers and current element type."""
        # Always hide for complementary elements
        if self.isLayerValid(self.currentLayer):
            identifier = self.currentLayer.customProperty("qgisred_identifier", "")
            complementaryIdentifiers = ["qgisred_serviceconnections", "qgisred_isolationvalves", "qgisred_meters"]
            if identifier in complementaryIdentifiers:
                self.tabWidget.setTabVisible(1, False)
                return

        # Show tab if Results group has layers
        hasResultsLayers = len(self.getAllResultsGroupLayers()) > 0
        self.tabWidget.setTabVisible(1, hasResultsLayers)

    # --- End Results Tab ---
