# coding=utf-8
#
# Copyright (C) 2018 Martin Owens <doctormo@gmail.com>
# Copyright (C) 2023 Jonathan Neuhauser <jonathan.neuhauser@outlook.com>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
"""Interfaces for path commands"""

from __future__ import annotations

import abc
import math
from typing import (
    List,
    Any,
    Tuple,
    Dict,
    Type,
    Generator,
    Union,
    TYPE_CHECKING,
    Optional,
    Callable,
)
from dataclasses import dataclass

import numpy as np
from ..utils import classproperty, rational_limit
from ..transforms import Vector2d, BoundingBox, Transform, ComplexLike

if TYPE_CHECKING:
    from .curves import Curve
    from .lines import Line


@dataclass
class LengthSettings:
    """Settings for :func:`PathCommand.length`

    .. versionadded::  1.4
    """

    min_depth: int = 5
    error: float = 1e-5


@dataclass
class ILengthSettings:
    """Settings for :func:`PathCommand.ilength`

    .. versionadded:: 1.4
    """

    min_depth: int = 5

    error: float = 1e-5
    """
    Error tolerance for the computations of the test segment that is performed
    for each iteration.

    The defaults from svgpathtools are ILENGTH_ERROR=ILENGTH_LENGTH_TOL=1e-12.
    This is rather slow, particularly _length (which then subdivides the path into
    2^12 or more segments and adds up the length). For visual editing, this is rather
    irrelevant (and a lot more accurate than the previous methods)."""

    length_tol: float = 1e-5
    """Total (absolute) tolerance of the resulting s value."""

    maxits: int = 10000


class PathCommand(abc.ABC):
    """
    Base class of all path commands
    """

    letter = ""
    # Number of arguments that follow this path commands letter
    nargs = -1

    @classproperty  # From python 3.9 on, just combine @classmethod and @property
    def name(cls):  # pylint: disable=no-self-argument
        """The full name of the segment (i.e. Line, Arc, etc)"""
        return cls.__name__  # pylint: disable=no-member

    @classproperty
    def next_command(self):
        """The implicit next command. This is for automatic chains where the next
        command isn't given, just a bunch on numbers which we automatically parse."""
        return self

    @property
    def is_relative(self) -> bool:
        """Whether the command is defined in relative coordinates, i.e. relative to
        the previous endpoint (lower case path command letter)"""
        raise NotImplementedError

    @property
    def is_absolute(self) -> bool:
        """Whether the command is defined in absolute coordinates (upper case path
        command letter)"""
        raise NotImplementedError

    def to_relative(self, prev: ComplexLike) -> RelativePathCommand:
        """Return absolute counterpart for absolute commands or copy for relative"""
        raise NotImplementedError

    def to_absolute(self, prev: ComplexLike) -> AbsolutePathCommand:
        """Return relative counterpart for relative commands or copy for absolute"""
        raise NotImplementedError

    def reverse(self, first: ComplexLike, prev: ComplexLike) -> PathCommand:
        """Reverse path command

        .. versionadded:: 1.1
        """
        raise NotImplementedError

    def to_non_shorthand(
        self,
        prev: ComplexLike,
        prev_control: ComplexLike,  # pylint: disable=unused-argument
    ) -> AbsolutePathCommand:
        """Return an absolute non-shorthand command

        .. versionadded:: 1.1
        """
        return self.to_absolute(prev)

    # The precision of the numbers when converting to string
    number_template = "{:.6g}"

    # Maps single letter path command to corresponding class
    # (filled at the bottom of file, when all classes already defined)
    _letter_to_class: Dict[str, Type[Any]] = {}

    @staticmethod
    def letter_to_class(letter):
        """Returns class for given path command letter"""
        return PathCommand._letter_to_class[letter]

    @property
    @abc.abstractmethod
    def args(self) -> List[float]:
        """Returns path command arguments as tuple of floats"""

    def control_points(
        self,
        first: ComplexLike,
        prev: ComplexLike,
        prev_prev: ComplexLike,
    ) -> Generator[Vector2d, None, None]:
        """Returns list of path command control points"""
        first, prev, prev_prev = complex(first), complex(prev), complex(prev_prev)
        yield from [Vector2d(i) for i in self.ccontrol_points(first, prev, prev_prev)]

    @abc.abstractmethod
    def ccontrol_points(
        self, first: complex, prev: complex, prev_prev: complex
    ) -> Tuple[complex, ...]:
        """Returns list of path command control points"""

    @classmethod
    def _argt(cls, sep):
        return sep.join([cls.number_template] * cls.nargs)

    def __str__(self):
        return f"{self.letter} {self._argt(' ').format(*self.args)}".strip()

    def __repr__(self):
        # pylint: disable=consider-using-f-string
        return "{{}}({})".format(self._argt(", ")).format(self.name, *self.args)

    def __eq__(self, other):
        previous = 0j
        if type(self) == type(other):  # pylint: disable=unidiomatic-typecheck
            return self.args == other.args
        if isinstance(other, tuple):
            return self.args == other
        if not isinstance(other, PathCommand):
            raise ValueError("Can't compare types")
        try:
            if self.is_relative == other.is_relative:
                return self.to_curve(previous) == other.to_curve(previous)
        except ValueError:
            pass
        return False

    @abc.abstractmethod
    def cend_point(self, first: complex, prev: complex) -> complex:
        """Complex version of end_point"""

    def end_point(self, first: ComplexLike, prev: ComplexLike) -> Vector2d:
        """Returns last control point of path command"""
        return Vector2d(self.cend_point(complex(first or 0), complex(prev or 0)))

    @abc.abstractmethod
    def update_bounding_box(
        self, first: complex, last_two_points: List[complex], bbox: BoundingBox
    ):
        """Enlarges given bbox to contain path element.

        Args:
            first (complex): first point of path. Required to calculate Z segment
            last_two_points (List[complex]): list with last two control points in abs
                coords.
            bbox (BoundingBox): bounding box to update
        """

    def to_curve(self, prev: ComplexLike, prev_prev: ComplexLike = 0) -> Curve:
        # pylint: disable=unused-argument
        """Convert command to :py:class:`Curve`

        Curve().to_curve() returns a copy
        """
        return NotImplemented

    def to_curves(self, prev: ComplexLike, prev_prev: ComplexLike = 0) -> List[Curve]:
        """Convert command to list of :py:class:`Curve` commands"""
        return [self.to_curve(prev, prev_prev)]

    def to_line(self, prev: ComplexLike) -> Line:
        # pylint: disable=unused-argument
        """Converts this segment to a line (copies if already a line)"""
        return NotImplemented

    @abc.abstractmethod
    def ccurve_points(
        self, first: complex, prev: complex, prev_prev: complex
    ) -> Tuple[complex, ...]:
        # pylint: disable=unused-argument
        """Converts the path element into a single cubic bezier"""

    # Derivation functionality

    def __check_t(self, t: Optional[float], allow_none=True):
        if not allow_none and (t is None and self.letter not in "zZmMlLhHvV"):
            raise ValueError("t=None only supported for Line-like commands")
        if t is not None and not 0 <= t <= 1:
            raise ValueError("t should be between 0 and 1")
        return t if t is not None else 0.0

    def cderivative(
        self,
        first: complex,
        prev: complex,
        prev_control: complex,
        t: Optional[float] = None,
        n: int = 1,
    ) -> complex:
        """Returns the nth derivative of the segment at t as a complex number.

        .. versionadded:: 1.4
        """
        if n < 1:
            raise ValueError("n should be a positive integer")
        # pylint: disable=protected-access
        return self._cderivative(first, prev, prev_control, self.__check_t(t), n)

    def derivative(
        self,
        first: ComplexLike,
        prev: ComplexLike,
        prev_control: ComplexLike,
        t: Optional[float] = None,
        n: int = 1,
    ) -> Vector2d:
        """Returns the nth derivative of the segment at t as a :class:`Vector2D`.

        .. versionadded:: 1.4
        """
        return Vector2d(
            self.cderivative(
                complex(first or 0),
                complex(prev or 0),
                complex(prev_control or 0),
                t,
                n,
            )
        )

    @abc.abstractmethod
    def _cderivative(
        self, first: complex, prev: complex, prev_control: complex, t: float, n: int = 1
    ) -> complex: ...

    def cunit_tangent(
        self,
        first: complex,
        prev: complex,
        prev_control: complex,
        t: Optional[float] = None,
    ) -> complex:
        """Returns the unit tangent of the segment at t as a complex number.

        ..versionadded:: 1.4
        """
        return self._cunit_tangent(first, prev, prev_control, self.__check_t(t))

    def unit_tangent(
        self,
        first: ComplexLike,
        prev: ComplexLike,
        prev_control: ComplexLike,
        t: Optional[float] = None,
    ) -> Vector2d:
        """Returns the unit tangent of the segment at t as a :class:`Vector2D`.

        ..versionadded:: 1.4
        """
        return Vector2d(
            self.cunit_tangent(
                complex(first or 0), complex(prev or 0), complex(prev_control or 0), t
            )
        )

    def _cunit_tangent(
        self, first: complex, prev: complex, prev_control: complex, t: float
    ) -> complex:
        dseg = self._cderivative(first, prev, t, 1)
        return dseg / abs(dseg)

    def cnormal(
        self,
        first: complex,
        prev: complex,
        prev_control: complex,
        t: Optional[float] = None,
    ) -> complex:
        """Returns the (right-hand-rule) normal vector of the segment at t as a complex
        number.

        ..versionadded:: 1.4
        """
        return self.cunit_tangent(first, prev, prev_control, t) * 1j

    def normal(
        self,
        first: ComplexLike,
        prev: ComplexLike,
        prev_control: ComplexLike,
        t: Optional[float] = None,
    ) -> Vector2d:
        """Returns the (right-hand-rule) normal vector of the segment at t as
        :class:`Vector2D`.

        ..versionadded:: 1.4
        """
        return Vector2d(
            self.cnormal(
                complex(first or 0), complex(prev or 0), complex(prev_control or 0), t
            )
        )

    def curvature(
        self,
        first: ComplexLike,
        prev: ComplexLike,
        prev_control: ComplexLike,
        t: Optional[float] = None,
    ) -> float:
        """Returns the curvature of the segment at t.

        ..versionadded:: 1.4
        """
        # pylint: disable=protected-access
        return self._curvature(
            complex(first or 0),
            complex(prev or 0),
            complex(prev_control or 0),
            self.__check_t(t),
        )

    @abc.abstractmethod
    def _curvature(
        self, first: complex, prev: complex, prev_control: complex, t: float
    ) -> float: ...

    # Point evaluation, splitting
    def cpoint(
        self, first: complex, prev: complex, prev_control: complex, t: float
    ) -> complex:
        """Returns the coordinates of the Bezier curve evaluated at t as complex number.

        .. versionadded:: 1.
        """
        return self._cpoint(first, prev, prev_control, self.__check_t(t, False))

    def point(
        self, first: ComplexLike, prev: ComplexLike, prev_control: ComplexLike, t: float
    ) -> Vector2d:
        """Returns the coordinates of the Bezier curve evaluated at t as :class:`Vector2d`.

        .. versionadded:: 1.4
        """
        return Vector2d(
            self.cpoint(
                complex(first or 0), complex(prev or 0), complex(prev_control or 0), t
            )
        )

    @abc.abstractmethod
    def _cpoint(
        self, first: complex, prev: complex, prev_control: complex, t: float
    ) -> complex: ...

    def split(
        self, first: ComplexLike, prev: ComplexLike, prev_control: ComplexLike, t: float
    ) -> Tuple[PathCommand, PathCommand]:
        """Returns two segments, whose union is this segment and which join at
        self.point(t).

        .. versionadded:: 1.4
        """
        # no simplification here, we want to preserve the original type
        return self._split(
            complex(first),
            complex(prev),
            complex(prev_control),
            self.__check_t(t, False),
        )

    @abc.abstractmethod
    def _split(
        self, first: complex, prev: complex, prev_control: complex, t: float
    ) -> Tuple[PathCommand, PathCommand]: ...

    # Line integration

    def length(
        self,
        first: ComplexLike,
        prev: ComplexLike,
        prev_control: ComplexLike,
        t0: float = 0,
        t1: float = 1,
        settings=LengthSettings(),
    ) -> float:
        """Returns the length of the segment between t0 and t1.

        .. versionadded:: 1.4
        """
        # pylint: disable=protected-access
        return self._length(
            complex(first),
            complex(prev),
            complex(prev_control),
            self.__check_t(t0, False),
            self.__check_t(t1, False),
            settings,
        )

    @abc.abstractmethod
    def _length(
        self,
        first: complex,
        prev: complex,
        prev_control: complex,
        t0: float = 0,
        t1: float = 1,
        settings=LengthSettings(),
    ) -> float: ...

    def ilength(
        self,
        first: ComplexLike,
        prev: ComplexLike,
        prev_control: ComplexLike,
        length: float,
        settings: ILengthSettings = ILengthSettings(),
    ):
        """Returns a float ``t``, such that ``self.length(0, t)`` is approximately
        ``length``.

        .. versionadded:: 1.4
        """
        # pylint: disable=protected-access
        return self._ilength(
            complex(first), complex(prev), complex(prev_control), length, settings
        )

    @abc.abstractmethod
    def _ilength(
        self,
        first: complex,
        prev: complex,
        prev_control: complex,
        length: float,
        settings: ILengthSettings = ILengthSettings(),
    ): ...


class RelativePathCommand(PathCommand):
    """
    Abstract base class for relative path commands.

    Implements most of methods of :py:class:`PathCommand` through
    conversion to :py:class:`AbsolutePathCommand`
    """

    @property
    def is_relative(self):
        return True

    @property
    def is_absolute(self):
        return False

    def to_relative(self, prev: ComplexLike) -> RelativePathCommand:
        return self.__class__(*self.args)

    def update_bounding_box(self, first, last_two_points, bbox):
        self.to_absolute(last_two_points[-1]).update_bounding_box(
            first, last_two_points, bbox
        )


class AbsolutePathCommand(PathCommand):
    """Absolute path command. Unlike :py:class:`RelativePathCommand` can be transformed
    directly."""

    @property
    def is_relative(self):
        return False

    @property
    def is_absolute(self):
        return True

    def to_absolute(self, prev: ComplexLike) -> AbsolutePathCommand:
        return self.__class__(*self.args)

    @abc.abstractmethod
    def transform(self, transform: Transform) -> AbsolutePathCommand:
        """Returns new transformed segment

        :param transform: a transformation to apply
        """

    def rotate(self, degrees: float, center: Vector2d) -> AbsolutePathCommand:
        """
        Returns new transformed segment

        :param degrees: rotation angle in degrees
        :param center: invariant point of rotation
        """
        return self.transform(Transform(rotate=(degrees, center[0], center[1])))

    def translate(self, dr: Vector2d) -> AbsolutePathCommand:
        """Translate or scale this path command by dr"""
        return self.transform(Transform(translate=dr))

    def scale(self, factor: Union[float, Tuple[float, float]]) -> AbsolutePathCommand:
        """Returns new transformed segment

        :param factor: scale or (scale_x, scale_y)
        """
        return self.transform(Transform(scale=factor))


class BezierArcComputationMixin:
    """Functionality that works the same way for Arcs, Cubic and Quadratic

    .. versionadded:: 1.4
    """

    _cpoint: Callable[[complex, complex, complex, float], complex]
    _cderivative: Callable[..., complex]
    poly: Callable[[complex, complex, complex, Optional[bool]], np.poly1d]

    def inv_arclength(self, prev, prev_control, s, settings=ILengthSettings()):
        """Ported from https://github.com/mathandy/svgpathtools/blob/19df25b99b405ec4fc7616b58384eca7879b6fd4/svgpathtools/path.py#L541
        (MIT Licensed)"""
        t_upper = 1
        t_lower = 0
        iteration = 0
        while iteration < settings.maxits:
            iteration += 1
            t = (t_lower + t_upper) / 2
            s_t = self._length(0j, prev, prev_control, t1=t, settings=settings)
            if abs(s_t - s) < settings.length_tol:
                return t
            elif s_t < s:  # t too small
                t_lower = t
            else:  # s < s_t, t too big
                t_upper = t
            if t_upper == t_lower:
                # warn("t is as close as a float can be to the correct value, "
                #        "but |s(t) - s| = {} > s_tol".format(abs(s_t-s)))
                return t
        raise Exception(f"Maximum iterations reached with s(t) - s = {s_t - s}.")

    def segment_length(
        self,
        start,
        end,
        start_point,
        end_point,
        pos_eval,
        settings=LengthSettings(),
        depth=0,
    ):
        """
        Ported from https://github.com/mathandy/svgpathtools/blob/19df25b99b405ec4fc7616b58384eca7879b6fd4/svgpathtools/path.py#L479
        (MIT licensed)
        # TODO better treatment of degenerate beziers from
        # https://github.com/linebender/kurbo/blob/c229a914d303c5989c9e6b1d766def2df27a8185/src/cubicbez.rs#L431
        """
        mid = (start + end) / 2
        mid_point = pos_eval(mid)
        length = abs(end_point - start_point)
        first_half = abs(mid_point - start_point)
        second_half = abs(end_point - mid_point)

        length2 = first_half + second_half
        if (length2 - length > settings.error) or (depth < settings.min_depth):
            # Calculate the length of each segment:
            depth += 1
            return self.segment_length(
                start, mid, start_point, mid_point, pos_eval, settings, depth
            ) + self.segment_length(
                mid, end, mid_point, end_point, pos_eval, settings, depth
            )
        # This is accurate enough.
        return length2

    def segment_curvature(self, prev: complex, prev_prev: complex, t: float):
        """Returns the curvature of the segment at t.

        Ported from https://github.com/mathandy/svgpathtools/blob/19df25b99b405ec4fc7616b58384eca7879b6fd4/svgpathtools/path.py#L386
        (MIT licensed)
        """

        dz = self._cderivative(0j, prev, prev_prev, t)
        ddz = self._cderivative(0j, prev, prev_prev, t, n=2)
        dx, dy = dz.real, dz.imag
        ddx, ddy = ddz.real, ddz.imag
        try:
            kappa = abs(dx * ddy - dy * ddx) / math.sqrt(dx * dx + dy * dy) ** 3
        except (ZeroDivisionError, FloatingPointError):
            # tangent vector is zero at t, use polytools to find limit
            p = self.poly(0j, prev, prev_prev, False)
            dp = p.deriv()
            ddp = dp.deriv()
            dx2, dy2 = np.real(dp), np.imag(dp)
            ddx2, ddy2 = np.real(ddp), np.imag(ddp)
            f2 = (dx2 * ddy2 - dy2 * ddx2) ** 2
            g2 = (dx2 * dx2 + dy2 * dy2) ** 3
            lim2 = rational_limit(f2, g2, t)
            if lim2 < 0:  # impossible, must be numerical error
                return 0
            kappa = math.sqrt(lim2)
        return kappa

    def _length(
        self,
        first: complex,
        prev: complex,
        prev_control: complex,
        t0=0,
        t1=1,
        settings=LengthSettings(),
    ) -> float:
        return self.segment_length(
            t0,
            t1,
            self._cpoint(first, prev, prev_control, t0),
            self._cpoint(first, prev, prev_control, t1),
            lambda t: self._cpoint(first, prev, prev_control, t),
            settings,
            0,
        )

    def _ilength(
        self,
        first: complex,
        prev: complex,
        prev_control: complex,
        length,
        settings=ILengthSettings(),
    ):
        return self.inv_arclength(prev, prev_control, length, settings)

    def _curvature(
        self, first: complex, prev: complex, prev_control: complex, t: float
    ) -> float:
        return self.segment_curvature(prev, prev_control, t)


class BezierComputationMixin:
    """Functionality that works the same for all Beziers (Quadratic, Cubic)

    .. versionadded:: 1.4
    """

    def _bpoints(self, prev, prev_prev):
        return (prev,) + self.ccontrol_points(0j, prev, prev_prev)

    def bezier_unit_tangent(self, prev, prev_prev, t):
        """Returns the unit tangent of the segment at t.

        Ported from https://github.com/mathandy/svgpathtools/blob/19df25b99b405ec4fc7616b58384eca7879b6fd4/svgpathtools/path.py#L348
        (MIT licensed)
        """

        dseg = self._cderivative(0j, prev, prev_prev, t)

        try:
            unit_tangent = dseg / abs(dseg)
        except (ZeroDivisionError, FloatingPointError):
            # This may be a removable singularity, if so we just need to compute
            # the limit.
            # Note: limit{{dseg / abs(dseg)} = sqrt(limit{dseg**2 / abs(dseg)**2})
            dseg_poly = self.poly(prev, prev_prev).deriv()
            dseg_abs_squared_poly = np.real(dseg_poly) ** 2 + np.imag(dseg_poly) ** 2
            try:
                unit_tangent = np.sqrt(
                    rational_limit(dseg_poly**2, dseg_abs_squared_poly, t)
                )
            except ValueError:
                bef = self.poly(prev, prev_prev).deriv()(t - 1e-4)
                aft = self.poly(prev, prev_prev).deriv()(t + 1e-4)
                mes = (
                    "Unit tangent appears to not be well-defined at "
                    f"t = {t}, \n"
                    f"seg.poly().deriv()(t - 1e-4) = {bef}\n"
                    f"seg.poly().deriv()(t + 1e-4) = {aft}"
                )
                raise ValueError(mes)
        return unit_tangent
