import math
from dataclasses import dataclass
from datetime import datetime
from typing import Union

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

from openlog.datamodel.assay.generic_assay import AssayDomainType
from openlog.gui.assay_visualization.item_tags import InspectorTag
from openlog.gui.pyqtgraph.ItemSwitcher import InspectorSwitcher


class CustomTextItem(TextItem, GraphicsWidgetAnchor):
    """
    Custom class to get a TextItem with additional features :
        - anchoring policy in regard to its parent
        - being draggable
    """

    def __init__(
        self,
        text="",
        color=(200, 200, 200),
        html=None,
        anchor=(0, 0),
        border=None,
        fill=None,
        angle=0,
        rotateAxis=None,
        ensureInBounds=False,
    ):
        TextItem.__init__(
            self,
            text=text,
            color=color,
            html=html,
            anchor=anchor,
            border=border,
            fill=fill,
            angle=angle,
            rotateAxis=rotateAxis,
        )
        GraphicsWidgetAnchor.__init__(self)

    def set_anchor(self, itemPos, parentPos, offset=...):
        """
        Method to define anchoring
        """
        return GraphicsWidgetAnchor.anchor(self, itemPos, parentPos, offset)

    def hoverEvent(self, ev):
        ev.acceptDrags(QtCore.Qt.MouseButton.LeftButton)

    def mouseDragEvent(self, ev):
        if ev.button() == QtCore.Qt.MouseButton.LeftButton:
            ev.accept()
            dpos = ev.pos() - ev.lastPos()
            # self.autoAnchor(self.pos() + dpos)
            self.setPos(self.pos() + dpos)


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
        item: InspectorTag = None
        interpolated: bool = False

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

        For curves, an interpolation is done between available values.

        Args:
            domain: (AssayDomainType)
        """
        self.widget = widget
        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
        self.switcher = InspectorSwitcher(self)
        self.text_item = None
        self.color = None
        self.effective_trace_vertices = None
        self.planned_trace_vertices = None

    def set_trace_vertices(self, array: np.ndarray, planned: bool = False):
        """
        Store trace vertex coordinates for precise label interpolation.
        """
        if planned:
            self.planned_trace_vertices = array
        else:
            self.effective_trace_vertices = array

    def _get_trace_coordinates(self, value: float):
        """
        Return interpolated length, x,y,z according to inspector position.
        Args:
            - value : length or z (inspector position)
        """
        x = y = z = d = np.nan
        # value is assay length : interpolate x,y,z
        if self.switcher.is_altitude == "length":
            d = value
            if self.effective_trace_vertices is not None:
                diff = value - self.effective_trace_vertices[:, 4]
                if np.all(diff > 0) or np.all(diff < 0):
                    return x, y, z, d
                idx = np.argmin(np.where(diff < 0, np.inf, diff))

                x1, y1, z1, _, d1 = self.effective_trace_vertices[idx, :]
                x2, y2, z2, _, d2 = self.effective_trace_vertices[idx + 1, :]

                rate = (value - d1) / (d2 - d1)
                x = x1 + (x2 - x1) * rate
                y = y1 + (y2 - y1) * rate
                z = z1 + (z2 - z1) * rate

        # value is altitude : interpolate x,y,assay length
        else:
            z = value
            if self.switcher.is_altitude == "effective":
                if self.effective_trace_vertices is not None:
                    diff = value - self.effective_trace_vertices[:, 2]
                    if np.all(diff > 0) or np.all(diff < 0):
                        return x, y, z, d
                    idx = np.argmin(np.where(diff < 0, np.inf, diff))
                    x1, y1, z1, _, d1 = self.effective_trace_vertices[idx, :]
                    x2, y2, z2, _, d2 = self.effective_trace_vertices[idx - 1, :]

                    rate = (value - z2) / (z1 - z2)
                    x = x2 + (x1 - x2) * rate
                    y = y2 + (y1 - y2) * rate
                    d = d2 + (d1 - d2) * rate

            else:
                if self.planned_trace_vertices is not None:
                    diff = value - self.planned_trace_vertices[:, 2]
                    if np.all(diff > 0) or np.all(diff < 0):
                        return x, y, z, d
                    idx = np.argmin(np.where(diff < 0, np.inf, diff))
                    x1, y1, z1, _, d1 = self.planned_trace_vertices[idx, :]
                    x2, y2, z2, _, d2 = self.planned_trace_vertices[idx - 1, :]

                    rate = (value - z2) / (z1 - z2)
                    x = x2 + (x1 - x2) * rate
                    y = y2 + (y1 - y2) * rate
                    d = d2 + (d1 - d2) * rate

        return x, y, z, d

    def set_inspector_color(self):

        self.setPen(self.color)

    def _getBounds(self) -> list:
        """
        Get min and max values of the plot
        """
        # index for item.dataBounds()
        if self.domain == AssayDomainType.DEPTH:
            index = 1
        else:
            index = 0

        # iterate through items
        min_value = max_value = []
        for c in self._plot_item.curves:
            if isinstance(c, InspectorTag):
                data = c.dataBounds(index)
            else:
                continue
            if data is None:
                continue
            if len(data) == 0 or data == [None, None] or data == (None, None):
                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

        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, InspectorTag):
                points += c.get_intersection_points(self)

        self._createLabels(points)

    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 _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)
            point = points[0]
            if point.item is None:
                return

            if self.domain == AssayDomainType.DEPTH:
                x, y, z, d = self._get_trace_coordinates(point.y_value)

                text = f"Depth={point.y_value:.2f}"
                if x is not np.nan:
                    text = f"Depth={d:.2f}"
                    text += f"\nX={x:.2f}"
                    text += f"\nY={y:.2f}"
                    text += f"\nZ={z:.2f}"

            else:
                text = self._define_axis_text(points)

            border_color = "black"
            if type(x_val) is not str and math.isnan(x_val):
                border_color = "red"
            if math.isnan(y_val):
                border_color = "red"

            value_interpolated = False
            is_stacked = len(points) > 1
            for point in points:
                value_interpolated |= point.interpolated
                name = point.name if is_stacked else "Value"
                if self.domain == AssayDomainType.DEPTH:
                    if type(point.x_value) is str:
                        text += f"\n{name}={point.x_value} "
                    else:
                        text += f"\n{name}={np.round(point.x_value, decimals=3) if point.x_value > 0.1 else np.format_float_scientific(point.x_value, precision = 3)} "
                else:
                    text += f"\n{name}={point.y_value} "
            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
            )
            if self.text_item is None:
                text_item = CustomTextItem(text=text, fill="white", border=border_pen)
                text_item.setParentItem(self._plot_item)
                if self.domain == AssayDomainType.DEPTH:
                    text_item.set_anchor(
                        itemPos=(1, 1), parentPos=(1, 1), offset=(0, -50)
                    )
                else:
                    text_item.set_anchor(
                        itemPos=(1, 0), parentPos=(1, 0), offset=(0, 50)
                    )
                text_item.setZValue(1)
                self.text_item = text_item

            else:
                self.text_item.setText(text)
                self.text_item.border = border_pen

    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 = "Depth"
            text = f"{axis_name}={points[0].y_value:.2g} "

        else:
            axis_name = "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

        """
        if self.text_item:
            self.text_item.deleteLater()
            self.text_item.setParentItem(None)
            self.text_item = None

    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
