# -*- coding: utf-8 -*-
"""
Net2INP Dialog - Main GUI and processing logic for EPANET .inp generation
"""

import os
import csv
import math
import traceback
from collections import defaultdict

from qgis.PyQt.QtCore import Qt, QVariant, QCoreApplication, QSize
from qgis.PyQt.QtGui import QColor, QFont, QIcon
from qgis.PyQt.QtWidgets import (
    QDialog, QFileDialog, QMessageBox, QVBoxLayout, QHBoxLayout,
    QLabel, QLineEdit, QPushButton, QComboBox, QCheckBox, QSpinBox,
    QDoubleSpinBox, QGroupBox, QFormLayout, QProgressBar, QTextEdit,
    QRadioButton, QButtonGroup, QWidget, QTabWidget, QFrame,
    QListWidget, QListWidgetItem, QAbstractItemView, QSplitter,
    QTableWidget, QTableWidgetItem, QHeaderView, QScrollArea
)

from qgis.core import (
    QgsProject, QgsVectorLayer, QgsRasterLayer, QgsField, QgsFeature,
    QgsGeometry, QgsPointXY, QgsPoint, QgsCoordinateReferenceSystem,
    QgsCoordinateTransform, QgsCoordinateTransformContext,
    QgsVectorFileWriter, QgsWkbTypes, QgsSymbol, QgsLineSymbol,
    QgsSingleSymbolRenderer, QgsMarkerSymbol, QgsUnitTypes,
    QgsDistanceArea, QgsSpatialIndex, QgsRectangle, QgsFeatureRequest,
    QgsMapLayerProxyModel, QgsLayerTreeLayer
)
from qgis.gui import QgsMapLayerComboBox, QgsFileWidget, QgsProjectionSelectionWidget

# ---------------------------------------------------------------------------
# Qt5 / Qt6 compatibility helpers
# ---------------------------------------------------------------------------
def _enum(parent, *names):
    for name in names:
        obj = parent
        try:
            for part in name.split('.'):
                obj = getattr(obj, part)
            return obj
        except AttributeError:
            continue
    raise AttributeError(f"None of {names!r} found on {parent!r}")


AlignCenter = _enum(Qt, 'AlignmentFlag.AlignCenter', 'AlignCenter')
ItemIsUserCheckable = _enum(Qt, 'ItemFlag.ItemIsUserCheckable', 'ItemIsUserCheckable')
Checked = _enum(Qt, 'CheckState.Checked', 'Checked')
Unchecked = _enum(Qt, 'CheckState.Unchecked', 'Unchecked')
UserRole = _enum(Qt, 'ItemDataRole.UserRole', 'UserRole')
LineGeometry = _enum(QgsWkbTypes, 'GeometryType.LineGeometry', 'LineGeometry')
PointGeometry = _enum(QgsWkbTypes, 'GeometryType.PointGeometry', 'PointGeometry')


class Net2INPDialog(QDialog):
    """Dialog for Net2INP plugin."""

    def __init__(self, iface, parent=None):
        super().__init__(parent)
        self.iface = iface
        self.setWindowTitle("Net2INP – Create EPANET Networks from GIS")
        self.setMinimumWidth(640)
        self.setMinimumHeight(480)
        self.resize(700, 620)

        self.preview_node_layer = None
        self.highlight_layers = []  # original layers we temporarily style
        self.tabs = None

        self._build_ui()
        self._connect_signals()
        self.refresh_layer_list()
        self._update_highlights()
        self._update_run_buttons()

    # ------------------------------------------------------------------
    # UI construction
    # ------------------------------------------------------------------
    def _build_ui(self):
        main_layout = QVBoxLayout(self)
        main_layout.setSpacing(6)
        main_layout.setContentsMargins(8, 8, 8, 8)

        # Title
        title = QLabel("<b>Net2INP</b> – Convert line networks &amp; XYZ data to EPANET .inp")
        title.setAlignment(AlignCenter)
        title.setStyleSheet("font-size: 13px; margin-bottom: 2px;")
        main_layout.addWidget(title)

        # Scrollable area for tabs so bottom buttons always stay visible
        scroll = QScrollArea()
        scroll.setWidgetResizable(True)
        try:
            scroll.setFrameShape(_enum(QFrame, 'Shape.NoFrame', 'NoFrame'))
        except Exception as _exc:
            _ = _exc  # non-critical; keep defaults / skip
        try:
            scroll.setHorizontalScrollBarPolicy(
                _enum(Qt, 'ScrollBarPolicy.ScrollBarAlwaysOff', 'ScrollBarAlwaysOff')
            )
        except Exception as _exc:
            _ = _exc  # non-critical; keep defaults / skip
        scroll_content = QWidget()
        scroll_layout = QVBoxLayout(scroll_content)
        scroll_layout.setContentsMargins(0, 0, 0, 0)
        scroll.setWidget(scroll_content)
        main_layout.addWidget(scroll, stretch=1)

        self.tabs = QTabWidget()
        scroll_layout.addWidget(self.tabs)
        tabs = self.tabs

        # ===== Tab 1: Line Layers =====
        tab_layers = QWidget()
        tabs.addTab(tab_layers, "Line Layers → INP")
        lay_layers = QVBoxLayout(tab_layers)

        gb_layers = QGroupBox("Select interconnected line / MultiLineString layers")
        lay_gb = QVBoxLayout(gb_layers)
        lay_gb.setContentsMargins(6, 4, 6, 4)
        lay_gb.setSpacing(2)

        hint = QLabel(
            "LineString / MultiLineString layers are listed. "
            "Tick those that form your pipe network — they highlight on the map."
        )
        hint.setWordWrap(True)
        hint.setStyleSheet(
            "QLabel { color: #555; margin: 0px; padding: 0px; }"
        )
        hint.setContentsMargins(0, 0, 0, 0)
        try:
            from qgis.PyQt.QtWidgets import QSizePolicy
            hint.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum)
        except Exception as _exc:
            _ = _exc  # non-critical; keep defaults / skip
        lay_gb.addWidget(hint)

        self.layer_list = QListWidget()
        self.layer_list.setSelectionMode(QAbstractItemView.NoSelection)
        self.layer_list.setMinimumHeight(80)
        self.layer_list.setMaximumHeight(160)
        lay_gb.addWidget(self.layer_list)

        btn_row = QHBoxLayout()
        self.btn_refresh = QPushButton("Refresh list")
        self.btn_select_all = QPushButton("Select all")
        self.btn_deselect_all = QPushButton("Deselect all")
        btn_row.addWidget(self.btn_refresh)
        btn_row.addWidget(self.btn_select_all)
        btn_row.addWidget(self.btn_deselect_all)
        btn_row.addStretch()
        lay_gb.addLayout(btn_row)

        lay_layers.addWidget(gb_layers)

        # Node snapping & geometry options
        gb_geom = QGroupBox("Node detection & geometry")
        form_geom = QFormLayout(gb_geom)

        self.spin_tolerance = QDoubleSpinBox()
        self.spin_tolerance.setRange(0.001, 1000.0)
        self.spin_tolerance.setValue(0.5)
        self.spin_tolerance.setDecimals(3)
        self.spin_tolerance.setSuffix(" map units")
        form_geom.addRow("Node snap tolerance:", self.spin_tolerance)

        self.chk_use_vertices = QCheckBox("Use intermediate vertices of MultiLineStrings as pipe vertices")
        self.chk_use_vertices.setChecked(True)
        form_geom.addRow(self.chk_use_vertices)

        self.chk_split_multi = QCheckBox("Treat each part of MultiLineString as separate pipe")
        self.chk_split_multi.setChecked(True)
        form_geom.addRow(self.chk_split_multi)

        self.chk_merge_degree2 = QCheckBox(
            "Merge intermediate connections (degree-2 nodes) into continuous pipes"
        )
        self.chk_merge_degree2.setChecked(True)
        self.chk_merge_degree2.setToolTip(
            "Removes nodes that only join two pipes end-to-end (not true junctions). "
            "Keeps the reservoir/tank, true branch points (3+ pipes), and network terminals. "
            "EPANET still needs a node at every pipe end — terminals get demand = 0."
        )
        form_geom.addRow(self.chk_merge_degree2)

        lay_layers.addWidget(gb_geom)

        # Attribute mapping (optional)
        gb_attr = QGroupBox("Optional attribute field mapping (leave empty = use defaults)")
        form_attr = QFormLayout(gb_attr)

        self.cmb_diam_field = QComboBox()
        self.cmb_rough_field = QComboBox()
        self.cmb_elev_field = QComboBox()  # for nodes derived from points? mainly for future
        self.cmb_id_field = QComboBox()

        for cmb in (self.cmb_diam_field, self.cmb_rough_field, self.cmb_elev_field, self.cmb_id_field):
            cmb.addItem("— (use default) —", None)
            cmb.setMinimumWidth(160)

        form_attr.addRow("Pipe diameter field:", self.cmb_diam_field)
        form_attr.addRow("Pipe roughness field:", self.cmb_rough_field)
        form_attr.addRow("Pipe ID field (optional):", self.cmb_id_field)

        lay_layers.addWidget(gb_attr)

        # ===== Tab 2: XYZ / CSV =====
        tab_csv = QWidget()
        tabs.addTab(tab_csv, "XYZ / CSV → INP")
        lay_csv = QVBoxLayout(tab_csv)

        gb_nodes = QGroupBox("Nodes CSV (required)")
        form_nodes = QFormLayout(gb_nodes)

        self.file_nodes = QgsFileWidget()
        self.file_nodes.setFilter("CSV / Text (*.csv *.txt *.xyz);;All files (*)")
        self.file_nodes.setStorageMode(QgsFileWidget.GetFile)
        form_nodes.addRow("Nodes file:", self.file_nodes)

        self.cmb_n_id = QComboBox()
        self.cmb_n_x = QComboBox()
        self.cmb_n_y = QComboBox()
        self.cmb_n_z = QComboBox()
        self.cmb_n_type = QComboBox()
        self.cmb_n_demand = QComboBox()
        self.cmb_n_head = QComboBox()

        for cmb in (self.cmb_n_id, self.cmb_n_x, self.cmb_n_y, self.cmb_n_z,
                    self.cmb_n_type, self.cmb_n_demand, self.cmb_n_head):
            cmb.addItem("— auto / none —", None)

        form_nodes.addRow("ID column:", self.cmb_n_id)
        form_nodes.addRow("X column:", self.cmb_n_x)
        form_nodes.addRow("Y column:", self.cmb_n_y)
        form_nodes.addRow("Z / Elev column:", self.cmb_n_z)
        form_nodes.addRow("Type column (J/R/T):", self.cmb_n_type)
        form_nodes.addRow("Demand column:", self.cmb_n_demand)
        form_nodes.addRow("Head (reservoir) column:", self.cmb_n_head)

        self.btn_load_nodes_cols = QPushButton("Load columns from file")
        form_nodes.addRow(self.btn_load_nodes_cols)

        lay_csv.addWidget(gb_nodes)

        gb_links = QGroupBox("Links / Pipes CSV (optional – if omitted, only nodes are written)")
        form_links = QFormLayout(gb_links)

        self.file_links = QgsFileWidget()
        self.file_links.setFilter("CSV / Text (*.csv *.txt);;All files (*)")
        self.file_links.setStorageMode(QgsFileWidget.GetFile)
        form_links.addRow("Links file:", self.file_links)

        self.cmb_l_id = QComboBox()
        self.cmb_l_from = QComboBox()
        self.cmb_l_to = QComboBox()
        self.cmb_l_len = QComboBox()
        self.cmb_l_diam = QComboBox()
        self.cmb_l_rough = QComboBox()

        for cmb in (self.cmb_l_id, self.cmb_l_from, self.cmb_l_to,
                    self.cmb_l_len, self.cmb_l_diam, self.cmb_l_rough):
            cmb.addItem("— auto / none —", None)

        form_links.addRow("Pipe ID column:", self.cmb_l_id)
        form_links.addRow("From node column:", self.cmb_l_from)
        form_links.addRow("To node column:", self.cmb_l_to)
        form_links.addRow("Length column (optional):", self.cmb_l_len)
        form_links.addRow("Diameter column:", self.cmb_l_diam)
        form_links.addRow("Roughness column:", self.cmb_l_rough)

        self.btn_load_links_cols = QPushButton("Load columns from file")
        form_links.addRow(self.btn_load_links_cols)

        lay_csv.addWidget(gb_links)

        # CSV CRS
        gb_crs = QGroupBox("Coordinate system of CSV data")
        form_crs = QFormLayout(gb_crs)
        self.crs_widget = QgsProjectionSelectionWidget()
        self.crs_widget.setCrs(QgsProject.instance().crs())
        form_crs.addRow("CRS:", self.crs_widget)
        lay_csv.addWidget(gb_crs)

        lay_csv.addStretch()

        # ===== Tab 3: Defaults & Options =====
        tab_opts = QWidget()
        tabs.addTab(tab_opts, "Defaults & Options")
        lay_opts = QVBoxLayout(tab_opts)

        gb_defaults = QGroupBox("Default element properties")
        form_def = QFormLayout(gb_defaults)

        self.spin_def_diam = QDoubleSpinBox()
        self.spin_def_diam.setRange(1.0, 5000.0)
        self.spin_def_diam.setValue(150.0)
        self.spin_def_diam.setDecimals(1)
        self.spin_def_diam.setSuffix(" (mm or in)")
        form_def.addRow("Default pipe diameter:", self.spin_def_diam)

        self.spin_def_rough = QDoubleSpinBox()
        self.spin_def_rough.setRange(0.001, 1000.0)
        self.spin_def_rough.setValue(100.0)
        self.spin_def_rough.setDecimals(2)
        form_def.addRow("Default roughness (HW / DW / CM):", self.spin_def_rough)

        self.spin_def_mloss = QDoubleSpinBox()
        self.spin_def_mloss.setRange(0.0, 100.0)
        self.spin_def_mloss.setValue(0.0)
        self.spin_def_mloss.setDecimals(3)
        form_def.addRow("Default minor loss coeff.:", self.spin_def_mloss)

        self.spin_def_elev = QDoubleSpinBox()
        self.spin_def_elev.setRange(-1000.0, 10000.0)
        self.spin_def_elev.setValue(0.0)
        self.spin_def_elev.setDecimals(2)
        form_def.addRow("Default junction elevation:", self.spin_def_elev)

        self.spin_def_demand = QDoubleSpinBox()
        self.spin_def_demand.setRange(0.0, 1e6)
        self.spin_def_demand.setValue(0.0)
        self.spin_def_demand.setDecimals(3)
        form_def.addRow("Default junction demand:", self.spin_def_demand)

        self.spin_def_res_head = QDoubleSpinBox()
        self.spin_def_res_head.setRange(-1000.0, 10000.0)
        self.spin_def_res_head.setValue(100.0)
        self.spin_def_res_head.setDecimals(2)
        form_def.addRow("Default reservoir head:", self.spin_def_res_head)

        # Tank defaults
        self.spin_tank_init = QDoubleSpinBox()
        self.spin_tank_init.setRange(0.0, 1000.0)
        self.spin_tank_init.setValue(5.0)
        form_def.addRow("Default tank initial level:", self.spin_tank_init)

        self.spin_tank_min = QDoubleSpinBox()
        self.spin_tank_min.setRange(0.0, 1000.0)
        self.spin_tank_min.setValue(0.0)
        form_def.addRow("Default tank min level:", self.spin_tank_min)

        self.spin_tank_max = QDoubleSpinBox()
        self.spin_tank_max.setRange(0.0, 1000.0)
        self.spin_tank_max.setValue(20.0)
        form_def.addRow("Default tank max level:", self.spin_tank_max)

        self.spin_tank_diam = QDoubleSpinBox()
        self.spin_tank_diam.setRange(0.1, 10000.0)
        self.spin_tank_diam.setValue(50.0)
        form_def.addRow("Default tank diameter:", self.spin_tank_diam)

        lay_opts.addWidget(gb_defaults)

        gb_options = QGroupBox("EPANET [OPTIONS]")
        form_opt = QFormLayout(gb_options)

        self.cmb_units = QComboBox()
        self.cmb_units.addItems([
            "LPS (litres/sec)", "LPM (litres/min)", "MLD (megalitres/day)",
            "CMH (m³/hour)", "CMD (m³/day)",
            "CFS (cfs)", "GPM (gpm)", "MGD (mgd)", "IMGD", "AFD"
        ])
        self.cmb_units.setCurrentIndex(0)
        form_opt.addRow("Flow units:", self.cmb_units)

        self.cmb_headloss = QComboBox()
        self.cmb_headloss.addItems(["H-W (Hazen-Williams)", "D-W (Darcy-Weisbach)", "C-M (Chezy-Manning)"])
        form_opt.addRow("Headloss formula:", self.cmb_headloss)

        self.edit_title = QLineEdit("Network generated by Net2INP")
        form_opt.addRow("Title:", self.edit_title)

        lay_opts.addWidget(gb_options)

        gb_extra = QGroupBox("Extra features")
        form_extra = QFormLayout(gb_extra)

        self.chk_preview = QCheckBox("Add preview nodes layer (Net2INP_Nodes) to the map")
        self.chk_preview.setChecked(True)
        form_extra.addRow(self.chk_preview)

        self.chk_add_dummy_res = QCheckBox(
            "If no reservoir/tank exists, add a dummy reservoir at the first node"
        )
        self.chk_add_dummy_res.setChecked(True)
        form_extra.addRow(self.chk_add_dummy_res)

        self.chk_status_open = QCheckBox("Set all pipes status = Open")
        self.chk_status_open.setChecked(True)
        form_extra.addRow(self.chk_status_open)

        lay_opts.addWidget(gb_extra)
        lay_opts.addStretch()

        # ===== Bottom: Output & Run (always visible, outside scroll) =====
        gb_out = QGroupBox("Output")
        form_out = QFormLayout(gb_out)

        self.file_out = QgsFileWidget()
        self.file_out.setFilter("EPANET input (*.inp);;All files (*)")
        self.file_out.setStorageMode(QgsFileWidget.SaveFile)
        self.file_out.setDialogTitle("Save EPANET .inp file")
        form_out.addRow("Output .inp file:", self.file_out)

        main_layout.addWidget(gb_out)

        # Progress + log (compact)
        self.progress = QProgressBar()
        self.progress.setRange(0, 100)
        self.progress.setValue(0)
        self.progress.setMaximumHeight(18)
        main_layout.addWidget(self.progress)

        self.log = QTextEdit()
        self.log.setReadOnly(True)
        self.log.setMaximumHeight(72)
        self.log.setPlaceholderText("Log messages appear here…")
        main_layout.addWidget(self.log)

        # Buttons – always visible at bottom (include :disabled so they gray out clearly)
        btn_bar = QHBoxLayout()
        self.btn_run_layers = QPushButton("Generate INP from Line Layers")
        self.btn_run_layers.setStyleSheet(
            "QPushButton { background-color: #2e7d32; color: white; font-weight: bold; padding: 6px 12px; border: none; border-radius: 3px; }"
            "QPushButton:disabled { background-color: #bdbdbd; color: #757575; }"
        )
        self.btn_run_csv = QPushButton("Generate INP from CSV")
        self.btn_run_csv.setStyleSheet(
            "QPushButton { background-color: #1565c0; color: white; font-weight: bold; padding: 6px 12px; border: none; border-radius: 3px; }"
            "QPushButton:disabled { background-color: #bdbdbd; color: #757575; }"
        )
        self.btn_export_csv = QPushButton("Export network CSV")
        self.btn_export_csv.setStyleSheet(
            "QPushButton { background-color: #6a1b9a; color: white; font-weight: bold; padding: 6px 12px; border: none; border-radius: 3px; }"
            "QPushButton:disabled { background-color: #bdbdbd; color: #757575; }"
        )
        self.btn_export_csv.setToolTip(
            "Build network from selected line layers and save nodes or pipes as CSV"
        )
        self.btn_close = QPushButton("Close")
        btn_bar.addWidget(self.btn_run_layers)
        btn_bar.addWidget(self.btn_run_csv)
        btn_bar.addWidget(self.btn_export_csv)
        btn_bar.addStretch()
        btn_bar.addWidget(self.btn_close)
        main_layout.addLayout(btn_bar)

    def _connect_signals(self):
        self.btn_refresh.clicked.connect(self.refresh_layer_list)
        self.btn_select_all.clicked.connect(lambda: self._set_all_layers(True))
        self.btn_deselect_all.clicked.connect(lambda: self._set_all_layers(False))
        self.layer_list.itemChanged.connect(self._on_layer_check_changed)
        self.btn_load_nodes_cols.clicked.connect(self._load_nodes_columns)
        self.btn_load_links_cols.clicked.connect(self._load_links_columns)
        self.btn_run_layers.clicked.connect(self.run_from_layers)
        self.btn_run_csv.clicked.connect(self.run_from_csv)
        self.btn_export_csv.clicked.connect(self.export_network_csv)
        self.btn_close.clicked.connect(self._on_close)

        if self.tabs is not None:
            self.tabs.currentChanged.connect(self._update_run_buttons)

        # When project layers change, refresh list
        QgsProject.instance().layersAdded.connect(lambda _: self.refresh_layer_list())
        QgsProject.instance().layersRemoved.connect(lambda _: self.refresh_layer_list())

    # ------------------------------------------------------------------
    # Layer list helpers
    # ------------------------------------------------------------------
    def refresh_layer_list(self):
        self.layer_list.blockSignals(True)
        self.layer_list.clear()
        # Also clear attribute combo boxes of old fields
        for cmb in (self.cmb_diam_field, self.cmb_rough_field, self.cmb_id_field):
            cmb.blockSignals(True)
            while cmb.count() > 1:
                cmb.removeItem(1)
            cmb.blockSignals(False)

        project = QgsProject.instance()
        for layer in project.mapLayers().values():
            if not isinstance(layer, QgsVectorLayer):
                continue
            if layer.geometryType() != LineGeometry:
                continue
            # Skip plugin preview layers
            name = layer.name() or ""
            if name.startswith("Net2INP_"):
                continue
            item = QListWidgetItem(layer.name())
            item.setFlags(item.flags() | ItemIsUserCheckable)
            item.setCheckState(Unchecked)
            item.setData(UserRole, layer.id())
            self.layer_list.addItem(item)

        self.layer_list.blockSignals(False)
        self._log(f"Found {self.layer_list.count()} line / MultiLineString layer(s).")

    def _set_all_layers(self, checked):
        self.layer_list.blockSignals(True)
        state = Checked if checked else Unchecked
        for i in range(self.layer_list.count()):
            self.layer_list.item(i).setCheckState(state)
        self.layer_list.blockSignals(False)
        self._update_highlights()
        self._update_field_combos()

    def _on_layer_check_changed(self, item):
        self._update_highlights()
        self._update_field_combos()

    def _selected_layers(self):
        layers = []
        project = QgsProject.instance()
        for i in range(self.layer_list.count()):
            item = self.layer_list.item(i)
            if item.checkState() == Checked:
                lid = item.data(UserRole)
                layer = project.mapLayer(lid)
                if layer and layer.isValid():
                    layers.append(layer)
        return layers

    def _update_highlights(self):
        """Temporarily restyle selected layers with a bright colour."""
        # Restore previous
        for layer, old_renderer in self.highlight_layers:
            if layer and layer.isValid():
                try:
                    layer.setRenderer(old_renderer)
                    layer.triggerRepaint()
                except Exception as _exc:
                    _ = _exc  # non-critical; keep defaults / skip
        self.highlight_layers = []

        selected = self._selected_layers()
        for layer in selected:
            try:
                old = layer.renderer().clone()
                symbol = QgsLineSymbol.createSimple({
                    'color': '255,0,0,180',
                    'width': '1.8',
                    'line_style': 'solid'
                })
                layer.setRenderer(QgsSingleSymbolRenderer(symbol))
                layer.triggerRepaint()
                self.highlight_layers.append((layer, old))
            except Exception as _exc:
                _ = _exc  # non-critical; keep defaults / skip
        if self.iface:
            self.iface.mapCanvas().refresh()

    def _update_field_combos(self):
        """Populate diameter / roughness / id field combos from selected layers."""
        fields_seen = set()
        for cmb in (self.cmb_diam_field, self.cmb_rough_field, self.cmb_id_field):
            cmb.blockSignals(True)
            while cmb.count() > 1:
                cmb.removeItem(1)

        for layer in self._selected_layers():
            for f in layer.fields():
                name = f.name()
                if name in fields_seen:
                    continue
                fields_seen.add(name)
                for cmb in (self.cmb_diam_field, self.cmb_rough_field, self.cmb_id_field):
                    cmb.addItem(name, name)

        for cmb in (self.cmb_diam_field, self.cmb_rough_field, self.cmb_id_field):
            cmb.blockSignals(False)

    # ------------------------------------------------------------------
    # CSV column loading
    # ------------------------------------------------------------------
    def _read_csv_header(self, path):
        if not path or not os.path.isfile(path):
            return []
        try:
            with open(path, 'r', newline='', encoding='utf-8-sig') as f:
                # sniff dialect
                sample = f.read(4096)
                f.seek(0)
                try:
                    dialect = csv.Sniffer().sniff(sample, delimiters=',;\t ')
                except csv.Error:
                    dialect = csv.excel
                reader = csv.reader(f, dialect)
                header = next(reader, None)
                return header or []
        except Exception as e:
            self._log(f"Error reading header: {e}")
            return []

    def _populate_combo(self, combo, headers, preferred=None):
        combo.blockSignals(True)
        while combo.count() > 1:
            combo.removeItem(1)
        for h in headers:
            combo.addItem(h, h)
        # auto-select preferred names
        if preferred:
            for pref in preferred:
                for i in range(combo.count()):
                    if combo.itemText(i).lower() == pref.lower():
                        combo.setCurrentIndex(i)
                        break
                else:
                    continue
                break
        combo.blockSignals(False)

    def _load_nodes_columns(self):
        path = self.file_nodes.filePath()
        headers = self._read_csv_header(path)
        if not headers:
            QMessageBox.warning(self, "No columns", "Could not read columns from the nodes file.")
            return
        self._populate_combo(self.cmb_n_id, headers, ['id', 'node', 'node_id', 'name', 'junction'])
        self._populate_combo(self.cmb_n_x, headers, ['x', 'lon', 'longitude', 'easting', 'x_coord'])
        self._populate_combo(self.cmb_n_y, headers, ['y', 'lat', 'latitude', 'northing', 'y_coord'])
        self._populate_combo(self.cmb_n_z, headers, ['z', 'elev', 'elevation', 'height', 'z_coord'])
        self._populate_combo(self.cmb_n_type, headers, ['type', 'nodetype', 'node_type', 'kind'])
        self._populate_combo(self.cmb_n_demand, headers, ['demand', 'base_demand', 'q', 'flow'])
        self._populate_combo(self.cmb_n_head, headers, ['head', 'res_head', 'pressure_head'])
        self._log(f"Loaded {len(headers)} columns from nodes file.")

    def _load_links_columns(self):
        path = self.file_links.filePath()
        headers = self._read_csv_header(path)
        if not headers:
            QMessageBox.warning(self, "No columns", "Could not read columns from the links file.")
            return
        self._populate_combo(self.cmb_l_id, headers, ['id', 'pipe', 'pipe_id', 'link', 'name'])
        self._populate_combo(self.cmb_l_from, headers, ['from', 'node1', 'start', 'from_node', 'start_node'])
        self._populate_combo(self.cmb_l_to, headers, ['to', 'node2', 'end', 'to_node', 'end_node'])
        self._populate_combo(self.cmb_l_len, headers, ['length', 'len', 'distance', 'l'])
        self._populate_combo(self.cmb_l_diam, headers, ['diameter', 'diam', 'd', 'dn'])
        self._populate_combo(self.cmb_l_rough, headers, ['roughness', 'rough', 'c', 'hw', 'dw'])
        self._log(f"Loaded {len(headers)} columns from links file.")

    # ------------------------------------------------------------------
    # Logging & progress
    # ------------------------------------------------------------------
    def _log(self, msg):
        self.log.append(msg)
        QCoreApplication.processEvents()

    def _set_progress(self, value):
        self.progress.setValue(int(value))
        QCoreApplication.processEvents()

    # ------------------------------------------------------------------
    # Core: build network from line layers
    # ------------------------------------------------------------------
    def run_from_layers(self):
        try:
            layers = self._selected_layers()
            if not layers:
                QMessageBox.warning(self, "No layers", "Please select at least one line layer.")
                return

            out_path = self.file_out.filePath()
            if not out_path:
                QMessageBox.warning(self, "No output", "Please choose an output .inp file path.")
                return
            if not out_path.lower().endswith('.inp'):
                out_path += '.inp'

            self._log("=== Building network from line layers ===")
            self._set_progress(5)

            tolerance = self.spin_tolerance.value()
            segments = self._collect_segments(layers)
            if not segments:
                QMessageBox.warning(self, "No geometry", "No valid line segments found in selected layers.")
                return

            self._log(f"Collected {len(segments)} pipe segment(s). Snapping nodes (tol={tolerance})…")
            self._set_progress(50)

            nodes, node_map, pipes = self._snap_and_build_pipes(segments, tolerance)

            self._log(f"Created {len(nodes)} unique junctions and {len(pipes)} pipes.")
            self._set_progress(70)

            # Build EPANET data structures
            # Terminal (degree-1) junctions get demand 0 — they are geometry ends, not demand points
            degree = defaultdict(int)
            for p in pipes:
                degree[p['node1']] += 1
                degree[p['node2']] += 1

            junctions = []
            reservoirs = []
            tanks = []
            coordinates = {}
            for nid, (x, y) in nodes.items():
                dem = self.spin_def_demand.value()
                if degree.get(nid, 0) <= 1:
                    dem = 0.0  # pure pipe end – not a demand junction
                junctions.append({
                    'id': nid,
                    'elev': self.spin_def_elev.value(),
                    'demand': dem
                })
                coordinates[nid] = (x, y)

            # Optional dummy reservoir – prefer a degree-1 (network end / source) node
            if self.chk_add_dummy_res.isChecked() and not reservoirs and not tanks:
                if junctions:
                    degree = defaultdict(int)
                    for p in pipes:
                        degree[p['node1']] += 1
                        degree[p['node2']] += 1
                    # Prefer a terminal (degree 1) as the reservoir location
                    res_idx = 0
                    for i, j in enumerate(junctions):
                        if degree.get(j['id'], 0) == 1:
                            res_idx = i
                            break
                    j = junctions.pop(res_idx)
                    reservoirs.append({
                        'id': j['id'],
                        'head': self.spin_def_res_head.value()
                    })
                    self._log(
                        f"Added dummy reservoir at node {j['id']} "
                        f"(head={self.spin_def_res_head.value()}, degree={degree.get(j['id'], 0)})."
                    )

            # Write INP
            self._write_inp(
                out_path,
                title=self.edit_title.text() or "Network from Net2INP",
                junctions=junctions,
                reservoirs=reservoirs,
                tanks=tanks,
                pipes=pipes,
                coordinates=coordinates,
                vertices={p['id']: p.get('inter', []) for p in pipes if p.get('inter')}
            )
            self._set_progress(90)

            # Preview layers
            if self.chk_preview.isChecked():
                self._create_preview_layers(nodes, pipes, layers[0].crs())

            self._set_progress(100)
            self._log(f"Successfully wrote: {out_path}")
            QMessageBox.information(
                self, "Success",
                f"EPANET network written to:\n{out_path}\n\n"
                f"Junctions: {len(junctions)}\n"
                f"Reservoirs: {len(reservoirs)}\n"
                f"Pipes: {len(pipes)}"
            )

        except Exception as e:
            self._log(f"ERROR: {e}\n{traceback.format_exc()}")
            QMessageBox.critical(self, "Error", f"Failed to generate network:\n{e}")
            self._set_progress(0)

    def _calc_polyline_length(self, pts, da=None, geom=None):
        """
        Pipe length from GIS geometry — same basis as QGIS $length / feature length
        in the layer CRS. Does not use ellipsoid measureLength (that was shortening values).
        Never recalculates from snapped node coordinates.
        """
        length = None
        xy_pts = [QgsPointXY(p) for p in pts]

        # 1) Planar length of THIS part only (matches QGIS geometry.length() / $length)
        try:
            length = float(QgsGeometry.fromPolylineXY(xy_pts).length())
        except Exception:
            length = None

        # 2) If single-part feature, original geometry length is equivalent and reliable
        if (length is None or not math.isfinite(length) or length <= 0) and geom is not None:
            try:
                if not geom.isMultipart() and not geom.isEmpty():
                    length = float(geom.length())
            except Exception:
                length = None

        # 3) Vertex-to-vertex sum in map units (last resort)
        if length is None or not math.isfinite(length) or length <= 0:
            try:
                total = 0.0
                for i in range(len(pts) - 1):
                    x1, y1 = float(pts[i].x()), float(pts[i].y())
                    x2, y2 = float(pts[i + 1].x()), float(pts[i + 1].y())
                    total += math.hypot(x2 - x1, y2 - y1)
                length = total
            except Exception:
                length = None

        if length is None or not math.isfinite(length) or length <= 0:
            length = 0.001
        return float(length)

    def _snap_and_build_pipes(self, segments, tolerance):
        """Snap nearby endpoints to unique nodes and build pipe list."""
        points = []  # list of (x, y)
        for seg in segments:
            points.append(seg['start'])
            points.append(seg['end'])

        def key(p, tol):
            # Guard against zero / tiny tolerance
            t = max(tol, 1e-9)
            return (round(p[0] / t), round(p[1] / t))

        clusters = defaultdict(list)
        for i, p in enumerate(points):
            clusters[key(p, tolerance)].append(i)

        node_coords = []
        point_to_node = {}

        for k, idxs in clusters.items():
            xs = [points[i][0] for i in idxs]
            ys = [points[i][1] for i in idxs]
            cx = sum(xs) / len(xs)
            cy = sum(ys) / len(ys)
            nid = len(node_coords)
            node_coords.append((cx, cy))
            for i in idxs:
                point_to_node[i] = nid

        nodes = {}
        node_id_list = []
        for i, (x, y) in enumerate(node_coords):
            nid = f"J{i + 1}"
            nodes[nid] = (x, y)
            node_id_list.append(nid)

        def node_of(pt_idx):
            return node_id_list[point_to_node[pt_idx]]

        pipes = []
        pipe_counter = 1
        for si, seg in enumerate(segments):
            start_idx = si * 2
            end_idx = si * 2 + 1
            n1 = node_of(start_idx)
            n2 = node_of(end_idx)
            if n1 == n2:
                continue

            inter = seg.get('inter', [])
            # Always keep the original GIS-measured segment length.
            # Do NOT recompute from snapped node coordinates (that shortens pipes).
            length = seg.get('length')
            try:
                length = float(length)
            except (TypeError, ValueError):
                length = 0.001
            if not math.isfinite(length) or length <= 0:
                length = 0.001

            pid = seg.get('id') or f"P{pipe_counter}"
            pipe_counter += 1
            pipes.append({
                'id': str(pid),
                'node1': n1,
                'node2': n2,
                'length': length,
                'diam': seg['diam'],
                'rough': seg['rough'],
                'mloss': self.spin_def_mloss.value(),
                'status': 'Open' if self.chk_status_open.isChecked() else 'Open',
                'inter': inter
            })

        # Merge degree-2 nodes (simple end-to-end connections) into continuous pipes
        if getattr(self, 'chk_merge_degree2', None) and self.chk_merge_degree2.isChecked():
            before_n, before_p = len(nodes), len(pipes)
            nodes, pipes = self._merge_degree2_nodes(nodes, pipes)
            self._log(
                f"Merged degree-2 connections: {before_n}→{len(nodes)} nodes, "
                f"{before_p}→{len(pipes)} pipes."
            )

        return nodes, point_to_node, pipes

    def _merge_degree2_nodes(self, nodes, pipes):
        """
        Collapse nodes that only connect two pipes (degree 2) into longer pipes.
        Keeps degree-1 terminals, degree≥3 junctions, and any node that would form a loop edge.
        """
        if not pipes:
            return nodes, pipes

        # Work on mutable copies
        pipe_list = [dict(p) for p in pipes]
        # id -> pipe dict (we'll reassign ids at end if needed)
        # adjacency: node -> list of pipe indices in pipe_list
        def build_adj():
            adj = defaultdict(list)
            for i, p in enumerate(pipe_list):
                if p is None:
                    continue
                adj[p['node1']].append(i)
                adj[p['node2']].append(i)
            return adj

        adj = build_adj()
        merged_count = 0
        max_iter = len(pipe_list) + 5  # safety

        for _ in range(max_iter):
            # Find a degree-2 node that is not a self-loop connector
            candidate = None
            for nid, idxs in adj.items():
                live = [i for i in idxs if pipe_list[i] is not None]
                if len(live) != 2:
                    continue
                p_a = pipe_list[live[0]]
                p_b = pipe_list[live[1]]
                # Other ends
                other_a = p_a['node2'] if p_a['node1'] == nid else p_a['node1']
                other_b = p_b['node2'] if p_b['node1'] == nid else p_b['node1']
                if other_a == other_b:
                    # would create a multi-edge or loop through same neighbour – skip
                    continue
                if other_a == nid or other_b == nid:
                    continue
                candidate = (nid, live[0], live[1], other_a, other_b)
                break

            if candidate is None:
                break

            nid, ia, ib, other_a, other_b = candidate
            p_a = pipe_list[ia]
            p_b = pipe_list[ib]

            # Build intermediate vertex chain through the merged node
            # Orient p_a as other_a → nid
            if p_a['node1'] == other_a and p_a['node2'] == nid:
                inter_a = list(p_a.get('inter') or [])
            else:
                inter_a = list(reversed(p_a.get('inter') or []))
            # Orient p_b as nid → other_b
            if p_b['node1'] == nid and p_b['node2'] == other_b:
                inter_b = list(p_b.get('inter') or [])
            else:
                inter_b = list(reversed(p_b.get('inter') or []))

            mid_xy = nodes[nid]
            new_inter = inter_a + [mid_xy] + inter_b
            # Sum original GIS lengths only — never recalculate from snapped coords
            try:
                new_length = float(p_a['length']) + float(p_b['length'])
            except (TypeError, ValueError):
                new_length = 0.001
            if not math.isfinite(new_length) or new_length <= 0:
                new_length = 0.001

            # Prefer non-default-looking diameter/roughness (use first pipe's)
            new_pipe = {
                'id': p_a['id'],
                'node1': other_a,
                'node2': other_b,
                'length': new_length,
                'diam': p_a['diam'],
                'rough': p_a['rough'],
                'mloss': p_a.get('mloss', 0.0),
                'status': p_a.get('status', 'Open'),
                'inter': new_inter
            }

            # Remove old pipes, add new
            pipe_list[ia] = None
            pipe_list[ib] = None
            pipe_list.append(new_pipe)
            del nodes[nid]
            merged_count += 1
            adj = build_adj()

        # Compact pipe list and re-number sequential pipe IDs where auto-generated
        pipes_out = [p for p in pipe_list if p is not None]
        # Keep only nodes still referenced
        used = set()
        for p in pipes_out:
            used.add(p['node1'])
            used.add(p['node2'])
        nodes_out = {nid: xy for nid, xy in nodes.items() if nid in used}

        return nodes_out, pipes_out

    # ------------------------------------------------------------------
    # Export network as CSV (from line layers)
    # ------------------------------------------------------------------
    def export_network_csv(self):
        """Build network from selected line layers and export either nodes XYZ or pipes CSV."""
        try:
            layers = self._selected_layers()
            if not layers:
                QMessageBox.warning(self, "No layers", "Please select at least one line layer.")
                return

            # Ask: nodes or pipes?
            try:
                AcceptRole = _enum(QMessageBox, 'ButtonRole.AcceptRole', 'AcceptRole')
                RejectRole = _enum(QMessageBox, 'ButtonRole.RejectRole', 'RejectRole')
            except Exception:
                AcceptRole = QMessageBox.AcceptRole
                RejectRole = QMessageBox.RejectRole
            msg = QMessageBox(self)
            msg.setWindowTitle("Export network CSV")
            msg.setText("What do you want to export?")
            msg.setInformativeText(
                "Nodes: id, x, y, z, type, demand, degree\n"
                "Pipes: id, from, to, length, diameter, roughness, minor_loss, status"
            )
            btn_nodes = msg.addButton("Nodes (XYZ)", AcceptRole)
            btn_pipes = msg.addButton("Pipes network", AcceptRole)
            btn_cancel = msg.addButton("Cancel", RejectRole)
            msg.exec()
            clicked = msg.clickedButton()
            if clicked == btn_cancel or clicked is None:
                return
            export_nodes = (clicked == btn_nodes)

            path, _ = QFileDialog.getSaveFileName(
                self,
                "Save nodes CSV" if export_nodes else "Save pipes CSV",
                "network_nodes.csv" if export_nodes else "network_pipes.csv",
                "CSV (*.csv);;All files (*)"
            )
            if not path:
                return
            if not path.lower().endswith('.csv'):
                path += '.csv'

            self._log("=== Exporting network CSV from line layers ===")
            self._set_progress(5)

            segments = self._collect_segments(layers)
            if not segments:
                QMessageBox.warning(self, "No geometry", "No valid line segments found.")
                return

            tolerance = self.spin_tolerance.value()
            nodes, _, pipes = self._snap_and_build_pipes(segments, tolerance)
            self._log(f"Network: {len(nodes)} nodes, {len(pipes)} pipes.")
            self._set_progress(60)

            degree = defaultdict(int)
            for p in pipes:
                degree[p['node1']] += 1
                degree[p['node2']] += 1

            elev = self.spin_def_elev.value()
            demand = self.spin_def_demand.value()

            if export_nodes:
                first_terminal = None
                for nid in nodes:
                    if degree.get(nid, 0) == 1:
                        first_terminal = nid
                        break
                with open(path, 'w', newline='', encoding='utf-8') as f:
                    w = csv.writer(f)
                    w.writerow(['id', 'x', 'y', 'z', 'type', 'demand', 'degree'])
                    for nid, (x, y) in sorted(nodes.items(), key=lambda t: t[0]):
                        d = degree.get(nid, 0)
                        if d == 1 and nid == first_terminal:
                            ntype = 'R'
                            dem = 0.0
                        elif d <= 1:
                            ntype = 'J'
                            dem = 0.0
                        else:
                            ntype = 'J'
                            dem = demand
                        w.writerow([
                            nid, f'{x:.6f}', f'{y:.6f}', f'{elev:.4f}',
                            ntype, f'{dem:.4f}', d
                        ])
                self._log(f"Nodes CSV: {path} ({len(nodes)} rows)")
                QMessageBox.information(
                    self, "CSV export complete",
                    f"Nodes written to:\n{path}\n\nRows: {len(nodes)}"
                )
            else:
                with open(path, 'w', newline='', encoding='utf-8') as f:
                    w = csv.writer(f)
                    w.writerow([
                        'id', 'from', 'to', 'length',
                        'diameter', 'roughness', 'minor_loss', 'status'
                    ])
                    for p in pipes:
                        length = p['length']
                        if length is None or not math.isfinite(length):
                            length = 0.001
                        w.writerow([
                            p['id'], p['node1'], p['node2'],
                            f'{length:.4f}', f'{p["diam"]:.4f}', f'{p["rough"]:.4f}',
                            f'{p.get("mloss", 0):.4f}', p.get('status', 'Open')
                        ])
                self._log(f"Pipes CSV: {path} ({len(pipes)} rows)")
                QMessageBox.information(
                    self, "CSV export complete",
                    f"Pipes written to:\n{path}\n\nRows: {len(pipes)}"
                )

            self._set_progress(100)

        except Exception as e:
            self._log(f"ERROR: {e}\n{traceback.format_exc()}")
            QMessageBox.critical(self, "Error", f"CSV export failed:\n{e}")
            self._set_progress(0)

    def _collect_segments(self, layers):
        """Collect pipe segments from selected line layers (shared by INP + CSV export)."""
        tolerance = self.spin_tolerance.value()
        use_vertices = self.chk_use_vertices.isChecked()
        split_multi = self.chk_split_multi.isChecked()
        diam_field = self.cmb_diam_field.currentData()
        rough_field = self.cmb_rough_field.currentData()
        id_field = self.cmb_id_field.currentData()

        segments = []
        da = QgsDistanceArea()
        da.setSourceCrs(layers[0].crs(), QgsProject.instance().transformContext())
        da.setEllipsoid(QgsProject.instance().ellipsoid())

        for layer in layers:
            self._log(f"Processing layer: {layer.name()} ({layer.featureCount()} features)")
            for feat in layer.getFeatures():
                geom = feat.geometry()
                if geom is None or geom.isEmpty():
                    continue

                diam = self.spin_def_diam.value()
                if diam_field and diam_field in feat.fields().names():
                    try:
                        v = feat[diam_field]
                        if v is not None and str(v).strip() != '':
                            diam = float(v)
                    except Exception as _exc:
                        _ = _exc  # non-critical; keep defaults / skip

                rough = self.spin_def_rough.value()
                if rough_field and rough_field in feat.fields().names():
                    try:
                        v = feat[rough_field]
                        if v is not None and str(v).strip() != '':
                            rough = float(v)
                    except Exception as _exc:
                        _ = _exc  # non-critical; keep defaults / skip

                pipe_id = None
                if id_field and id_field in feat.fields().names():
                    try:
                        pipe_id = str(feat[id_field]).strip()
                    except Exception as _exc:
                        _ = _exc  # non-critical; keep defaults / skip

                if geom.isMultipart():
                    parts = geom.asMultiPolyline()
                else:
                    parts = [geom.asPolyline()]

                for pts in parts:
                    if len(pts) < 2:
                        continue
                    start = pts[0]
                    end = pts[-1]
                    use_inter = use_vertices and len(pts) > 2
                    inter = pts[1:-1] if use_inter else []
                    length = self._calc_polyline_length(pts, da, geom)
                    segments.append({
                        'start': (float(start.x()), float(start.y())),
                        'end': (float(end.x()), float(end.y())),
                        'length': length,
                        'diam': diam,
                        'rough': rough,
                        'id': pipe_id if (pipe_id and not (geom.isMultipart() and split_multi)) else None,
                        'inter': [(float(p.x()), float(p.y())) for p in inter],
                        'layer': layer.name()
                    })
        return segments

    # ------------------------------------------------------------------
    # Core: build from CSV
    # ------------------------------------------------------------------
    def run_from_csv(self):
        try:
            nodes_path = self.file_nodes.filePath()
            if not nodes_path or not os.path.isfile(nodes_path):
                QMessageBox.warning(self, "No nodes file", "Please select a valid nodes CSV/XYZ file.")
                return

            out_path = self.file_out.filePath()
            if not out_path:
                QMessageBox.warning(self, "No output", "Please choose an output .inp file path.")
                return
            if not out_path.lower().endswith('.inp'):
                out_path += '.inp'

            self._log("=== Building network from CSV ===")
            self._set_progress(10)

            # --- parse nodes ---
            nodes_data = self._parse_csv(nodes_path)
            if not nodes_data:
                QMessageBox.warning(self, "Empty", "Nodes file appears empty.")
                return

            col_id = self.cmb_n_id.currentData()
            col_x = self.cmb_n_x.currentData()
            col_y = self.cmb_n_y.currentData()
            col_z = self.cmb_n_z.currentData()
            col_type = self.cmb_n_type.currentData()
            col_demand = self.cmb_n_demand.currentData()
            col_head = self.cmb_n_head.currentData()

            # auto-detect if not set
            headers = list(nodes_data[0].keys()) if nodes_data else []
            if not col_x:
                col_x = self._find_col(headers, ['x', 'lon', 'longitude', 'easting'])
            if not col_y:
                col_y = self._find_col(headers, ['y', 'lat', 'latitude', 'northing'])
            if not col_z:
                col_z = self._find_col(headers, ['z', 'elev', 'elevation', 'height'])
            if not col_id:
                col_id = self._find_col(headers, ['id', 'node', 'node_id', 'name'])

            if not col_x or not col_y:
                QMessageBox.warning(self, "Columns", "Could not determine X and Y columns.")
                return

            junctions = []
            reservoirs = []
            tanks = []
            coordinates = {}
            node_ids = set()

            for i, row in enumerate(nodes_data):
                try:
                    x = float(row[col_x])
                    y = float(row[col_y])
                except (KeyError, ValueError, TypeError):
                    continue

                nid = str(row.get(col_id, f"J{i+1}")).strip() if col_id else f"J{i+1}"
                if not nid:
                    nid = f"J{i+1}"
                # unique
                base = nid
                c = 1
                while nid in node_ids:
                    nid = f"{base}_{c}"
                    c += 1
                node_ids.add(nid)

                elev = self.spin_def_elev.value()
                if col_z and col_z in row:
                    try:
                        elev = float(row[col_z])
                    except (ValueError, TypeError):
                        _ = None  # non-critical

                demand = self.spin_def_demand.value()
                if col_demand and col_demand in row:
                    try:
                        demand = float(row[col_demand])
                    except (ValueError, TypeError):
                        _ = None  # non-critical

                ntype = 'J'
                if col_type and col_type in row:
                    t = str(row[col_type]).strip().upper()
                    if t in ('R', 'RES', 'RESERVOIR', 'SOURCE'):
                        ntype = 'R'
                    elif t in ('T', 'TANK', 'STORAGE'):
                        ntype = 'T'

                head = self.spin_def_res_head.value()
                if col_head and col_head in row:
                    try:
                        head = float(row[col_head])
                    except (ValueError, TypeError):
                        _ = None  # non-critical

                coordinates[nid] = (x, y)

                if ntype == 'R':
                    reservoirs.append({'id': nid, 'head': head})
                elif ntype == 'T':
                    tanks.append({
                        'id': nid,
                        'elev': elev,
                        'initlvl': self.spin_tank_init.value(),
                        'minlvl': self.spin_tank_min.value(),
                        'maxlvl': self.spin_tank_max.value(),
                        'diam': self.spin_tank_diam.value(),
                        'minvol': 0.0
                    })
                else:
                    junctions.append({'id': nid, 'elev': elev, 'demand': demand})

            self._log(f"Parsed {len(junctions)} junctions, {len(reservoirs)} reservoirs, {len(tanks)} tanks.")
            self._set_progress(40)

            # --- parse links ---
            pipes = []
            links_path = self.file_links.filePath()
            if links_path and os.path.isfile(links_path):
                links_data = self._parse_csv(links_path)
                col_lid = self.cmb_l_id.currentData()
                col_from = self.cmb_l_from.currentData()
                col_to = self.cmb_l_to.currentData()
                col_len = self.cmb_l_len.currentData()
                col_diam = self.cmb_l_diam.currentData()
                col_rough = self.cmb_l_rough.currentData()

                lheaders = list(links_data[0].keys()) if links_data else []
                if not col_from:
                    col_from = self._find_col(lheaders, ['from', 'node1', 'start', 'from_node'])
                if not col_to:
                    col_to = self._find_col(lheaders, ['to', 'node2', 'end', 'to_node'])
                if not col_lid:
                    col_lid = self._find_col(lheaders, ['id', 'pipe', 'pipe_id', 'link'])

                if not col_from or not col_to:
                    self._log("Warning: could not determine From/To columns – skipping links.")
                else:
                    for i, row in enumerate(links_data):
                        try:
                            n1 = str(row[col_from]).strip()
                            n2 = str(row[col_to]).strip()
                        except (KeyError, TypeError):
                            continue
                        if n1 not in node_ids or n2 not in node_ids:
                            continue
                        pid = str(row.get(col_lid, f"P{i+1}")).strip() if col_lid else f"P{i+1}"
                        if not pid:
                            pid = f"P{i+1}"

                        length = None
                        if col_len and col_len in row:
                            try:
                                length = float(row[col_len])
                            except (ValueError, TypeError):
                                _ = None  # non-critical
                        if length is None or length <= 0:
                            # calculate from coordinates
                            x1, y1 = coordinates[n1]
                            x2, y2 = coordinates[n2]
                            length = math.hypot(x2 - x1, y2 - y1)
                            if length < 1e-6:
                                length = 0.001

                        diam = self.spin_def_diam.value()
                        if col_diam and col_diam in row:
                            try:
                                diam = float(row[col_diam])
                            except (ValueError, TypeError):
                                _ = None  # non-critical

                        rough = self.spin_def_rough.value()
                        if col_rough and col_rough in row:
                            try:
                                rough = float(row[col_rough])
                            except (ValueError, TypeError):
                                _ = None  # non-critical

                        pipes.append({
                            'id': pid,
                            'node1': n1,
                            'node2': n2,
                            'length': length,
                            'diam': diam,
                            'rough': rough,
                            'mloss': self.spin_def_mloss.value(),
                            'status': 'Open',
                            'inter': []
                        })
                    self._log(f"Parsed {len(pipes)} pipes from links file.")
            else:
                self._log("No links file – writing nodes only.")

            self._set_progress(70)

            # Dummy reservoir if needed
            if self.chk_add_dummy_res.isChecked() and not reservoirs and not tanks and junctions:
                j = junctions.pop(0)
                reservoirs.append({'id': j['id'], 'head': self.spin_def_res_head.value()})
                self._log(f"Added dummy reservoir at {j['id']}.")

            self._write_inp(
                out_path,
                title=self.edit_title.text() or "Network from Net2INP (CSV)",
                junctions=junctions,
                reservoirs=reservoirs,
                tanks=tanks,
                pipes=pipes,
                coordinates=coordinates,
                vertices={}
            )
            self._set_progress(90)

            if self.chk_preview.isChecked():
                # create simple memory layers
                crs = self.crs_widget.crs()
                nodes_dict = {nid: coordinates[nid] for nid in coordinates}
                self._create_preview_layers(nodes_dict, pipes, crs)

            self._set_progress(100)
            self._log(f"Successfully wrote: {out_path}")
            QMessageBox.information(
                self, "Success",
                f"EPANET network written to:\n{out_path}\n\n"
                f"Junctions: {len(junctions)}\n"
                f"Reservoirs: {len(reservoirs)}\n"
                f"Tanks: {len(tanks)}\n"
                f"Pipes: {len(pipes)}"
            )

        except Exception as e:
            self._log(f"ERROR: {e}\n{traceback.format_exc()}")
            QMessageBox.critical(self, "Error", f"Failed to generate network:\n{e}")
            self._set_progress(0)

    def _parse_csv(self, path):
        rows = []
        try:
            with open(path, 'r', newline='', encoding='utf-8-sig') as f:
                sample = f.read(4096)
                f.seek(0)
                try:
                    dialect = csv.Sniffer().sniff(sample, delimiters=',;\t ')
                except csv.Error:
                    dialect = csv.excel
                reader = csv.DictReader(f, dialect=dialect)
                for row in reader:
                    # strip keys/values
                    clean = { (k.strip() if k else k): (v.strip() if isinstance(v, str) else v)
                              for k, v in row.items() }
                    rows.append(clean)
        except Exception as e:
            self._log(f"CSV parse error: {e}")
        return rows

    def _find_col(self, headers, candidates):
        lower = {h.lower(): h for h in headers}
        for c in candidates:
            if c.lower() in lower:
                return lower[c.lower()]
        return None

    # ------------------------------------------------------------------
    # Write EPANET .inp
    # ------------------------------------------------------------------
    def _write_inp(self, path, title, junctions, reservoirs, tanks, pipes,
                   coordinates, vertices):
        units = self.cmb_units.currentText().split()[0]  # LPS, GPM, …
        headloss = self.cmb_headloss.currentText().split()[0]  # H-W, D-W, C-M

        lines = []
        lines.append("[TITLE]")
        lines.append(title)
        lines.append("")

        # JUNCTIONS
        lines.append("[JUNCTIONS]")
        lines.append(";ID                  Elev         Demand       Pattern")
        for j in junctions:
            lines.append(f" {j['id']:<20} {j['elev']:<12.4f} {j.get('demand', 0):<12.4f}")
        lines.append("")

        # RESERVOIRS
        if reservoirs:
            lines.append("[RESERVOIRS]")
            lines.append(";ID                  Head         Pattern")
            for r in reservoirs:
                lines.append(f" {r['id']:<20} {r['head']:<12.4f}")
            lines.append("")

        # TANKS
        if tanks:
            lines.append("[TANKS]")
            lines.append(";ID                  Elev         InitLvl      MinLvl       MaxLvl       Diam         MinVol")
            for t in tanks:
                lines.append(
                    f" {t['id']:<20} {t['elev']:<12.4f} {t['initlvl']:<12.4f} "
                    f"{t['minlvl']:<12.4f} {t['maxlvl']:<12.4f} {t['diam']:<12.4f} {t.get('minvol', 0):<.4f}"
                )
            lines.append("")

        # PIPES
        if pipes:
            lines.append("[PIPES]")
            lines.append(";ID                  Node1                Node2                Length       Diam         Roughness    MinorLoss    Status")
            for p in pipes:
                status = p.get('status', 'Open')
                length = p.get('length', 0.001)
                if length is None or not math.isfinite(length) or length <= 0:
                    length = 0.001
                diam = p.get('diam', 100.0)
                if diam is None or not math.isfinite(diam):
                    diam = 100.0
                rough = p.get('rough', 100.0)
                if rough is None or not math.isfinite(rough):
                    rough = 100.0
                mloss = p.get('mloss', 0.0)
                if mloss is None or not math.isfinite(mloss):
                    mloss = 0.0
                lines.append(
                    f" {p['id']:<20} {p['node1']:<20} {p['node2']:<20} "
                    f"{length:<12.4f} {diam:<12.4f} {rough:<12.4f} "
                    f"{mloss:<12.4f} {status}"
                )
            lines.append("")

        # COORDINATES
        if coordinates:
            lines.append("[COORDINATES]")
            lines.append(";Node                X-Coord              Y-Coord")
            for nid, (x, y) in coordinates.items():
                lines.append(f" {nid:<20} {x:<20.6f} {y:<20.6f}")
            lines.append("")

        # VERTICES
        if vertices:
            lines.append("[VERTICES]")
            lines.append(";Link                X-Coord              Y-Coord")
            for lid, pts in vertices.items():
                for (x, y) in pts:
                    lines.append(f" {lid:<20} {x:<20.6f} {y:<20.6f}")
            lines.append("")

        # OPTIONS
        lines.append("[OPTIONS]")
        lines.append(f" Units              {units}")
        lines.append(f" Headloss           {headloss}")
        lines.append("")

        lines.append("[END]")

        with open(path, 'w', encoding='utf-8') as f:
            f.write('\n'.join(lines))
            f.write('\n')

    # ------------------------------------------------------------------
    # Preview layers on map
    # ------------------------------------------------------------------
    def _create_preview_layers(self, nodes, pipes, crs):
        """Add Net2INP_Nodes only (top-level). No pipes preview layer."""
        # Remove previous nodes preview if any
        if self.preview_node_layer and self.preview_node_layer.isValid():
            try:
                QgsProject.instance().removeMapLayer(self.preview_node_layer.id())
            except Exception as _exc:
                _ = _exc  # non-critical; keep defaults / skip
        self.preview_node_layer = None

        # Also remove any leftover Net2INP_Pipes from older versions
        for lid, lyr in list(QgsProject.instance().mapLayers().items()):
            if lyr.name() in ("Net2INP_Pipes", "Net2INP_Nodes"):
                try:
                    QgsProject.instance().removeMapLayer(lid)
                except Exception as _exc:
                    _ = _exc  # non-critical; keep defaults / skip

        node_uri = f"Point?crs={crs.authid()}&field=id:string&field=type:string&index=yes"
        node_layer = QgsVectorLayer(node_uri, "Net2INP_Nodes", "memory")
        node_prov = node_layer.dataProvider()

        node_feats = []
        for nid, (x, y) in nodes.items():
            feat = QgsFeature()
            feat.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(x, y)))
            feat.setAttributes([nid, "Junction"])
            node_feats.append(feat)
        node_prov.addFeatures(node_feats)
        node_layer.updateExtents()

        symbol = QgsMarkerSymbol.createSimple({
            'name': 'circle',
            'color': '0,120,255',
            'size': '3.5',
            'outline_color': '0,60,140',
            'outline_width': '0.4'
        })
        node_layer.setRenderer(QgsSingleSymbolRenderer(symbol))

        # Add at top level of the layer tree (not inside a group)
        root = QgsProject.instance().layerTreeRoot()
        QgsProject.instance().addMapLayer(node_layer, False)  # don't auto-insert
        root.insertLayer(0, node_layer)  # top of legend

        self.preview_node_layer = node_layer
        self._log("Preview nodes layer added (Net2INP_Nodes).")

        if self.iface:
            self.iface.mapCanvas().refresh()

    def _clear_highlights(self):
        """Restore original renderers on highlighted line layers."""
        for layer, old_renderer in self.highlight_layers:
            if layer and layer.isValid():
                try:
                    layer.setRenderer(old_renderer)
                    layer.triggerRepaint()
                except Exception as _exc:
                    _ = _exc  # non-critical; keep defaults / skip
        self.highlight_layers = []
        if self.iface:
            self.iface.mapCanvas().refresh()

    def _update_run_buttons(self, *args):
        """Enable CSV generate only on the XYZ/CSV tab; layers buttons on line-layers tab."""
        idx = 0
        if self.tabs is not None:
            idx = self.tabs.currentIndex()
        # 0 = Line Layers, 1 = XYZ/CSV, 2 = Defaults
        self.btn_run_csv.setEnabled(idx == 1)
        self.btn_run_layers.setEnabled(idx == 0)
        self.btn_export_csv.setEnabled(idx == 0)

    def _on_close(self):
        self._clear_highlights()
        self.reject()

    def reject(self):
        self._clear_highlights()
        super().reject()

    def closeEvent(self, event):
        self._clear_highlights()
        super().closeEvent(event)

    def showEvent(self, event):
        super().showEvent(event)
        # Re-apply highlights for any still-checked layers when panel opens
        self._update_highlights()
        self._update_run_buttons()
