"""
Charting widget
"""

import datetime
from functools import partial
from itertools import cycle
from typing import Optional
import numpy
from PyQt5 import QtCore, QtWidgets
from . import utils
from .fit_manager import FitManager
from .user_settings import settings

from matplotlib.figure import Figure
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar


FONTSIZE = 10
LEFT_BUTTON = 1
MIDDLE_BUTTON = 2
RIGHT_BUTTON = 3

# https://pyqtgraph.readthedocs.io/en/latest/api_reference/graphicsItems/scatterplotitem.html#pyqtgraph.ScatterPlotItem.setSymbol
# https://developer.mozilla.org/en-US/docs/Web/CSS/named-color
# Do NOT sort these colors alphabetically!  There should be obvious
# contrast between adjacent colors.
PLOT_COLORS = """
    r g b c m
    goldenrod
    lime
    orange
    blueviolet
    brown
    teal
    olive
    lightcoral
    cornflowerblue
    forestgreen
    salmon
""".split()
PLOT_SYMBOLS = """o + x star s d t t2 t3""".split()
_AUTO_COLOR_CYCLE = cycle(PLOT_COLORS)
_AUTO_SYMBOL_CYCLE = cycle(PLOT_SYMBOLS)


def auto_color():
    """Returns next color for pens and brushes."""
    return next(_AUTO_COLOR_CYCLE)


def auto_symbol():
    """Returns next symbol for scatter plots."""
    return next(_AUTO_SYMBOL_CYCLE)


class ChartView(QtWidgets.QWidget):
    """TODO: docstrings"""

    # Fit signals for main window connection
    fitAdded = QtCore.pyqtSignal(str, str)  # curveID, fitID
    fitUpdated = QtCore.pyqtSignal(str, str)  # curveID, fitID
    fitRemoved = QtCore.pyqtSignal(str, str)  # curveID, fitID

    def __init__(self, parent, **kwargs):
        # parent=<mdaviz.mda_folder.MDA_MVC object at 0x10e7ff520>
        self.mda_mvc = parent
        super().__init__()

        ############# UI initialization:

        # Set size policy to prevent unwanted expansion
        self.setSizePolicy(
            QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding
        )

        # Get maximum height from user settings with default fallback
        max_height = settings.getKey("plot_max_height")
        try:
            max_height = int(max_height)
        except (TypeError, ValueError):
            max_height = 800  # Default value
            settings.setKey("plot_max_height", max_height)

        # Set minimum and maximum height to constrain vertical growth
        self.setMinimumHeight(200)  # Minimum reasonable height
        self.setMaximumHeight(max_height)  # Configurable maximum height

        # Create a Matplotlib figure and canvas
        self.figure = Figure()
        self.canvas = FigureCanvas(self.figure)
        self.main_axes = self.figure.add_subplot(111)
        # Adjust margins
        self.figure.subplots_adjust(bottom=0.1, top=0.9, right=0.92)

        # Set size constraints on the canvas to prevent vertical expansion
        canvas_max_height = max_height - 50  # Leave room for toolbar
        self.canvas.setMaximumHeight(canvas_max_height)
        self.canvas.setSizePolicy(
            QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred
        )

        # Create the navigation toolbar
        self.toolbar = NavigationToolbar(self.canvas, self)

        # Use a QVBoxLayout for stacking the toolbar and canvas vertically
        layout = QtWidgets.QVBoxLayout(self)
        layout.addWidget(self.toolbar)
        layout.addWidget(self.canvas)
        # Apply the QVBoxLayout to the ChartView widget
        self.setLayout(layout)

        # Plot configuration
        plot_options = kwargs.get("plot_options", {})
        self.setTitle(plot_options.get("title", ""))
        self.setXlabel(plot_options.get("y", ""))
        self.setYlabel(plot_options.get("x", ""))
        self.configPlot()

        ############# Signals & slots:

        # Track curves and display in QComboBox:
        self.plotObjects = {}  # all the Line2D on the graph, key = curveID
        self.fitObjects = {}  # all the fit Line2D on the graph, key = curveID
        self.curveBox = self.mda_mvc.mda_file_viz.curveBox
        self.curveBox.currentIndexChanged.connect(self.onCurveSelected)

        # Initialize CurveManager
        self.curveManager = CurveManager(self)
        self.curveManager.curveAdded.connect(self.onCurveAdded)
        self.curveManager.curveUpdated.connect(self.onCurveUpdated)
        self.curveManager.curveRemoved.connect(self.onCurveRemoved)
        self.curveManager.allCurvesRemoved.connect(self.onAllCurvesRemoved)

        # Initialize FitManager
        self.fitManager = FitManager(self)
        self.fitManager.fitAdded.connect(self.onFitAdded)
        self.fitManager.fitUpdated.connect(self.onFitUpdated)
        self.fitManager.fitRemoved.connect(self.onFitRemoved)
        self.fitManager.fitVisibilityChanged.connect(self.onFitVisibilityChanged)
        # # Debug signals:
        # self.curveManager.curveAdded.connect(utils.debug_signal)
        # self.curveManager.curveRemoved.connect(utils.debug_signal)
        # self.curveManager.curveUpdated.connect(utils.debug_signal)
        # self.curveManager.allCurvesRemoved.connect(utils.debug_signal)

        # Remove buttons definitions:
        self.clearAll = self.mda_mvc.mda_file_viz.clearAll
        self.removeButton = self.mda_mvc.mda_file_viz.curveRemove
        self.removeCursor1 = self.mda_mvc.mda_file_viz.cursor1_remove
        self.removeCursor2 = self.mda_mvc.mda_file_viz.cursor2_remove

        # Remove button connections:
        utils.reconnect(self.clearAll.clicked, self.curveManager.allCurvesRemoved)
        utils.reconnect(self.removeButton.clicked, self.onRemoveButtonClicked)
        self.removeCursor1.clicked.connect(partial(self.onRemoveCursor, cursor_num=1))
        self.removeCursor2.clicked.connect(partial(self.onRemoveCursor, cursor_num=2))

        # File tableview & graph synchronization:
        self.mda_mvc.mda_file.tabManager.tabRemoved.connect(self.onTabRemoved)
        self.mda_mvc.detRemoved.connect(self.onDetRemoved)
        # self.mda_mvc.detRemoved.connect(utils.debug_signal)

        # Connect offset & factor QLineEdit:
        self.offset_value = self.mda_mvc.mda_file_viz.offset_value
        self.factor_value = self.mda_mvc.mda_file_viz.factor_value
        self.offset_value.editingFinished.connect(self.onOffsetUpdated)
        self.factor_value.editingFinished.connect(self.onFactorUpdated)

        # Connect the click event to a handler
        self.cid = self.figure.canvas.mpl_connect("button_press_event", self.onclick)
        self.alt_pressed = False

        # Set up a timer to check modifier key state
        self.key_check_timer = QtCore.QTimer()
        self.key_check_timer.timeout.connect(self.check_modifier_keys)
        self.key_check_timer.start(50)  # Check every 50ms

        self.cursors = {
            1: None,
            "pos1": None,
            "text1": "middle click or alt+right click",
            2: None,
            "pos2": None,
            "text2": "right click",
            "diff": "n/a",
            "midpoint": "n/a",
        }

    def check_modifier_keys(self):
        """Check for modifier keys using Qt's global state."""
        try:
            # Get the global keyboard state
            modifiers = QtWidgets.QApplication.keyboardModifiers()
            self.alt_pressed = modifiers & QtCore.Qt.AltModifier
        except Exception:
            # Fallback if Qt method fails
            pass

    def closeEvent(self, event):
        """Clean up resources when the widget is closed."""
        if hasattr(self, "key_check_timer"):
            self.key_check_timer.stop()
        super().closeEvent(event)

    ########################################## Set & get methods:

    def setPlotTitle(self, txt=""):
        self.main_axes.set_title(txt, fontsize=FONTSIZE)

    def setBottomAxisText(self, text):
        self.main_axes.set_xlabel(text, fontsize=FONTSIZE, labelpad=10)

    def setLeftAxisText(self, text):
        self.main_axes.set_ylabel(text, fontsize=FONTSIZE, labelpad=20)

    def title(self):
        return self._title

    def xlabel(self):
        return self._xlabel

    def ylabel(self):
        return self._ylabel

    def setTitle(self, txt=""):
        self._title = txt

    def setXlabel(self, txt=""):
        self._xlabel = txt

    def setYlabel(self, txt=""):
        self._ylabel = txt

    def getSelectedCurveID(self):
        # Get the curve ID from the item data instead of display text
        current_index = self.curveBox.currentIndex()
        if current_index >= 0:
            curve_id = self.curveBox.itemData(current_index, QtCore.Qt.UserRole)

            # Check if the curve ID exists in the curve manager
            if curve_id in self.curveManager.curves():
                return curve_id
            else:
                # If curve_id is None or not found, return None
                return None
        return None

    def setMaximumPlotHeight(self, height: int):
        """
        Set the maximum height for the plot widget and save to user settings.

        Parameters:
        - height (int): Maximum height in pixels
        """
        if height < 200:
            height = 200  # Minimum reasonable height

        # Update the widget maximum height
        self.setMaximumHeight(height)

        # Update the canvas maximum height (leave room for toolbar)
        canvas_max_height = height - 50
        self.canvas.setMaximumHeight(canvas_max_height)

        # Save to user settings
        settings.setKey("plot_max_height", height)

        # Redraw the canvas
        self.canvas.draw()

    ########################################## Slot methods:

    def onCurveAdded(self, curveID):
        # Add to graph
        curveData = self.curveManager.getCurveData(curveID)
        ds = curveData["ds"]
        ds_options = curveData["ds_options"].copy()  # Copy to avoid modifying original

        # Apply the curve style
        style = curveData.get("style", "-")
        ds_options["linestyle"] = style

        # Handle marker styles
        if style.startswith("o"):
            ds_options["marker"] = "o"
            ds_options["linestyle"] = "-" if len(style) > 1 else ""
        elif style.startswith("s"):
            ds_options["marker"] = "s"
            ds_options["linestyle"] = "-" if len(style) > 1 else ""
        elif style.startswith("^"):
            ds_options["marker"] = "^"
            ds_options["linestyle"] = "-" if len(style) > 1 else ""
        elif style.startswith("D"):
            ds_options["marker"] = "D"
            ds_options["linestyle"] = "-" if len(style) > 1 else ""
        elif style == ".":
            ds_options["marker"] = "."
            ds_options["linestyle"] = ""
        else:
            ds_options["marker"] = ""

        # Apply offset and factor to the data
        factor = curveData.get("factor", 1)
        offset = curveData.get("offset", 0)
        style = curveData.get("style", "-")
        print(
            f"DEBUG: onCurveAdded - curve {curveID}: style={style}, offset={offset}, factor={factor}"
        )
        if factor != 1 or offset != 0:
            new_y = numpy.multiply(ds[1], factor) + offset
            ds = [ds[0], new_y]

        # Plot and store the plot object associated with curveID:
        try:
            plot_obj = self.main_axes.plot(*ds, **ds_options)[0]
            self.plotObjects[curveID] = plot_obj
        except Exception as exc:
            print(str(exc))
        # Update plot
        self.updatePlot(update_title=True)
        # Add to the comboBox
        index = self.curveBox.count()  # Get the next index
        # Use the user-friendly label for display, but store curveID as data
        display_label = curveData.get("ds_options", {}).get("label", curveID)
        self.curveBox.addItem(display_label)
        # Store the curveID as item data for later retrieval
        self.curveBox.setItemData(index, curveID, QtCore.Qt.UserRole)
        file_path = curveData.get("file_path", "No file path available")
        self.curveBox.setItemData(index, file_path, QtCore.Qt.ToolTipRole)
        # Only select the new curve if it's the first one
        if self.curveBox.count() == 1:
            self.curveBox.setCurrentIndex(0)
            # Manually trigger curve selection for the first curve
            self.onCurveSelected(0)

        # Update any existing combo box items to use new curve ID format
        self.updateComboBoxCurveIDs()

    def onCurveUpdated(self, curveID, recompute_y=False, update_x=False):
        print(
            f"DEBUG: onCurveUpdated called for {curveID}, recompute_y={recompute_y}, update_x={update_x}"
        )
        curve_data = self.curveManager.getCurveData(curveID)
        if curve_data and recompute_y:
            factor = curve_data.get("factor", 1)
            offset = curve_data.get("offset", 0)
            ds = curve_data["ds"]
            new_y = numpy.multiply(ds[1], factor) + offset
            if curveID in self.plotObjects:
                self.plotObjects[curveID].set_ydata(new_y)
        if curve_data and update_x:
            # For x-data updates, we need to recreate the plot object to maintain style
            if curveID in self.plotObjects:
                # Remove the old plot object
                old_plot_obj = self.plotObjects[curveID]
                old_plot_obj.remove()
                del self.plotObjects[curveID]

            # Recreate the plot object with proper style
            ds = curve_data["ds"]
            ds_options = curve_data["ds_options"].copy()

            # Apply the curve style
            style = curve_data.get("style", "-")
            ds_options["linestyle"] = style

            # Handle marker styles
            if style.startswith("o"):
                ds_options["marker"] = "o"
                ds_options["linestyle"] = "-" if len(style) > 1 else ""
            elif style.startswith("s"):
                ds_options["marker"] = "s"
                ds_options["linestyle"] = "-" if len(style) > 1 else ""
            elif style.startswith("^"):
                ds_options["marker"] = "^"
                ds_options["linestyle"] = "-" if len(style) > 1 else ""
            elif style.startswith("D"):
                ds_options["marker"] = "D"
                ds_options["linestyle"] = "-" if len(style) > 1 else ""
            elif style == ".":
                ds_options["marker"] = "."
                ds_options["linestyle"] = ""
            else:
                ds_options["marker"] = ""

            # Apply offset and factor
            factor = curve_data.get("factor", 1)
            offset = curve_data.get("offset", 0)
            if factor != 1 or offset != 0:
                new_y = numpy.multiply(ds[1], factor) + offset
                ds = [ds[0], new_y]

            # Create new plot object
            try:
                plot_obj = self.main_axes.plot(*ds, **ds_options)[0]
                self.plotObjects[curveID] = plot_obj
            except Exception as exc:
                print(str(exc))

        self.updatePlot(update_title=False)

    def onRemoveButtonClicked(self):
        curveID = self.getSelectedCurveID()
        if curveID in self.curveManager.curves():
            curveID = self.getSelectedCurveID()
        if curveID in self.curveManager.curves():
            if len(self.curveManager.curves()) == 1:
                self.curveManager.removeAllCurves(doNotClearCheckboxes=False)
            else:
                self.curveManager.removeCurve(curveID)

    def onCurveRemoved(self, *arg):
        curveID, curveData, count = arg
        # Remove curve from graph & plotObject dict
        if curveID in self.plotObjects:
            curve_obj = self.plotObjects[curveID]
            curve_obj.remove()
            del self.plotObjects[curveID]
        # Remove checkbox from corresponding tableview
        row = curveData["row"]
        file_path = curveData["file_path"]
        tableview = self.mda_mvc.mda_file.tabPath2Tableview(file_path)
        if tableview and tableview.tableView.model():
            tableview.tableView.model().uncheckCheckBox(row)
        # Remove curve from comboBox
        self.removeItemCurveBox(curveID)
        # Update plot labels, legend and title
        self.updatePlot(update_title=False)
        # If this was the last curve for this file, remove the tab
        if count == 0 and self.mda_mvc.mda_file.mode() == "Auto-add":
            self.mda_mvc.mda_file.tabManager.removeTab(file_path)

    def onAllCurvesRemoved(self, doNotClearCheckboxes=True):
        # Clears the plot completely, removing all curve representations.
        self.clearPlot()
        for curveID in self.curveManager.curves().keys():
            self.curveManager.removeCurve(curveID)
        if not doNotClearCheckboxes:
            # Iterates over each tab, accessing its associated tableview to clear all checkbox selections.
            for index in range(self.mda_mvc.mda_file.tabWidget.count()):
                tableview = self.mda_mvc.mda_file.tabIndex2Tableview(index)
                if tableview and tableview.tableView.model():
                    tableview.tableView.model().clearAllCheckboxes()
                    tableview.tableView.model().setHighlightRow()

    def onDetRemoved(self, file_path, row):
        curveID = self.curveManager.findCurveID(file_path, row)
        if curveID:
            self.curveManager.removeCurve(curveID)

    def onTabRemoved(self, file_path):
        if self.mda_mvc.mda_file.mode() in ["Auto-add"]:
            for curveID in self.curveManager.curves().keys():
                if self.curveManager.curves()[curveID]["file_path"] == file_path:
                    self.curveManager.removeCurve(curveID)

    ########################################## UI methods:

    def plot(self, row, *ds, **options):
        """The main method called by MDA_MVC"""
        self.main_axes.axis("on")
        self.curveManager.addCurve(row, *ds, **options)

    def configPlot(self, grid=True):
        self.setLeftAxisText(self.ylabel())
        self.setBottomAxisText(self.xlabel())
        self.setPlotTitle(self.title())
        if grid:
            self.main_axes.grid(True, color="#cccccc", linestyle="-", linewidth=0.5)
        else:
            self.main_axes.grid(False)
        self.canvas.draw()

    def updatePlot(self, update_title=True):
        # Collect positioner PVs from all curves and update x label:
        x_label_set = set()
        for curveID in self.curveManager.curves():
            plot_options = self.curveManager.getCurveData(curveID).get("plot_options")
            x_label = plot_options.get("x", "")
            if x_label:
                x_label_set.add(x_label)
        self.setXlabel(", ".join(list(x_label_set)))

        # Simple y-axis label logic: match combo box text and add /I0 if I0 is toggled
        curveID = self.getSelectedCurveID()
        if curveID in self.curveManager.curves():
            self.updateBasicMathInfo(curveID)

            # Get the combo box text for the selected curve
            current_index = self.curveBox.currentIndex()
            if current_index >= 0:
                combo_text = self.curveBox.currentText()

                # Extract detector name from combo box text (remove file name)
                if ": " in combo_text:
                    detector_name = combo_text.split(": ", 1)[1]
                else:
                    detector_name = combo_text

                # Check if I0 is toggled on by looking at the plot_options y-label
                curve_data = self.curveManager.getCurveData(curveID)
                plot_options = curve_data.get("plot_options", {})
                y_label = plot_options.get("y", "")

                # Check if I0 normalization is active by looking for "/" in the y_label
                # But we need to check if this y_label corresponds to the current curve's detector
                if "/" in y_label and detector_name in y_label:
                    # This curve has I0 normalization, use the y_label
                    self.setYlabel(y_label)
                else:
                    # No I0 normalization or wrong y_label, use just the detector name
                    self.setYlabel(detector_name)
            else:
                self.setYlabel("")
        else:
            # No selected curve or curve not in manager, clear y-axis label
            self.setYlabel("")

        # Update title:
        if update_title:
            now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            self.setTitle(f"Plot Date & Time: {now}")
        # Recompute the axes limits and autoscale:
        self.main_axes.relim()
        self.main_axes.autoscale_view()
        self.updateLegend()
        self.configPlot()
        self.canvas.draw()

    def updateLegend(self):
        labels = self.main_axes.get_legend_handles_labels()[1]
        valid_labels = [label for label in labels if not label.startswith("_")]
        if valid_labels:
            self.main_axes.legend()

    def clearPlot(self):
        self.main_axes.clear()
        self.main_axes.axis("off")
        self.main_axes.set_title("")
        # Clear axis labels
        self.setXlabel("")
        self.setYlabel("")
        self.clearCursors()
        self.clearCursorInfo()
        self.clearBasicMath()
        self.figure.canvas.draw()
        self.plotObjects = {}
        self.curveBox.clear()

    def hasDataItems(self):
        # Return whether any artists have been added to the Axes (bool)
        return self.main_axes.has_data()

    ########################################## Interaction with UI elements:

    def onCurveSelected(self, index):
        # Get the curve ID from the combo box item data
        curveID = None
        if index >= 0:
            curveID = self.curveBox.itemData(index, QtCore.Qt.UserRole)

        # Update QLineEdit & QLabel widgets with the values for the selected curve
        if (
            curveID
            and curveID in self.plotObjects
            and curveID in self.curveManager.curves()
        ):
            curve_data = self.curveManager.getCurveData(curveID)
            file_path = curve_data["file_path"]
            row = curve_data["row"]
            self.offset_value.setText(str(curve_data["offset"]))
            self.factor_value.setText(str(curve_data["factor"]))
            self.curveBox.setToolTip(file_path)
            try:
                self.mda_mvc.mda_file.highlightRowInTab(file_path, row)
            except Exception as exc:
                print(str(exc))
                print("highlightRowInTab failed; ignoring exception.")
        else:
            self.offset_value.setText("0")
            self.factor_value.setText("1")
            self.curveBox.setToolTip("Selected curve")

        # Update basic math info:
        self.updateBasicMathInfo(curveID)

        # Update fit UI
        self.updateFitUI(curveID)

        # Update plot labels (including y-axis label)
        self.updatePlot(update_title=False)

    def updateFitUI(self, curveID: str) -> None:
        """
        Update fit UI based on curve selection.

        Parameters:
        - curveID: ID of the selected curve
        """
        # Update fit controls state
        has_curve = curveID in self.curveManager.curves()
        self.mda_mvc.mda_file_viz.updateFitControls(has_curve)

        # Update fit list
        self.updateFitList(curveID)

        # Update curve style combo box to show current curve's style
        if has_curve:
            self.updateCurveStyleComboBox(curveID)

        # Clear fit details if no curve selected or if switching to a different curve
        if not has_curve:
            self.mda_mvc.mda_file_viz.fitDetails.clear()
        else:
            # Clear fit when switching to a different curve
            current_fitted_curve = None
            for curve_id in self.curveManager.curves():
                if self.fitManager.hasFits(curve_id):
                    current_fitted_curve = curve_id
                    break

            # If we're switching to a different curve than the one with the fit, clear the fit
            if current_fitted_curve and current_fitted_curve != curveID:
                self.fitManager.removeFit(current_fitted_curve)
                self.mda_mvc.mda_file_viz.fitDetails.clear()

    def removeItemCurveBox(self, curveID):
        # Find the item by curve ID stored in item data
        for i in range(self.curveBox.count()):
            if self.curveBox.itemData(i, QtCore.Qt.UserRole) == curveID:
                self.curveBox.removeItem(i)
                break

    def updateComboBoxCurveIDs(self):
        """Update combo box items to use new curve ID format."""
        for i in range(self.curveBox.count()):
            old_curve_id = self.curveBox.itemData(i, QtCore.Qt.UserRole)
            display_text = self.curveBox.itemText(i)
            file_path = self.curveBox.itemData(i, QtCore.Qt.ToolTipRole)

            # Try to find the corresponding curve in the manager
            for curve_id, curve_data in self.curveManager.curves().items():
                if (
                    curve_data.get("file_path") == file_path
                    and curve_data.get("ds_options", {}).get("label") == display_text
                ):
                    # Found matching curve, update the combo box item
                    if old_curve_id != curve_id:
                        self.curveBox.setItemData(i, curve_id, QtCore.Qt.UserRole)
                    break

    def onOffsetUpdated(self):
        curveID = self.getSelectedCurveID()
        try:
            offset = float(self.offset_value.text())
        except ValueError:
            offset = 0
            # Reset to default if conversion fails
            self.offset_value.setText(str(offset))
            return
        self.curveManager.updateCurveOffset(curveID, offset)

    def onFactorUpdated(self):
        curveID = self.getSelectedCurveID()
        try:
            factor = float(self.factor_value.text())
        except ValueError:
            factor = 1
            # Reset to default if conversion fails or zero
            self.factor_value.setText(str(factor))
            return
        self.curveManager.updateCurveFactor(curveID, factor)

    ########################################## Basic maths methods:

    def updateBasicMathInfo(self, curveID):
        if curveID and curveID in self.curveManager.curves():
            try:
                curve_data = self.curveManager.getCurveData(curveID)
                x = curve_data["ds"][0]
                y = curve_data["ds"][1]
                stats = self.calculateBasicMath(x, y)
                for i, txt in zip(
                    stats, ["min_text", "max_text", "com_text", "mean_text"]
                ):
                    if isinstance(i, tuple):
                        result = f"({utils.num2fstr(i[0])}, {utils.num2fstr(i[1])})"
                    else:
                        result = f"{utils.num2fstr(i)}" if i else "n/a"
                    self.mda_mvc.findChild(QtWidgets.QLabel, txt).setText(result)
            except Exception as exc:
                print(str(exc))
                self.clearBasicMath()
        else:
            self.clearBasicMath()

    def clearBasicMath(self):
        for txt in ["min_text", "max_text", "com_text", "mean_text"]:
            self.mda_mvc.findChild(QtWidgets.QLabel, txt).setText("n/a")

    def calculateBasicMath(self, x_data, y_data):
        x_array = numpy.array(x_data, dtype=float)
        y_array = numpy.array(y_data, dtype=float)
        # Find y_min and y_max
        y_min = numpy.min(y_array)
        y_max = numpy.max(y_array)
        # Find the indices of the min and max y value
        y_min_index = numpy.argmin(y_array)
        y_max_index = numpy.argmax(y_array)
        # Find the corresponding x values for y_min and y_max
        x_at_y_min = x_array[y_min_index]
        x_at_y_max = x_array[y_max_index]
        # Calculate x_com and y_mean
        x_com = (
            numpy.sum(x_array * y_array) / numpy.sum(y_array)
            if numpy.sum(y_array) != 0
            else None
        )
        y_mean = numpy.mean(y_array)
        return (x_at_y_min, y_min), (x_at_y_max, y_max), x_com, y_mean

    ########################################## Cursors methods:

    def onRemoveCursor(self, cursor_num):
        cross = self.cursors.get(cursor_num)
        if cross:
            cross.remove()
            self.cursors[cursor_num] = None
            self.cursors[f"pos{cursor_num}"] = None
            self.cursors[f"text{cursor_num}"] = (
                "middle click or alt+right click" if cursor_num == 1 else "right click"
            )
        self.cursors["diff"] = "n/a"
        self.cursors["midpoint"] = "n/a"
        self.updateCursorInfo()
        # Recompute the axes limits and autoscale:
        self.main_axes.relim()
        self.main_axes.autoscale_view()
        self.canvas.draw()

    def clearCursors(self):
        self.onRemoveCursor(1)
        self.onRemoveCursor(2)

    def findNearestPoint(
        self, x_click: float, y_click: float
    ) -> Optional[tuple[float, float]]:
        """
        Find the nearest data point in the selected curve to the given click position.

        Parameters:
        - x_click: X coordinate of the click
        - y_click: Y coordinate of the click

        Returns:
        - Tuple of (x_nearest, y_nearest) if a curve is selected and has data, None otherwise
        """
        curveID = self.getSelectedCurveID()
        if not curveID or curveID not in self.curveManager.curves():
            return None

        curve_data = self.curveManager.getCurveData(curveID)
        if not curve_data:
            return None

        ds = curve_data.get("ds")
        if not ds or len(ds) < 2:
            return None

        x_data = ds[0]
        y_data = ds[1]

        # Ensure data are numpy arrays
        if not isinstance(x_data, numpy.ndarray):
            x_data = numpy.array(x_data, dtype=float)
        if not isinstance(y_data, numpy.ndarray):
            y_data = numpy.array(y_data, dtype=float)

        # Apply offset and factor to y_data to match what's displayed
        factor = curve_data.get("factor", 1)
        offset = curve_data.get("offset", 0)
        y_data = numpy.multiply(y_data, factor) + offset

        # Calculate distances to all points
        distances = numpy.sqrt((x_data - x_click) ** 2 + (y_data - y_click) ** 2)

        # Find the index of the nearest point
        nearest_index = numpy.argmin(distances)

        return (float(x_data[nearest_index]), float(y_data[nearest_index]))

    def onclick(self, event):
        # Check if the click was in the main_axes
        if event.inaxes is self.main_axes:
            # Find the nearest point in the selected curve
            nearest_point = self.findNearestPoint(event.xdata, event.ydata)

            if nearest_point is None:
                # No curve selected or no data available
                return

            x_nearest, y_nearest = nearest_point

            # Middle click or Alt+right click for red cursor (cursor 1)
            if event.button == MIDDLE_BUTTON or (
                event.button == RIGHT_BUTTON and self.alt_pressed
            ):
                if self.cursors[1]:
                    self.cursors[1].remove()  # Remove existing red cursor
                (self.cursors[1],) = self.main_axes.plot(
                    x_nearest, y_nearest, "r+", markersize=15, linewidth=2
                )
                # Update cursor position to nearest point
                self.cursors["pos1"] = (x_nearest, y_nearest)

            # Right click (without Alt) for blue cursor (cursor 2)
            elif event.button == RIGHT_BUTTON and not self.alt_pressed:
                if self.cursors[2]:
                    self.cursors[2].remove()  # Remove existing blue cursor
                (self.cursors[2],) = self.main_axes.plot(
                    x_nearest, y_nearest, "b+", markersize=15, linewidth=2
                )

                # Update cursor position to nearest point
                self.cursors["pos2"] = (x_nearest, y_nearest)

            # Update the info panel with cursor positions
            self.calculateCursors()

            # Redraw the canvas to display the new markers
            self.canvas.draw()

    def calculateCursors(self):
        """
        Update cursor information in info panel widget.
        """
        # Check for the first cursor and update text accordingly
        if self.cursors[1]:
            x1, y1 = self.cursors["pos1"]
            self.cursors["text1"] = f"({utils.num2fstr(x1)}, {utils.num2fstr(y1)})"
        # Check for the second cursor and update text accordingly
        if self.cursors[2]:
            x2, y2 = self.cursors["pos2"]
            self.cursors["text2"] = f"({utils.num2fstr(x2)}, {utils.num2fstr(y2)})"
        # Calculate differences and midpoints only if both cursors are present
        if self.cursors[1] and self.cursors[2]:
            delta_x = x2 - x1
            delta_y = y2 - y1
            midpoint_x = (x1 + x2) / 2
            midpoint_y = (y1 + y2) / 2
            self.cursors["diff"] = (
                f"({utils.num2fstr(delta_x)}, {utils.num2fstr(delta_y)})"
            )
            self.cursors["midpoint"] = (
                f"({utils.num2fstr(midpoint_x)}, {utils.num2fstr(midpoint_y)})"
            )
        self.updateCursorInfo()

    def updateCursorInfo(self):
        self.mda_mvc.mda_file_viz.pos1_text.setText(self.cursors["text1"])
        self.mda_mvc.mda_file_viz.pos2_text.setText(self.cursors["text2"])
        self.mda_mvc.mda_file_viz.diff_text.setText(self.cursors["diff"])
        self.mda_mvc.mda_file_viz.midpoint_text.setText(self.cursors["midpoint"])

    def clearCursorInfo(self):
        self.mda_mvc.mda_file_viz.pos1_text.setText("middle click or alt+right click")
        self.mda_mvc.mda_file_viz.pos2_text.setText("right click")
        self.mda_mvc.mda_file_viz.diff_text.setText("n/a")
        self.mda_mvc.mda_file_viz.midpoint_text.setText("n/a")

    ########################################## Fit methods:

    def onFitAdded(self, curveID: str) -> None:
        """
        Handle when a new fit is added.

        Parameters:
        - curveID: ID of the curve that was fitted
        """
        # Get fit data and plot the fit curve
        fit_data = self.fitManager.getFitCurveData(curveID)
        if fit_data:
            x_fit, y_fit = fit_data
            # Plot fit curve with dashed line style and higher z-order to ensure it's on top
            fit_line = self.main_axes.plot(
                x_fit, y_fit, "--", alpha=0.8, linewidth=2, zorder=10
            )[0]
            self.fitObjects[curveID] = fit_line

            # Update plot
            self.updatePlot(update_title=False)

            # Update fit details display
            self.updateFitDetails(curveID)

            # Emit signal for main window
            self.fitAdded.emit(curveID, "single_fit")

    def onFitUpdated(self, curveID: str) -> None:
        """
        Handle when a fit is updated.

        Parameters:
        - curveID: ID of the curve
        """
        # Remove existing fit line if any
        if curveID in self.fitObjects:
            self.fitObjects[curveID].remove()
            del self.fitObjects[curveID]

        # Add new fit line
        fit_data = self.fitManager.getFitCurveData(curveID)
        if fit_data:
            x_fit, y_fit = fit_data
            # Plot fit curve with dashed line style and higher z-order to ensure it's on top
            fit_line = self.main_axes.plot(
                x_fit, y_fit, "--", alpha=0.8, linewidth=2, zorder=10
            )[0]
            self.fitObjects[curveID] = fit_line

            # Update plot
            self.updatePlot(update_title=False)

            # Update fit details display
            self.updateFitDetails(curveID)

            # Emit signal for main window
            self.fitUpdated.emit(curveID, "single_fit")

    def onFitRemoved(self, curveID: str) -> None:
        """
        Handle when a fit is removed.

        Parameters:
        - curveID: ID of the curve
        """
        # Remove fit line from plot
        if curveID in self.fitObjects:
            self.fitObjects[curveID].remove()
            del self.fitObjects[curveID]

            # Update plot
            self.updatePlot(update_title=False)

            # Clear fit details display
            self.mda_mvc.mda_file_viz.fitDetails.clear()

            # Emit signal for main window
            self.fitRemoved.emit(curveID, "single_fit")

    def onFitVisibilityChanged(self, curveID: str, visible: bool) -> None:
        """
        Handle when fit visibility changes.

        Parameters:
        - curveID: ID of the curve
        - visible: Whether the fit should be visible
        """
        if curveID in self.fitObjects:
            self.fitObjects[curveID].set_visible(visible)
            self.canvas.draw()

    def updateFitList(self, curveID: str) -> None:
        """
        Update the fit list in the UI for a given curve.
        Note: This method is kept for compatibility but no longer needed with single-fit system.

        Parameters:
        - curveID: ID of the curve
        """
        # With single-fit system, we don't need a fit list
        # The fit details are shown directly when a fit exists
        pass

    def getCursorRange(self) -> Optional[tuple[float, float]]:
        """
        Get the range defined by cursors.

        Returns:
        - Tuple of (x_min, x_max) if both cursors are set, None otherwise
        """
        if "pos1" in self.cursors and "pos2" in self.cursors:
            if self.cursors["pos1"] is not None and self.cursors["pos2"] is not None:
                x1, y1 = self.cursors["pos1"]
                x2, y2 = self.cursors["pos2"]
                return (min(x1, x2), max(x1, x2))
        return None

    def performFit(self, model_name: str, use_range: bool = False) -> None:
        """
        Perform a fit on the currently selected curve.

        Parameters:
        - model_name: Name of the fit model to use
        - use_range: Whether to use cursor range for fitting
        """
        curveID = self.getSelectedCurveID()
        if not curveID or curveID not in self.curveManager.curves():
            return

        # Get curve data
        curve_data = self.curveManager.getCurveData(curveID)
        if not curve_data:
            return

        x_data = curve_data["ds"][0]
        y_data = curve_data["ds"][1]

        # Ensure data are numpy arrays with proper types
        if not isinstance(x_data, numpy.ndarray):
            x_data = numpy.array(x_data, dtype=float)
        if not isinstance(y_data, numpy.ndarray):
            y_data = numpy.array(y_data, dtype=float)

        # Check for valid data
        if len(x_data) == 0 or len(y_data) == 0:
            QtWidgets.QMessageBox.warning(
                self, "Fit Error", "No data available for fitting"
            )
            return

        if len(x_data) != len(y_data):
            QtWidgets.QMessageBox.warning(
                self, "Fit Error", "X and Y data have different lengths"
            )
            return

        # Apply offset and factor
        factor = curve_data.get("factor", 1)
        offset = curve_data.get("offset", 0)
        y_data = numpy.multiply(y_data, factor) + offset

        # Determine fit range
        x_range = None
        if use_range:
            x_range = self.getCursorRange()

        try:
            # Clear all existing fits from other curves before performing new fit
            all_curves = list(self.curveManager.curves().keys())
            for other_curve_id in all_curves:
                if other_curve_id != curveID and self.fitManager.hasFits(
                    other_curve_id
                ):
                    self.fitManager.removeFit(other_curve_id)

            # Perform the fit (this will replace any existing fit for the current curve)
            self.fitManager.addFit(curveID, model_name, x_data, y_data, x_range)

        except ValueError as e:
            # Show error message
            QtWidgets.QMessageBox.warning(self, "Fit Error", str(e))

    def updateFitDetails(self, curveID: str) -> None:
        """
        Update the fit details display.

        Parameters:
        - curveID: ID of the curve
        """
        fit_details = self.mda_mvc.mda_file_viz.fitDetails

        # Get fit data
        fit_data = self.fitManager.getFitData(curveID)
        if not fit_data:
            fit_details.clear()
            return

        # Format fit results
        result = fit_data.fit_result
        details_text = ""

        # Parameters
        details_text += "Parameters:\n"
        for param_name, param_value in result.parameters.items():
            details_text += f"  {param_name}: {utils.num2fstr(param_value)}\n"

        # Quality metrics
        details_text += "\nQuality Metrics:\n"
        details_text += f"  R²: {utils.num2fstr(result.r_squared)}\n"
        details_text += f"  χ²: {utils.num2fstr(result.chi_squared)}\n"
        details_text += f"  Reduced χ²: {utils.num2fstr(result.reduced_chi_squared)}\n"

        fit_details.setText(details_text)

    def clearAllFits(self) -> None:
        """Clear the fit from the currently selected curve."""
        curveID = self.getSelectedCurveID()
        if curveID:
            self.fitManager.removeFit(curveID)
            self.mda_mvc.mda_file_viz.fitDetails.clear()

    def updateCurveStyle(self, style_name: str) -> None:
        """
        Update the style of the currently selected curve.

        Parameters:
        - style_name: Name of the style to apply
        """
        curveID = self.getSelectedCurveID()
        if not curveID or curveID not in self.curveManager.curves():
            return

        # Get the matplotlib format string for the style
        style_map = self.mda_mvc.mda_file_viz.curve_styles
        if style_name not in style_map:
            return

        format_string = style_map[style_name]

        # Update the curve data with the new style
        curve_data = self.curveManager.getCurveData(curveID)
        if curve_data:
            old_style = curve_data.get("style", "-")
            print(
                f"DEBUG: Updating style for curve {curveID}: {old_style} -> {format_string}"
            )
            curve_data["style"] = format_string
            # Save to persistent storage
            persistent_key = (curve_data["file_path"], curve_data["row"])
            if persistent_key not in self.curveManager._persistent_properties:
                self.curveManager._persistent_properties[persistent_key] = {}
            self.curveManager._persistent_properties[persistent_key]["style"] = (
                format_string
            )
            print(
                f"DEBUG: Saved style to persistent storage: {persistent_key} -> {format_string}"
            )
            self.curveManager.updateCurve(curveID, curve_data)

            # Update the plot object with the new style
            if curveID in self.plotObjects:
                plot_obj = self.plotObjects[curveID]

                # Handle different style types properly
                if format_string == ".":
                    # Points only - no line
                    plot_obj.set_linestyle("")
                    plot_obj.set_marker(".")
                elif format_string == "o":
                    # Markers only - no line
                    plot_obj.set_linestyle("")
                    plot_obj.set_marker("o")
                elif format_string.startswith("o"):
                    # Line with circle markers
                    plot_obj.set_linestyle("-")
                    plot_obj.set_marker("o")
                elif format_string.startswith("s"):
                    # Line with square markers
                    plot_obj.set_linestyle("-")
                    plot_obj.set_marker("s")
                elif format_string.startswith("^"):
                    # Line with triangle markers
                    plot_obj.set_linestyle("-")
                    plot_obj.set_marker("^")
                elif format_string.startswith("D"):
                    # Line with diamond markers
                    plot_obj.set_linestyle("-")
                    plot_obj.set_marker("D")
                else:
                    # Regular line styles
                    plot_obj.set_linestyle(format_string)
                    plot_obj.set_marker("")

                # Update the plot
                self.updatePlot(update_title=False)

    def updateCurveStyleComboBox(self, curveID: str) -> None:
        """
        Update the curve style combo box to show the current curve's style.

        Parameters:
        - curveID: ID of the curve
        """
        curve_data = self.curveManager.getCurveData(curveID)
        if not curve_data:
            return

        # Get the current style (default to "Line" if not set)
        current_style = curve_data.get("style", "-")

        # Find the style name that matches the format string
        style_map = self.mda_mvc.mda_file_viz.curve_styles
        style_name = "Line"  # Default

        for name, fmt in style_map.items():
            if fmt == current_style:
                style_name = name
                break

        # Update the combo box (block signals to avoid triggering style change)
        self.mda_mvc.mda_file_viz.curveStyle.blockSignals(True)
        self.mda_mvc.mda_file_viz.curveStyle.setCurrentText(style_name)
        self.mda_mvc.mda_file_viz.curveStyle.blockSignals(False)


# ------ Curves management (data):


class CurveManager(QtCore.QObject):
    curveAdded = QtCore.pyqtSignal(str)  # Emit curveID when a curve is added
    curveRemoved = QtCore.pyqtSignal(str, dict, int)
    # Emit curveID & its corresponding data when a curve is removed, plus the
    # number of curves left on the graph for this file
    curveUpdated = QtCore.pyqtSignal(
        str, bool, bool
    )  # Emit curveID, recompute_y (bool) & update_x (bool) when a curve is updated
    allCurvesRemoved = QtCore.pyqtSignal(
        bool
    )  # Emit a doNotClearCheckboxes bool when all curve are removed

    def __init__(self, parent=None):
        super().__init__(parent)
        self._curves = {}  # Store curves with a unique identifier as the key
        # Persistent storage for curve properties across manager clears
        self._persistent_properties = {}  # key: (file_path, row), value: {style, offset, factor}

    def addCurve(self, row, *ds, **options):
        """Add a new curve to the manager if not already present on the graph."""
        # Extract info:
        plot_options = options.get("plot_options", {})
        ds_options = options.get("ds_options", {})
        label = ds_options.get("label", "unknown label")
        file_path = plot_options.get("filePath", "unknown path")
        # Generate unique curve ID & update options:
        curveID = self.generateCurveID(label, file_path, row)
        ds_options["label"] = label  # Keep the original label for display purposes
        print(
            f"DEBUG: Adding curve with ID: {curveID}, label: {label}, file_path: {file_path}"
        )
        print(f"DEBUG: Current curves in manager: {list(self._curves.keys())}")
        x_data = ds[0]
        if curveID in self._curves:
            print(f"DEBUG: Curve {curveID} already exists")
            # Check if x_data is the same
            existing_x_data = self._curves[curveID]["ds"][0]
            existing_label = (
                self._curves[curveID].get("ds_options", {}).get("label", "")
            )

            # Update the curve if x_data is different OR if the label has changed (I0 normalization)
            if numpy.array_equal(x_data, existing_x_data) and label == existing_label:
                print(" x_data and label are the same, do not add or update the curve")
                # x_data and label are the same, do not add or update the curve
                return
            else:
                print(" x_data is different OR label has changed, update the curve")
                # x_data is different or label has changed, update the curve:
                # Get existing curve data and preserve all properties
                existing_curve_data = self._curves[curveID]
                print(
                    f"DEBUG: Existing style: {existing_curve_data.get('style')}, offset: {existing_curve_data.get('offset')}, factor: {existing_curve_data.get('factor')}"
                )
                existing_curve_data["ds"] = ds
                existing_curve_data["plot_options"] = plot_options
                existing_curve_data["ds_options"] = ds_options  # Update the label
                # Preserve existing style, offset, factor, and other properties
                self.updateCurve(curveID, existing_curve_data, recompute_y=True)
                return
        else:
            print(f"DEBUG: Curve {curveID} does NOT exist, creating new curve")
            # Check if this might be a re-creation after removeAllCurves was called
            # Look for any existing curve with the same file_path and row
            for existing_curve_id, existing_data in self._curves.items():
                if (
                    existing_data["file_path"] == file_path
                    and existing_data["row"] == row
                ):
                    print(
                        f"DEBUG: Found existing curve with same file_path and row: {existing_curve_id}"
                    )
                    print(
                        f"DEBUG: Transferring properties: style={existing_data.get('style')}, offset={existing_data.get('offset')}, factor={existing_data.get('factor')}"
                    )
                    # Transfer properties from existing curve
                    self._curves[curveID] = {
                        "ds": ds,  # ds = [x_data, y_data]
                        "offset": existing_data.get("offset", 0),  # preserve offset
                        "factor": existing_data.get("factor", 1),  # preserve factor
                        "style": existing_data.get("style", "-"),  # preserve style
                        "row": row,  # DET checkbox row in the file tableview
                        "file_path": file_path,
                        "file_name": plot_options.get("fileName", ""),  # without ext
                        "plot_options": plot_options,
                        "ds_options": ds_options,
                    }
                    # Remove the old curve entry
                    del self._curves[existing_curve_id]
                    print(f"DEBUG: Transferred properties to new curve ID: {curveID}")
                    self.curveAdded.emit(curveID)
                    return
        # Add new curve if not already present on the graph:
        # Check for persistent properties first
        persistent_key = (file_path, row)
        persistent_props = self._persistent_properties.get(persistent_key, {})

        self._curves[curveID] = {
            "ds": ds,  # ds = [x_data, y_data]
            "offset": persistent_props.get("offset", 0),  # restore offset
            "factor": persistent_props.get("factor", 1),  # restore factor
            "style": persistent_props.get("style", "-"),  # restore style
            "row": row,  # DET checkbox row in the file tableview
            "file_path": file_path,
            "file_name": plot_options.get("fileName", ""),  # without ext
            "plot_options": plot_options,
            "ds_options": ds_options,
        }
        print(
            f"DEBUG: Created curve {curveID} with persistent properties: {persistent_props}"
        )
        self.curveAdded.emit(curveID)

    def updateCurve(self, curveID, curveData, recompute_y=False, update_x=False):
        """Update an existing curve."""
        if curveID in self._curves:
            print(f"Emits curveUpdated {curveID=}, {recompute_y=}, {update_x=}")
            self._curves[curveID] = curveData
            self.curveUpdated.emit(curveID, recompute_y, update_x)

    def removeCurve(self, curveID):
        """Remove a curve from the manager."""
        if curveID in self._curves:
            curveData = self._curves[curveID]
            file_path = curveData["file_path"]
            # Remove curve entry from self.curves & emit signal:
            del self._curves[curveID]
            # How many curves are left for this file:
            count = 0
            for curve_data in self._curves.values():
                if curve_data["file_path"] == file_path:
                    count += 1
            # Emit signal:
            self.curveRemoved.emit(curveID, curveData, count)

    def removeAllCurves(self, doNotClearCheckboxes=True):
        """Remove all curves from the manager."""
        self._curves.clear()
        self.allCurvesRemoved.emit(doNotClearCheckboxes)

    def getCurveData(self, curveID):
        """Get curve data by ID."""
        return self._curves.get(curveID, None)

    def curves(self):
        """Returns a read-only view of the currently managed curves."""
        return dict(self._curves)

    def updateCurveOffset(self, curveID, new_offset):
        curve_data = self.getCurveData(curveID)
        if curve_data:
            offset = curve_data["offset"]
            if offset != new_offset:
                print(
                    f"DEBUG: Updating offset for curve {curveID}: {offset} -> {new_offset}"
                )
                curve_data["offset"] = new_offset
                # Save to persistent storage
                persistent_key = (curve_data["file_path"], curve_data["row"])
                if persistent_key not in self._persistent_properties:
                    self._persistent_properties[persistent_key] = {}
                self._persistent_properties[persistent_key]["offset"] = new_offset
                print(
                    f"DEBUG: Saved offset to persistent storage: {persistent_key} -> {new_offset}"
                )
                self.updateCurve(curveID, curve_data, recompute_y=True)

    def updateCurveFactor(self, curveID, new_factor):
        curve_data = self.getCurveData(curveID)
        if curve_data:
            factor = curve_data["factor"]
            if factor != new_factor:
                print(
                    f"DEBUG: Updating factor for curve {curveID}: {factor} -> {new_factor}"
                )
                curve_data["factor"] = new_factor
                # Save to persistent storage
                persistent_key = (curve_data["file_path"], curve_data["row"])
                if persistent_key not in self._persistent_properties:
                    self._persistent_properties[persistent_key] = {}
                self._persistent_properties[persistent_key]["factor"] = new_factor
                print(
                    f"DEBUG: Saved factor to persistent storage: {persistent_key} -> {new_factor}"
                )
                self.updateCurve(curveID, curve_data, recompute_y=True)

    def generateCurveID(self, label, file_path, row):
        """
        Generates a unique curve ID based on file_path and row, ensuring the same detector
        always gets the same curve ID regardless of I0 normalization.

        Parameters:
        - label (str): The original label for the curve (used for display purposes)
        - file_path (str): The file path associated with the curve
        - row (int): The row number in the file tableview associated with the curve

        Returns:
        - str: A unique curve ID based on file_path and row
        """
        # Generate curve ID based on file_path and row
        curve_id = f"{file_path}_{row}"

        # Check if this curve ID already exists
        if curve_id in self._curves:
            # If it exists, return the existing curve ID (this should not happen in normal usage)
            return curve_id
        else:
            return curve_id

    def findCurveID(self, file_path, row):
        """
        Find the curveID based on the file path and row number.

        Parameters:
        - file_path (str): The path of the file associated with the curve.
        - row (int): The row number in the file tableview associated with the curve.

        Returns:
        - str: The curveID if a matching curve is found; otherwise, None.
        """
        for curveID, curveData in self._curves.items():
            if curveData["file_path"] == file_path and curveData["row"] == row:
                return curveID
        return None
