# /*##########################################################################
#
# Copyright (c) 2015-2023 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ###########################################################################*/
"""This module provides a :class:`RangeSlider` widget.

.. image:: img/RangeSlider.png
   :align: center
"""

__authors__ = ["D. Naudet", "T. Vincent"]
__license__ = "MIT"
__date__ = "14/12/2023"


import numpy

from silx.gui import qt, icons, colors
from silx.gui.utils.image import convertArrayToQImage


class StyleOptionRangeSlider(qt.QStyleOption):
    def __init__(self):
        super(StyleOptionRangeSlider, self).__init__()
        self.minimum = None
        self.maximum = None
        self.sliderPosition1 = None
        self.sliderPosition2 = None
        self.handlerRect1 = None
        self.handlerRect2 = None


class RangeSlider(qt.QWidget):
    """Range slider with 2 thumbs and an optional colored groove.

    The position of the slider thumbs can be retrieved either as values
    in the slider range or as a number of steps or pixels.

    :param QWidget parent: See QWidget
    """

    _SLIDER_WIDTH = 10
    """Width of the slider rectangle"""

    _PIXMAP_VOFFSET = 7
    """Vertical groove pixmap offset"""

    sigRangeChanged = qt.Signal(float, float)
    """Signal emitted when the value range has changed.

    It provides the new range (min, max).
    """

    sigValueChanged = qt.Signal(float, float)
    """Signal emitted when the value of the sliders has changed.

    It provides the slider values (first, second).
    """

    sigPositionCountChanged = qt.Signal(object)
    """This signal is emitted when the number of steps has changed.

    It provides the new position count.
    """

    sigPositionChanged = qt.Signal(int, int)
    """Signal emitted when the position of the sliders has changed.

    It provides the slider positions in steps or pixels (first, second).
    """

    def __init__(self, parent=None):
        self.__pixmap = None
        self.__positionCount = None
        self.__firstValue = 0.0
        self.__secondValue = 1.0
        self.__minValue = 0.0
        self.__maxValue = 1.0
        self.__hoverRect = qt.QRect()
        self.__hoverControl = None

        self.__focus = None
        self.__moving = None

        self.__icons = {
            "first": icons.getQIcon("previous"),
            "second": icons.getQIcon("next"),
        }

        # call the super constructor AFTER defining all members that
        # are used in the "paint" method
        super(RangeSlider, self).__init__(parent)

        self.setFocusPolicy(qt.Qt.ClickFocus)
        self.setAttribute(qt.Qt.WA_Hover)

        self.setMinimumSize(qt.QSize(50, 20))
        self.setMaximumHeight(20)

        # Broadcast value changed signal
        self.sigValueChanged.connect(self.__emitPositionChanged)

    def event(self, event):
        t = event.type()
        if (
            t == qt.QEvent.HoverEnter
            or t == qt.QEvent.HoverLeave
            or t == qt.QEvent.HoverMove
        ):
            if qt.BINDING in ("PyQt5",):
                # qt-5
                return self.__updateHoverControl(event.pos())
            else:
                # qt-6
                return self.__updateHoverControl(event.position().toPoint())
        else:
            return super(RangeSlider, self).event(event)

    def __updateHoverControl(self, pos):
        hoverControl, hoverRect = self.__findHoverControl(pos)
        if hoverControl != self.__hoverControl:
            self.update(self.__hoverRect)
            self.update(hoverRect)
            self.__hoverControl = hoverControl
            self.__hoverRect = hoverRect
            return True
        return hoverControl is not None

    def __findHoverControl(self, pos):
        """Returns the control at the position and it's rect location"""
        for name in ["first", "second"]:
            rect = self.__sliderRect(name)
            if rect.contains(pos):
                return name, rect
        rect = self.__drawArea()
        if rect.contains(pos):
            return "groove", rect
        return None, qt.QRect()

    # Position <-> Value conversion

    def __positionToValue(self, position):
        """Returns value corresponding to position

        :param int position:
        :rtype: float
        """
        min_, max_ = self.getMinimum(), self.getMaximum()
        maxPos = self.__getCurrentPositionCount() - 1
        return min_ + (max_ - min_) * int(position) / maxPos

    def __valueToPosition(self, value):
        """Returns closest position corresponding to value

        :param float value:
        :rtype: int
        """
        min_, max_ = self.getMinimum(), self.getMaximum()
        maxPos = self.__getCurrentPositionCount() - 1
        return int(0.5 + maxPos * (float(value) - min_) / (max_ - min_))

    # Position (int) API

    def __getCurrentPositionCount(self):
        """Return current count (either position count or widget width

        :rtype: int
        """
        count = self.getPositionCount()
        if count is not None:
            return count
        else:
            return max(2, self.width() - self._SLIDER_WIDTH)

    def getPositionCount(self):
        """Returns the number of positions.

        :rtype: Union[int,None]"""
        return self.__positionCount

    def setPositionCount(self, count):
        """Set the number of positions.

        Slider values are eventually adjusted.

        :param Union[int,None] count:
            Either the number of possible positions or
            None to allow any values.
        :raise ValueError: If count <= 1
        """
        count = None if count is None else int(count)
        if count != self.getPositionCount():
            if count is not None and count <= 1:
                raise ValueError("Position count must be higher than 1")
            self.__positionCount = count
            emit = self.__setValues(*self.getValues())
            self.sigPositionCountChanged.emit(count)
            if emit:
                self.sigValueChanged.emit(*self.getValues())

    def getFirstPosition(self):
        """Returns first slider position

        :rtype: int
        """
        return self.__valueToPosition(self.getFirstValue())

    def setFirstPosition(self, position):
        """Set the position of the first slider

        The position is adjusted to valid values

        :param int position:
        """
        self.setFirstValue(self.__positionToValue(position))

    def getSecondPosition(self):
        """Returns second slider position

        :rtype: int
        """
        return self.__valueToPosition(self.getSecondValue())

    def setSecondPosition(self, position):
        """Set the position of the second slider

        The position is adjusted to valid values

        :param int position:
        """
        self.setSecondValue(self.__positionToValue(position))

    def getPositions(self):
        """Returns slider positions (first, second)

        :rtype: List[int]
        """
        return self.getFirstPosition(), self.getSecondPosition()

    def setPositions(self, first, second):
        """Set the position of both sliders at once

        First is clipped to the slider range: [0, max].
        Second is clipped to valid values: [first, max]

        :param int first:
        :param int second:
        """
        self.setValues(self.__positionToValue(first), self.__positionToValue(second))

    # Value (float) API

    def __emitPositionChanged(self, *args, **kwargs):
        self.sigPositionChanged.emit(*self.getPositions())

    def __rangeChanged(self):
        """Handle change of value range"""
        emit = self.__setValues(*self.getValues())
        self.sigRangeChanged.emit(*self.getRange())
        if emit:
            self.sigValueChanged.emit(*self.getValues())

    def getMinimum(self):
        """Returns the minimum value of the slider range

        :rtype: float
        """
        return self.__minValue

    def setMinimum(self, minimum):
        """Set the minimum value of the slider range.

        It eventually adjusts maximum.
        Slider positions remains unchanged and slider values are modified.

        :param float minimum:
        :raises ValueError:
        """
        minimum = float(minimum)
        if minimum == self.getMaximum():
            raise ValueError("min and max must be different")

        if minimum != self.getMinimum():
            if minimum > self.getMaximum():
                self.__maxValue = minimum
            self.__minValue = minimum
            self.__rangeChanged()

    def getMaximum(self):
        """Returns the maximum value of the slider range

        :rtype: float
        """
        return self.__maxValue

    def setMaximum(self, maximum):
        """Set the maximum value of the slider range

        It eventually adjusts minimum.
        Slider positions remains unchanged and slider values are modified.

        :param float maximum:
        :raises ValueError:
        """
        maximum = float(maximum)
        if maximum == self.getMinimum():
            raise ValueError("min and max must be different")

        if maximum != self.getMaximum():
            if maximum < self.getMinimum():
                self.__minValue = maximum
            self.__maxValue = maximum
            self.__rangeChanged()

    def getRange(self):
        """Returns the range of values (min, max)

        :rtype: List[float]
        """
        return self.getMinimum(), self.getMaximum()

    def setRange(self, minimum, maximum):
        """Set the range of values.

        If maximum is lower than minimum, minimum is the only valid value.
        Slider positions remains unchanged and slider values are modified.

        :param float minimum:
        :param float maximum:
        :raises ValueError:
        """
        minimum, maximum = float(minimum), float(maximum)
        if minimum == maximum:
            raise ValueError("min and max must be different")
        if minimum != self.getMinimum() or maximum != self.getMaximum():
            self.__minValue = minimum
            self.__maxValue = max(maximum, minimum)
            self.__rangeChanged()

    def getFirstValue(self):
        """Returns the value of the first slider

        :rtype: float
        """
        return self.__firstValue

    def __clipFirstValue(self, value, max_=None):
        """Clip first value to range and steps

        :param float value:
        :param float max_: Alternative maximum to use
        """
        if max_ is None:
            max_ = self.getSecondValue()
        value = min(max(self.getMinimum(), float(value)), max_)
        if self.getPositionCount() is not None:  # Clip to steps
            value = self.__positionToValue(self.__valueToPosition(value))
        return value

    def setFirstValue(self, value):
        """Set the value of the first slider

        Value is clipped to valid values.

        :param float value:
        """
        value = self.__clipFirstValue(value)
        if value != self.getFirstValue():
            self.__firstValue = value
            self.update()
            self.sigValueChanged.emit(*self.getValues())

    def getSecondValue(self):
        """Returns the value of the second slider

        :rtype: float
        """
        return self.__secondValue

    def __clipSecondValue(self, value):
        """Clip second value to range and steps

        :param float value:
        """
        value = min(max(self.getFirstValue(), float(value)), self.getMaximum())
        if self.getPositionCount() is not None:  # Clip to steps
            value = self.__positionToValue(self.__valueToPosition(value))
        return value

    def setSecondValue(self, value):
        """Set the value of the second slider

        Value is clipped to valid values.

        :param float value:
        """
        value = self.__clipSecondValue(value)
        if value != self.getSecondValue():
            self.__secondValue = value
            self.update()
            self.sigValueChanged.emit(*self.getValues())

    def getValues(self):
        """Returns value of both sliders at once

        :return: (first value, second value)
        :rtype: List[float]
        """
        return self.getFirstValue(), self.getSecondValue()

    def setValues(self, first, second):
        """Set values for both sliders at once

        First is clipped to the slider range: [minimum, maximum].
        Second is clipped to valid values: [first, maximum]

        :param float first:
        :param float second:
        """
        if self.__setValues(first, second):
            self.sigValueChanged.emit(*self.getValues())

    def __setValues(self, first, second):
        """Set values for both sliders at once

        First is clipped to the slider range: [minimum, maximum].
        Second is clipped to valid values: [first, maximum]

        :param float first:
        :param float second:
        :return: True if values has changed, False otherwise
        :rtype: bool
        """
        first = self.__clipFirstValue(first, self.getMaximum())
        second = self.__clipSecondValue(second)
        values = first, second

        if self.getValues() != values:
            self.__firstValue, self.__secondValue = values
            self.update()
            return True
        return False

    # Groove API

    def getGroovePixmap(self):
        """Returns the pixmap displayed in the slider groove if any.

        :rtype: Union[QPixmap,None]
        """
        return self.__pixmap

    def setGroovePixmap(self, pixmap):
        """Set the pixmap displayed in the slider groove.

        :param Union[QPixmap,None] pixmap: The QPixmap to use or None to unset.
        """
        assert pixmap is None or isinstance(pixmap, qt.QPixmap)
        self.__pixmap = pixmap
        self.update()

    def setGroovePixmapFromProfile(self, profile, colormap=None):
        """Set the pixmap displayed in the slider groove from histogram values.

        :param Union[numpy.ndarray,None] profile:
            1D array of values to display
        :param Union[~silx.gui.colors.Colormap,str] colormap:
            The colormap name or object to convert profile values to colors
        """
        if profile is None:
            self.setSliderPixmap(None)
            return

        profile = numpy.asarray(profile)

        if profile.size == 0:
            self.setSliderPixmap(None)
            return

        if colormap is None:
            colormap = colors.Colormap()
        elif isinstance(colormap, str):
            colormap = colors.Colormap(name=colormap)
        assert isinstance(colormap, colors.Colormap)

        rgbImage = colormap.applyToData(profile.reshape(1, -1))[:, :, :3]
        qimage = convertArrayToQImage(rgbImage)
        qpixmap = qt.QPixmap.fromImage(qimage)
        self.setGroovePixmap(qpixmap)

    # Handle interaction
    def _mouseEventPosition(self, event):
        if qt.BINDING in ("PyQt5",):
            # qt-5 returns QPoint
            position = event.pos()
        else:
            # qt-6 returns QPointF
            # convert it to QPoint
            position = event.position().toPoint()
        return position

    def mousePressEvent(self, event):
        super(RangeSlider, self).mousePressEvent(event)

        if event.buttons() == qt.Qt.LeftButton:
            picked = None
            for name in ("first", "second"):
                area = self.__sliderRect(name)
                position = self._mouseEventPosition(event)
                if area.contains(position):
                    picked = name
                    break

            self.__moving = picked
            self.__focus = picked
            self.update()

    def mouseMoveEvent(self, event):
        super(RangeSlider, self).mouseMoveEvent(event)

        if self.__moving is not None:
            event_pos = self._mouseEventPosition(event)
            delta = self._SLIDER_WIDTH // 2
            if self.__moving == "first":
                position = self.__xPixelToPosition(event_pos.x() + delta)
                self.setFirstPosition(position)
            else:
                position = self.__xPixelToPosition(event_pos.x() - delta)
                self.setSecondPosition(position)

    def mouseReleaseEvent(self, event):
        super(RangeSlider, self).mouseReleaseEvent(event)

        if event.button() == qt.Qt.LeftButton and self.__moving is not None:
            self.__moving = None
            self.update()

    def focusOutEvent(self, event):
        if self.__focus is not None:
            self.__focus = None
            self.update()
        super(RangeSlider, self).focusOutEvent(event)

    def keyPressEvent(self, event):
        key = event.key()
        if event.modifiers() == qt.Qt.NoModifier and self.__focus is not None:
            if key in (qt.Qt.Key_Left, qt.Qt.Key_Down):
                if self.__focus == "first":
                    self.setFirstPosition(self.getFirstPosition() - 1)
                else:
                    self.setSecondPosition(self.getSecondPosition() - 1)
                return  # accept event
            elif key in (qt.Qt.Key_Right, qt.Qt.Key_Up):
                if self.__focus == "first":
                    self.setFirstPosition(self.getFirstPosition() + 1)
                else:
                    self.setSecondPosition(self.getSecondPosition() + 1)
                return  # accept event

        super(RangeSlider, self).keyPressEvent(event)

    # Handle resize

    def resizeEvent(self, event):
        super(RangeSlider, self).resizeEvent(event)

        # If no step, signal position update when width change
        if (
            self.getPositionCount() is None
            and event.size().width() != event.oldSize().width()
        ):
            self.sigPositionChanged.emit(*self.getPositions())

    # Handle repaint

    def __xPixelToPosition(self, x):
        """Convert position in pixel to slider position

        :param int x: X in pixel coordinates
        :rtype: int
        """
        sliderArea = self.__sliderAreaRect()
        maxPos = self.__getCurrentPositionCount() - 1
        position = maxPos * (x - sliderArea.left()) / (sliderArea.width() - 1)
        return int(position + 0.5)

    def __sliderRect(self, name):
        """Returns rectangle corresponding to slider in pixels

        :param str name: 'first' or 'second'
        :rtype: QRect
        :raise ValueError: If wrong name
        """
        assert name in ("first", "second")
        if name == "first":
            offset = -self._SLIDER_WIDTH
            position = self.getFirstPosition()
        elif name == "second":
            offset = 0
            position = self.getSecondPosition()
        else:
            raise ValueError("Unknown name")

        sliderArea = self.__sliderAreaRect()

        maxPos = self.__getCurrentPositionCount() - 1
        xOffset = int((sliderArea.width() - 1) * position / maxPos)
        xPos = sliderArea.left() + xOffset + offset

        return qt.QRect(xPos, sliderArea.top(), self._SLIDER_WIDTH, sliderArea.height())

    def __drawArea(self):
        return self.rect().adjusted(self._SLIDER_WIDTH, 0, -self._SLIDER_WIDTH, 0)

    def __sliderAreaRect(self):
        return self.__drawArea().adjusted(
            self._SLIDER_WIDTH // 2, 0, -self._SLIDER_WIDTH // 2 + 1, 0
        )

    def __pixMapRect(self):
        return self.__sliderAreaRect().adjusted(
            0, self._PIXMAP_VOFFSET, -1, -self._PIXMAP_VOFFSET
        )

    def paintEvent(self, event):
        painter = qt.QPainter(self)

        style = qt.QApplication.style()

        area = self.__drawArea()
        if self.__pixmap is not None:
            pixmapRect = self.__pixMapRect()

            option = qt.QStyleOptionProgressBar()
            option.initFrom(self)
            option.rect = area
            option.state = (
                qt.QStyle.State_Enabled if self.isEnabled() else qt.QStyle.State_None
            )
            style.drawControl(qt.QStyle.CE_ProgressBarGroove, option, painter, self)

            painter.save()
            pen = painter.pen()
            pen.setWidth(1)
            pen.setColor(qt.Qt.black if self.isEnabled() else qt.Qt.gray)
            painter.setPen(pen)
            painter.drawRect(pixmapRect.adjusted(-1, -1, 0, 1))
            painter.restore()

            if self.isEnabled():
                rect = area.adjusted(
                    self._SLIDER_WIDTH // 2,
                    self._PIXMAP_VOFFSET,
                    -self._SLIDER_WIDTH // 2,
                    -self._PIXMAP_VOFFSET + 1,
                )
                painter.drawPixmap(rect, self.__pixmap, self.__pixmap.rect())
        else:
            option = StyleOptionRangeSlider()
            option.initFrom(self)
            option.rect = area
            option.sliderPosition1 = self.__firstValue
            option.sliderPosition2 = self.__secondValue
            option.handlerRect1 = self.__sliderRect("first")
            option.handlerRect2 = self.__sliderRect("second")
            option.minimum = self.__minValue
            option.maximum = self.__maxValue
            option.state = (
                qt.QStyle.State_Enabled if self.isEnabled() else qt.QStyle.State_None
            )
            if self.__hoverControl == "groove":
                option.state |= qt.QStyle.State_MouseOver
            elif option.state & qt.QStyle.State_MouseOver:
                option.state ^= qt.QStyle.State_MouseOver
            self.drawRangeSliderBackground(painter, option, self)

        # Avoid glitch when moving handles
        hoverControl = self.__moving or self.__hoverControl

        for name in ("first", "second"):
            rect = self.__sliderRect(name)
            option = qt.QStyleOptionButton()
            option.initFrom(self)
            option.icon = self.__icons[name]
            option.iconSize = rect.size() * 0.7
            if hoverControl == name:
                option.state |= qt.QStyle.State_MouseOver
            elif option.state & qt.QStyle.State_MouseOver:
                option.state ^= qt.QStyle.State_MouseOver
            if self.__focus == name:
                option.state |= qt.QStyle.State_HasFocus
            elif option.state & qt.QStyle.State_HasFocus:
                option.state ^= qt.QStyle.State_HasFocus
            option.rect = rect
            style.drawControl(qt.QStyle.CE_PushButton, option, painter, self)

    def sizeHint(self):
        return qt.QSize(200, self.minimumHeight())

    @classmethod
    def drawRangeSliderBackground(cls, painter, option, widget):
        """Draw the background of the RangeSlider widget into the painter.

        :param qt.QPainter painter: A painter
        :param StyleOptionRangeSlider option: Options to draw the widget
        :param qt.QWidget: The widget which have to be drawn
        """
        painter.save()
        painter.translate(0.5, 0.5)

        backgroundRect = qt.QRect(option.rect)
        if backgroundRect.height() > 8:
            center = backgroundRect.center()
            backgroundRect.setHeight(8)
            backgroundRect.moveCenter(center)

        selectedRangeRect = qt.QRect(backgroundRect)
        selectedRangeRect.setLeft(option.handlerRect1.center().x())
        selectedRangeRect.setRight(option.handlerRect2.center().x())

        highlight = option.palette.color(qt.QPalette.Highlight)
        activeHighlight = highlight
        selectedOutline = option.palette.color(qt.QPalette.Highlight)

        buttonColor = option.palette.button().color()
        val = qt.qGray(buttonColor.rgb())
        buttonColor = buttonColor.lighter(100 + max(1, (180 - val) // 6))
        buttonColor.setHsv(
            buttonColor.hue(), (buttonColor.saturation() * 3) // 4, buttonColor.value()
        )

        grooveColor = qt.QColor()
        grooveColor.setHsv(
            buttonColor.hue(),
            min(255, (int)(buttonColor.saturation())),
            min(255, (int)(buttonColor.value() * 0.9)),
        )

        selectedInnerContrastLine = qt.QColor(255, 255, 255, 30)

        outline = option.palette.color(qt.QPalette.Window).darker(140)
        if (
            option.state & qt.QStyle.State_HasFocus
            and option.state & qt.QStyle.State_KeyboardFocusChange
        ):
            outline = highlight.darker(125)
            if outline.value() > 160:
                outline.setHsl(highlight.hue(), highlight.saturation(), 160)

        # Draw background groove
        painter.setRenderHint(qt.QPainter.Antialiasing, True)
        gradient = qt.QLinearGradient()
        gradient.setStart(backgroundRect.center().x(), backgroundRect.top())
        gradient.setFinalStop(backgroundRect.center().x(), backgroundRect.bottom())
        painter.setPen(qt.QPen(outline))
        gradient.setColorAt(0, grooveColor.darker(110))
        gradient.setColorAt(1, grooveColor.lighter(110))
        painter.setBrush(gradient)
        painter.drawRoundedRect(backgroundRect.adjusted(1, 1, -2, -2), 1, 1)

        # Draw slider background for the value
        gradient = qt.QLinearGradient()
        gradient.setStart(selectedRangeRect.center().x(), selectedRangeRect.top())
        gradient.setFinalStop(
            selectedRangeRect.center().x(), selectedRangeRect.bottom()
        )
        painter.setRenderHint(qt.QPainter.Antialiasing, True)
        painter.setPen(qt.QPen(selectedOutline))
        gradient.setColorAt(0, activeHighlight)
        gradient.setColorAt(1, activeHighlight.lighter(130))
        painter.setBrush(gradient)
        painter.drawRoundedRect(selectedRangeRect.adjusted(1, 1, -2, -2), 1, 1)
        painter.setPen(selectedInnerContrastLine)
        painter.setBrush(qt.Qt.NoBrush)
        painter.drawRoundedRect(selectedRangeRect.adjusted(2, 2, -3, -3), 1, 1)

        painter.restore()
