# /*##########################################################################
#
# Copyright (c) 2004-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.
#
# ###########################################################################*/
"""Functions to apply pan and zoom on a Plot"""

from __future__ import annotations

__authors__ = ["T. Vincent", "V. Valls"]
__license__ = "MIT"
__date__ = "08/08/2017"


import logging
import math
from typing import NamedTuple
import numpy


_logger = logging.getLogger(__name__)


# Float 32 info ###############################################################
# Using min/max value below limits of float32
# so operation with such value (e.g., max - min) do not overflow

FLOAT32_SAFE_MIN = -1e37
FLOAT32_MINPOS = numpy.finfo(numpy.float32).tiny
FLOAT32_SAFE_MAX = 1e37
# TODO double support


def checkAxisLimits(vmin: float, vmax: float, isLog: bool = False, name: str = ""):
    """Makes sure axis range is not empty and within supported range.

    :param vmin: Min axis value
    :param vmax: Max axis value
    :return: (min, max) making sure min < max
    :rtype: 2-tuple of float
    """
    min_ = FLOAT32_MINPOS if isLog else FLOAT32_SAFE_MIN
    vmax = numpy.clip(vmax, min_, FLOAT32_SAFE_MAX)
    vmin = numpy.clip(vmin, min_, FLOAT32_SAFE_MAX)

    if vmax < vmin:
        _logger.debug("%s axis: max < min, inverting limits.", name)
        vmin, vmax = vmax, vmin
    elif vmax == vmin:
        _logger.debug("%s axis: max == min, expanding limits.", name)
        if vmin == 0.0:
            vmin, vmax = -0.1, 0.1
        elif vmin < 0:
            vmax *= 0.9
            vmin = max(vmin * 1.1, FLOAT32_SAFE_MIN)  # Clip to range
        else:  # vmin > 0
            vmax = min(vmin * 1.1, FLOAT32_SAFE_MAX)  # Clip to range
            vmin *= 0.9

    return vmin, vmax


def scale1DRange(
    min_: float, max_: float, center: float, scale: float, isLog: bool
) -> tuple[float, float]:
    """Scale a 1D range given a scale factor and an center point.

    Keeps the values in a smaller range than float32.

    :param min_: The current min value of the range.
    :param max_: The current max value of the range.
    :param center: The center of the zoom (i.e., invariant point).
    :param scale: The scale to use for zoom
    :param isLog: Whether using log scale or not.
    :return: The zoomed range (min, max)
    """
    if isLog:
        # Min and center can be < 0 when
        # autoscale is off and switch to log scale
        # max_ < 0 should not happen
        min_ = numpy.log10(min_) if min_ > 0.0 else FLOAT32_MINPOS
        center = numpy.log10(center) if center > 0.0 else FLOAT32_MINPOS
        max_ = numpy.log10(max_) if max_ > 0.0 else FLOAT32_MINPOS

    if min_ == max_:
        return min_, max_

    offset = (center - min_) / (max_ - min_)
    range_ = (max_ - min_) / scale
    newMin = center - offset * range_
    newMax = center + (1.0 - offset) * range_

    if isLog:
        # No overflow as exponent is log10 of a float32
        newMin = pow(10.0, newMin)
        newMax = pow(10.0, newMax)
        newMin = numpy.clip(newMin, FLOAT32_MINPOS, FLOAT32_SAFE_MAX)
        newMax = numpy.clip(newMax, FLOAT32_MINPOS, FLOAT32_SAFE_MAX)
    else:
        newMin = numpy.clip(newMin, FLOAT32_SAFE_MIN, FLOAT32_SAFE_MAX)
        newMax = numpy.clip(newMax, FLOAT32_SAFE_MIN, FLOAT32_SAFE_MAX)
    return newMin, newMax


class EnabledAxes(NamedTuple):
    """Toggle zoom for each axis"""

    xaxis: bool = True
    yaxis: bool = True
    y2axis: bool = True

    def isDisabled(self) -> bool:
        """True only if all axes are disabled"""
        return not (self.xaxis or self.yaxis or self.y2axis)


def applyZoomToPlot(
    plot,
    scale: float,
    center: tuple[float, float] = None,
    enabled: EnabledAxes = EnabledAxes(),
):
    """Zoom in/out plot given a scale and a center point.

    :param plot: The plot on which to apply zoom.
    :param scale: Scale factor of zoom.
    :param center: (x, y) coords in pixel coordinates of the zoom center.
    :param enabled: Toggle zoom for each axis independently
    """
    xMin, xMax = plot.getXAxis().getLimits()
    yMin, yMax = plot.getYAxis().getLimits()
    y2Min, y2Max = plot.getYAxis(axis="right").getLimits()

    if center is None:
        left, top, width, height = plot.getPlotBoundsInPixels()
        cx, cy = left + width // 2, top + height // 2
    else:
        cx, cy = center

    dataCenterPos = plot.pixelToData(cx, cy)
    assert dataCenterPos is not None

    if enabled.xaxis:
        xMin, xMax = scale1DRange(
            xMin, xMax, dataCenterPos[0], scale, plot.getXAxis()._isLogarithmic()
        )

    if enabled.yaxis:
        yMin, yMax = scale1DRange(
            yMin, yMax, dataCenterPos[1], scale, plot.getYAxis()._isLogarithmic()
        )

    if enabled.y2axis:
        dataPos = plot.pixelToData(cx, cy, axis="right")
        assert dataPos is not None
        y2Center = dataPos[1]
        y2Min, y2Max = scale1DRange(
            y2Min, y2Max, y2Center, scale, plot.getYAxis()._isLogarithmic()
        )

    plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max)


def applyPan(min_, max_, panFactor, isLog10):
    """Returns a new range with applied panning.

    Moves the range according to panFactor.
    If isLog10 is True, converts to log10 before moving.

    :param float min_: Min value of the data range to pan.
    :param float max_: Max value of the data range to pan.
                       Must be >= min.
    :param float panFactor: Signed proportion of the range to use for pan.
    :param bool isLog10: True if log10 scale, False if linear scale.
    :return: New min and max value with pan applied.
    :rtype: 2-tuple of float.
    """
    if isLog10 and min_ > 0.0:
        # Negative range and log scale can happen with matplotlib
        logMin, logMax = math.log10(min_), math.log10(max_)
        logOffset = panFactor * (logMax - logMin)
        newMin = pow(10.0, logMin + logOffset)
        newMax = pow(10.0, logMax + logOffset)

        # Takes care of out-of-range values
        if newMin > 0.0 and newMax < float("inf"):
            min_, max_ = newMin, newMax

    else:
        offset = panFactor * (max_ - min_)
        newMin, newMax = min_ + offset, max_ + offset

        # Takes care of out-of-range values
        if newMin > -float("inf") and newMax < float("inf"):
            min_, max_ = newMin, newMax
    return min_, max_


class _Unset(object):
    """To be able to have distinction between None and unset"""

    pass


class ViewConstraints(object):
    """
    Store constraints applied on the view box and compute the resulting view box.
    """

    def __init__(self):
        self._min = [None, None]
        self._max = [None, None]
        self._minRange = [None, None]
        self._maxRange = [None, None]

    def update(
        self,
        xMin=_Unset,
        xMax=_Unset,
        yMin=_Unset,
        yMax=_Unset,
        minXRange=_Unset,
        maxXRange=_Unset,
        minYRange=_Unset,
        maxYRange=_Unset,
    ):
        """
        Update the constraints managed by the object

        The constraints are the same as the ones provided by PyQtGraph.

        :param float xMin: Minimum allowed x-axis value.
            (default do not change the stat, None remove the constraint)
        :param float xMax: Maximum allowed x-axis value.
            (default do not change the stat, None remove the constraint)
        :param float yMin: Minimum allowed y-axis value.
            (default do not change the stat, None remove the constraint)
        :param float yMax: Maximum allowed y-axis value.
            (default do not change the stat, None remove the constraint)
        :param float minXRange: Minimum allowed left-to-right span across the
            view (default do not change the stat, None remove the constraint)
        :param float maxXRange: Maximum allowed left-to-right span across the
            view (default do not change the stat, None remove the constraint)
        :param float minYRange: Minimum allowed top-to-bottom span across the
            view (default do not change the stat, None remove the constraint)
        :param float maxYRange: Maximum allowed top-to-bottom span across the
            view (default do not change the stat, None remove the constraint)
        :return: True if the constraints was changed
        """
        updated = False

        minRange = [minXRange, minYRange]
        maxRange = [maxXRange, maxYRange]
        minPos = [xMin, yMin]
        maxPos = [xMax, yMax]

        for axis in range(2):
            value = minPos[axis]
            if value is not _Unset and value != self._min[axis]:
                self._min[axis] = value
                updated = True

            value = maxPos[axis]
            if value is not _Unset and value != self._max[axis]:
                self._max[axis] = value
                updated = True

            value = minRange[axis]
            if value is not _Unset and value != self._minRange[axis]:
                self._minRange[axis] = value
                updated = True

            value = maxRange[axis]
            if value is not _Unset and value != self._maxRange[axis]:
                self._maxRange[axis] = value
                updated = True

        # Sanity checks

        for axis in range(2):
            if (
                self._maxRange[axis] is not None
                and self._min[axis] is not None
                and self._max[axis] is not None
            ):
                # max range cannot be larger than bounds
                diff = self._max[axis] - self._min[axis]
                self._maxRange[axis] = min(self._maxRange[axis], diff)
                updated = True

        return updated

    def normalize(self, xMin, xMax, yMin, yMax, allow_scaling=True):
        """Normalize a view range defined by x and y corners using predefined
        containts.

        :param float xMin: Min position of the x-axis
        :param float xMax: Max position of the x-axis
        :param float yMin: Min position of the y-axis
        :param float yMax: Max position of the y-axis
        :param bool allow_scaling: Allow or not to apply scaling for the
            normalization. Used according to the interaction mode.
        :return: A normalized tuple of (xMin, xMax, yMin, yMax)
        """
        viewRange = [[xMin, xMax], [yMin, yMax]]

        for axis in range(2):
            # clamp xRange and yRange
            if allow_scaling:
                diff = viewRange[axis][1] - viewRange[axis][0]
                delta = None
                if self._maxRange[axis] is not None and diff > self._maxRange[axis]:
                    delta = self._maxRange[axis] - diff
                elif self._minRange[axis] is not None and diff < self._minRange[axis]:
                    delta = self._minRange[axis] - diff
                if delta is not None:
                    viewRange[axis][0] -= delta * 0.5
                    viewRange[axis][1] += delta * 0.5

            # clamp min and max positions
            outMin = (
                self._min[axis] is not None and viewRange[axis][0] < self._min[axis]
            )
            outMax = (
                self._max[axis] is not None and viewRange[axis][1] > self._max[axis]
            )

            if outMin and outMax:
                if allow_scaling:
                    # we can clamp both sides
                    viewRange[axis][0] = self._min[axis]
                    viewRange[axis][1] = self._max[axis]
                else:
                    # center the result
                    delta = viewRange[axis][1] - viewRange[axis][0]
                    mid = self._min[axis] + self._max[axis] - self._min[axis]
                    viewRange[axis][0] = mid - delta
                    viewRange[axis][1] = mid + delta
            elif outMin:
                delta = self._min[axis] - viewRange[axis][0]
                viewRange[axis][0] += delta
                viewRange[axis][1] += delta
            elif outMax:
                delta = self._max[axis] - viewRange[axis][1]
                viewRange[axis][0] += delta
                viewRange[axis][1] += delta

        return viewRange[0][0], viewRange[0][1], viewRange[1][0], viewRange[1][1]
