import time
from enum import Enum

import pyqtgraph as pg
from qgis.PyQt import QtCore
from qgis.PyQt.QtCore import QEvent, QObject, Qt
from qgis.PyQt.QtGui import QGuiApplication, QIcon
from qgis.PyQt.QtWidgets import QAction, QMenu, QSplitter, QToolBar, QWidget

from openlog.__about__ import DIR_PLUGIN_ROOT
from openlog.datamodel.assay.generic_assay import (
    AssayColumn,
    AssayDomainType,
    GenericAssay,
)
from openlog.datamodel.connection.openlog_connection import OpenLogConnection
from openlog.gui.assay_visualization.assay_plot_widget import AssayPlotWidget
from openlog.gui.assay_visualization.config.assay_visualization_config import (
    AssayVisualizationConfigList,
)
from openlog.gui.assay_visualization.stacked.stacked_config import (
    StackedConfiguration,
    StackedConfigurationList,
)
from openlog.toolbelt import PlgLogger, PlgTranslator


class AssaySort(Enum):
    ASSAY_NAME = "assay_name"
    HOLE_ID = "hole_id"


class PlotGrid(QSplitter):
    STACKED_KEY = "stacked"
    FLAT_KEY = "flat_splitter"

    def __init__(
        self,
        domain: AssayDomainType,
        visualization_config: AssayVisualizationConfigList,
        stacked_config: StackedConfigurationList,
        openlog_connection: OpenLogConnection = None,
        parent: QWidget = None,
    ) -> None:
        """
        QSplitter to display assay and stacked assay with sorting

        Args:
            domain: AssayDomainType assay domain type
            visualization_config: (AssayVisualizationConfigList) visualization configuration from AssayWidget
            stacked_config: (StackedConfigurationList) stacked configuration from AssayWidget
            openlog_connection: OpenLogConnection used to get assays
            parent: parent QWidget
        """
        super().__init__(parent)
        self.domain = domain
        self._openlog_connection = openlog_connection
        self.log = PlgLogger().log
        self.tr = PlgTranslator().tr

        self._visualization_config = visualization_config
        self._stacked_config = stacked_config

        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._stacked_plot_list = []

        self.enable_inspector_line_action = QAction(self.tr("Enable"))
        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(self.tr("Synchronization"))
        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
        )

    def add_action_to_toolbar(self, toolbar: QToolBar) -> None:
        # Add menu for inspector line
        inspector_line_menu = QMenu(self)
        inspector_line_menu.setIcon(
            QIcon(str(DIR_PLUGIN_ROOT / "resources" / "images" / "inspector_line.svg"))
        )
        inspector_line_menu.menuAction().setToolTip(self.tr("Inspector line"))
        inspector_line_menu.addAction(self.enable_inspector_line_action)
        inspector_line_menu.addAction(self.synchronize_inspector_line_action)

        toolbar.addAction(inspector_line_menu.menuAction())

    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()

        # 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_stacked_plot_widget(self) -> pg.PlotWidget:
        """
        Add plot widget for stacked configuration

        Returns: (pg.PlotWidget) created plot item

        """
        # Create plot widget
        plot = self._create_plot_widget(collar_stack=True)

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

    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():
                    nb_visible = nb_visible + 1
            self.widget(i).setVisible(nb_visible > 1)

    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), AssayPlotWidget)
                        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._sync_inspector_line(self.synchronize_inspector_line_action.isChecked())

    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:
            self._sort = sort
            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 refresh_grid(self) -> None:
        """
        Refresh grid by removing all plot splitter widget and creating grid from current options

        """

        # 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)

        # Sort visualization configuration list for correct insertion order in plot splitter
        reverse = True if self._sort_order == Qt.DescendingOrder else False
        if self._sort == AssaySort.HOLE_ID:
            config_list = sorted(
                self._visualization_config.list,
                key=lambda x: x.hole_display_name,
                reverse=reverse,
            )
        else:
            config_list = sorted(
                self._visualization_config.list,
                key=lambda x: x.assay_display_name
                if not x.hole_id
                else f"{x.assay_display_name}/{x.hole_display_name}",
                reverse=reverse,
            )

        # Sort stacked config list
        stacked_config_list = sorted(
            self._stacked_config.list, key=lambda x: x.name, reverse=reverse
        )

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

            if config.plot_stacked:
                self._get_plot_splitter(
                    assay_name, hole_id, config.assay_display_name
                ).addWidget(config.plot_stacked)

            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 stacked_config_list:
            if config.plot:
                self._get_plot_splitter(
                    self.STACKED_KEY, self.STACKED_KEY, self.STACKED_KEY
                ).addWidget(config.plot)

        # 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()

    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 == AssaySort.HOLE_ID 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 = QSplitter(self._get_plot_splitter_orientation())
            plot_layout.setChildrenCollapsible(False)
            # Add row/col to grid
            self.addWidget(plot_layout)
            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 == AssaySort.HOLE_ID else assay_name

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

        # Synchronization of splitter sizes if not stacked plot
        if hole_id != self.STACKED_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 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 == AssaySort.HOLE_ID:
            # No conversion of hole id if stacked plot
            if assay_name != self.STACKED_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 != "stacked" 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)

        """
        y_range = None
        for i in range(0, plot_layout.count()):
            if (
                isinstance(plot_layout.widget(i), pg.PlotWidget)
                and not plot_layout.widget(i).isHidden()
            ):
                bounds = plot_layout.widget(i).getViewBox().childrenBounds()
                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), pg.PlotWidget)
                and not plot_layout.widget(i).isHidden()
            ):
                bounds = plot_layout.widget(i).getViewBox().childrenBounds()
                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 _define_plot_splitter_sync_plot(
        self, sync_plot: pg.PlotWidget, plot_layout: QSplitter
    ) -> 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), pg.PlotWidget):
                self._define_plot_sync_plot(plot_layout.widget(i), sync_plot)

    def _define_plot_sync_plot(self, plot: pg.PlotWidget, sync_plot: pg.PlotWidget):
        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) -> pg.PlotWidget:
        """
        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), pg.PlotWidget)
                and not plot_layout.widget(i).isHidden()
            ):
                sync_plot = plot_layout.widget(i)
                break
        return sync_plot

    def _enable_inspector_line(self, enable: bool) -> None:
        self.synchronize_inspector_line_action.setEnabled(enable)
        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), AssayPlotWidget)
                    and plot_layout.widget(i).isVisible()
                ):
                    plot_layout.widget(i).enable_inspector_line(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) -> (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), AssayPlotWidget)
                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), AssayPlotWidget)
                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: AssayPlotWidget, 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), AssayPlotWidget)
                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, pg.PlotWidget) 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)
