"""
Hydro++/HydroPRO GUI
====================

This module implements a simple graphical interface around the command-line
programs **HYDROPRO** and **HYDRO++**.  Both programs belong to the HYDRO
software suite created by J. García de la Torre and colleagues and are used
to compute hydrodynamic and other solution properties for bead models of
macromolecules.  The 2011 revision of HYDROPRO adds support for
coarse–grained residue‑level models and a graphical interface【804691996725103†L40-L52】,
while HYDRO++ improves the accuracy of rotational diffusion and intrinsic
viscosity calculations for bead models【434851143996887†L74-L82】.  A single run of either
program reads a *main input file* specifying one or more calculations and
writes human‑readable report files (``*.res``) that contain the translational
diffusion coefficient among other quantities.

The goal of this script is to make it easy to:

* Select one or more structural files (e.g. PDB or bead coordinate files).
* Assemble a minimal input file for the command‑line program.
* Run the external executable for each file and parse the resulting ``.res``
  reports for the translational diffusion coefficient.
* Present the results in a table and allow the user to export them to CSV.
* Provide a button to open the official download page for HYDRO++/HYDROPRO and
  an optional field for a user‑supplied alternative URL.
* Display a reference to the principal publication describing the HYDRO++
  algorithm【434851143996887†L74-L82】.

This implementation is intentionally conservative: it does not attempt to
interpret PDB files or build bead models on the fly.  Instead, it assumes
that the selected structural files are already in a format accepted by
HYDRO++/HYDROPRO (either a bead coordinate file as described in the
documentation or, for HYDROPRO, a PDB file).  The user is responsible for
providing appropriate files and verifying the results.  The program
constructs a simple input file for each case using default hydrodynamic
parameters; these may be adjusted in the code if necessary.
"""

import os
import re
import csv
import subprocess
import shutil
from pathlib import Path
from typing import List, Optional, Tuple
from dataclasses import dataclass, asdict, fields

from PyQt5.QtCore import Qt, QUrl, QSettings
from PyQt5.QtGui import QDesktopServices, QTextCursor
from PyQt5.QtWidgets import (
    QApplication,
    QFileDialog,
    QGridLayout,
    QHBoxLayout,
    QLabel,
    QLineEdit,
    QMainWindow,
    QMessageBox,
    QPushButton,
    QTableWidget,
    QTableWidgetItem,
    QVBoxLayout,
    QWidget,
    QDialog,
    QPlainTextEdit,
    QProgressBar,
    QFormLayout,
    QDialogButtonBox,
    QCheckBox,
)


def parse_diffusion_coefficient(res_path: Path) -> Optional[float]:
    """Extract the translational diffusion coefficient from a HYDRO++/PRO
    ``*.res`` file.

    The report files generated by HYDROPRO and HYDRO++ contain a
    human‑readable summary section where the translational diffusion
    coefficient is reported as a number in cm^2/s.  For example, a
    HYDRO++ result file may contain a line like::

        Translational diffusion coefficient:  3.760E-07 cm^2/s

    This function scans the file for the first occurrence of the phrase
    ``diffusion coefficient`` and returns the numeric value as a float.

    Parameters
    ----------
    res_path: Path
        Path to the ``*.res`` file.

    Returns
    -------
    float or None
        The extracted diffusion coefficient, or ``None`` if it could not be
        found or converted.
    """
    # Case‑insensitive search for the diffusion coefficient using scientific
    # notation (e.g. 3.760E-07).  We deliberately capture only the numeric
    # portion; units are ignored.
    pattern = re.compile(r"(?i)diffusion\s+coefficient\s*:\s*([-+]?\d*\.\d+E[-+]?\d+|[-+]?\d+\.\d+|[-+]?\d+)")
    try:
        with res_path.open('r', encoding='utf-8', errors='ignore') as f:
            for line in f:
                match = pattern.search(line)
                if match:
                    try:
                        value_str = match.group(1)
                        return float(value_str)
                    except ValueError:
                        continue
    except FileNotFoundError:
        return None
    return None


def construct_input_file(struct_files: List[Path], input_path: Path) -> List[str]:
    """Create a HYDRO++/PRO main input file for the given structural files.

    This helper writes a simple input file containing one block per structure.
    Each block provides placeholder hydrodynamic parameters that can be
    adjusted to reflect typical laboratory conditions【434851143996887†L120-L168】.  The structural file
    name is taken directly from ``struct_files``.  The resulting input file
    will be saved to ``input_path`` and a list of expected output basenames
    (without extensions) is returned.

    Parameters
    ----------
    struct_files: list of Path
        List of structural files to include.  These may be PDB files or
        coordinate files in the bead format expected by HYDRO++/PRO.

    input_path: Path
        Path where the input file will be written.

    Returns
    -------
    List[str]
        A list of base filenames (without extension) that will be used for
        the output files.  HYDRO++/PRO appends extensions such as ``.res``.
    """
    output_basenames: List[str] = []
    lines: List[str] = []
    for idx, struct_file in enumerate(struct_files, start=1):
        title = struct_file.stem[:20].ljust(20)  # up to 20 characters for TITLE
        basename = f"case{idx:03d}"
        output_basenames.append(basename)
        # A block of parameters; see Section 3 of the HYDRO++ user guide【434851143996887†L120-L168】.
        block = [
            title,
            basename.ljust(30),
            str(struct_file).ljust(30),
            "12",  # ICASE (12 = recommended option for HYDRO++)
            "20.0",  # Temperature (°C)
            "0.010",  # Solvent viscosity (poise)
            "100000.0",  # Molecular weight (Da); default placeholder
            "0.74",  # Partial specific volume (cm^3/g); typical for proteins
            "1.0",  # Solution density (g/cm^3)
            "0",  # NQ: number of scattering angles; 0 disables scattering calculations
            "0.0",  # HMAX; unused when NQ=0
            "0",  # Number of intervals for distance distribution
            "0.0",  # RMAX; unused when intervals=0
            "0",  # NTRIALS; unused when scattering/covolume is disabled
            "1",  # IDIF=1; compute full diffusion tensors
        ]
        lines.extend(block)
    lines.append('*')  # End of file marker
    input_content = '\n'.join(lines)
    input_path.write_text(input_content)
    return output_basenames


def write_hydropro_input(struct_file: Path, job_dir: Path, settings: 'HydroProSettings') -> Tuple[str, Path]:
    """Create a HYDROPRO-compatible hydropro.dat for a single structure using settings.

    Returns a tuple (generic_basename, input_path).
    The structural file is assumed to be located in job_dir (relative path used).
    """
    generic = struct_file.stem  # base for output files
    title = (generic[:28] + "…") if len(generic) > 28 else generic

    lines: List[str] = []
    # First part: structure and primary model
    lines.append(f"{title}                        !TITLE (CHAR*20)")
    lines.append(f"{generic}                        !FILENAME (base for outputs)")
    lines.append(f"{struct_file.name}        !INPUT PDB filename (relative)")
    lines.append(f"{settings.indmode}               !INDMODE: 1 atomic/shell, 2 residue/shell, 4 residue/bead")
    lines.append(f"{settings.aer},            !AER (Å) hydrodynamic radius of primary elements")
    if settings.indmode in (1, 2):
        # Shell model needs NSIG and optionally SIGMIN/SIGMAX
        if settings.nsig == -1:
            lines.append(f"-1,              !NSIG=-1 for automatic sigma range")
        else:
            lines.append(f"{int(settings.nsig)},              !NSIG (>=3 typical 5-8)")
            lines.append(f"{settings.sigmin},            !SIGMIN (Å) minibead radius")
            lines.append(f"{settings.sigmax},            !SIGMAX (Å) minibead radius")
    # Second part: macromolecule and solvent properties
    lines.append(f"{settings.t},            !T (°C)")
    lines.append(f"{settings.eta},           !ETA (poise)")
    lines.append(f"{settings.rm},        !RM (Da)")
    lines.append(f"{settings.vbar},           !VBAR (cm3/g)")
    lines.append(f"{settings.rho},            !RHO (g/cm3)")
    # Third part: optional calculations
    lines.append(f"{int(settings.nq)}              !NQ: 0 omit, -1 automatic, >0 specify QMAX")
    if settings.nq and settings.nq > 0:
        lines.append(f"{settings.qmax}              !QMAX (cm^-1)")
    lines.append(f"{int(settings.ns)}              !NS: 0 omit, -1 automatic, >0 specify RMAX")
    if settings.ns and settings.ns > 0:
        lines.append(f"{settings.rmax}              !RMAX (cm)")
    lines.append(f"{int(settings.ntrials)},              !NTRIALS for covolume (MC)")
    lines.append(f"{int(settings.idif)}               !IDIF=1 for full diffusion tensors")
    lines.append("*                                    !End of file")

    input_path = job_dir / 'hydropro.dat'
    input_path.write_text("\n".join(lines))
    return generic, input_path


@dataclass
class HydroProSettings:
    indmode: int = 1            # 1 atomic/shell, 2 residue/shell, 4 residue/bead
    aer: float = 2.9            # AER per manual (Å)
    nsig: int = 6               # number of sigma values; -1 for automatic
    sigmin: float = 1.0         # only when nsig != -1
    sigmax: float = 2.0         # only when nsig != -1
    t: float = 20.0             # temperature (°C)
    eta: float = 0.01           # solvent viscosity (poise)
    rm: float = 100000.0        # molecular weight (Da)
    vbar: float = 0.74          # partial specific volume (cm^3/g)
    rho: float = 1.0            # solution density (g/cm^3)
    nq: int = -1                # scattering values; 0 omit, -1 automatic, >0 provide qmax
    qmax: float = 0.0           # only when nq > 0
    ns: int = -1                # distance distribution intervals; 0 omit, -1 automatic, >0 provide rmax
    rmax: float = 0.0           # only when ns > 0
    ntrials: int = 0            # Monte Carlo covolume trials
    idif: int = 1               # 1 to output full diffusion tensors


class OutputDialog(QDialog):
    """Simple dialog to display CLI output and a progress bar."""
    def __init__(self, parent=None, total_steps: int = 0):
        super().__init__(parent)
        self.setWindowTitle("HYDRO Output")
        self.resize(900, 600)
        self.canceled = False

        self.status_label = QLabel("Ready")
        self.progress = QProgressBar()
        self.progress.setRange(0, total_steps)
        self.progress.setValue(0)
        self.log = QPlainTextEdit()
        self.log.setReadOnly(True)

        # Buttons
        self.clear_btn = QPushButton("Clear")
        self.save_btn = QPushButton("Save Log…")
        self.cancel_btn = QPushButton("Cancel")

        self.clear_btn.clicked.connect(self.log.clear)
        self.save_btn.clicked.connect(self._save_log)
        self.cancel_btn.clicked.connect(self._on_cancel)

        # Layout
        layout = QVBoxLayout(self)
        layout.addWidget(self.status_label)
        layout.addWidget(self.progress)
        layout.addWidget(self.log)

        btn_row = QHBoxLayout()
        btn_row.addWidget(self.clear_btn)
        btn_row.addStretch(1)
        btn_row.addWidget(self.save_btn)
        btn_row.addWidget(self.cancel_btn)
        layout.addLayout(btn_row)

    def _on_cancel(self) -> None:
        self.canceled = True
        self.cancel_btn.setEnabled(False)
        self.status_label.setText("Cancelling after current job finishes…")

    def _save_log(self) -> None:
        path, _ = QFileDialog.getSaveFileName(self, "Save output log", str(Path.home() / "hydro_output.txt"), "Text files (*.txt)")
        if path:
            try:
                with open(path, 'w', encoding='utf-8') as f:
                    f.write(self.log.toPlainText())
            except Exception:
                pass

    def set_status(self, text: str) -> None:
        self.status_label.setText(text)

    def set_progress(self, value: int) -> None:
        self.progress.setValue(value)

    def append(self, text: str) -> None:
        self.log.appendPlainText(text)
        # Auto-scroll to bottom
        self.log.moveCursor(QTextCursor.End)


class DownloadInfoDialog(QDialog):
    """Dialog informing the user to download/select the HYDRO executable."""
    def __init__(self, download_url: str, parent: Optional[QWidget] = None) -> None:
        super().__init__(parent)
        self.setWindowTitle("HYDRO executable required")
        self.resize(560, 260)
        self.selected_path: Optional[Path] = None
        self.dont_show_startup = False

        msg = (
            "HYDROPRO / HYDRO++ executable is not configured.\n\n"
            "Please download the official ZIP archive and select the executable\n"
            "(hydropro10.exe or hydro++10.exe) before running calculations."
        )
        self.info_label = QLabel(msg)
        self.info_label.setWordWrap(True)

        self.path_display = QLineEdit("")
        self.path_display.setReadOnly(True)

        self.btn_download = QPushButton("Open download page")
        self.btn_select = QPushButton("Select executable…")
        self.btn_close = QPushButton("Close")
        self.chk_no_startup = QCheckBox("Don't show this on startup")

        self.btn_download.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(download_url)))
        self.btn_select.clicked.connect(self._select_exe)
        self.btn_close.clicked.connect(self.accept)
        self.chk_no_startup.toggled.connect(self._set_no_startup)

        # Layout
        layout = QVBoxLayout(self)
        layout.addWidget(self.info_label)

        path_row = QHBoxLayout()
        path_row.addWidget(self.path_display)
        path_row.addWidget(self.btn_select)
        layout.addLayout(path_row)

        btn_row = QHBoxLayout()
        btn_row.addWidget(self.chk_no_startup)
        btn_row.addStretch(1)
        btn_row.addWidget(self.btn_download)
        btn_row.addWidget(self.btn_close)
        layout.addLayout(btn_row)

    def _set_no_startup(self, checked: bool) -> None:
        self.dont_show_startup = bool(checked)

    def _select_exe(self) -> None:
        path, _ = QFileDialog.getOpenFileName(self, "Select HYDRO executable", str(Path.home()), "Executables (*.exe);;All files (*.*)")
        if path:
            self.selected_path = Path(path)
            self.path_display.setText(path)


class SettingsDialog(QDialog):
    """Dialog to configure HYDRO executable and HYDROPRO input parameters."""
    def __init__(self, current_exe: Optional[Path] = None, current_settings: Optional['HydroProSettings'] = None, parent: Optional[QWidget] = None) -> None:
        super().__init__(parent)
        self.setWindowTitle("HYDRO Pro Settings")

        self.path_edit = QLineEdit(str(current_exe) if current_exe else "")
        browse_btn = QPushButton("…")
        browse_btn.clicked.connect(self._browse)

        # Settings model
        self._settings = current_settings or HydroProSettings()

        form = QFormLayout()
        # Executable path
        path_row = QHBoxLayout()
        path_row.addWidget(self.path_edit)
        path_row.addWidget(browse_btn)
        form.addRow("HYDRO executable:", path_row)

        # HYDROPRO parameters (manual-based)
        self.indmode_edit = QLineEdit(str(self._settings.indmode))
        self.indmode_edit.setToolTip("INDMODE: 1=Atomic/shell; 2=Residue/shell; 4=Residue/bead (manual §3.a)")
        self._add_row_with_info(
            form,
            "INDMODE",
            self.indmode_edit,
            "Type of calculation mode (manual §3.a):\n1 = Atomic-level primary model, shell calculation\n2 = Residue-level primary model, shell calculation\n4 = Residue-level primary model, bead calculation."
        )

        self.aer_edit = QLineEdit(str(self._settings.aer))
        self.aer_edit.setToolTip("AER (Å): hydrodynamic radius of primary elements. Typical: 2.9(INDMODE 1), 4.8(2), 6.1(4)")
        self._add_row_with_info(
            form,
            "AER (Å)",
            self.aer_edit,
            "Hydrodynamic radius of primary elements (manual §3.a). Suggested values:\nINDMODE 1: 2.9 Å; INDMODE 2: 4.8 Å; INDMODE 4: 6.1 Å."
        )

        self.nsig_edit = QLineEdit(str(self._settings.nsig))
        self.nsig_edit.setToolTip("NSIG: number of minibead radii (>2, typical 5–8). Use -1 for automatic SIGMIN/SIGMAX")
        self._add_row_with_info(
            form,
            "NSIG",
            self.nsig_edit,
            "Number of values of minibead radius sigma (manual §3.a). Must be >2 (typ. 5–8). Use -1 to let program choose SIGMIN/SIGMAX automatically (omit bounds)."
        )

        self.sigmin_edit = QLineEdit(str(self._settings.sigmin))
        self.sigmin_edit.setToolTip("SIGMIN (Å): minimum minibead radius (shell model, INDMODE 1/2)")
        self._add_row_with_info(
            form,
            "SIGMIN (Å)",
            self.sigmin_edit,
            "Minimum minibead radius sigma (manual §3.a). Only for shell modes (INDMODE 1/2) when NSIG != -1. Too small values may exceed the 2000 minibead limit."
        )

        self.sigmax_edit = QLineEdit(str(self._settings.sigmax))
        self.sigmax_edit.setToolTip("SIGMAX (Å): maximum minibead radius (shell model, INDMODE 1/2)")
        self._add_row_with_info(
            form,
            "SIGMAX (Å)",
            self.sigmax_edit,
            "Maximum minibead radius sigma (manual §3.a). For shell modes (INDMODE 1/2). Choose so the shell uses neither too many nor too few minibeads (typ. 200–400; program caps at ~2000)."
        )

        self.t_edit = QLineEdit(str(self._settings.t))
        self.t_edit.setToolTip("Temperature T (°C); manual §3.b")
        self._add_row_with_info(
            form,
            "T (°C)",
            self.t_edit,
            "Temperature in centigrade (manual §3.b). Typical lab default 20 °C; affects solution properties and diffusion."
        )

        self.eta_edit = QLineEdit(str(self._settings.eta))
        self.eta_edit.setToolTip("Solvent viscosity ETA (poise); manual §3.b")
        self._add_row_with_info(
            form,
            "ETA (poise)",
            self.eta_edit,
            "Solvent viscosity in poises (manual §3.b). Typical water at 20 °C ~0.01 P."
        )

        self.rm_edit = QLineEdit(str(self._settings.rm))
        self.rm_edit.setToolTip("Molecular weight RM (Da); manual §3.b")
        self._add_row_with_info(
            form,
            "RM (Da)",
            self.rm_edit,
            "Molecular weight in daltons (manual §3.b). Used in hydrodynamic and solution property calculations."
        )

        self.vbar_edit = QLineEdit(str(self._settings.vbar))
        self.vbar_edit.setToolTip("Partial specific volume VBAR (cm³/g); manual §3.b")
        self._add_row_with_info(
            form,
            "VBAR (cm³/g)",
            self.vbar_edit,
            "Partial specific volume (manual §3.b), in cm³/g. Typical proteins ~0.73–0.75 cm³/g. Used to estimate macromolecular volume."
        )

        self.rho_edit = QLineEdit(str(self._settings.rho))
        self.rho_edit.setToolTip("Solution density RHO (g/cm³); manual §3.b")
        self._add_row_with_info(
            form,
            "RHO (g/cm³)",
            self.rho_edit,
            "Solution (approx. solvent) density (manual §3.b), in g/cm³."
        )

        self.nq_edit = QLineEdit(str(self._settings.nq))
        self.nq_edit.setToolTip("NQ: 0 omit scattering; -1 automatic; >0 then provide QMAX (cm⁻1). Manual §3.c")
        self._add_row_with_info(
            form,
            "NQ",
            self.nq_edit,
            "Number of scattering q values (manual §3.c). 0 = omit scattering; -1 = automatic choice; >0 specify QMAX (cm⁻1). Recommended NQ: multiples of 5 or 10 plus 1 (e.g., 11, 51)."
        )

        self.qmax_edit = QLineEdit(str(self._settings.qmax))
        self.qmax_edit.setToolTip("QMAX (cm⁻1): only when NQ>0")
        self._add_row_with_info(
            form,
            "QMAX (cm⁻1)",
            self.qmax_edit,
            "Upper limit of scattering variable q (cm⁻1) (manual §3.c). Only when NQ > 0."
        )

        self.ns_edit = QLineEdit(str(self._settings.ns))
        self.ns_edit.setToolTip("NS: intervals for p(r). 0 omit; -1 automatic; >0 then provide RMAX (cm). Manual §3.c")
        self._add_row_with_info(
            form,
            "NS",
            self.ns_edit,
            "Number of intervals for the distribution of distances p(r) (manual §3.c). 0 = omit; -1 = automatic; >0 specify RMAX (cm)."
        )

        self.rmax_edit = QLineEdit(str(self._settings.rmax))
        self.rmax_edit.setToolTip("RMAX (cm): only when NS>0")
        self._add_row_with_info(
            form,
            "RMAX (cm)",
            self.rmax_edit,
            "Maximum distance for p(r) (manual §3.c), in cm. Only when NS > 0."
        )

        self.ntrials_edit = QLineEdit(str(self._settings.ntrials))
        self.ntrials_edit.setToolTip("NTRIALS: Monte Carlo trials for covolume; 0 to skip (time-consuming). Manual §3.c")
        self._add_row_with_info(
            form,
            "NTRIALS",
            self.ntrials_edit,
            "Number of trials for Monte Carlo covolume calculation (manual §3.c). Set 0 to omit; note this is very time-consuming."
        )

        self.idif_edit = QLineEdit(str(self._settings.idif))
        self.idif_edit.setToolTip("IDIF: 1 for detailed diffusion tensors and hydrodynamic center. Manual §3.c")
        self._add_row_with_info(
            form,
            "IDIF",
            self.idif_edit,
            "Flag for detailed diffusivity report (manual §3.c). 1 = full 6×6 diffusion tensor and diffusion center; 0 = basic output."
        )

        # Buttons
        buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
        buttons.accepted.connect(self._on_accept)
        buttons.rejected.connect(self.reject)

        layout = QVBoxLayout(self)
        layout.addLayout(form)
        layout.addWidget(buttons)

        # Initial enable/disable
        self._update_enables()
        # React to edits
        self.nq_edit.textChanged.connect(self._update_enables)
        self.ns_edit.textChanged.connect(self._update_enables)
        self.nsig_edit.textChanged.connect(self._update_enables)

        self.result_settings: Optional[HydroProSettings] = None

    def _update_enables(self) -> None:
        def to_int(txt: str, default: int) -> int:
            try:
                return int(float(txt.strip()))
            except Exception:
                return default
        nq = to_int(self.nq_edit.text(), self._settings.nq)
        ns = to_int(self.ns_edit.text(), self._settings.ns)
        self.qmax_edit.setEnabled(nq > 0)
        self.rmax_edit.setEnabled(ns > 0)
        nsig = to_int(self.nsig_edit.text(), self._settings.nsig)
        enable_sig_bounds = (nsig != -1) and (to_int(self.indmode_edit.text(), self._settings.indmode) in (1, 2))
        self.sigmin_edit.setEnabled(enable_sig_bounds)
        self.sigmax_edit.setEnabled(enable_sig_bounds)

    def _add_row_with_info(self, form: QFormLayout, label: str, field_widget: QWidget, info_text: str) -> None:
        container = QWidget()
        h = QHBoxLayout(container)
        h.setContentsMargins(0, 0, 0, 0)
        h.addWidget(field_widget)
        h.addStretch(1)
        info_btn = QPushButton("i")
        info_btn.setFixedWidth(24)
        info_btn.setToolTip("Show info")
        info_btn.clicked.connect(lambda: QMessageBox.information(self, label, info_text))
        h.addWidget(info_btn)
        form.addRow(label, container)

    def _browse(self) -> None:
        path, _ = QFileDialog.getOpenFileName(self, "Select HYDRO executable", str(Path.home()), "Executables (*.exe);;All files (*.*)")
        if path:
            self.path_edit.setText(path)

    def _on_accept(self) -> None:
        try:
            s = HydroProSettings(
                indmode=int(float(self.indmode_edit.text().strip() or '1')),
                aer=float(self.aer_edit.text().strip() or '2.9'),
                nsig=int(float(self.nsig_edit.text().strip() or '6')),
                sigmin=float(self.sigmin_edit.text().strip() or '1.0'),
                sigmax=float(self.sigmax_edit.text().strip() or '2.0'),
                t=float(self.t_edit.text().strip() or '20.0'),
                eta=float(self.eta_edit.text().strip() or '0.01'),
                rm=float(self.rm_edit.text().strip() or '100000.0'),
                vbar=float(self.vbar_edit.text().strip() or '0.74'),
                rho=float(self.rho_edit.text().strip() or '1.0'),
                nq=int(float(self.nq_edit.text().strip() or '-1')),
                qmax=float(self.qmax_edit.text().strip() or '0.0'),
                ns=int(float(self.ns_edit.text().strip() or '-1')),
                rmax=float(self.rmax_edit.text().strip() or '0.0'),
                ntrials=int(float(self.ntrials_edit.text().strip() or '0')),
                idif=int(float(self.idif_edit.text().strip() or '1')),
            )
            # Basic validations per manual
            if s.indmode not in (1, 2, 4):
                raise ValueError("INDMODE must be 1, 2, or 4")
            if s.indmode in (1, 2) and s.nsig != -1 and s.nsig <= 2:
                raise ValueError("NSIG must be > 2 or -1 for automatic in shell modes")
            if s.nq > 0 and s.qmax <= 0:
                raise ValueError("QMAX must be > 0 when NQ>0")
            if s.ns > 0 and s.rmax <= 0:
                raise ValueError("RMAX must be > 0 when NS>0")
            self.result_settings = s
            self.accept()
        except Exception as exc:
            QMessageBox.warning(self, "Invalid settings", f"{exc}")


class HydroGui(QMainWindow):
    """Main window for the HYDRO++/PRO GUI."""

    def __init__(self, parent: Optional[QWidget] = None) -> None:
        super().__init__(parent)
        self.setWindowTitle("HYDRO++ / HYDROPRO Diffusion Coefficient Calculator")
        self.resize(600, 300)
        
        # Default HYDROPRO settings (can be changed in Settings)
        self.hp_settings = HydroProSettings()
        # QSettings persistence
        self._qsettings = QSettings("ChiSurf", "HydroPRO")
        self.show_info_on_startup: bool = True
        # Executable path (persisted); default to empty Path (do not point to plugin file)
        self.hydro_exe: Path = Path()
        # Load persisted settings (overwrites defaults if present)
        self._load_settings()

        # Selected files and results
        self.struct_files: List[Path] = []
        self.results: List[Tuple[str, Optional[float]]] = []

        # UI elements
        self.table = QTableWidget(0, 2)
        self.table.setHorizontalHeaderLabels(["File", "Diffusion coefficient (cm²/s)"])
        self.table.horizontalHeader().setStretchLastSection(True)

        self.select_button = QPushButton("Select structural files…")
        self.select_button.clicked.connect(self.select_files)

        self.run_button = QPushButton("Run HYDRO++/PRO")
        self.run_button.clicked.connect(self.run_hydro)
        self.run_button.setEnabled(False)

        self.save_button = QPushButton("Save results to CSV")
        self.save_button.clicked.connect(self.save_csv)
        self.save_button.setEnabled(False)

        # Clear button
        self.clear_button = QPushButton("Clear")
        self.clear_button.setToolTip("Clear table and remove computation folder")
        self.clear_button.clicked.connect(self.clear_all)

        # Settings button
        self.settings_button = QPushButton("Settings")
        self.settings_button.clicked.connect(self.open_settings)

        # Download URL field and button
        self.url_edit = QLineEdit()
        self.url_edit.setPlaceholderText("Download URL…")
        # Default URL points to the HYDRO++ program page【434851143996887†L74-L82】
        self.url_edit.setText(
            "https://leonardo.inf.um.es/macromol/programs/hydro%2B%2B/hydro%2B%2B.htm"
        )
        self.download_button = QPushButton("Download page")
        self.download_button.clicked.connect(self.open_download_page)

        # Reference label with hyperlink to the HYDRO++ publication【434851143996887†L74-L82】
        ref_html = (
            '<p style="font-size:10pt">'
            '<a href="https://leonardo.inf.um.es/macromol/publications/181JPC07.pdf">Reference</a>'
            '</p>'
        )
        self.reference_label = QLabel(ref_html)
        self.reference_label.setOpenExternalLinks(True)

        # Layout
        central = QWidget()
        self.setCentralWidget(central)

        top_layout = QHBoxLayout()
        top_layout.addWidget(self.select_button)
        top_layout.addWidget(self.run_button)
        top_layout.addWidget(self.save_button)
        top_layout.addWidget(self.clear_button)
        top_layout.addWidget(self.settings_button)

        url_layout = QHBoxLayout()
        url_layout.addWidget(self.url_edit)
        url_layout.addWidget(self.download_button)

        main_layout = QVBoxLayout(central)
        main_layout.addLayout(top_layout)
        main_layout.addWidget(self.table)
        main_layout.addLayout(url_layout)
        main_layout.addWidget(self.reference_label, alignment=Qt.AlignLeft)

        # Startup check for executable
        self._maybe_show_download_info_on_startup()

    def _set_hydro_exe(self, path: Optional[Path]) -> None:
        self.hydro_exe = Path(path) if path else Path()

    def _load_settings(self) -> None:
        try:
            exe_str = self._qsettings.value("hydro_exe", "", type=str)
            self._set_hydro_exe(Path(exe_str) if exe_str else None)
            self.show_info_on_startup = bool(self._qsettings.value("show_info_on_startup", True, type=bool))
            # Load HP settings
            for f in fields(HydroProSettings):
                key = f"hp.{f.name}"
                default_val = getattr(self.hp_settings, f.name)
                val = self._qsettings.value(key, default_val)
                try:
                    if isinstance(default_val, int):
                        setattr(self.hp_settings, f.name, int(float(val)))
                    elif isinstance(default_val, float):
                        setattr(self.hp_settings, f.name, float(val))
                    elif isinstance(default_val, bool):
                        sval = str(val).strip().lower()
                        setattr(self.hp_settings, f.name, sval in ("1", "true", "yes", "on"))
                    else:
                        setattr(self.hp_settings, f.name, val)
                except Exception:
                    setattr(self.hp_settings, f.name, default_val)
        except Exception:
            pass

    def _save_settings(self) -> None:
        try:
            self._qsettings.setValue("hydro_exe", str(self.hydro_exe) if self.hydro_exe else "")
            self._qsettings.setValue("show_info_on_startup", self.show_info_on_startup)
            for f in fields(HydroProSettings):
                self._qsettings.setValue(f"hp.{f.name}", getattr(self.hp_settings, f.name))
        except Exception:
            pass

    def _maybe_show_download_info_on_startup(self) -> None:
        # Show dialog if no executable configured or missing
        if (not self.hydro_exe) or (not self.hydro_exe.exists()):
            if self.show_info_on_startup:
                self._show_download_info_dialog()

    def _show_download_info_dialog(self) -> None:
        url = self.url_edit.text().strip() if hasattr(self, 'url_edit') and self.url_edit.text().strip() else "https://leonardo.inf.um.es/macromol/programs/hydro%2B%2B/hydro%2B%2B.htm"
        dlg = DownloadInfoDialog(url, self)
        if dlg.exec_() == QDialog.Accepted:
            if dlg.selected_path and dlg.selected_path.exists():
                self._set_hydro_exe(dlg.selected_path)
            self.show_info_on_startup = not dlg.dont_show_startup
            self._save_settings()

    def _ensure_executable_available(self) -> Optional[Path]:
        if self.hydro_exe and self.hydro_exe.exists():
            return self.hydro_exe
        self._show_download_info_dialog()
        return self.hydro_exe if (self.hydro_exe and self.hydro_exe.exists()) else None

    def select_files(self) -> None:
        """Show a file dialog to select one or more structural files."""
        paths, _ = QFileDialog.getOpenFileNames(
            self,
            "Select structural files",
            str(Path.home()),
            "Structural files (*.pdb *.txt *.bea *.*)"
        )
        if paths:
            self.struct_files = [Path(p) for p in paths]
            self.update_table()
            self.run_button.setEnabled(True)
        else:
            self.struct_files = []
            self.update_table()
            self.run_button.setEnabled(False)

    def update_table(self) -> None:
        """Update the table to reflect the selected files and results."""
        self.table.setRowCount(0)
        # Populate with selected files (before running)
        for file_path in self.struct_files:
            row = self.table.rowCount()
            self.table.insertRow(row)
            self.table.setItem(row, 0, QTableWidgetItem(str(file_path)))
            self.table.setItem(row, 1, QTableWidgetItem(""))
        # Populate with results if available
        for file_name, value in self.results:
            # find the row with matching file
            for row in range(self.table.rowCount()):
                if self.table.item(row, 0).text() == file_name:
                    coef_item = QTableWidgetItem(
                        f"{value:.3e}" if value is not None else "N/A"
                    )
                    coef_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
                    self.table.setItem(row, 1, coef_item)
                    break

    def run_hydro(self) -> None:
        """Execute the HYDRO++/PRO executable on the selected files with live output and progress."""
        if not self.struct_files:
            QMessageBox.warning(self, "No files", "Please select one or more files first.")
            return
        exe_path = self._ensure_executable_available()
        if not exe_path:
            QMessageBox.information(self, "Executable required", "Please configure the HYDRO executable before running.")
            return

        total = len(self.struct_files)
        base_work_dir = Path.home() / '.hydropp_gui'
        base_work_dir.mkdir(exist_ok=True)

        # Show output/progress dialog
        out_dlg = OutputDialog(self, total_steps=total)
        out_dlg.set_status(f"Starting HYDRO for {total} file(s)…")
        out_dlg.append(f"Executable: {exe_path}")
        out_dlg.show()
        QApplication.processEvents()

        self.results = []
        for idx, struct_file in enumerate(self.struct_files, start=1):
            if out_dlg.canceled:
                break
            job_dir = base_work_dir / f"job_{idx:03d}"
            job_dir.mkdir(parents=True, exist_ok=True)

            # Determine executable type
            is_hydropro = 'hydropro' in exe_path.name.lower()

            if is_hydropro:
                # HYDROPRO expects hydropro.dat and the structural file accessible in CWD
                job_struct = job_dir / struct_file.name
                try:
                    shutil.copyfile(str(struct_file), str(job_struct))
                    out_dlg.append(f"Copied structure file to job folder: {job_struct}")
                except Exception as ex:
                    out_dlg.append(f"ERROR: failed to copy structure file: {ex}")
                generic, hp_input = write_hydropro_input(job_struct, job_dir, self.hp_settings)
                out_dlg.append(f"Generated hydropro.dat: {hp_input} (Generic base: {generic})")
                input_to_feed = hp_input.name
                base_name_for_results = generic
                expected_res_path = job_dir / f"{generic}-res.txt"
            else:
                # HYDRO++ style input
                input_file = job_dir / 'hydro_input.dat'
                basenames = construct_input_file([struct_file], input_file)
                basename = basenames[0] if basenames else "case001"
                input_to_feed = input_file.name
                base_name_for_results = basename
                expected_res_path = job_dir / f"{basename}-res.txt"

            try:
                cmd = [str(exe_path)]
                out_dlg.set_status(f"[{idx}/{total}] Running {struct_file.name}…")
                out_dlg.append(f"\n=== Job {idx}/{total}: {struct_file} ===")
                out_dlg.append(f"Working directory: {job_dir}")
                QApplication.processEvents()

                process = subprocess.Popen(
                    cmd,
                    stdin=subprocess.PIPE,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.PIPE,
                    cwd=str(job_dir),
                )
                out_bytes, err_bytes = process.communicate(input=f"{input_to_feed}\n".encode('utf-8'), timeout=300)
                out_text = out_bytes.decode('utf-8', errors='ignore') if out_bytes else ''
                err_text = err_bytes.decode('utf-8', errors='ignore') if err_bytes else ''

                if out_text.strip():
                    out_dlg.append(out_text.strip())
                if err_text.strip():
                    out_dlg.append("\n[STDERR]\n" + err_text.strip())
            except FileNotFoundError:
                QMessageBox.critical(self, "Execution error", "Failed to launch the external program.")
                out_dlg.append("ERROR: Executable not found.")
                break
            except subprocess.TimeoutExpired:
                process.kill()
                QMessageBox.critical(self, "Timeout", f"The HYDRO calculation for {struct_file.name} timed out.")
                out_dlg.append("ERROR: Timeout.")
                break

            # Parse result for this job
            res_path = expected_res_path
            coeff = parse_diffusion_coefficient(res_path)
            self.results.append((str(struct_file), coeff))
            if coeff is not None:
                out_dlg.append(f"Result: diffusion coefficient = {coeff:.3e} cm^2/s")
            else:
                out_dlg.append("Result: diffusion coefficient not found (see output above)")

            out_dlg.set_progress(idx)
            QApplication.processEvents()

            if out_dlg.canceled:
                out_dlg.append("\nCancelled by user.")
                break

        out_dlg.set_status("Finished.")
        out_dlg.cancel_btn.setEnabled(False)

        self.save_button.setEnabled(bool(self.results))
        self.update_table()

    def save_csv(self) -> None:
        """Save the current results table to a CSV file."""
        if not self.results:
            QMessageBox.warning(self, "No results", "There are no results to save.")
            return
        path, _ = QFileDialog.getSaveFileName(
            self,
            "Save CSV",
            str(Path.home() / "hydro_results.csv"),
            "CSV files (*.csv)"
        )
        if not path:
            return
        try:
            with open(path, 'w', newline='', encoding='utf-8') as csvfile:
                writer = csv.writer(csvfile)
                writer.writerow(["File", "DiffusionCoefficient(cm^2/s)"])
                for file_name, value in self.results:
                    writer.writerow([file_name, f"{value:.3e}" if value is not None else ""])
            QMessageBox.information(self, "Saved", f"Results saved to {path}")
        except Exception as exc:
            QMessageBox.critical(self, "Error", f"Failed to save CSV: {exc}")

    def clear_all(self) -> None:
        """Clear the table and cleanup the computation folder (.hydropp_gui)."""
        # Reset selections and results
        self.struct_files = []
        self.results = []
        self.update_table()
        # Disable actions until new files are selected/run
        self.run_button.setEnabled(False)
        self.save_button.setEnabled(False)
        # Remove computation folder recursively
        base_work_dir = Path.home() / '.hydropp_gui'
        try:
            if base_work_dir.exists():
                shutil.rmtree(str(base_work_dir), ignore_errors=False)
        except Exception:
            # Ensure best-effort cleanup
            try:
                shutil.rmtree(str(base_work_dir), ignore_errors=True)
            except Exception:
                pass
        # Recreate empty folder to keep workflow consistent
        try:
            base_work_dir.mkdir(exist_ok=True)
        except Exception:
            pass
        # Minimal user feedback
        QMessageBox.information(self, "Cleared", "Cleared the table and cleaned the computation folder.")

    def open_settings(self) -> None:
        """Open the Settings dialog to configure the HYDRO executable and HYDROPRO parameters."""
        dlg = SettingsDialog(current_exe=self.hydro_exe, current_settings=self.hp_settings, parent=self)
        if dlg.exec_() == QDialog.Accepted:
            # Update executable path if valid
            new_text = dlg.path_edit.text().strip()
            if new_text:
                new_path = Path(new_text)
                if new_path.exists() and new_path.is_file():
                    self._set_hydro_exe(new_path)
                else:
                    QMessageBox.warning(self, "Invalid path", f"The selected executable does not exist:\n{new_path}")
            # Update HYDROPRO settings
            if getattr(dlg, 'result_settings', None) is not None:
                self.hp_settings = dlg.result_settings
            self._save_settings()
            QMessageBox.information(self, "Settings", "Settings updated.")

    def open_download_page(self) -> None:
        """Open the HYDRO++/PRO download page specified in the URL field."""
        url_str = self.url_edit.text().strip()
        if not url_str:
            QMessageBox.warning(self, "No URL", "Please enter a valid URL.")
            return
        QDesktopServices.openUrl(QUrl(url_str))


def main() -> None:
    """Entry point for the application."""
    import sys
    app = QApplication(sys.argv)
    gui = HydroGui()
    gui.show()
    sys.exit(app.exec_())

