import time

import pyqtgraph as pg
from qgis.PyQt import QtCore
from qgis.PyQt.QtCore import QEvent, QObject, Qt, QTimer, pyqtSignal
from qgis.PyQt.QtGui import QGuiApplication, QIcon
from qgis.PyQt.QtWidgets import (
    QAction,
    QActionGroup,
    QMenu,
    QSplitter,
    QToolBar,
    QToolButton,
    QWidget,
)

from openlog.__about__ import DIR_PLUGIN_ROOT
from openlog.datamodel.assay.generic_assay import (
    AssayColumn,
    AssayDomainType,
    AssaySeriesType,
    GenericAssay,
)
from openlog.datamodel.connection.openlog_connection import OpenLogConnection
from openlog.gui.assay_visualization.assay_plot_widget import AssayPlotWidget
from openlog.gui.assay_visualization.assay_sort import AssaySort
from openlog.gui.assay_visualization.config.assay_visualization_config import (
    AssayVisualizationConfigList,
)
from openlog.gui.assay_visualization.multiplot.multiplot_config import (
    MultiplotConfiguration,
    MultiplotConfigurationList,
)
from openlog.gui.assay_visualization.plot_container import PlotWidgetContainer
from openlog.plugins.extensions.crossplots.classes import CrossPlotConfigList
from openlog.plugins.extensions.stacked_charts.classes import StackedPlotConfigList
from openlog.plugins.manager import get_plugin_manager
from openlog.toolbelt import PlgLogger

plugin_manager = get_plugin_manager()


class CommonAxisLabel(pg.VerticalLabel):
    pass


class CustomSplitter(QSplitter):
    def addWidget(self, widget):

        if isinstance(widget, AssayPlotWidget):
            container = PlotWidgetContainer(main=widget, parent=self)
            super().addWidget(container)
        else:
            super().addWidget(widget)


class PlotGrid(QSplitter):
    MULTIPLOT_KEY = "Multiplots"
    CROSSPLOTS_KEY = "Crossplots"
    STACKED_KEY = "Stacked plots"
    FLAT_KEY = "flat_splitter"
    inspectorEnabledSignal = pyqtSignal(bool)

    def __init__(
        self,
        domain: AssayDomainType,
        visualization_config: AssayVisualizationConfigList,
        multiplot_config: MultiplotConfigurationList,
        crossplot_config: CrossPlotConfigList,
        stacked_config: StackedPlotConfigList,
        openlog_connection: OpenLogConnection = None,
        parent: QWidget = None,
    ) -> None:
        """
        QSplitter to display assay and multiplot assay with sorting

        Args:
            domain: AssayDomainType assay domain type
            visualization_config: (AssayVisualizationConfigList) visualization configuration from AssayWidget
            multiplot_config: (MultiplotConfigurationList) multiplot configuration from AssayWidget
            openlog_connection: OpenLogConnection used to get assays
            parent: parent QWidget
        """
        super().__init__(parent)
        self._parent = parent
        self.domain = domain
        self._openlog_connection = openlog_connection
        self.log = PlgLogger().log

        self._visualization_config = visualization_config
        self._multiplot_config = multiplot_config
        self._crossplot_config = crossplot_config
        self._stacked_config = stacked_config

        # assay sorter
        self.sorter = plugin_manager.get_azimuth_sorting_plugin().sorter(self)
        self._flat_grid = False
        self._sort = AssaySort.HOLE_ID
        self._sort_order = Qt.AscendingOrder

        # Plot layout
        if self.domain == AssayDomainType.TIME:
            orientation = Qt.Horizontal
        else:
            orientation = Qt.Vertical
        self.setOrientation(orientation)
        self.setChildrenCollapsible(False)

        self._plot_layout_map = {}
        self._plot_widget_map = {}
        self._multiplot_plot_list = []

        self.enable_inspector_line_action = QAction("Inspector line")
        self.enable_inspector_line_action.setIcon(
            QIcon(str(DIR_PLUGIN_ROOT / "resources" / "images" / "inspector_line.svg"))
        )
        self.enable_inspector_line_action.setCheckable(True)
        self.enable_inspector_line_action.setChecked(False)
        self.enable_inspector_line_action.triggered.connect(self._enable_inspector_line)

        self.synchronize_inspector_line_action = QAction("Synchronize")
        self.synchronize_inspector_line_action.setCheckable(True)
        self.synchronize_inspector_line_action.setChecked(False)
        self.synchronize_inspector_line_action.setEnabled(False)
        self.synchronize_inspector_line_action.triggered.connect(
            self._sync_inspector_line
        )

        self.project_inspector_enabled = (
            get_plugin_manager().get_inspector_plugin().enable
        )
        self.project_inspector_line_action = QAction("Project on canvas")
        self.project_inspector_line_action.setCheckable(True)
        self.project_inspector_line_action.setChecked(False)
        self.project_inspector_line_action.setEnabled(False)
        self.project_inspector_line_action.triggered.connect(
            self._project_inspector_line
        )

    def refresh_diagrams(self):
        """
        Refresh all diagrams.
        """
        for config in self._visualization_config.list:
            if config.hole_id == "":
                continue
            for col_name, col_config in config.column_config.items():

                col_config.enable_diagrams()

    def swap_widgets(self, widget1: AssayPlotWidget, widget2: AssayPlotWidget) -> None:
        """
        Swap 2 widgets. Method used for drag and drop operations.
        """

        # check widget rows
        if widget1.parent().parent() != widget2.parent().parent():
            return

        if widget1 == widget2:
            return

        current_order = self._get_list_of_displayed_widgets()
        index_1 = [
            index for index, widget in enumerate(current_order) if widget == widget1
        ][0]
        index_2 = [
            index for index, widget in enumerate(current_order) if widget == widget2
        ][0]

        # swap indexes
        current_order[index_1], current_order[index_2] = (
            current_order[index_2],
            current_order[index_1],
        )
        self.refresh_grid(current_order)

        # update sorting combobox
        if widget1 != widget2:
            self._parent.sort_menu.setCurrentIndex(4)

    def _get_list_of_displayed_widgets(self) -> list:
        """
        Return an ordered config list, as AssayVisualizationConfigList.list, corresponding to current displayed order.
        Plots are scanned by row.
        """
        result = list(
            reversed([x for x in self.findChildren(AssayPlotWidget) if x.isVisible()])
        )
        return result

    def add_action_to_toolbar(self, toolbar: QToolBar) -> None:
        # Add menu for inspector line
        inspector_line_button = QToolButton(self)
        inspector_line_button.setPopupMode(QToolButton.MenuButtonPopup)
        inspector_line_button.setCheckable(True)
        inspector_line_button.setDefaultAction(self.enable_inspector_line_action)
        inspector_menu = QMenu(self)
        inspector_menu.addAction(self.synchronize_inspector_line_action)
        # add button depending extension
        if self.domain == AssayDomainType.DEPTH:
            inspector_menu.addAction(self.project_inspector_line_action)
        if not self.project_inspector_enabled:
            self.project_inspector_line_action.setText(
                "Project on canvas (Premium only)"
            )
        inspector_line_button.setMenu(inspector_menu)

        toolbar.addWidget(inspector_line_button)

    def set_openlog_connection(self, openlog_connection: OpenLogConnection):
        """
        Define used OpenLogConnection

        Args:
            openlog_connection: OpenLogConnection
        """
        self._openlog_connection = openlog_connection

    def add_plot_widget(self, assay: GenericAssay) -> pg.PlotWidget:
        """
        Add plot widget for assay to plot grid

        Args:
            assay (GenericAssay):  assay to be displayed

        Returns: (pg.PlotWidget) created plot item

        """
        # Create plot widget for assay and collar
        plot = self._create_plot_widget()

        plot.plotItem.alignAxes.connect(self.update_sync_plot)
        # Add plot to layout
        self._get_plot_splitter(
            assay.assay_definition.variable,
            assay.hole_id,
            assay.assay_definition.display_name,
        ).addWidget(plot)
        return plot

    def add_crossplot_widget(self, plot_widget: pg.PlotWidget) -> None:
        """
        Add plot widget for crossplot configuration

        Returns: (pg.PlotWidget) created plot item

        """
        # Install event filter to override wheel commands if CTRL is pressed
        plot_widget.getViewBox().installEventFilter(self)
        plot_widget.installEventFilter(self)
        # plot_widget.enable_inspector_line(self.enable_inspector_line_action.isChecked())

        # Add plot to layout
        self._get_plot_splitter(
            self.CROSSPLOTS_KEY, self.CROSSPLOTS_KEY, self.CROSSPLOTS_KEY
        ).addWidget(plot_widget)
        return plot_widget

    def add_stacked_plot_widget(self, plot_widget: pg.PlotWidget) -> None:
        """
        Add plot widget for stacked configuration

        Returns: (pg.PlotWidget) created plot item

        """
        # Install event filter to override wheel commands if CTRL is pressed
        plot_widget.getViewBox().installEventFilter(self)
        plot_widget.installEventFilter(self)
        plot_widget.enable_inspector_line(self.enable_inspector_line_action.isChecked())

        # must align axes when this signal is emitted
        plot_widget.plotItem.alignAxes.connect(self.update_sync_plot)

        # Add plot to layout
        self._get_plot_splitter(
            self.STACKED_KEY, self.STACKED_KEY, self.STACKED_KEY
        ).addWidget(plot_widget)
        return plot_widget

    def add_multiplot_plot_widget(self, plot_widget: pg.PlotWidget) -> None:
        """
        Add plot widget for multiplot configuration

        Returns: (pg.PlotWidget) created plot item

        """
        # Install event filter to override wheel commands if CTRL is pressed
        plot_widget.getViewBox().installEventFilter(self)
        plot_widget.installEventFilter(self)
        plot_widget.enable_inspector_line(self.enable_inspector_line_action.isChecked())

        # must align axes when this signal is emitted
        plot_widget.plotItem.alignAxes.connect(self.update_sync_plot)

        # Add plot to layout
        self._get_plot_splitter(
            self.MULTIPLOT_KEY, self.MULTIPLOT_KEY, self.MULTIPLOT_KEY
        ).addWidget(plot_widget)
        return plot_widget

    def update_empty_cells_visibility(self) -> None:
        """
        Update visibility for empty cells (if only row/col label is visible)

        """
        #  In flat grid always display the only row/col available : no check of number of widget
        if self._flat_grid:
            return

        for i in reversed(range(0, self.count())):
            nb_visible = 0
            for j in range(0, self.widget(i).count()):
                # Use isHidden instead of isVisible
                # Because isVisible check for current screen visibility
                # It can be not updated if events was not processed
                if not self.widget(i).widget(j).isHidden() and isinstance(
                    self.widget(i).widget(j), PlotWidgetContainer
                ):
                    nb_visible = nb_visible + 1
            self.widget(i).setVisible(nb_visible > 0)

    def automatic_limits(self) -> None:
        """
        Define automatic limits for each row/col

        """
        start = time.perf_counter()

        for row in range(0, self.count()):
            plot_layout = self.widget(row)

            if self.domain == AssayDomainType.DEPTH:
                range_ = self._get_visible_plot_items_y_range(plot_layout)
            else:
                range_ = self._get_visible_plot_items_x_range(plot_layout)

            if range_ is not None:
                for i in range(0, plot_layout.count()):
                    if (
                        isinstance(plot_layout.widget(i), PlotWidgetContainer)
                        and plot_layout.widget(i).isVisible()
                    ):
                        plot_layout.widget(i).set_limits(
                            min_domain_axis=range_[0], max_domain_axis=range_[1]
                        )

                self.log(message=f"New automatic limit : {range_[0]} / {range_[1]}")

        self.log(
            message=f"Automatic limit updated in {(time.perf_counter() - start) * 1000.0} ms"
        )

    def update_sync_plot(self) -> None:
        """
        Update synchronization plot for a visible plot and define plot with Y axis displayed

        """
        for row in range(0, self.count()):
            plot_layout = self.widget(row)
            if isinstance(plot_layout, QSplitter):
                sync_plot = self._get_first_visible_plot(plot_layout)

                if sync_plot:
                    self._define_plot_splitter_sync_plot(sync_plot, plot_layout)
                    sync_plot.showAxis("left")
                    self._update_sync_axis_label(sync_plot, plot_layout)

        self._sync_inspector_line(self.synchronize_inspector_line_action.isChecked())

    def _update_sync_axis_label(
        self, sync_plot: PlotWidgetContainer, layout: QSplitter
    ) -> None:
        """
        Update VerticalLabel used as sync axis label.
        """
        axis_label = sync_plot.getAxis("left").label.toPlainText()
        label_widget = layout.findChild(CommonAxisLabel)
        if label_widget:
            label_widget.setText(axis_label)
            # hide plot label
            sync_plot.showLabel("left", False)

    def set_full_range(self):
        """
        For each row (splitter), set union range to show all visible data.
        """

        splitters = self.findChildren(QSplitter)
        for splitter in splitters:

            ry = self._get_visible_plot_items_y_range(splitter)
            rx = self._get_visible_plot_items_x_range(splitter)

            if ry is None or rx is None:
                continue

            widgets = splitter.findChildren(PlotWidgetContainer)
            widgets = [widget for widget in widgets if widget.isVisible()]
            for widget in widgets:
                if self.domain == AssayDomainType.DEPTH:
                    widget.setYRange(*ry)
                else:
                    widget.setXRange(*rx)

    def set_sort_order(self, sort_order: int) -> None:
        """
        Define sort order Qt.SortOrder

        Args:
            sort_order: Qt.SortOrder
        """
        if self._sort_order != sort_order:
            self._sort_order = sort_order
            self.refresh_grid()

    def set_assay_sort(self, sort: AssaySort) -> None:
        """
        Define assay sort for grid display

        Args:
            sort: (AssaySort) by collar or by assay
        """
        if self._sort != sort or sort == AssaySort.AZIMUTH:
            self._sort = sort
            if sort != AssaySort.UNDEFINED:
                self.refresh_grid()

    def enable_flat_grid(self, enabled: bool) -> None:
        """
        Enable flat grid (no row/columns but only one row/column with all assays with current order options)

        Args:
            enabled:
        """
        if self._flat_grid != enabled:
            self._flat_grid = enabled
            self.refresh_grid()

    def _display_plots_from_config_list(self):

        config_list = self.sorter.sort_assay()
        multiplot_config_list = self.sorter.sort_multiplot_assay()
        crossplot_config_list = self.sorter.sort_crossplots()
        stacked_config_list = self.sorter.sort_stacked()

        # Add created plot to splitter depending on sort
        for config in config_list:
            assay_name = config.assay_name
            hole_id = config.hole_id

            for col, col_config in config.column_config.items():
                if col_config.plot_widget:

                    self._get_plot_splitter(
                        assay_name, hole_id, config.assay_display_name
                    ).addWidget(col_config.plot_widget)

        for config in multiplot_config_list:
            if config.plot:
                self._get_plot_splitter(
                    self.MULTIPLOT_KEY, self.MULTIPLOT_KEY, self.MULTIPLOT_KEY
                ).addWidget(config.plot)

        for config in crossplot_config_list:
            if config.plot:
                self._get_plot_splitter(
                    self.CROSSPLOTS_KEY, self.CROSSPLOTS_KEY, self.CROSSPLOTS_KEY
                ).addWidget(config.plot)

        for config in stacked_config_list:
            if config.plot:
                self._get_plot_splitter(
                    self.STACKED_KEY, self.STACKED_KEY, self.STACKED_KEY
                ).addWidget(config.plot)

    def _display_plots_from_widget_list(self, widget_list: list[AssayPlotWidget]):
        n_widget = {}
        for key, splitter in self._plot_layout_map.items():
            visible_widgets = [
                w for w in splitter.findChildren(AssayPlotWidget) if w in widget_list
            ]

            # remove widgets
            n_widget[key] = len(visible_widgets)

        for widget in widget_list:
            widget.parent().setParent(None)

        for key, splitter in self._plot_layout_map.items():

            for widget in range(n_widget[key]):
                splitter.addWidget(widget_list.pop(0))

    def refresh_grid(self, widget_list: list = None) -> None:
        """
        Refresh grid by removing all plot splitter widget and creating grid from current options

        """

        if widget_list is not None:
            self._display_plots_from_widget_list(widget_list)
        else:
            # Remove all plots from row/col
            for key, plot_layout in self._plot_layout_map.items():
                for i in reversed(range(0, plot_layout.count())):
                    plot_layout.widget(i).setParent(None)
                plot_layout.deleteLater()

            self._plot_layout_map.clear()

            # Delete row/col splitter
            while self.count():
                self.widget(0).deleteLater()
                self.widget(0).setParent(None)

            self._display_plots_from_config_list()

        self.refresh_diagrams()

        # Refresh limits for each row/col
        self.automatic_limits()

        # Update empty row/col visibility
        self.update_empty_cells_visibility()

        # Define new synchronization plot
        self.update_sync_plot()

        # resize widgets in each QSplitter (equal space)
        ## use of a timer to wait rendering
        QTimer().singleShot(0, self._resize_widgets)

        self.set_full_range()

        self._clean_separators()

    def _clean_separators(self) -> None:
        """
        Hide separator supposed to be not visible (probably Qt bug).
        Just call widget.setVisible(False) if widget.isVisible() return False.
        """

        for row in range(self.count()):
            for col in range(self.widget(row).count()):
                w = self.widget(row).widget(col)
                if isinstance(w, PlotWidgetContainer):
                    if not w.isVisible():
                        w.setVisible(False)

    def _resize_widgets(self):
        """
        Resize widgets to share equal space in each QSplitter.
        Axis label and VerticalLabel are taken into account.
        """

        # self.refresh_diagrams()

        splitters = self.findChildren(CustomSplitter)
        for splitter in splitters:

            widgets = [splitter.widget(i) for i in range(splitter.count())]
            width = splitter.size().width()

            if len(widgets) == 0:
                return

            # number of plots (columns) by widget
            n_plots = [
                w.number_of_columns if isinstance(w, PlotWidgetContainer) else 1
                for w in widgets
            ]
            widget_width = width / sum(n_plots)

            size_list = []
            for n_plot in n_plots:
                size_list.append(widget_width * n_plot)

            splitter.setSizes([int(size) for size in size_list])

            # resize widgets inside container
            for widget in widgets:
                if isinstance(widget, PlotWidgetContainer):
                    widget._resize_widgets()

    def _create_plot_widget(self, collar_stack=False) -> pg.PlotWidget:
        """
        Create plot widget depending on current domain

        Args:
            collar_stack (bool) : True if created plotWidget is for collar stack (multiple collar available), default False

        Returns: pg.PlotWidget

        """
        plot = AssayPlotWidget(domain=self.domain, collar_stack=collar_stack)

        # Install event filter to override wheel commands if CTRL is pressed
        plot.getViewBox().installEventFilter(self)
        plot.installEventFilter(self)
        plot.enable_inspector_line(self.enable_inspector_line_action.isChecked())
        return plot

    def _get_plot_splitter(
        self, assay_name: str, hole_id: str, assay_display_name: str
    ) -> QSplitter:
        """
        Get plot splitter to insert pg.PlotWidget from assay name and hole id depending on current sort

        Args:
            assay_name: (str) assay name
            hole_id: (str) hole id

        Returns: (QSplitter) plot splitter

        """
        # If flat grid, all plot goes in the same splitter
        if self._flat_grid:
            plot_layout = self._create_or_get_flat_grid_splitter()
        else:
            key = (
                hole_id
                if self._sort
                in [AssaySort.HOLE_ID, AssaySort.X, AssaySort.Y, AssaySort.AZIMUTH]
                else assay_name
            )
            if key not in self._plot_layout_map:
                plot_layout = self._create_plot_splitter(
                    assay_name, hole_id, assay_display_name
                )
            else:
                plot_layout = self._plot_layout_map[key]
        return plot_layout

    def _get_plot_splitter_orientation(self) -> int:
        """
        Get plot splitter orientation for current domain

        Returns: (int) Qt.Vertical or Qt.Horizontal

        """
        if self.domain == AssayDomainType.TIME:
            orientation = Qt.Vertical
        else:
            orientation = Qt.Horizontal
        return orientation

    def _create_or_get_flat_grid_splitter(self) -> QSplitter:
        """
        Create or get flat grid splitter

        Returns: (QSplitter) available or created flat grid splitter

        """
        if self.FLAT_KEY in self._plot_layout_map:
            plot_layout = self._plot_layout_map[self.FLAT_KEY]
        else:

            plot_layout = CustomSplitter(self._get_plot_splitter_orientation())
            plot_layout.setChildrenCollapsible(False)
            # Add row/col to grid
            self.addWidget(plot_layout)
            if self.domain == AssayDomainType.DEPTH:
                # add sync axis label
                dimension_label = CommonAxisLabel("", orientation="vertical")
                dimension_label.setStyleSheet("background-color: white; color: black;")
                dimension_label.setAlignment(Qt.AlignCenter)
                plot_layout.addWidget(dimension_label)
            self._plot_layout_map[self.FLAT_KEY] = plot_layout

        return plot_layout

    def _create_plot_splitter(
        self, assay_name: str, hole_id: str, assay_display_name: str
    ) -> QSplitter:
        """
        Create plot splitter from assay name and hole id depending on current sort

        Args:
            assay_name: (str) assay name
            hole_id: (str) hole id

        Returns: (QSplitter) plot splitter

        """
        key = (
            hole_id
            if self._sort in [AssaySort.HOLE_ID, AssaySort.X, AssaySort.Y]
            else assay_name
        )

        # Plot layout
        if self.domain == AssayDomainType.TIME:
            orientation_label = "horizontal"
        else:
            orientation_label = "vertical"
        plot_layout = CustomSplitter(self._get_plot_splitter_orientation())
        plot_layout.setChildrenCollapsible(False)

        # Synchronization of splitter sizes if not multiplot plot
        if hole_id != self.MULTIPLOT_KEY:
            plot_layout.splitterMoved.connect(
                lambda pos, index: self._plot_splitter_moved(plot_layout)
            )

        # Add label for row/col
        label_text = self._get_plot_splitter_label_text(assay_display_name, hole_id)
        label = pg.VerticalLabel(label_text, orientation=orientation_label)
        label.setAlignment(Qt.AlignCenter)
        plot_layout.addWidget(label)

        # add sync axis label
        if self.domain == AssayDomainType.DEPTH:
            dimension_label = CommonAxisLabel("", orientation=orientation_label)
            dimension_label.setStyleSheet("background-color: white; color: black;")
            dimension_label.setAlignment(Qt.AlignCenter)
            plot_layout.addWidget(dimension_label)

        # Add row/col to grid
        self.addWidget(plot_layout)
        self._plot_layout_map[key] = plot_layout

        return plot_layout

    def _get_plot_splitter_label_text(self, assay_name: str, hole_id: str) -> str:
        """
        Return plot splitter label text depending on current sort

        Args:
            assay_name: (str) assay name
            hole_id: (str) hole id

        Returns: (str) plot splitter label text

        """
        if self._sort in [AssaySort.HOLE_ID, AssaySort.X, AssaySort.Y]:
            # No conversion of hole id if multiplot plot
            if assay_name != self.MULTIPLOT_KEY:
                label_text = (
                    self._openlog_connection.get_read_iface().get_collar_display_name(
                        hole_id
                    )
                )
            else:
                label_text = hole_id
        else:
            label_text = assay_name
        return label_text

    def _plot_splitter_moved(self, plot_splitter: QSplitter) -> None:
        """
        Plot splitter size synchronization

        Args:
            plot_splitter: (QSplitter) updated plot splitter
        """
        for key, plot_layout in self._plot_layout_map.items():
            if key != "multiplot" and plot_layout != plot_splitter:
                not_null_current_size = [i for i in plot_layout.sizes() if i != 0]
                not_null_updated_size = [i for i in plot_splitter.sizes() if i != 0]
                # Update size only if same number of not null row/col
                if len(not_null_current_size) == len(not_null_updated_size):
                    current_size = plot_layout.sizes()
                    val_index = 0
                    for i in range(0, len(current_size)):
                        if current_size[i] != 0:
                            current_size[i] = not_null_updated_size[val_index]
                            val_index = val_index + 1
                    plot_layout.setSizes(current_size)

    @staticmethod
    def _get_visible_plot_items_y_range(plot_layout: QSplitter) -> tuple:
        """
        Return y range for visible plot items

        Returns: y_range as tuple [min,max], None if no y range available (i.e no item displayed)

        """
        # QGuiApplication.processEvents()

        y_range = None
        for i in range(0, plot_layout.count()):
            if (
                isinstance(plot_layout.widget(i), PlotWidgetContainer)
                and not plot_layout.widget(i).isHidden()
            ):
                bounds = plot_layout.widget(i).get_data_bounds()
                plot_y_range = bounds[1]
                if plot_y_range is not None:
                    if y_range is None:
                        y_range = plot_y_range
                    else:
                        y_range[0] = min(y_range[0], plot_y_range[0])
                        y_range[1] = max(y_range[1], plot_y_range[1])
        return y_range

    @staticmethod
    def _get_visible_plot_items_x_range(plot_layout: QSplitter) -> tuple:
        """
        Return x range for visible plot items

        Returns: x_range as tuple [min,max], None if no x range available (i.e no item displayed)

        """
        x_range = None
        for i in range(0, plot_layout.count()):
            if (
                isinstance(plot_layout.widget(i), PlotWidgetContainer)
                and not plot_layout.widget(i).isHidden()
            ):
                bounds = plot_layout.widget(i).get_data_bounds()
                plot_x_range = bounds[0]
                if plot_x_range is not None:
                    if x_range is None:
                        x_range = plot_x_range
                    else:
                        x_range[0] = min(x_range[0], plot_x_range[0])
                        x_range[1] = max(x_range[1], plot_x_range[1])
        return x_range

    def _align_depth_axes(self, plot_layout: QSplitter) -> None:

        axis_ys = []
        # first, get lowest axis coordinates
        for i in range(0, plot_layout.count()):
            if isinstance(plot_layout.widget(i), PlotWidgetContainer):
                pw = plot_layout.widget(i).main
                if not pw.isVisible():
                    continue
                pw.plotItem.empty_item.setPreferredHeight(0)
                pw.plotItem.layout.setRowMinimumHeight(0, 0)
                pw.plotItem.layout.setRowMaximumHeight(0, 10000)
                axis_ys.append(pw.get_axis_position())

        if len(axis_ys) == 0:
            return

        # add height to other widgets axis
        max_y = max(axis_ys)
        # first, get lowest axis coordinates
        for i in range(0, plot_layout.count()):
            if isinstance(plot_layout.widget(i), PlotWidgetContainer):
                pw = plot_layout.widget(i).main
                if not pw.isVisible():
                    continue
                delta = max_y - pw.get_axis_position()
                if delta > 0:
                    pw.plotItem.empty_item.setPreferredHeight(delta)

    def _align_time_axes(self, plot_layout: QSplitter) -> None:

        axis_xs = []
        # first, get lowest axis coordinates
        for i in range(0, plot_layout.count()):
            if isinstance(plot_layout.widget(i), PlotWidgetContainer):
                pw = plot_layout.widget(i).main
                if not pw.isVisible():
                    continue
                pw.plotItem.empty_item.setPreferredWidth(0)
                pw.plotItem.layout.setColumnMinimumWidth(0, 0)
                pw.plotItem.layout.setColumnMaximumWidth(0, 10000)
                axis_xs.append(pw.get_axis_position())

        if len(axis_xs) == 0:
            return

        # add width to other widgets axis
        max_y = max(axis_xs)
        # first, get lowest axis coordinates
        for i in range(0, plot_layout.count()):
            if isinstance(plot_layout.widget(i), PlotWidgetContainer):
                pw = plot_layout.widget(i).main
                if not pw.isVisible():
                    continue
                delta = max_y - pw.get_axis_position()
                if delta > 0:
                    pw.plotItem.empty_item.setPreferredWidth(delta)

    def _align_axes(self, plot_layout: QSplitter) -> None:
        """
        Check if axes are aligned.
        Adjust alignment by increasing/decreasing empty item.
        """

        if self.domain == AssayDomainType.DEPTH:
            self._align_depth_axes(plot_layout)
        else:
            self._align_time_axes(plot_layout)

    def _define_plot_splitter_sync_plot(
        self, sync_plot: PlotWidgetContainer, plot_layout: QSplitter
    ) -> None:
        """
        Define synchronization plot for a plot splitter

        Args:
            sync_plot:
            plot_layout:
        """
        self._align_axes(plot_layout)

        for i in range(0, plot_layout.count()):
            if isinstance(plot_layout.widget(i), PlotWidgetContainer):
                self._define_plot_sync_plot(plot_layout.widget(i), sync_plot)

    def _define_plot_sync_plot(
        self, plot: PlotWidgetContainer, sync_plot: PlotWidgetContainer
    ):
        if plot:
            if self.domain == AssayDomainType.DEPTH:
                plot.hideAxis("left")
                plot.setYLink(sync_plot)
            else:
                plot.setXLink(sync_plot)

    @staticmethod
    def _get_first_visible_plot(plot_layout: QSplitter) -> PlotWidgetContainer:
        """
        Get first visible plot widget

        Returns: first visible plot widget, None if no plot widget visible

        """
        sync_plot = None
        for i in range(0, plot_layout.count()):
            if (
                isinstance(plot_layout.widget(i), PlotWidgetContainer)
                and not plot_layout.widget(i).isHidden()
            ):
                sync_plot = plot_layout.widget(i)
                break
        return sync_plot

    def _project_inspector_line(self) -> None:
        """
        Project inspector position on QGIS canvas.
        """

        self._visualization_config.project_inspector_lines(
            self.project_inspector_line_action.isChecked()
            and self.enable_inspector_line_action.isChecked()
        )

    def _enable_inspector_line(self, enable: bool) -> None:
        self.synchronize_inspector_line_action.setEnabled(enable)
        self.project_inspector_line_action.setEnabled(
            enable and self.project_inspector_enabled
        )

        for row in range(0, self.count()):
            plot_layout = self.widget(row)
            for i in range(0, plot_layout.count()):
                if (
                    isinstance(plot_layout.widget(i), PlotWidgetContainer)
                    and plot_layout.widget(i).isVisible()
                ):
                    plot_layout.widget(i).enable_inspector_line(enable)

        self._project_inspector_line()
        self.inspectorEnabledSignal.emit(enable)

    def _sync_inspector_line(self, enable: bool) -> None:

        for row in range(0, self.count()):
            plot_layout = self.widget(row)
            if isinstance(plot_layout, QSplitter):
                sync_bounds = self._get_inspector_bounds(plot_layout)
                sync_plot = self._get_first_visible_plot(plot_layout)
                if sync_plot:
                    self._plot_splitter_enable_sync_inspector_line(
                        sync_plot, plot_layout, enable
                    )
                self._set_inspector_bounds(sync_bounds, plot_layout, enable)

    def _get_inspector_bounds(self, plot_layout: QSplitter) -> tuple[float, float]:
        """
        Scan all individual inspector lines et return union min/max bounds
        """
        mins = []
        maxs = []
        for i in range(0, plot_layout.count()):
            if (
                isinstance(plot_layout.widget(i), PlotWidgetContainer)
                and not plot_layout.widget(i).isHidden()
            ):
                widget = plot_layout.widget(i)
                if widget.inspector._plot_item:
                    min_bound, max_bound = widget.inspector._getBounds()
                    mins.append(min_bound)
                    maxs.append(max_bound)
        mins = [x for x in mins if x is not None]
        maxs = [x for x in maxs if x is not None]
        if len(mins) == 0:
            res = (None, None)
        else:
            res = (min(mins), max(maxs))
        return res

    def _set_inspector_bounds(
        self, bounds: tuple, plot_layout: QSplitter, sync: bool
    ) -> None:
        """
        Set bounds to all inspector lines within plot_layout.
        If sync is False individual bounds are set, else it is bounds parameter.
        """
        if bounds[0] is None:
            return

        for i in range(0, plot_layout.count()):
            if (
                isinstance(plot_layout.widget(i), PlotWidgetContainer)
                and not plot_layout.widget(i).isHidden()
            ):
                widget = plot_layout.widget(i)
                if widget.inspector._plot_item:
                    if sync:
                        widget.inspector.setBounds(bounds)
                    else:
                        widget.inspector.setBounds(widget.inspector._getBounds())

    @staticmethod
    def _plot_splitter_enable_sync_inspector_line(
        sync_plot: PlotWidgetContainer, plot_layout: QSplitter, enable: bool
    ) -> None:
        """
        Define synchronization plot for a plot splitter

        Args:
            sync_plot:
            plot_layout:
        """
        for i in range(0, plot_layout.count()):
            if (
                isinstance(plot_layout.widget(i), PlotWidgetContainer)
                and not plot_layout.widget(i).isHidden()
            ):
                plot_layout.widget(i).enable_inspector_sync(sync_plot.inspector, enable)

    def eventFilter(self, obj: QObject, event: QEvent) -> bool:
        """
        Event filter for pg.PlotWidget view box to override wheel command if CTRL is pressed to allow pan of plot

        Args:
            obj: (QObject) object affected by this event
            event: (QEvent) event for the object

        Returns: False if event must be managed by object, False otherwise

        """
        crtl_clicked = QGuiApplication.keyboardModifiers() & QtCore.Qt.ControlModifier
        wheel_event = (
            event.type() == QEvent.Wheel or event.type() == QEvent.GraphicsSceneWheel
        )

        # If wheel + CTRL : pan range
        if wheel_event and crtl_clicked and isinstance(obj, pg.ViewBox):
            # Ge initial range to define translation values
            ranges = obj.viewRange()
            if self.domain == AssayDomainType.TIME:
                multiplier = 1 if event.delta() < 0 else -1
                pan_x = (
                    (ranges[0][1] - ranges[0][0])
                    * obj.state["wheelScaleFactor"]
                    * multiplier
                )
                pan_y = None
            else:
                multiplier = 1 if event.delta() > 0 else -1
                pan_x = None
                pan_y = (
                    (ranges[1][1] - ranges[1][0])
                    * obj.state["wheelScaleFactor"]
                    * multiplier
                )
            obj.translateBy(None, pan_x, pan_y)
            return True

        e_type = event.type()
        if isinstance(obj, PlotWidgetContainer) and (
            e_type == QEvent.Show
            or e_type == QEvent.Hide
            or e_type == QEvent.ShowToParent
            or e_type == QEvent.HideToParent
        ):
            self.update_empty_cells_visibility()
            self.update_sync_plot()
            self.automatic_limits()

        return super().eventFilter(obj, event)
