# system modules
from dataclasses import dataclass
from typing import Callable
import itertools
import functools
import warnings

# external modules
import numpy as np
import scipy.optimize


@dataclass
@functools.total_ordering
class Extremum:
    """
    Container for min and max in Easing
    """

    pos: float
    value: float

    def __eq__(self, other):
        return self.value == other.value

    def __lt__(self, other):
        return self.value < other.value


@dataclass
@functools.total_ordering
class Easing:
    """
    A function defined on the interval [0;1]
    """

    f: Callable[float, float]
    name: str

    def modifier(decorated_fun):
        @functools.wraps(decorated_fun)
        def wrapper(self, *args, **kwargs):
            newfun = decorated_fun(self, *args, **kwargs)
            arglist = ",".join(
                itertools.chain(map(str, args), (f"{k}={v}" for k, v in kwargs.items()))
            )
            newfun.__name__ = f"{self.f.__name__}.{decorated_fun.__name__}({arglist})"
            return type(self)(f=newfun, name=newfun.__name__)

        return wrapper

    def __repr__(self):
        return self.name

    def __str__(self):
        return self.name

    @functools.cached_property
    def is_ascending(self):
        return np.all(np.diff(self.f(np.linspace(0, 1, 100))) >= 0)

    @functools.cached_property
    def is_symmetric(self):
        t = np.linspace(0, 0.5, 100)
        return np.allclose(self.f(t), self.f(1 - t))

    @property
    @modifier
    def reverse(self):
        """
        Revert the function so it goes the other way round (starts at the end)
        """
        return lambda t: self.f(1 - t)

    @property
    @modifier
    def symmetric(self):
        """
        Mirror and squash function to make it symmetric
        """
        return lambda t: self.f(-2 * (np.abs(t - 0.5) - 0.5))

    @modifier
    def mirror(self, x=None, y=None, copy=False):
        """
        Mirror function around an x and/or y value.

        Args:
            x (float): x value to mirror around
            y (float): y value to mirror around
            copy (bool): when mirroring around x, do copy-mirror
        """
        if (x, y) == (None, None):
            x = 0.5

        def mirrored(t):
            if x is not None:
                t = 2 * x - t
            if copy:
                t = np.abs(-t)
            if y is None:
                return self.f(t)
            else:
                return y - self.f(t)

        return mirrored

    @modifier
    def clip(self, min=None, max=None):
        """
        Clip function at low and/or high values
        """
        if min is None and max is None:
            min = 0
            max = 1
        return lambda t: np.clip(self.f(t), min, max)

    @modifier
    def clip_input(self, min=None, max=None):
        """
        Clip input parameter, i.e. extrapolate constantly outside the interval.
        """
        if min is None and max is None:
            min = 0
            max = 1
        return lambda t: self.f(np.clip(t, min, max))

    @property
    @modifier
    def clipped(self):
        """
        Clipped parameter and result to [0;1]
        """
        return lambda t: np.clip(self(np.clip(t, 0, 1)), 0, 1)

    @modifier
    def append(self, other, e=None):
        """
        Append another easing function and squish both into the [0;1] interval
        """
        if e is None:
            e = in_out_square

        def f(t):
            mix = e(t)
            return self.f(t * 2) * (1 - mix) + other((t - 0.5) * 2) * mix

        return f

    @modifier
    def prepend(self, other, e=None):
        """
        Prepend another easing function and squish both into the [0;1] interval
        """
        if e is None:
            e = in_out_square

        def f(t):
            mix = e(t)
            return other(t * 2) * (1 - mix) + self.f((t - 0.5) * 2) * mix

        return f

    @modifier
    def shift(self, offset):
        """
        Shift function on x-axis into positive direction by ``offset``.
        """
        return lambda t: self.f(t - offset)

    @modifier
    def repeat(self, n=2):
        """
        Repeat the function a total of n times in the interval [0;1].
        """
        return lambda t: self.f(t % (1 / n) * n)

    @modifier
    def multiply(self, factor):
        """
        Scale function by ``factor``
        """
        if isinstance(factor, Easing):
            return lambda t: self(t) * factor(t)
        else:
            return lambda t: factor * self.f(t)

    @modifier
    def add(self, offset):
        """
        Add ``offset`` to function
        """
        if isinstance(offset, Easing):
            return lambda t: self(t) + offset(t)
        else:
            return lambda t: self.f(t) + offset

    def __add__(self, offset):
        return self.add(offset)

    def __radd__(self, offset):
        return self.add(offset)

    def __sub__(self, offset):
        return self.add(-offset)

    def __rsub__(self, offset):
        return self.add(-offset)

    def __mul__(self, factor):
        return self.multiply(factor)

    def __rmul__(self, factor):
        return self.multiply(factor)

    def __neg__(self):
        return self.multiply(-1)

    def __truediv__(self, factor):
        return self.multiply(1 / factor)

    def __or__(self, other):
        return self.transition(other)

    def __rshift__(self, offset):
        return self.shift(offset)

    def __lshift__(self, offset):
        return self.shift(-offset)

    def __getitem__(self, index):
        if isinstance(index, Easing):
            return self.chain(index)
        if isinstance(index, slice):
            return self.zoom(
                0 if index.start is None else index.start,
                1 if index.stop is None else index.stop,
            )
        else:
            raise ValueError(
                f"{index = } has to be slice of floats or an easing function"
            )

    @modifier
    def chain(self, f=None):
        """
        Feed parameter through the given function before evaluating this function.
        """
        if f is None:
            f = self.f
        return lambda t: self.f(f(t))

    @modifier
    def zoom(self, left, right=None):
        """
        Arrange so that the interval [left;right] is moved into [0;1]
        If only one argument is given, zoom in/out by moving edges that far.
        """
        if left is not None and right is None:
            if left >= 0.5:
                raise ValueError(
                    f"{left = } is > 0.5 which doesn't make sense (bounds would cross)"
                )
            left = left
            right = 1 - left
        if left >= right:
            raise ValueError(f"{right = } bound must be greater than {left = }")
        return self.chain(linear.between(left, right)).f

    @modifier
    def between(self, left=0, right=1, e=None):
        """
        Arrange so ``f(0)==a`` and ``f(1)==b``.
        """
        f0, f1 = self.f(np.array([0, 1]))
        la = f0 - left
        lb = f1 - right
        if e is None:  # linear is defined later
            e = (
                self  # use ourself as transition when we're ascending within [0;1]
                if (self.is_ascending and np.allclose(self.f(np.array([0, 1])), [0, 1]))
                else linear
            )

        def f(t):
            t_ = e(t)
            return self.f(t_) - (la * (1 - t_)) - lb * t_

        return f

    @modifier
    def transition(self, other, e=None):
        """
        Transiton from one easing to another
        """
        if e is None:
            e = linear

        def f(t):
            t_ = e(t)
            return self.f(t) * (1 - t_) + other(t) * t_

        return f

    @classmethod
    def function(cls, decorated_fun):
        return cls(f=decorated_fun, name=decorated_fun.__name__)

    def plot(self, *others, xlim=(0, 1), ax=None):
        import matplotlib.pyplot as plt  # lazy import for speed
        from cycler import cycler

        if ax is None:
            fig, ax_ = plt.subplots()
        else:
            ax_ = ax

        try:
            ax_.set_prop_cycle(
                cycler(linestyle=["solid", "dashed", "dotted"], linewidth=[1, 1, 2])
                * plt.rcParams["axes.prop_cycle"]
            )
        except ValueError as e:
            pass

        t = np.linspace(*xlim, 1000)
        funs = list(others or [])
        if isinstance(self, Easing):
            funs.insert(0, self)
        for f in funs:
            ax_.plot(t, f(t), label=getattr(f, "name", getattr(f, "__name__", str(f))))
        ax_.legend(ncol=int(np.ceil(len(ax_.get_lines()) / 10)))
        if ax is None:
            plt.show()
        return ax_

    @functools.cached_property
    def min(self):
        v = self.f(t := np.linspace(0, 1, 1000))
        approxmin = Extremum(pos=t[i := np.argmin(v)], value=v[i])
        opt = scipy.optimize.minimize(self, x0=[approxmin.pos], bounds=[(0, 1)])
        optmin = Extremum(pos=opt.x[0], value=opt.fun)
        return min(approxmin, optmin)

    @functools.cached_property
    def max(self):
        """
        Determine the maximum value
        """
        v = self.f(t := np.linspace(0, 1, 1000))
        approxmax = Extremum(pos=t[i := np.argmax(v)], value=v[i])
        opt = scipy.optimize.minimize(-self, x0=[approxmax.pos], bounds=[(0, 1)])
        optmax = Extremum(pos=opt.x[0], value=-opt.fun)
        return max(approxmax, optmax)

    @functools.cached_property
    def mean(self):
        return np.mean(self.f(np.linspace(0, 1, 1000)))

    def __lt__(self, e):
        return np.all(self.f(t := np.linspace(0, 1, 50)) < e.f(t))

    def __eq__(self, e):
        return np.allclose(self.f(t := np.linspace(0, 1, 50)), e.f(t))

    def __call__(self, t):
        return self.f(t)


@Easing.function
def linear(t):
    return t


@Easing.function
def in_quad(t):
    return t * t


@Easing.function
def out_quad(t):
    return -t * (t - 2)


@Easing.function
def in_out_quad(t):
    u = 2 * t - 1
    a = 2 * t * t
    b = -0.5 * (u * (u - 2) - 1)
    return np.where(t < 0.5, a, b)


@Easing.function
def in_cubic(t):
    return t * t * t


@Easing.function
def out_cubic(t):
    u = t - 1
    return u * u * u + 1


@Easing.function
def in_out_cubic(t):
    u = t * 2
    v = u - 2
    a = 0.5 * u * u * u
    b = 0.5 * (v * v * v + 2)
    return np.where(u < 1, a, b)


@Easing.function
def in_quart(t):
    return t * t * t * t


@Easing.function
def out_quart(t):
    u = t - 1
    return -(u * u * u * u - 1)


@Easing.function
def in_out_quart(t):
    u = t * 2
    v = u - 2
    a = 0.5 * u * u * u * u
    b = -0.5 * (v * v * v * v - 2)
    return np.where(u < 1, a, b)


@Easing.function
def in_quint(t):
    return t * t * t * t * t


@Easing.function
def out_quint(t):
    u = t - 1
    return u * u * u * u * u + 1


@Easing.function
def in_out_quint(t):
    u = t * 2
    v = u - 2
    a = 0.5 * u * u * u * u * u
    b = 0.5 * (v * v * v * v * v + 2)
    return np.where(u < 1, a, b)


@Easing.function
def in_sine(t):
    return -np.cos(t * np.pi / 2) + 1


@Easing.function
def out_sine(t):
    return np.sin(t * np.pi / 2)


@Easing.function
def in_out_sine(t):
    return -0.5 * (np.cos(np.pi * t) - 1)


@Easing.function
def in_expo(t):
    a = np.zeros(len(t))
    b = 2 ** (10 * (t - 1))
    return np.where(t == 0, a, b)


@Easing.function
def out_expo(t):
    a = np.zeros(len(t)) + 1
    b = 1 - 2 ** (-10 * t)
    return np.where(t == 1, a, b)


@Easing.function
def in_out_expo(t):
    zero = np.zeros(len(t))
    one = zero + 1
    a = 0.5 * 2 ** (20 * t - 10)
    b = 1 - 0.5 * 2 ** (-20 * t + 10)
    return np.where(t == 0, zero, np.where(t == 1, one, np.where(t < 0.5, a, b)))


@Easing.function
def in_circ(t):
    return -1 * (np.sqrt(1 - t * t) - 1)


@Easing.function
def out_circ(t):
    u = t - 1
    return np.sqrt(1 - u * u)


@Easing.function
def in_out_circ(t):
    u = t * 2
    v = u - 2
    a = -0.5 * (np.sqrt(1 - u * u) - 1)
    b = 0.5 * (np.sqrt(1 - v * v) + 1)
    return np.where(u < 1, a, b)


@Easing.function
def in_elastic(t, k=0.5):
    u = t - 1
    return -1 * (2 ** (10.0 * u) * np.sin((u - k / 4) * (2 * np.pi) / k))


@Easing.function
def out_elastic(t, k=0.5):
    return 2 ** (-10.0 * t) * np.sin((t - k / 4) * (2 * np.pi / k)) + 1


@Easing.function
def in_out_elastic(t, k=0.5):
    u = t * 2
    v = u - 1
    a = -0.5 * (2 ** (10 * v) * np.sin((v - k / 4) * 2 * np.pi / k))
    b = 2 ** (-10 * v) * np.sin((v - k / 4) * 2 * np.pi / k) * 0.5 + 1
    return np.where(u < 1, a, b)


@Easing.function
def in_back(t):
    k = 1.70158
    return t * t * ((k + 1) * t - k)


@Easing.function
def out_back(t):
    k = 1.70158
    u = t - 1
    return u * u * ((k + 1) * u + k) + 1


@Easing.function
def in_out_back(t):
    k = 1.70158 * 1.525
    u = t * 2
    v = u - 2
    a = 0.5 * (u * u * ((k + 1) * u - k))
    b = 0.5 * (v * v * ((k + 1) * v + k) + 2)
    return np.where(u < 1, a, b)


@Easing.function
def in_bounce(t):
    return 1 - out_bounce(1 - t)


@Easing.function
def out_bounce(t):
    a = (121 * t * t) / 16
    b = (363 / 40 * t * t) - (99 / 10 * t) + 17 / 5
    c = (4356 / 361 * t * t) - (35442 / 1805 * t) + 16061 / 1805
    d = (54 / 5 * t * t) - (513 / 25 * t) + 268 / 25
    return np.where(t < 4 / 11, a, np.where(t < 8 / 11, b, np.where(t < 9 / 10, c, d)))


@Easing.function
def in_out_bounce(t):
    a = in_bounce(2 * t) * 0.5
    b = out_bounce(2 * t - 1) * 0.5 + 0.5
    return np.where(t < 0.5, a, b)


@Easing.function
def in_square(t):
    return np.heaviside(t - 1, 0)


@Easing.function
def out_square(t):
    return np.heaviside(t + 1, 0)


@Easing.function
def in_out_square(t):
    return np.heaviside(t - 0.5, 0)


def constant(x):
    return Easing(f=lambda t: np.full_like(t, x), name=f"constant({x})")


zero = constant(0)
one = constant(1)


@Easing.function
def smoothstep(t):
    t = np.clip(t, 0, 1)
    return 3 * t * t - 2 * t * t * t


def _main():
    import matplotlib.pyplot as plt
    from cycler import cycler

    plt.rcParams["axes.prop_cycle"] *= cycler(
        linestyle=["solid", "dashed", "dotted"], linewidth=[1, 2, 3]
    )
    plt.rcParams["figure.autolayout"] = True
    plt.rcParams["axes.grid"] = True
    plt.rcParams["axes.axisbelow"] = True
    plt.rcParams["legend.fontsize"] = "small"
    LOCALS = globals()
    print(f"{LOCALS = }")
    fig, axes = plt.subplots(nrows=2)
    Easing.plot(
        *sorted((obj for n, obj in LOCALS.items() if isinstance(obj, Easing)), key=str),
        ax=axes[0],
    )
    Easing.plot(
        in_sine.symmetric,
        in_out_sine.symmetric.multiply(-0.6),
        linear.symmetric.multiply(-0.7),
        in_out_sine.multiply(-0.6).symmetric,
        out_sine.multiply(-0.6).reverse.symmetric.multiply(2),
        out_bounce.add(-0.5),
        ax=axes[1],
    )
    axes[0].set_title("Standard")
    axes[1].set_title("Derived")
    plt.show()


if __name__ == "__main__":
    _main()
