import math
from dataclasses import dataclass
from datetime import datetime

import numpy as np
import pyqtgraph as pg
from pyqtgraph import InfiniteLine, TextItem
from qgis.PyQt.QtCore import Qt
from qgis.PyQt.QtGui import QColor, QPen

from openlog.datamodel.assay.generic_assay import AssayDomainType
from openlog.gui.assay_visualization.assay_bar_graph_item import AssayBarGraphItem
from openlog.gui.assay_visualization.assay_plot_data_item import AssayPlotDataItem


class AssayInspectorLine(InfiniteLine):
    @dataclass
    class IntersectedPoint:
        """Class for intersection point definition.

        Args:
            x_value (float): intersected x value
            y_value (float): intersected y value
            name (str): name of value
            interpolated (bool) is value interpolated
        """

        x_value: float
        y_value: float
        name: str
        interpolated: bool = False

    def __init__(
        self,
        domain: AssayDomainType,
    ):
        """
        pg.InfiniteLine to display intersected curve / bar values.

        For curves, an interpolation is done between available values.

        Args:
            domain: (AssayDomainType)
        """
        self.domain = domain
        if domain == AssayDomainType.DEPTH:
            angle = 0.0
        else:
            angle = 90.0

        super(AssayInspectorLine, self).__init__(angle=angle, movable=True)
        self._labels = []
        self._plot_item = None
        self.sigPositionChanged.connect(self._onMoved)
        self.valueDefined = False

    def _getBounds(self) -> list:
        """
        Get min and max values of the plot
        """
        # index for AssayPlotDataItem.getData() list and keys for AssayBarGraphItem.opts dictionnary.
        if self.domain == AssayDomainType.DEPTH:
            index = 1
            keys = ("y0", "y1")
        else:
            index = 0
            keys = ("x0", "x1")

        # iterate through items
        min_value = max_value = []
        for c in self._plot_item.curves:
            if isinstance(c, AssayBarGraphItem):
                data = np.concatenate([c.opts.get(key) for key in keys])
            elif isinstance(c, AssayPlotDataItem):
                data = c.getData()[index]
            else:
                continue
            if len(data) == 0:
                continue
            min_value.append(np.nanmin(data))
            max_value.append(np.nanmax(data))

        if len(min_value) == 0 or len(max_value) == 0:
            return [None, None]

        return [np.min(min_value), np.max(max_value)]

    def _onMoved(self) -> None:
        """
        Update displayed labels depending on line position

        """
        if not self.getViewBox():
            return

        self._removeLabels()
        points = []

        # update bounds if not set
        if self.bounds() == [None, None]:
            bounds = self._getBounds()
            self.setBounds(bounds)

        # iterate over the existing curves
        for c in self._plot_item.curves:

            # For now only AssayBarGraphItem and AssayPlotDataItem are supported
            if not c.isVisible():
                continue
            if isinstance(c, AssayBarGraphItem):
                points += self._get_intersection_points_for_bar(c)
            elif isinstance(c, AssayPlotDataItem):
                points.append(self._get_intersection_point_for_curve(c))

        self._createLabels(points)

    def _get_intersection_points_for_bar(
        self, b: AssayBarGraphItem
    ) -> [IntersectedPoint]:
        """
        Get intersection points for a AssayBarGraphItem

        Args:
            b: (AssayBarGraphItem)

        Returns: [IntersectedPoint]

        """
        points = []
        # For now define nam with column name and hole display name
        name = f"{b.config.column.name} ({b.config.hole_display_name})"

        if self.domain == AssayDomainType.DEPTH:
            x = zip(b.opts.get("y0"), b.opts.get("y1"))
            y = b.opts.get("width")
        else:
            x = zip(b.opts.get("x0"), b.opts.get("x1"))
            y = b.opts.get("height")

        inspector_x = self.value()
        mask = [x_min <= inspector_x <= x_max for x_min, x_max in x]
        for i, intersect in enumerate(mask):
            if intersect:
                if self.domain == AssayDomainType.DEPTH:
                    x_val = y[i]
                    y_val = inspector_x
                else:
                    x_val = inspector_x
                    y_val = y[i]

                points.append(
                    self.IntersectedPoint(x_value=x_val, y_value=y_val, name=name)
                )
        if not len(points):
            points.append(self._create_nan_intersected_point(inspector_x, name))
        return points

    def _get_intersection_point_for_curve(
        self, c: AssayPlotDataItem
    ) -> IntersectedPoint:
        """
        Get intersection points for a AssayPlotDataItem

        Args:
            c: AssayPlotDataItem

        Returns: IntersectedPoint

        """
        # For now define name with column name and hole display name
        name = f"{c.config.column.name} ({c.config.hole_display_name})"

        # Line value
        inspector_x = self.value()

        # Curve value
        x, y = self._get_x_y_values(c)

        # Return nan if no data
        if x is None or y is None:
            return self._create_nan_intersected_point(inspector_x, name)

        # find the index of the closest point of this curve
        pix_size = self._get_view_pixel_size()
        adiff = np.abs(x - inspector_x)
        idx = np.argmin(adiff)

        # only add a label with defined value if the line touches the symbol
        tolerance = 0.5 * max(1, c.opts["symbolSize"]) * pix_size
        if adiff[idx] < tolerance:
            point = self.IntersectedPoint(
                x_value=c.xData[idx], y_value=c.yData[idx], name=name
            )
        else:
            # Check if we can interpolate values
            point = self._create_interpolated_intersection(idx, inspector_x, name, x, y)
        return point

    def _get_view_pixel_size(self) -> float:
        """
        Get view pixel size depending on current domain

        Returns: (float)

        """
        x_px_size, y_px_size = self.getViewBox().viewPixelSize()
        if self.domain == AssayDomainType.DEPTH:
            pix_size = y_px_size
        else:
            pix_size = x_px_size
        return pix_size

    def _create_interpolated_intersection(
        self, idx: int, inspector_x: float, name: str, x: [float], y: [float]
    ) -> IntersectedPoint:
        """
        Create interpolated intersection

        Args:
            idx: (int) index of the closest value
            inspector_x: (float) value of inspector line
            name: (str) name of value
            x: [float] available x values for interpolation
            y: [float] available y values for interpolation

        Returns: IntersectedPoint

        """
        # Check if we must use previous or next value
        if inspector_x > x[idx]:
            a = idx
            b = idx + 1
        else:
            a = idx - 1
            b = idx

        # Check if index are valid
        if a >= 0 and b < len(x):
            # Interpolate value at inspector line position
            val = (y[a] - y[b]) * inspector_x / (x[a] - x[b]) + (
                x[a] * y[b] - x[b] * y[a]
            ) / (x[a] - x[b])

            # Create IntersectedPoint depending on domain
            if self.domain == AssayDomainType.DEPTH:
                x_val = val
                y_val = inspector_x
            else:
                x_val = inspector_x
                y_val = val

            point = self.IntersectedPoint(
                x_value=x_val,
                y_value=y_val,
                name=name,
                interpolated=True,
            )
        else:
            point = self._create_nan_intersected_point(inspector_x, name)
        return point

    def _get_x_y_values(self, c: AssayPlotDataItem) -> ([float], [float]):
        """
        Get available x/y values from AssayPlotDataItem

        Args:
            c: AssayPlotDataItem

        Returns: ([float], [float]): x,y values

        """
        if self.domain == AssayDomainType.DEPTH:
            x = c.yData
            y = c.xData
        else:
            x = c.xData
            y = c.yData
        return x, y

    def _create_nan_intersected_point(
        self, inspector_x: float, name: str
    ) -> IntersectedPoint:
        """
        Create an invalid intersected point

        Args:
            inspector_x: (float) inspector line value
            name: (str) value name

        Returns: IntersectedPoint

        """
        if self.domain == AssayDomainType.DEPTH:
            point = self.IntersectedPoint(
                x_value=float("NaN"), y_value=inspector_x, name=name
            )
        else:
            point = self.IntersectedPoint(
                x_value=inspector_x, y_value=float("NaN"), name=name
            )
        return point

    def _createLabels(self, points: [IntersectedPoint]) -> None:
        """
        Create labels from IntersectedPoint

        Args:
            points: [IntersectedPoint]
        """
        if len(points):
            x_val, y_val = self._define_x_y_values(points)
            text = self._define_axis_text(points)

            border_color = "black"
            if math.isnan(x_val):
                x_val = self._plot_item.viewRange()[0][0]
                border_color = "red"
            if math.isnan(y_val):
                y_val = self._plot_item.viewRange()[1][1]
                border_color = "red"

            value_interpolated = False
            for point in points:
                value_interpolated |= point.interpolated
                if self.domain == AssayDomainType.DEPTH:
                    text += f"\n{point.name}={point.x_value:.2g} "
                else:
                    text += f"\n{point.name}={point.y_value:.2g} "
            if value_interpolated:
                pen_style = Qt.DashLine
            else:
                pen_style = Qt.SolidLine

            border_pen = QPen(
                QColor(border_color), 1, pen_style, Qt.RoundCap, Qt.RoundJoin
            )
            text_item = TextItem(text=text, fill="white", border=border_pen)

            # transform coordinates if axes are expressed in log scale
            if self._plot_item.getAxis("top").logMode:
                x_val = np.log10(x_val)

            if self._plot_item.getAxis("left").logMode:
                y_val = np.log10(y_val)

            text_item.setPos(x_val, y_val)
            text_item.setZValue(1)
            text_item.setAnchor(self._updateAnchor(x_val, y_val))

            self._labels.append(text_item)
            self._plot_item.addItem(text_item, ignoreBounds=True)

    def _updateAnchor(self, x_val, y_val) -> tuple:
        """
        Switch anchor when infiniteline cross the middle of plot area.
        This method avoid label being hidden by axis
        """
        anchor = (0, 0)
        if self.bounds() == [None, None]:
            return anchor

        # compute middle
        min_value, max_value = self.bounds()
        middle = min_value + (max_value - min_value) / 2

        if self.domain == AssayDomainType.DEPTH and y_val > middle:
            anchor = (0, 1)
        elif self.domain == AssayDomainType.TIME and x_val > middle:
            anchor = (1, 0)

        return anchor

    def _define_x_y_values(self, points: [IntersectedPoint]) -> (float, float):
        """
        Define used x y value from points

        Args:
            points: [IntersectedPoint]

        Returns: (float, float) x_val, y_val

        """
        if self.domain == AssayDomainType.DEPTH:
            x_val = max([p.x_value for p in points])
            y_val = points[0].y_value
        else:
            x_val = points[0].x_value
            y_val = max([p.y_value for p in points])
        return x_val, y_val

    def _define_axis_text(self, points: [IntersectedPoint]) -> str:
        """
        Define axis text from current domains and points

        Args:
            points: [IntersectedPoint]

        Returns: (str) axis text

        """
        if self.domain == AssayDomainType.DEPTH:
            axis_name = self.tr("Depth")
            text = f"{axis_name}={points[0].y_value:.2g} "
        else:
            axis_name = self.tr("Time")
            text = f"{axis_name}={datetime.fromtimestamp(points[0].x_value).strftime('%Y/%m/%d %H:%M:%S')} "
        return text

    def _removeLabels(self) -> None:
        """
        Remove existing texts

        """
        for item in self._labels:
            self._plot_item.removeItem(item)
        self._labels = []

    def attachToPlotItem(self, plot_item: pg.PlotItem) -> None:
        """
        Attach to plot item for curve value display

        Args:
            plot_item:
        """
        self._plot_item = plot_item
        plot_item.addItem(self, ignoreBounds=True)

    def dettach(self) -> None:
        """
        Dettach from current plot item

        """
        self._removeLabels()
        if self._plot_item:
            self._plot_item.removeItem(self)
            self._plot_item = None
