import math
from typing import Dict, List

import pyqtgraph as pg
from pyqtgraph import InfiniteLine
from pyqtgraph.graphicsItems.ViewBox.ViewBoxMenu import ViewBoxMenu
from qgis.PyQt import QtCore, QtGui
from qgis.PyQt.QtCore import Qt, pyqtSignal

from openlog.core.pint_utilities import unit_conversion
from openlog.datamodel.assay.generic_assay import AssayDomainType
from openlog.gui.assay_visualization.assay_inspector_line import AssayInspectorLine
from openlog.gui.assay_visualization.config.assay_column_visualization_config import (
    AssayColumnVisualizationConfig,
)
from openlog.gui.assay_visualization.config.assay_visualization_config import (
    AssayVisualizationConfig,
)
from openlog.gui.assay_visualization.item_tags import (
    CategoricalLegendTag,
    LegendTag,
    SimpleLegendTag,
)
from openlog.gui.assay_visualization.minimap import DepthMiniMap, TimeMiniMap
from openlog.gui.assay_visualization.stacked.stacked_config import StackedConfiguration


class CustomItemSample(pg.ItemSample):
    def __init__(self, item):
        """
        custom pg.ItemSample used to override mouseClickEvent method over legend items.
        This class is used when instantiating a pg.LegendItem class.
        When a legend item is clicked, it check or uncheck visibility parameter of AssayColumnVisualizationConfig.
        """
        super().__init__(item)

    def _synchronize_scatterplot_visibility(self):
        """
        Since each brush is unique by category, we scan each pg.SpotItem and update visibility according to its brush.
        """
        is_visible = self.item.isVisible()
        brush = self.item.opts.get("brush")
        plot_item = self.topLevelWidget().items[0]
        spots = plot_item.points()
        for spot in spots:
            if spot.brush() == brush:
                spot.setVisible(is_visible)

    def mouseClickEvent(self, event):
        """
        If categoricalScatterPlotItem, we have to manually show/hide concerned spots.
        Else, it synchronize visibility with config
        """
        super().mouseClickEvent(event)
        if isinstance(self.item, pg.ScatterPlotItem):

            self._synchronize_scatterplot_visibility()

        else:
            is_visible = self.item.config.visibility_param.value()
            self.item.config.visibility_param.setValue(not is_visible)


class CustomVbMenu(ViewBoxMenu):
    """
    Subclass of ViewBoxMenu which is the contextual menu (right click).
    Override of autoRange method to autoRange properly with or without minimap (View all option).
    """

    def autoRange(self):
        plotitem = self.view().parentWidget()
        super().autoRange()
        if plotitem.minimap:
            plotitem.display_minimap(plotitem.minimap.isVisible())


class AssayPlotItem(pg.PlotItem):
    def __init__(
        self,
        parent=None,
        name=None,
        labels=None,
        title=None,
        viewBox=None,
        axisItems=None,
        enableMenu=True,
        collar_stack=False,
        **kargs,
    ):
        """
        pg.PlotItem override for automatic legend of plot graph

        Args:
            parent : see pg documentation
            name : see pg documentation
            labels : see pg documentation
            title : see pg documentation
            viewBox : see pg documentation
            axisItems : see pg documentation
            enableMenu : see pg documentation
            collar_stack (bool) : True if plotItem is for collar stack (multiple collar available), default False
            **kargs,
        """
        super().__init__(
            parent, name, labels, title, viewBox, axisItems, enableMenu, **kargs
        )
        self.collar_stack = collar_stack
        self.minimap = None

        # No legend if no item inserted
        self.legend = None

        self.set_menu()

        self.geometryChanged.connect(self._adjust_y_axis_font)

    def set_menu(self):
        vb = self.getViewBox()
        vb.menu = CustomVbMenu(vb)

    def _adjust_y_axis_font(self):
        """
        Method used to adjust y axis title font size, depending ViewBox geometry.
        """

        # y axis
        axis = self.getAxis("left")
        label = axis.label
        label_size = label.boundingRect().width()
        # get available height
        height = self.getViewBox().height()
        font_style = axis.labelStyle

        if not font_style.get("font-size"):
            return

        font_size = int(font_style["font-size"].replace("px", ""))
        if font_size == 0:
            font_size = 1

        # minimum font size : 9, max : 15
        new_font_size = math.ceil(font_size * height / label_size)
        new_font_size = max(9, new_font_size)
        new_font_size = min(15, new_font_size)

        font_style["font-size"] = str(new_font_size) + "px"
        blocker = QtCore.QSignalBlocker(axis.label)
        axis.labelStyle = font_style
        axis._updateLabel()
        blocker.unblock()

    def set_minimap(self, depth: bool = True):

        if depth:
            self.minimap = DepthMiniMap(parent=self)
            self.sigYRangeChanged.connect(self.minimap.onMove)
        else:
            self.minimap = TimeMiniMap(parent=self)
            self.sigXRangeChanged.connect(self.minimap.onMove)

        self.minimap.setVisible(False)

    def display_minimap(self, display: bool):

        # logarithm
        if isinstance(self.minimap, DepthMiniMap):
            x_log = self.ctrl.logXCheck.isChecked()
            self.minimap.ctrl.logXCheck.setChecked(x_log)
        else:
            y_log = self.ctrl.logYCheck.isChecked()
            self.minimap.ctrl.logYCheck.setChecked(y_log)

        # create a lag to display minimap without overlaping plot
        xmin, xmax = self.viewRange()[0]
        ymin, ymax = self.viewRange()[1]
        self.autoRange()
        new_xmin, new_xmax = self.viewRange()[0]
        new_ymin, new_ymax = self.viewRange()[1]
        if isinstance(self.minimap, DepthMiniMap):
            if display:
                self.setXRange(new_xmin - (new_xmax - new_xmin) * 0.05, new_xmax)
            self.setYRange(ymin, ymax)
        else:
            if display:
                self.setYRange(new_ymin - (new_ymax - new_ymin) * 0.05, new_ymax)
            self.setXRange(xmin, xmax)

        # adjust anchor again for eventual zero-length ticklength
        self.minimap.setOffset()
        self.minimap._setGeom()
        self.minimap.setVisible(display)

        # trick to refresh displaying without focus with mouse
        self.minimap.autoRange(padding=0)
        r = self.minimap.viewRange()
        if isinstance(self.minimap, DepthMiniMap):
            self.minimap.setXRange(*r[0])
            self.minimap.setYRange(*r[1], padding=0)
        else:
            self.minimap.setXRange(*r[0], padding=0)
            self.minimap.setYRange(*r[1])

        # set current limits to minimap
        self.minimap._setRange()

    def onMinimapMove(self):
        """
        Method (slot) triggered when minimap is dragged by the user.
        """
        blocker = QtCore.QSignalBlocker(self)
        region_bounds = self.minimap.lr.getRegion()
        if isinstance(self.minimap, DepthMiniMap):
            self.setYRange(*region_bounds)
        else:
            self.setXRange(*region_bounds)

        blocker.unblock()

    def _mapColor(self) -> None:
        """
        Assign color to an item via configuration.
        """
        items = [item for item in self.items if isinstance(item, LegendTag)]
        for index, item in enumerate(items):
            current_pen = item.config.pen_params.pen
            current_pen.setColor(pg.intColor(index))
            item.config.pen_params.pen = current_pen
            item.config._update_plot_item_color_ramp()

    def addItem(self, item, *args, **kargs):
        """Override pg.PlotItem addItem for automatic legend definition"""
        super().addItem(item, *args, **kargs)
        self.minimap.addItem(item)
        if isinstance(item, LegendTag):

            # Add legend if not available
            if self.legend is None:
                self.legend = pg.LegendItem(
                    offset=(-1, 50),
                    brush="white",
                    pen="black",
                    sampleType=CustomItemSample,
                )

            if isinstance(item, CategoricalLegendTag):
                self.legend.setParentItem(self)
                for legend_item, name in item.legend_items:
                    self.legend.addItem(legend_item, name)

            else:
                # check number of items
                n_item = len([it for it in self.items if isinstance(it, LegendTag)])

                # legend is displayed if there are multiple items
                if n_item > 1:
                    self._mapColor()
                    self.legend.setParentItem(self)

                collar_display_name, column_name = (
                    item.config.hole_display_name,
                    item.config.column.name,
                )
                # for stacked plot, label is "collar_id - column name"
                if self.collar_stack:
                    full_name = f"{collar_display_name} - {column_name}"
                else:
                    full_name = column_name

                # add legend idem
                self.legend.addItem(item, full_name)

    def removeItem(self, item):
        """Override pg.PlotItem removeItem for automatic item deletion from legend"""
        super().removeItem(item)

        # only "legendable" items are considered

        if self.legend is not None and isinstance(item, SimpleLegendTag):
            self.legend.removeItem(item)


class AssayPlotWidget(pg.PlotWidget):
    visibilityChanged = pyqtSignal(bool)

    def __init__(
        self,
        domain: AssayDomainType,
        parent=None,
        background="default",
        plotItem=None,
        collar_stack=False,
        **kargs,
    ):
        """
        pg.PlotWidget override to store domain and extra functions to define limits and display AssayInspectorLine

        Args:
            domain: AssayDomainType
            parent:
            background:
            plotItem:
            collar_stack (bool) : True if PlotWidget is for collar stack (multiple collar available), default False
            **kargs:
        """
        if plotItem is None:
            plotItem = AssayPlotItem(collar_stack=collar_stack, **kargs)
            plotItem.set_minimap(domain == AssayDomainType.DEPTH)

        super().__init__(parent, background, plotItem, **kargs)
        self.domain = domain
        self.collar_stack = collar_stack
        self.setBackground("white")
        # horizontal line to mark planned geometry limit
        self.red_line = InfiniteLine(
            pos=0, angle=0, pen=pg.mkPen("red", width=1, style=QtCore.Qt.DashLine)
        )

        # Define axis from domain
        if self.domain == AssayDomainType.TIME:
            axis = pg.DateAxisItem()
            self.setAxisItems({"bottom": axis})
            self.getViewBox().setMouseEnabled(y=False)
        else:
            styles = {"color": "black", "font-size": "15px"}
            self.setLabel("left", self.tr("Depth"), "m", **styles)
            self.showAxis("top")

            self.getViewBox().invertY(True)  # Y inversion for depth assay
            self.getViewBox().setMouseEnabled(x=False)

        # Add an inspector line (not attached by default)
        self.inspector = AssayInspectorLine(self, domain=self.domain)
        self._sync_inspector = None
        self._connection_to_sync_inspector = None
        self._connection_from_sync_inspector = None

        self.assay_config = None
        self.assay_column_config = None
        self.stacked_config = None

        # Disable plot options
        self.disable_plot_option()

        # Store displayed unit
        self._displayed_unit = ""

        self.setAcceptDrops(True)

    def showEvent(self, e):
        self.visibilityChanged.emit(True)
        super().showEvent(e)

    def hideEvent(self, e):
        self.visibilityChanged.emit(False)
        super().hideEvent(e)

    def deleteLater(self):
        """
        Need to remove PlotWidgetContainer as well.
        """
        super().deleteLater()
        if self.parent():
            self.parent().deleteLater()

    def mouseReleaseEvent(self, e):
        """
        mouseReleaseEvent override to remove first right click in drag and drop operation.
        If not overriden, contextual menu appear in the wrong location.
        """
        ce = self.scene().clickEvents
        pos = e.pos()
        to_remove = [
            x for x in ce if x.button() == Qt.RightButton and x.scenePos() != pos
        ]
        ce = [x for x in ce if x not in to_remove]
        self.scene().clickEvents = ce
        pg.PlotWidget.mouseReleaseEvent(self, e)

    def dropEvent(self, e):
        pos = e.pos()
        widget = e.source()
        self.parent().parent().parent().swap_widgets(self, widget)

    def dragEnterEvent(self, e):

        e.accept()

    def dragMoveEvent(self, e):

        e.accept()

    def mouseMoveEvent(self, e):

        if e.buttons() == Qt.RightButton:
            drag = QtGui.QDrag(self)
            mime = QtCore.QMimeData()
            drag.setMimeData(mime)
            dropAction = drag.exec(Qt.MoveAction)
        else:
            pg.PlotWidget.mouseMoveEvent(self, e)

    def get_container(self):
        """
        Get PlotWidgetContainer.
        """

        parent = self.parent()
        return parent

    def _switch_items(self, altitude: str) -> None:
        """
        Switch items in altitude/length.
        """
        if self.domain == AssayDomainType.TIME:
            return

        items = self.plotItem.items
        activated_inspector = (
            len([item for item in items if isinstance(item, AssayInspectorLine)]) > 0
        )
        self.inspector.dettach()
        red_line_pos = []
        for item in items:
            if item == self.red_line:
                continue
            # get items planned_eoh limit only if it is < to data limit
            value = None
            if (
                not item.switcher.is_empty
                and item.switcher.min_planned_alt
                and item.switcher.planned_eoh
            ):
                if item.switcher.min_planned_alt < item.switcher.planned_eoh:
                    value = item.switcher.planned_eoh
            red_line_pos.append(value)
            item.switcher.switch(altitude)
            self.plotItem.minimap.addItem(item)

        self.automatic_limits()
        self.inspector.switcher.switch(altitude)

        # display planned geometry limit if it intersect data
        if altitude == "planned":
            red_line_pos = [value for value in red_line_pos if value is not None]

            if len(red_line_pos) > 0:
                pos = red_line_pos[0]
                self.red_line.setPos(pos)
                self.plotItem.addItem(self.red_line)
        else:
            self.plotItem.removeItem(self.red_line)

        self.enable_inspector_line(activated_inspector)
        self.getViewBox().invertY(altitude == "length")
        styles = {"color": "black", "font-size": "15px"}
        label = "Depth" if altitude == "length" else "Altitude"
        self.setLabel("left", self.tr(label), "m", **styles)

        self.plotItem.minimap.invertY(altitude == "length")

        # refresh minimap display
        if self.assay_column_config:
            self.plotItem.display_minimap(
                self.assay_column_config.minimap_param.value()
            )

        # refresh stacked minimap. trigger Plot options minimap
        if self.stacked_config:
            self.stacked_config.display_stacked_minimap()

    def get_data_bounds(self) -> tuple[list]:
        """
        Get real data bounds.
        """

        x = []
        y = []
        for item in self.plotItem.items:
            if not hasattr(item, "dataBounds"):
                continue
            bounds_x = item.dataBounds(0)
            bounds_y = item.dataBounds(1)
            if bounds_x is not None and (
                bounds_x[0] != bounds_x[1] or self.domain == AssayDomainType.DEPTH
            ):
                x += bounds_x
            if bounds_y is not None and (
                bounds_y[0] != bounds_y[1] or self.domain == AssayDomainType.TIME
            ):
                y += bounds_y

        try:
            res = [min(x), max(x)], [min(y), max(y)]
        except:
            res = [None, None]

        return res

    def automatic_limits(self):
        y_range = None
        bounds = self.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])
        if y_range:
            self.set_limits(*y_range)

    def disable_plot_option(self):
        menu = self.plotItem.getMenu()
        menu.menuAction().setVisible(False)
        # hide Mouse Mode options in contextual menu
        vbm_actions = self.getViewBox().menu.actions()
        for a in vbm_actions:
            if a.text() == "Mouse Mode":
                a.setVisible(False)

    def set_assay_config(self, assay_config: AssayVisualizationConfig) -> None:
        self.assay_config = assay_config
        self._displayed_unit = self.assay_config.unit_parameter.value()

        self._update_title()

    def set_assay_column_config(
        self, assay_column_config: AssayColumnVisualizationConfig
    ) -> None:
        self.assay_column_config = assay_column_config
        self._displayed_unit = self.assay_column_config.unit_parameter.value()
        self._update_title()

    def set_stacked_config(self, stacked_config: StackedConfiguration) -> None:
        self.stacked_config = stacked_config
        self._displayed_unit = self.stacked_config.unit_parameter.value()
        self._update_title()

    def update_displayed_unit(self, new_unit: str) -> None:
        if self._displayed_unit != new_unit:
            ranges = self.getViewBox().viewRange()

            if self.domain == AssayDomainType.TIME:
                update_range = ranges[1]
                new_range = unit_conversion(
                    from_unit=self._displayed_unit, to_unit=new_unit, array=update_range
                )
                self.getViewBox().setRange(yRange=new_range)

            else:
                update_range = ranges[0]
                new_range = unit_conversion(
                    from_unit=self._displayed_unit, to_unit=new_unit, array=update_range
                )
                self.getViewBox().setRange(xRange=new_range)
            self._displayed_unit = new_unit
        self._update_title()

    @property
    def _unit_str_suffix(self) -> str:
        if self._displayed_unit:
            return f" ({self._displayed_unit})"
        else:
            return ""

    @property
    def _time_assay_title(self) -> str:
        if self.assay_config:
            return f"{self.assay_config.assay_display_name}{self._unit_str_suffix}"
        elif self.assay_column_config:
            return (
                f"{self.assay_column_config.assay_display_name}{self._unit_str_suffix}"
            )
        elif self.stacked_config:
            return f"{self.stacked_config.name}{self._unit_str_suffix}"
        else:
            return ""

    @property
    def _depth_assay_title(self) -> str:
        if self.assay_config:
            return f"{self.assay_config.assay_display_name}{self._unit_str_suffix}"
        elif self.assay_column_config:
            # For column config, column name is indicated only if different from assay name
            column_name = self.assay_column_config.column.name
            assay_name = self.assay_column_config.assay_display_name
            if column_name != assay_name:
                return f"{assay_name}/{column_name}{self._unit_str_suffix}"
            else:
                return f"{assay_name}{self._unit_str_suffix}"
        elif self.stacked_config:
            return f"{self.stacked_config.name}{self._unit_str_suffix}"
        else:
            return ""

    @property
    def _plot_title(self) -> str:
        if self.domain == AssayDomainType.TIME:
            return self._time_plot_title
        else:
            return self._depth_plot_title

    @property
    def _depth_plot_title(self) -> str:
        # For depth plot we need indicate collar, assay name and unit
        if self.assay_config:
            return f"{self.assay_config.hole_display_name}<br>{self._depth_assay_title}"
        elif self.assay_column_config:
            return f"{self.assay_column_config.hole_display_name}<br>{self._depth_assay_title}"
        elif self.stacked_config:
            return f"{self.stacked_config.name}{self._unit_str_suffix}"
        else:
            return ""

    @property
    def _time_plot_title(self) -> str:
        # For time plot we need only indicate collar
        if self.assay_config:
            return self.assay_config.hole_display_name
        elif self.assay_column_config:
            # For column config, column name is indicated only if different from assay name
            column_name = self.assay_column_config.column.name
            assay_name = self.assay_column_config.assay_display_name
            if column_name != assay_name:
                return f"{self.assay_column_config.hole_display_name}<br>{column_name}"
            else:
                return self.assay_column_config.hole_display_name
        elif self.stacked_config:
            return self.stacked_config.name
        else:
            return ""

    def _update_title(self) -> None:
        self.setTitle(self._plot_title, color="black", size="15px")
        if self.domain == AssayDomainType.TIME:
            styles = {"color": "black", "font-size": "15px"}
            self.setLabel("left", self._time_assay_title, "", **styles)

    def set_limits(self, min_domain_axis: float, max_domain_axis: float) -> None:
        """
        Define limit depending on current domain

        Args:
            min_domain_axis: float
            max_domain_axis: float
        """

        tol = abs(max_domain_axis - min_domain_axis) / 20

        if self.domain == AssayDomainType.TIME:
            self.getViewBox().setLimits(
                xMin=min_domain_axis - tol, xMax=max_domain_axis + tol
            )
        else:
            self.getViewBox().setLimits(
                yMin=min_domain_axis - tol, yMax=max_domain_axis + tol
            )

        # update minimap range
        self.plotItem.minimap._setRange(
            range=[min_domain_axis - tol, max_domain_axis + tol]
        )

        if not self.inspector.valueDefined:
            self.inspector.setPos(
                min_domain_axis + (max_domain_axis - min_domain_axis) / 2.0
            )
            self.inspector.valueDefined = True

    def enable_inspector_line(self, enable: bool) -> None:
        """
        Enable or disable inspector line for this plot

        Args:
            enable: (bool)
        """
        if enable:
            self.inspector.attachToPlotItem(self.getPlotItem())
        else:
            self.inspector.dettach()

    def enable_inspector_sync(
        self, sync_inspector: AssayInspectorLine, enable: bool
    ) -> None:
        """
        Enable inspector line synchronisation

        Args:
            sync_inspector: (AssayInspectorLine) inspector line to be synchronized
            enable: (bool) enable or disable sync
        """
        # Disable current sync
        if self._sync_inspector and self._connection_to_sync_inspector:
            self._sync_inspector.disconnect(self._connection_to_sync_inspector)
            self._sync_inspector = None
            self._connection_to_sync_inspector = None

        if self._connection_from_sync_inspector:
            self.inspector.sigPositionChanged.disconnect(
                self._connection_from_sync_inspector
            )
            self._connection_from_sync_inspector = None

        # Enable if asked
        if enable and sync_inspector != self.inspector:
            self._sync_inspector = sync_inspector
            self._connection_to_sync_inspector = (
                self._sync_inspector.sigPositionChanged.connect(
                    lambda inspector: self.inspector.setPos(inspector.getPos())
                )
            )
            self._connection_from_sync_inspector = (
                self.inspector.sigPositionChanged.connect(
                    lambda inspector: self._sync_inspector.setPos(inspector.getPos())
                )
            )

    def minimumSizeHint(self):
        """
        Return minimum size hint with width calculation from displayed title and axes

        Returns: (QSize) minimum size hint

        """
        res = super().minimumSizeHint()
        if self.plotItem.titleLabel:
            size_title_label = self.plotItem.titleLabel.sizeHint(
                QtCore.Qt.SizeHint.MinimumSize, 0
            )
            res.setWidth(int(size_title_label.width()))
        if self.getAxis("left"):
            size_axis = self.getAxis("left").minimumSize()
            res.setWidth(res.width() + int(size_axis.width()))
            if self.getAxis("left").label:
                res.setHeight(
                    int(self.getAxis("left").label.boundingRect().width() * 0.8)
                )  ## bounding rect is usually an overestimate
        if self.getAxis("right"):
            size_axis = self.getAxis("right").minimumSize()
            res.setWidth(res.width() + int(size_axis.width()))
        if self.getAxis("top"):
            size_axis = self.getAxis("top").minimumSize()
            res.setHeight(res.height() + int(size_axis.height()))
        if self.getAxis("bottom"):
            size_axis = self.getAxis("bottom").minimumSize()
            res.setHeight(res.height() + int(size_axis.height()))
        return res
