
from PyQt5.QtCore import Qt, QLineF, QPointF, QMetaObject, pyqtSignal
from PyQt5.QtGui import QColor, QLinearGradient, QPainterPath, QPen, QPolygonF
from PyQt5.QtWidgets import QStyle, QStyleOption, QStylePainter, QWidget

from abc import ABCMeta, abstractmethod
from application.python import limit
from collections import deque
from itertools import chain, islice
from math import ceil, log10, modf

from blink.widgets.color import ColorHelperMixin
from blink.widgets.util import QtDynamicProperty


__all__ = ['Graph', 'GraphWidget', 'HeightScaler', 'LogarithmicScaler', 'MaxScaler', 'SoftScaler']


class HeightScaler(object, metaclass=ABCMeta):
    @abstractmethod
    def get_height(self, max_value):
        raise NotImplementedError


class LogarithmicScaler(HeightScaler):
    """A scaler that returns the closest next power of 10"""

    def get_height(self, max_value):
        return 10 ** int(ceil(log10(max_value or 1)))


class MaxScaler(HeightScaler):
    """A scaler that returns the max_value"""

    def get_height(self, max_value):
        return max_value


class SoftScaler(HeightScaler):
    """A scaler that returns the closest next value from the series: { ..., 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, ... }"""

    def __init__(self):
        self.point_20 = log10(2)
        self.point_50 = log10(5)

    def get_height(self, max_value):
        fraction, integer = modf(log10(max_value or 0.9))
        if fraction < -self.point_50:
            return 10**integer / 5
        elif fraction < -self.point_20:
            return 10**integer / 2
        elif fraction < 0:
            return 10**integer
        elif fraction < self.point_20:
            return 10**integer * 2
        elif fraction < self.point_50:
            return 10**integer * 5
        else:
            return 10**integer * 10


class Graph(object):
    def __init__(self, data, color, over_boundary_color=None, fill_envelope=False, enabled=True):
        self.data = data
        self.color = color
        self.over_boundary_color = over_boundary_color or color
        self.fill_envelope = fill_envelope
        self.enabled = enabled

    @property
    def max_value(self):
        return max(self.data) if self.data else 0

    @property
    def last_value(self):
        return self.data[-1] if self.data else 0


class GraphWidget(QWidget, ColorHelperMixin):
    graphStyle = QtDynamicProperty('graphStyle', type=int)
    graphHeight = QtDynamicProperty('graphHeight', type=float)
    minHeight = QtDynamicProperty('minHeight', type=float)
    lineThickness = QtDynamicProperty('lineThickness', type=float)
    horizontalPixelsPerUnit = QtDynamicProperty('horizontalPixelsPerUnit', type=int)
    boundary = QtDynamicProperty('boundary', type=float)
    boundaryColor = QtDynamicProperty('boundaryColor', type=QColor)
    smoothEnvelope = QtDynamicProperty('smoothEnvelope', type=bool)
    smoothFactor = QtDynamicProperty('smoothFactor', type=float)
    fillEnvelope = QtDynamicProperty('fillEnvelope', type=bool)
    fillTransparency = QtDynamicProperty('fillTransparency', type=int)

    EnvelopeStyle, BarStyle = list(range(2))
    AutomaticHeight = 0

    updated = pyqtSignal()

    def __init__(self, parent=None):
        super(GraphWidget, self).__init__(parent)
        self.graphStyle = self.EnvelopeStyle
        self.graphHeight = self.AutomaticHeight
        self.minHeight = 0
        self.lineThickness = 1.6
        self.horizontalPixelsPerUnit = 2
        self.boundary = None
        self.boundaryColor = None
        self.smoothEnvelope = True
        self.smoothFactor = 0.1
        self.fillEnvelope = True
        self.fillTransparency = 40
        self.scaler = SoftScaler()
        self.graphs = []
        self.__dict__['graph_width'] = 0
        self.__dict__['graph_height'] = 0
        self.__dict__['max_value'] = 0

    def _get_scaler(self):
        return self.__dict__['scaler']

    def _set_scaler(self, scaler):
        if not isinstance(scaler, HeightScaler):
            raise TypeError("scaler must be a HeightScaler instance")
        self.__dict__['scaler'] = scaler

    scaler = property(_get_scaler, _set_scaler)
    del _get_scaler, _set_scaler

    @property
    def graph_width(self):
        return self.__dict__['graph_width']

    @property
    def graph_height(self):
        return self.__dict__['graph_height']

    @property
    def max_value(self):
        return self.__dict__['max_value']

    def paintEvent(self, event):
        option = QStyleOption()
        option.initFrom(self)

        contents_rect = self.style().subElementRect(QStyle.SE_FrameContents, option, self) or self.contentsRect()  # the SE_FrameContents rect is Null unless the stylesheet defines decorations

        if self.graphStyle == self.BarStyle:
            graph_width = self.__dict__['graph_width'] = int(ceil(float(contents_rect.width()) / self.horizontalPixelsPerUnit))
        else:
            graph_width = self.__dict__['graph_width'] = int(ceil(float(contents_rect.width() - 1) / self.horizontalPixelsPerUnit) + 1)

        max_value = self.__dict__['max_value'] = max(chain([0], *(islice(reversed(graph.data), graph_width) for graph in self.graphs if graph.enabled)))

        if self.graphHeight == self.AutomaticHeight or self.graphHeight < 0:
            graph_height = self.__dict__['graph_height'] = max(self.scaler.get_height(max_value), self.minHeight)
        else:
            graph_height = self.__dict__['graph_height'] = max(self.graphHeight, self.minHeight)

        if self.graphStyle == self.BarStyle:
            height_scaling = float(contents_rect.height()) / graph_height
        else:
            height_scaling = float(contents_rect.height() - self.lineThickness) / graph_height

        painter = QStylePainter(self)
        painter.drawPrimitive(QStyle.PE_Widget, option)

        painter.setClipRect(contents_rect)

        painter.save()
        painter.translate(contents_rect.x() + contents_rect.width() - 1, contents_rect.y() + contents_rect.height() - 1)
        painter.scale(-1, -1)

        painter.setRenderHint(QStylePainter.Antialiasing, self.graphStyle != self.BarStyle)

        for graph in (graph for graph in self.graphs if graph.enabled and graph.data):
            if self.boundary is not None and 0 < self.boundary < graph_height:
                boundary_width = min(5.0/height_scaling, self.boundary-0, graph_height-self.boundary)
                pen_color = QLinearGradient(0, (self.boundary - boundary_width) * height_scaling, 0, (self.boundary + boundary_width) * height_scaling)
                pen_color.setColorAt(0, graph.color)
                pen_color.setColorAt(1, graph.over_boundary_color)
                brush_color = QLinearGradient(0, (self.boundary - boundary_width) * height_scaling, 0, (self.boundary + boundary_width) * height_scaling)
                brush_color.setColorAt(0, self.color_with_alpha(graph.color, self.fillTransparency))
                brush_color.setColorAt(1, self.color_with_alpha(graph.over_boundary_color, self.fillTransparency))
            else:
                pen_color = graph.color
                brush_color = self.color_with_alpha(graph.color, self.fillTransparency)
            dataset = islice(reversed(graph.data), graph_width)
            if self.graphStyle == self.BarStyle:
                lines = [QLineF(x*self.horizontalPixelsPerUnit, 0, x*self.horizontalPixelsPerUnit, y*height_scaling) for x, y in enumerate(dataset)]
                painter.setPen(QPen(pen_color, self.lineThickness))
                painter.drawLines(lines)
            else:
                painter.translate(0, +self.lineThickness/2 - 1)

                if self.smoothEnvelope and self.smoothFactor > 0:
                    min_value = 0
                    max_value = graph_height * height_scaling
                    cx_offset = self.horizontalPixelsPerUnit / 3.0
                    smoothness = self.smoothFactor

                    last_values = deque(3*[next(dataset) * height_scaling], maxlen=3)  # last 3 values: 0 last, 1 previous, 2 previous previous

                    envelope = QPainterPath()
                    envelope.moveTo(0, last_values[0])
                    for x, y in enumerate(dataset, 1):
                        x = x * self.horizontalPixelsPerUnit
                        y = y * height_scaling * (1 - smoothness) + last_values[0] * smoothness
                        last_values.appendleft(y)
                        c1x = x - cx_offset * 2
                        c2x = x - cx_offset
                        c1y = limit((1 + smoothness) * last_values[1] - smoothness * last_values[2], min_value, max_value)  # same gradient as previous previous value to previous value
                        c2y = limit((1 - smoothness) * last_values[0] + smoothness * last_values[1], min_value, max_value)  # same gradient as previous value to last value
                        envelope.cubicTo(c1x, c1y, c2x, c2y, x, y)
                else:
                    envelope = QPainterPath()
                    envelope.addPolygon(QPolygonF([QPointF(x*self.horizontalPixelsPerUnit, y*height_scaling) for x, y in enumerate(dataset)]))

                if self.fillEnvelope or graph.fill_envelope:
                    first_element = envelope.elementAt(0)
                    last_element = envelope.elementAt(envelope.elementCount() - 1)
                    fill_path = QPainterPath()
                    fill_path.moveTo(last_element.x, last_element.y)
                    fill_path.lineTo(last_element.x + 1, last_element.y)
                    fill_path.lineTo(last_element.x + 1, -self.lineThickness)
                    fill_path.lineTo(-self.lineThickness, -self.lineThickness)
                    fill_path.lineTo(-self.lineThickness, first_element.y)
                    fill_path.connectPath(envelope)
                    painter.fillPath(fill_path, brush_color)

                painter.strokePath(envelope, QPen(pen_color, self.lineThickness, join=Qt.RoundJoin))

                painter.translate(0, -self.lineThickness/2 + 1)

        if self.boundary is not None and self.boundaryColor:
            painter.setRenderHint(QStylePainter.Antialiasing, False)
            painter.setPen(QPen(self.boundaryColor, 1.0))
            painter.drawLine(0, self.boundary*height_scaling, contents_rect.width(), self.boundary*height_scaling)

        painter.restore()

        # queue the 'updated' signal to be emitted after returning to the main loop
        QMetaObject.invokeMethod(self, 'updated', Qt.QueuedConnection)

    def add_graph(self, graph):
        if not isinstance(graph, Graph):
            raise TypeError("graph should be an instance of Graph")
        self.graphs.append(graph)
        if graph.enabled:
            self.update()

    def remove_graph(self, graph):
        self.graphs.remove(graph)
        self.update()

    def clear(self):
        self.graphs = []
        self.update()

