from typing import Dict, List

import sys
import os
import json
import yaml
import typing

from . plot_control import SurfacePlotWidget
from . parameter_editor import ParameterEditor
try:
    from chisurf.gui.tools.code_editor import CodeEditor
except:
    from . qsci_editor import CodeEditor
try:
    from chisurf import logging
except:
    import logging
    logging.basicConfig()

from . data_source import DataSource

import pathlib

try:
    from chisurf.gui import QtGui, QtCore, uic, QtWidgets
except ImportError:
    from qtpy import QtCore, uic
    from qtpy import QtGui, QtWidgets

from qwt.plot import QwtPlot

import guiqwt.signals
import guiqwt.plot
import guiqwt.image
import guiqwt.curve
import guiqwt.styles
from guiqwt.plot import CurveDialog
from guiqwt.builder import make

import numpy as np
import matplotlib.pyplot as plt
from matplotlib import cm, colormaps
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas

from . import reader
from . import writer


class NDXplorer(QtWidgets.QMainWindow):

    settings = dict()  # type: Dict
    equations = list()  # type: List[Dict[str, str]]
    constants = dict()  # type: Dict[str, float]
    _histogram = {
        "x": (),
        "y": (),
        "z": (),
        "2d": ()
    }
    _mask_inf = True  # type: bool
    _mask_nan = True  # type: bool
    _data_source = DataSource()  # type: DataSource
    _default_data_source = DataSource(
        ["Tau (green)", "Proximity ratio", "r Experimental (green)"],
        np.vstack(
            [
                np.random.multivariate_normal(
                    [4.1, 0.0, 0.05], [[0.1, 0.0, 0.0], [0.0, 0.01, 0.0], [0.0, 0.0, 0.01]], size=500
                ),
                np.random.multivariate_normal(
                    [2.0, 0.5, 0.15], [[0.1, 0.0, 0.0], [0.0, 0.01, 0.0], [0.0, 0.0, 0.01]], size=500
                )
            ]
        )
    )

    def invalidate_values_cache(self) -> None:
        """
        Manually clear the cached 'values'. Call this whenever something
        changes that would invalidate the mask or the data.
        """
        self._cached_values = None
        self._cached_values_selections = None
        self._cached_values_p13 = None
        self._cached_values_mask_inf = None
        self._cached_values_mask_nan = None

    @property
    def data_source(self) -> DataSource:
        if self._data_source.empty:
            values = self._default_data_source
        else:
            values = self._data_source
        return values

    @data_source.setter
    def data_source(self, v: DataSource) -> None:
        self._data_source = v
        # Whenever the underlying DataSource changes, invalidate the cached 'values'
        self.invalidate_values_cache()
        self._data_source.compute_columns(
            constants=self.constants,
            equations=self.equations
        )

    @property
    def x_values(self) -> np.ndarray:
        return self.values[self.plot_control.p1[0]].astype('float64')

    @property
    def y_values(self) -> np.ndarray:
        return self.values[self.plot_control.p2[0]].astype('float64')

    @property
    def z_values(self)-> np.ndarray:
        return self.values[self.plot_control.p3[0]].astype('float64')

    @property
    def values(self) -> np.ndarray:
        """
        Return a 2D array of data (selected columns only), applying the
        user-defined mask for Inf/NaN. The result is cached to avoid repeated
        computation when .values is accessed multiple times.
        """
        # Step 1: Gather current parameters relevant to our cache
        selections = self.plot_control.get_selections()
        p13 = (self.plot_control.p1[0], self.plot_control.p2[0], self.plot_control.p3[0])
        mask_inf = self._mask_inf
        mask_nan = self._mask_nan

        # Step 2: Check if our existing cache is still valid
        cache_is_valid = (
            self._cached_values is not None
            and self._cached_values_selections == selections
            and self._cached_values_p13 == p13
            and self._cached_values_mask_inf == mask_inf
            and self._cached_values_mask_nan == mask_nan
        )
        if cache_is_valid:
            return self._cached_values

        # Step 3: If cache is invalid or empty, compute fresh data
        all_values = self.data_source.values
        mask = self.data_source.get_mask(
            selections=selections,
            idxs=[p13[0], p13[1], p13[2]],
            mask_inf=mask_inf,
            mask_nan=mask_nan
        )

        x = np.ma.array(all_values, mask=mask)
        oCol, oRow = x.shape
        re = np.ma.compressed(x)
        nD = re.shape[0]
        re = re.reshape((oCol, int(nD / oCol)))

        # Step 4: Store in the cache for next time
        self._cached_values = re
        self._cached_values_selections = selections
        self._cached_values_p13 = p13
        self._cached_values_mask_inf = mask_inf
        self._cached_values_mask_nan = mask_nan

        return re

    @property
    def ymax(self) -> float:
        return max(self.y_values)

    @property
    def zmin(self):
        v = self.z_values[self.z_values > -np.inf]
        if self.plot_control.scale_z == "log":
            v = v[np.where(v > 0)[0]]
        return min(v)

    @property
    def zmax(self) -> float:
        return max(self.z_values)

    @property
    def working_path(self):
        return self.lineEditWorkingPath.text()

    @working_path.setter
    def working_path(self, v):
        if pathlib.Path(v).is_dir():
            self.lineEditWorkingPath.setText(v)

    @property
    def xmin(self) -> float:
        v = self.x_values[self.x_values > -np.inf]
        if self.plot_control.scale_x == "log":
            v = v[np.where(v > 0)[0]]
        return min(v)

    @property
    def xmax(self) -> float:
        return max(self.x_values)

    @property
    def ymin(self) -> float:
        v = self.y_values[self.y_values > -np.inf]
        if self.plot_control.scale_y == "log":
            v = v[np.where(v > 0)[0]]
        return min(v)

    @property
    def vmin(self):
        return self.doubleSpinBox_vmin.value() / 10.0

    @vmin.setter
    def vmin(self, v):
        return self.doubleSpinBox_vmin.setValue(v)

    @property
    def vmax(self):
        return self.doubleSpinBox_vmax.value() / 10.0

    @vmax.setter
    def vmax(self, v):
        return self.doubleSpinBox_vmax.setValue(v)

    @property
    def current_cmap(self) -> str:
        return self.comboBoxCmap.currentText()

    def update_cmap(self, cmap_name = None):
        """
        Update the colormap of the imshow plot based on the selected cmap.
        """
        if cmap_name is None:
            cmap_name = self.current_cmap
        self.cax.set_cmap(cmap_name)  # Update the colormap
        self.canvas.draw()  # Redraw the canvas

    def populate_colormap_combobox(self):
        """Populate the QComboBox with matplotlib colormap names."""
        colormap_names = sorted(colormaps.keys())  # Get all colormap names
        self.comboBoxCmap.addItems(colormap_names)  # Add them to the QComboBox

        # Set default selection
        if self.current_cmap in colormap_names:
            default_index = colormap_names.index(self.current_cmap)
            self.comboBoxCmap.setCurrentIndex(default_index)

    def on_vmin_vmax_changed(self):
        # Get current values from the spin boxes using the properties
        current_vmin = self.vmin  # this should read from doubleSpinBox_vmin.value()
        current_vmax = self.vmax  # similarly for doubleSpinBox_vmax.value()

        # Update the colormap limits for the 2D histogram image
        self.cax.set_clim(current_vmin, current_vmax)
        self.canvas.draw_idle()  # Redraw the canvas to reflect the change

    def set_default_colormap(self, default_cmap):
        """Set the default colormap in the QComboBox."""
        index = self.comboBoxCmap.findText(default_cmap)  # Find the index of 'jet'
        if index != -1:  # Ensure it exists in the list
            self.comboBoxCmap.setCurrentIndex(index)  # Set the QComboBox to 'jet'
            self.cax.set_cmap(default_cmap)  # Update the plot's colormap

    def __init__(
            self,
            data_source=None,  # type: DataSource
            settings_json_fn=None,  # type: str
            parent=None,
            cmap: str = 'turbo',
            theme_file = "theme.qss"
    ) -> None:
        if isinstance(data_source, DataSource):
            self._data_source = data_source
        super(NDXplorer, self).__init__(parent=parent)

        # Initialize the cache variables to None
        self._cached_values = None
        self._cached_values_selections = None
        self._cached_values_p13 = None
        self._cached_values_mask_inf = None
        self._cached_values_mask_nan = None

        self.plot_control = SurfacePlotWidget(self)
        self.equation_editor = CodeEditor(parent=self)
        uic.loadUi(os.path.dirname(__file__) + '/plot_main.ui', self)
        self.verticalLayout_3.addWidget(self.plot_control)
        self.verticalLayout_15.addWidget(self.equation_editor)

        # -------------------------------------------------------------
        # Load a stylesheet from an external file and apply it here
        # -------------------------------------------------------------
        theme_file = os.path.join(os.path.dirname(__file__), theme_file)
        # or prompt the user to pick a file with QFileDialog
        # theme_file, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Open Theme", "", "Style Sheets (*.qss)")
        if os.path.exists(theme_file):
            with open(theme_file, "r") as f:
                style_sheet = f.read()
            self.setStyleSheet(style_sheet)
        else:
            logging.warning(f"Theme file not found: {theme_file}")

        def save_cb():
            logging.log(0, "Save CB")
            json_str = self.equation_editor.text()
            self.equations = yaml.load(json_str)
        self.equation_editor.save_callback = save_cb

        self.populate_colormap_combobox()

        # Plots
        #############
        # z-axis
        win_z = CurveDialog()
        self.g_zplot = win_z.get_plot()
        curveparam = guiqwt.styles.CurveParam("Curve", icon='curve.png')
        curveparam.curvestyle = "Steps"
        curveparam.line.color = '#ff00ff'
        curveparam.shade = 0.5
        curveparam.line.width = 2.0
        self.g_zhist_m = guiqwt.curve.CurveItem(curveparam=curveparam)
        self.g_zplot.add_item(self.g_zhist_m)
        self.selection_z = make.range(.25, .5)
        self.g_zplot.add_item(self.selection_z)

        self.plot_control.verticalLayout_4.addWidget(self.g_zplot)

        # x-axis
        win_x = CurveDialog()
        self.g_xplot = win_x.get_plot()
        self.g_xplot.enableAxis(QwtPlot.xBottom, True)
        self.g_xplot.enableAxis(QwtPlot.xTop, False)
        self.g_xplot.enableAxis(QwtPlot.yLeft, False)
        self.g_xplot.enableAxis(QwtPlot.yRight, False)

        curveparam = guiqwt.styles.CurveParam("Curve", icon='curve.png')
        curveparam.curvestyle = "Steps"
        curveparam.line.color = '#0066cc'
        curveparam.shade = 0.5
        curveparam.line.width = 2.0
        self.g_xhist_m = guiqwt.curve.CurveItem(curveparam=curveparam)
        self.g_xplot.add_item(self.g_xhist_m)
        self.verticalLayout_5.addWidget(self.g_xplot)

        # y-axis
        win_y = CurveDialog()
        self.g_yplot = win_y.get_plot()
        self.g_yplot.enableAxis(QwtPlot.xBottom, False)
        self.g_yplot.enableAxis(QwtPlot.xTop, False)
        self.g_yplot.enableAxis(QwtPlot.yLeft, True)
        self.g_yplot.enableAxis(QwtPlot.yRight, False)

        curveparam = guiqwt.styles.CurveParam()
        curveparam.curvestyle = "Steps"
        curveparam.line.color = '#00ff00'
        curveparam.shade = 0.1
        curveparam.line.width = 2.0
        self.g_yhist_m = guiqwt.curve.CurveItem(curveparam=curveparam)
        self.g_yplot.add_item(self.g_yhist_m)
        self.verticalLayout_7.addWidget(self.g_yplot)

        # -------------------------------------------------------------
        # Force background colors to white
        # -------------------------------------------------------------
        # For guiqwt / QwtPlot-based plots:
        self.g_xplot.canvas().setStyleSheet("background-color: white;")
        self.g_yplot.canvas().setStyleSheet("background-color: white;")
        self.g_zplot.canvas().setStyleSheet("background-color: white;")

        # 2D-Histogram
        # Create a matplotlib figure and axis
        fig, ax = plt.subplots()
        self.fig = fig
        d = np.ones((200, 200))
        d[120, 120] = 250
        d[150, 120] = 250
        # Display the data as an image using imshow
        self.cax = ax.imshow(d, cmap='hot', interpolation='nearest')

        self.set_default_colormap(cmap)

        # Create a FigureCanvas object to integrate matplotlib with PyQt
        self.canvas = FigureCanvas(fig)

        # Remove axes
        ax.axis('off')

        # Set aspect ratio to 'auto' to allow resizing
        ax.set_aspect('auto')

        # Adjust the layout to fill the widget
        self.fig.subplots_adjust(left=0, right=1, top=1, bottom=0)

        # Show the initial plot
        self.canvas.draw()

        # Add the widget to your PyQt layout
        self.verticalLayout_11.addWidget(self.canvas)

        self.g_xplot.setMaximumHeight(150)
        self.g_yplot.setMaximumWidth(150)
        self.g_zplot.setMaximumHeight(150)

        # Load settings
        ###############
        if settings_json_fn is None:
            settings_json_fn = pathlib.Path(__file__).parent / "settings" / "mfd.settings.json"
        self.onLoad_settings(settings_json_fn=str(settings_json_fn))

        # Parameter control
        ######################
        def parameter_update():
            self.constants = self.parameter_control.dict
            self.data_source.compute_columns(
                constants=self.constants,
                equations=self.equations
            )
            self.update_plots()
        self.parameter_control = ParameterEditor(
            parent=self,
            json_file=str(pathlib.Path(__file__).parent / "settings/mfd.constants.json"),
            callback=parameter_update
        )
        self.verticalLayout_4.addWidget(self.parameter_control)

        # Actions
        #############
        # Working path
        self.actionSelect_working_path.triggered.connect(self.onSelectWorkingPath)

        # Load / Save
        self.actionOpenChiSurfSampling.triggered.connect(self.onOpenChiSurfSampling)
        self.actionOpenParisDataset.triggered.connect(self.onOpenSmFRET)
        self.actionOpenCsv.triggered.connect(self.onOpenCsv)
        self.actionBurst_IDs.triggered.connect(self.onSaveBurstIDs)

        # Settings
        self.actionLoad_settings.triggered.connect(self.onLoad_settings)
        self.actionSave_axis_settings.triggered.connect(self.onSaveAxisSettings)
        # GUI updates
        self.actionUpdate_plot.triggered.connect(self.update_plots)
        self.actionClear_plot.triggered.connect(self.clear_plots)
        self.actionMask_toggle_changed.triggered.connect(self.onMaskChanged)
        # Axis range

        self.canvas.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
        self.canvas.customContextMenuRequested.connect(self.on_canvas_context_menu)

        # Assuming these combo boxes control the parameter selections for the 2D plot:
        self.plot_control.comboBoxSelX.currentIndexChanged.connect(self.update_spinbox_limits)
        self.plot_control.comboBoxSelY.currentIndexChanged.connect(self.update_spinbox_limits)

        # In your __init__ or setup method, after creating the spin boxes:
        self.doubleSpinBox_vmin.valueChanged.connect(self.on_vmin_vmax_changed)
        self.doubleSpinBox_vmax.valueChanged.connect(self.on_vmin_vmax_changed)

        ##########################################################
        #      Arrange Docks and window positions                #
        #      Window-controls tile, stack etc.                  #
        ##########################################################
        docks = [self.dockWidget_PlotControl, self.dockWidget_Parameters, self.dockWidget_Overlays]
        for i, d in enumerate(docks[:-1]):
            self.tabifyDockWidget(d, docks[i+1])
        self.dockWidget_PlotControl.raise_()
        self.dockWidget_Equations.setVisible(False)
        self.update()

    def clear_plots(self):
        logging.log(0, "clearing plots")
        # 1. Clear the user data => empty => fallback to _default_data_source
        self._data_source.clear()

        # 2. Clear the selection table so no old mask references remain
        #    (But remember, this does NOT fix comboBoxSelX/Y/Z)
        self.plot_control.onClearSelection()

        # 3. Update once so 'plot_control.update()' sees empty _data_source =>
        #    repopulates combo boxes with the default dataset columns
        self.update()

        # 4. Force combo box indices to match the default columns.
        #    Example: we want [ "Tau (green)", "Proximity ratio", "r Experimental (green)" ]
        default_names = self._default_data_source.parameter_names
        ix_tau = default_names.index("Tau (green)")
        ix_prox = default_names.index("Proximity ratio")
        ix_r = default_names.index("r Experimental (green)")

        self.plot_control.comboBoxSelX.setCurrentIndex(ix_tau)
        self.plot_control.comboBoxSelY.setCurrentIndex(ix_prox)
        self.plot_control.comboBoxSelZ.setCurrentIndex(ix_r)

        # 5. Trigger a final update for correct plots
        self.update()

    def copy_1d_hists_to_clipboard_csv(self):
        try:
            x_hist = self._histogram["x"]  # tuple: (bin_edges, counts)
            y_hist = self._histogram["y"]
            z_hist = self._histogram["z"]
        except Exception as e:
            logging.error("1D histogram data not available: {}".format(e))
            return

        import io
        output = io.StringIO()

        # Extract histogram components for X, Y, and Z
        x_edges, x_counts = x_hist
        y_edges, y_counts = y_hist
        z_edges, z_counts = z_hist

        n_x = len(x_counts)
        n_y = len(y_counts)
        n_z = len(z_counts)
        n_rows = max(n_x, n_y, n_z)

        # Write header with columns for each histogram
        header = "\t".join([
            "X Bin Start", "X Bin End", "X Count",
            "Y Bin Start", "Y Bin End", "Y Count",
            "Z Bin Start", "Z Bin End", "Z Count"
        ])
        output.write(header + "\n")

        # Write each row
        for i in range(n_rows):
            if i < n_x:
                x_bin_start = f"{x_edges[i]:12.4e}"
                x_bin_end = f"{x_edges[i + 1]:12.4e}"
                x_count = f"{x_counts[i]:12.4e}"
            else:
                x_bin_start = x_bin_end = x_count = ""
            if i < n_y:
                y_bin_start = f"{y_edges[i]:12.4e}"
                y_bin_end = f"{y_edges[i + 1]:12.4e}"
                y_count = f"{y_counts[i]:12.4e}"
            else:
                y_bin_start = y_bin_end = y_count = ""
            if i < n_z:
                z_bin_start = f"{z_edges[i]:12.4e}"
                z_bin_end = f"{z_edges[i + 1]:12.4e}"
                z_count = f"{z_counts[i]:12.4e}"
            else:
                z_bin_start = z_bin_end = z_count = ""

            row = "\t".join([
                x_bin_start, x_bin_end, x_count,
                y_bin_start, y_bin_end, y_count,
                z_bin_start, z_bin_end, z_count
            ])
            output.write(row + "\n")

        csv_text = output.getvalue()
        output.close()

        # Copy the resulting CSV text to the clipboard
        clipboard = QtWidgets.QApplication.clipboard()
        clipboard.setText(csv_text)
        logging.log(0, "1D histograms data copied to clipboard as CSV with side-by-side columns.")

    def on_canvas_context_menu(self, pos):
        menu = QtWidgets.QMenu(self.canvas)
        action_csv = menu.addAction("Copy 2D Histogram (CSV)")
        #action_json = menu.addAction("Copy 2D Histogram (JSON)")
        action_csv1d = menu.addAction("Copy 1D Histograms (CSV)")
        action = menu.exec_(self.canvas.mapToGlobal(pos))
        if action == action_csv:
            self.copy_2d_hist_to_clipboard_csv()
        #elif action == action_json:
        #    self.copy_2d_hist_to_clipboard_json()
        elif action == action_csv1d:
            self.copy_1d_hists_to_clipboard_csv()

    def onMaskChanged(self) -> None:
        """
        Whenever the user toggles the Inf/NaN masks,
        invalidate the cache and re-plot.
        """
        self._mask_inf = self.checkBoxMaskInf.isChecked()
        self._mask_nan = self.checkBoxMaskNaN.isChecked()
        self.invalidate_values_cache()
        self.update_plots()

    def onSelectWorkingPath(self):
        working_path = QtWidgets.QFileDialog.getExistingDirectory(None, 'Select current path', self.working_path)
        self.lineEditWorkingPath.blockSignals(True)
        self.lineEditWorkingPath.setText(working_path)
        self.lineEditWorkingPath.blockSignals(False)

    def onSaveBurstIDs(self, evt=None, folder=None):
        if folder is None:
            folder = QtWidgets.QFileDialog.getExistingDirectory(
                None, 'Folder for Burst IDs', self.working_path
            )
        logging.info(f"Saving burst IDs to {folder}...")
        writer.save_burst_ids(
            folder_name=folder,
            selections=self.plot_control.get_selections(),
            data_source=self.data_source
        )

    def onSaveAxisSettings(
            self,
            settings_json_fn=None  # type: str
    ):
        if settings_json_fn is None:
            settings_json_fn = QtWidgets.QFileDialog.getSaveFileName(
                self ,
                'Axis settings file',
                self.working_path,
                'Axis file (*.axis.json)'
            )
        with open(settings_json_fn, "w") as fp:
            json.dump(
                self.plot_control.axis_settings,
                fp,
                indent=4
            )

    def onLoad_settings(
            self,
            settings_json_fn=None  # type: str
    ):
        if settings_json_fn is None:
            settings_json_fn = QtWidgets.QFileDialog.getOpenFileName(
                None, 'ndXplorer settings file', self.working_path, 'ndXplorer settings (*.settings.json)'
            )
        with open(settings_json_fn, "r") as fp:
            d = json.load(fp)
            self.settings.update(d)
        fn_axis = pathlib.Path(settings_json_fn).parent / self.settings["axis"]
        with open(str(fn_axis), "r") as fp:
            d = json.load(fp)
            self.plot_control.axis_settings.update(d)
        fn_equations = pathlib.Path(settings_json_fn).parent / self.settings["equations"]
        with open(str(fn_equations), "r") as fp:
            d = yaml.load(fp, Loader=yaml.FullLoader)
            self.equations = d
        fn_constants = pathlib.Path(settings_json_fn).parent / self.settings["constants"]
        with open(str(fn_constants), "r") as fp:
            d = json.load(fp)
            self.constants.update(d)
        self.equation_editor.load_file(str(fn_equations))

    def open_files(
            self,
            file_handles: typing.List[str] = None,
            file_type: str = None
    ):
        wp = str(self.working_path)
        if file_type in ["cs_sampling", "er4"]:
            if not hasattr(file_handles, '__iter__'):
                file_handles, _ = QtWidgets.QFileDialog.getOpenFileNames(self, 'ChiSurf sampling files', wp, 'Sampling files (*.*)')
            logging.log(0, "Opening files: {}".format(file_handles))
            data_reader = reader.read_csv_sampling
        elif file_type in ["burst_dir"]:
            file_handles = QtWidgets.QFileDialog.getExistingDirectory(None, 'Open burst analysis folder', self.working_path)
            data_reader = reader.read_burst_analysis
        else: #if file_type in [None, "csv"]:
            if file_handles is None:
                file_handles = QtWidgets.QFileDialog.getOpenFileNames(None, 'Comma separated value files', self.working_path, 'Text files (*.*)')
            data_reader = reader.read_csv
        if file_handles:
            # self.working_path = str(pathlib.Path(file_handles[0]).parent)
            self._data_source = data_reader(file_handles)
            self.update()

    def onOpenCsv(
            self,
            filenames: List[str] = None
    ):
        self.open_files(file_type="csv", file_handles=filenames)

    def onOpenChiSurfSampling(
            self,
            filenames=None  # type: List[str]
    ):
        self.open_files(file_type="cs_sampling", file_handles=filenames)

    def onOpenSmFRET(self):
        self.open_files(file_type="burst_dir")

    def update(self, *args, **kwargs):
        super(NDXplorer, self).update()
        self.data_source.compute_columns(
            constants=self.constants,
            equations=self.equations
        )
        self.lineEditCountTotal.setText(str(self.data_source.size))
        self.plot_control.update()  # plot_control.update() - also updates plots

    def update_parameter_names(self):
        p1, p1_name = self.plot_control.p1
        p2, p2_name = self.plot_control.p2
        self.g_yplot.set_axis_title("top", p2_name)
        self.g_xplot.set_axis_title("top", p1_name)

    def get_bins(self, arange, scale, n_1d, n_2d):
        xmin, xmax = arange
        # set log scales
        if scale == "log":
            if xmin <= 0:
                xmin = 1e-6
            if xmax <= 0:
                xmax = 1e-6
            x_func = np.logspace
            x_start = np.log10(xmin)
            x_stop = np.log10(xmax)
        else:
            x_func = np.linspace
            x_start = xmin
            x_stop = xmax
        x_bins_1d = x_func(x_start, x_stop, n_1d)
        x_bins_2d = x_func(x_start, x_stop, n_2d)
        return x_bins_1d, x_bins_2d

    def get_x_bins(self):
        return self.get_bins(
            self.plot_control.x_range,
            self.plot_control.scale_x,
            self.plot_control.n_xhist_1d,
            self.plot_control.n_xhist_2d
        )

    def get_y_bins(self):
        return self.get_bins(
            self.plot_control.y_range,
            self.plot_control.scale_y,
            self.plot_control.n_yhist_1d,
            self.plot_control.n_yhist_2d
        )

    def get_z_bins(self):
        bins = self.get_bins(
            self.plot_control.z_range,
            self.plot_control.scale_z,
            self.plot_control.n_zhist_1d,
            10
        )
        return bins

    def update_histograms(self):
        d1 = self.x_values
        d2 = self.y_values
        d3 = self.z_values

        # Update GUI - Number of displayed data points
        self.lineEditCountCurrent.setText(str(len(d1)))
        x_bins_1d, x_bins_2d = self.get_x_bins()
        y_bins_1d, y_bins_2d = self.get_y_bins()
        z_bins_1d, _ = self.get_z_bins()

        # X, Y, Z Histogram
        ###################
        self._histogram["x"] = np.histogram(d1, bins=x_bins_1d, density=self.plot_control.normed_hist_x)[::-1]
        self._histogram["y"] = np.histogram(d2, bins=y_bins_1d, density=self.plot_control.normed_hist_y)[::-1]
        self._histogram["z"] = np.histogram(d3, bins=z_bins_1d, density=self.plot_control.normed_hist_z)[::-1]

        # 2D Histogram
        ####################
        try:
            H, x_edges, y_edges = np.histogram2d(x=d1, y=d2, bins=[x_bins_2d, y_bins_2d], density=True)
            self._histogram["2d"] = H, x_edges, y_edges
        except ValueError:
            logging.log(1, "Did not compute 2D histogram")

    def copy_2d_hist_to_clipboard_json(self):
        try:
            H, x_edges, y_edges = self._histogram["2d"]
        except Exception as e:
            logging.error("No 2D histogram data available: {}".format(e))
            return

        # Convert NumPy arrays to lists for serialization
        data_dict = {
            "H": H.tolist(),
            "x_edges": x_edges.tolist(),
            "y_edges": y_edges.tolist()
        }
        # Serialize the data as a nicely formatted JSON string
        text_data = json.dumps(data_dict, indent=2)

        # Use Qt's clipboard to store the data
        clipboard = QtWidgets.QApplication.clipboard()
        clipboard.setText(text_data)
        logging.log(0, "2D histogram data copied to clipboard.")

    def copy_2d_hist_to_clipboard_csv(self):
        try:
            H, x_edges, y_edges = self._histogram["2d"]
        except Exception as e:
            logging.error("No 2D histogram data available: {}".format(e))
            return

        # Compute bin centers from bin edges
        x_centers = (x_edges[:-1] + x_edges[1:]) / 2
        y_centers = (y_edges[:-1] + y_edges[1:]) / 2

        import io
        output = io.StringIO()

        # Build header row with tabs and uniform formatting
        header_cells = ["y/x"] + [f"{x:10.4f}" for x in x_centers]
        output.write("\t".join(header_cells) + "\n")

        # Build rows: each row starts with the y center, then the corresponding histogram counts.
        # Note: H is assumed to be shaped (len(x_centers), len(y_centers)).
        for j, y in enumerate(y_centers):
            row_cells = [f"{y:10.4e}"]  # y center formatted uniformly
            for i in range(len(x_centers)):
                row_cells.append(f"{H[i, j]:10.4e}")
            output.write("\t".join(row_cells) + "\n")

        csv_text = output.getvalue()
        output.close()

        # Copy the nicely formatted CSV text to the clipboard
        clipboard = QtWidgets.QApplication.clipboard()
        clipboard.setText(csv_text)
        logging.log(0, "2D histogram data copied to clipboard as CSV (formatted with tabs).")

    def update_plots(self):
        # If there's no data (or fewer than 3 columns), display the background image
        if self._data_source.empty or self._data_source.values.shape[0] == 0:
            # Clear any old histogram displays
            self.g_xhist_m.set_data([], [])
            self.g_yhist_m.set_data([], [])
            self.g_zhist_m.set_data([], [])

            # Construct the path to the background image
            bg_path = os.path.join(os.path.dirname(__file__), 'ui', 'background.png')
            try:
                # Load the background image using matplotlib's imread
                bg_img = plt.imread(bg_path)
            except Exception as e:
                logging.error("Could not load background image: %s", e)
                bg_img = np.zeros((1, 1))  # fallback to an empty array if needed

            # Set the background image in the 2D histogram axis
            self.cax.set_data(bg_img)
            self.cax.autoscale()
            self.canvas.draw_idle()
            return

        # Update parameter names, colormap, and recalc histograms
        self.update_parameter_names()
        self.update_cmap()
        self.update_histograms()

        # ----------------------------------------------------
        # 1. X histogram
        # _histogram["x"] = (bin_edges, counts), reversed via [::-1] in your code
        x_bin_edges = self._histogram["x"][0]
        x_counts = self._histogram["x"][1]
        # Plot with X=bin_edges[1:], Y=counts
        self.g_xhist_m.set_data(x_bin_edges[1:], x_counts)

        # 2. Y histogram (rotated)
        # _histogram["y"] = (bin_edges, counts)
        y_bin_edges = self._histogram["y"][0]
        y_counts = self._histogram["y"][1]
        # Plot with X=counts, Y=bin_edges[1:]
        self.g_yhist_m.set_data(y_counts, y_bin_edges[1:])

        # 3. Z histogram
        # _histogram["z"] = (bin_edges, counts)
        z_bin_edges = self._histogram["z"][0]
        z_counts = self._histogram["z"][1]
        # Plot with X=bin_edges[1:], Y=counts
        self.g_zhist_m.set_data(z_bin_edges[1:], z_counts)

        # ----------------------------------------------------
        # Manually set axis scales to start at 0 for the count axis

        # X histogram => x-axis: bin edges, y-axis: counts
        y_max_x = np.max(x_counts) if len(x_counts) else 1
        self.g_xplot.setAxisScale(QwtPlot.yLeft, 0, y_max_x * 1.05)
        self.g_xplot.setAxisScale(QwtPlot.xBottom, x_bin_edges[0], x_bin_edges[-1])

        # Y histogram => x-axis: counts, y-axis: bin edges
        x_max_y = np.max(y_counts) if len(y_counts) else 1
        self.g_yplot.setAxisScale(QwtPlot.xBottom, 0, x_max_y * 1.05)
        self.g_yplot.setAxisScale(QwtPlot.yLeft, y_bin_edges[0], y_bin_edges[-1])

        # Z histogram => x-axis: bin edges, y-axis: counts
        y_max_z = np.max(z_counts) if len(z_counts) else 1
        self.g_zplot.setAxisScale(QwtPlot.yLeft, 0, y_max_z * 1.05)
        self.g_zplot.setAxisScale(QwtPlot.xBottom, z_bin_edges[0], z_bin_edges[-1])

        # ----------------------------------------------------
        # 2D histogram updates as before
        self.update_2d_plot()

        # ----------------------------------------------------
        # Finally replot everything
        self.g_xplot.replot()
        self.g_yplot.replot()
        self.g_zplot.replot()
        self.canvas.draw_idle()

    def update_spinbox_limits(self):
        """
        Recompute the 2D histogram limits based on the newly selected parameters
        and update the vmin/vmax spin boxes.
        """
        # First, recalc the histogram data for the new parameter selection.
        self.update_histograms()  # Make sure the histogram data (_histogram["2d"]) is updated

        try:
            new_data, x_edges, y_edges = self._histogram["2d"]
        except ValueError:
            return

        # Optionally apply logarithmic transformation if needed.
        if self.checkBoxLogCounts.isChecked():
            new_data = np.log10(new_data)
            new_data = np.nan_to_num(new_data)

        # If you’re displaying a rotated image (as in your update_2d_plot),
        # rotate the data accordingly.
        image_data = np.rot90(new_data, k=1)

        # Compute new limits from the histogram data.
        new_vmin = np.min(image_data)
        new_vmax = np.max(image_data)

        # Update the spin boxes using their setter properties.
        self.vmin = new_vmin
        self.vmax = new_vmax

        # Also update the colormap limits of the displayed image.
        self.cax.set_clim(new_vmin, new_vmax)
        self.canvas.draw_idle()

    def update_2d_plot(self):
        try:
            new_data, x_edges, y_edges = self._histogram["2d"]
        except ValueError:
            return None

        log_counts = self.checkBoxLogCounts.isChecked()
        if log_counts:
            new_data = np.log10(new_data)
            new_data = np.nan_to_num(new_data)

        # Update the data of the displayed image
        self.cax.set_data(np.rot90(new_data, k=1))

        # Set the intensity range using the vmin and vmax properties from the UI
        self.cax.set_clim(self.vmin, self.vmax)

        # Redraw the canvas to update the display
        self.canvas.draw_idle()

