# -*- coding: utf-8 -*-
#
# Copyright (c) 2020 Martin Owens <doctormo@gmail.com>
#                    Sergei Izmailov <sergei.a.izmailov@gmail.com>
#                    Thomas Holder <thomas.holder@schrodinger.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.
#
# pylint: disable=arguments-differ
"""
Interface for all shapes/polygons such as lines, paths, rectangles, circles etc.
"""

from __future__ import annotations

from math import cos, pi, sin
import math
from typing import Optional, Tuple, Union

from ..paths.interfaces import PathCommand
from ..paths import Arc, Curve, Move, Path, ZoneClose
from ..paths import Line as PathLine
from ..transforms import Transform, ImmutableVector2d, Vector2d
from ..bezier import pointdistance

from ._utils import addNS
from ._base import ShapeElement


class PathElementBase(ShapeElement):
    """Base element for path based shapes"""

    def get_path(self) -> Path:
        """Gets the path of the element, which can also be used in a context manager"""
        p = Path(self.get("d"))
        p.callback = self.set_path
        return p

    @classmethod
    def new(cls, path, **attrs):
        return super().new(d=Path(path), **attrs)

    def set_path(self, path):
        """Set the given data as a path as the 'd' attribute"""
        self.set("d", str(Path(path)))

    def apply_transform(self):
        """Apply the internal transformation to this node and delete"""
        if "transform" in self.attrib:
            self.path = self.path.transform(self.transform)
            self.set("transform", Transform())

    @property
    def original_path(self):
        """Returns the original path if this is a LPE, or the path if not"""
        return Path(self.get("inkscape:original-d", self.path))

    @original_path.setter
    def original_path(self, path):
        if addNS("inkscape:original-d") in self.attrib:
            self.set("inkscape:original-d", str(Path(path)))
        else:
            self.path = path


class PathElement(PathElementBase):
    """Provide a useful extension for path elements"""

    tag_name = "path"

    MAX_ARC_SUBDIVISIONS = 4

    @staticmethod
    def _arcpath(
        cx: float,
        cy: float,
        rx: float,
        ry: float,
        start: float,
        end: float,
        arctype: str,
    ) -> Optional[Path]:
        """Compute the path for an arc defined by Inkscape-specific attributes.

        For details on arguments, see :func:`arc`.

        .. versionadded:: 1.2"""
        if abs(rx) < 1e-8 or abs(ry) < 1e-8:
            return None
        incr = end - start
        if incr < 0:
            incr += 2 * pi
        numsegs = min(1 + int(incr * 2.0 / pi), PathElement.MAX_ARC_SUBDIVISIONS)
        incr = incr / numsegs

        computed = Path()
        computed.append(Move(cos(start), sin(start)))
        for seg in range(1, numsegs + 1):
            computed.append(
                Arc(
                    1,
                    1,
                    0,
                    incr > pi,
                    1,
                    cos(start + seg * incr),
                    sin(start + seg * incr),
                )
            )
        if abs(incr * numsegs - 2 * pi) > 1e-8 and (
            arctype in ("slice", "")
        ):  # slice is default
            computed.append(PathLine(0, 0))
        if arctype != "arc":
            computed.append(ZoneClose())
        computed.transform(
            Transform().add_translate(cx, cy).add_scale(rx, ry), inplace=True
        )
        return computed.to_relative()

    @classmethod
    def arc(cls, center, rx, ry=None, arctype="", pathonly=False, **kw):  # pylint: disable=invalid-name
        """Generates a sodipodi elliptical arc (special type). Also computes the path
        that Inkscape uses under the hood.
        All data may be given as parseable strings or using numeric data types.

        Args:
            center (tuple-like): Coordinates of the star/polygon center as tuple or
                Vector2d
            rx (Union[float, str]): Radius in x direction
            ry (Union[float, str], optional): Radius in y direction. If not given,
                ry=rx. Defaults to None.
            arctype (str, optional): "arc", "chord" or "slice". Defaults to "", i.e.
                "slice".

                .. versionadded:: 1.2
                    Previously set to "arc" as fixed value
            pathonly (bool, optional): Whether to create the path without
                Inkscape-specific attributes. Defaults to False.

                .. versionadded:: 1.2
        Keyword args:
            start (Union[float, str]): start angle in radians
            end (Union[float, str]): end angle in radians
            open (str): whether the path should be open (true/false). Not used in
                Inkscape > 1.1

        Returns:
            PathElement : the created star/polygon
        """
        others = [(name, kw.pop(name, None)) for name in ("start", "end", "open")]
        elem = cls(**kw)
        elem.set("sodipodi:cx", center[0])
        elem.set("sodipodi:cy", center[1])
        elem.set("sodipodi:rx", rx)
        elem.set("sodipodi:ry", ry or rx)
        elem.set("sodipodi:type", "arc")
        if arctype != "":
            elem.set("sodipodi:arc-type", arctype)
        for name, value in others:
            if value is not None:
                elem.set("sodipodi:" + name, str(value).lower())

        path = cls._arcpath(
            float(center[0]),
            float(center[1]),
            float(rx),
            float(ry or rx),
            float(elem.get("sodipodi:start", 0)),
            float(elem.get("sodipodi:end", 2 * pi)),
            arctype,
        )
        if pathonly:
            elem = cls(**kw)
        if path is not None:
            elem.path = path
        return elem

    @classmethod
    def arc_from_3_points(
        cls,
        x: complex,
        y: complex,
        z: complex,
        arctype="slice",
    ) -> PathElement:
        """
        Create an arc through the points x, y, z.
        If those points are specified clockwise, the order is not preserved.
        This is indicated with the second return value (=clockwise)

        Returns a PathElement. May be a line if x,y,z are collinear.


        Idea: http://www.math.okstate.edu/~wrightd/INDRA/MobiusonCircles/node4.html

        .. versionadded:: 1.4
        """
        w = (z - x) / (y - x)
        if abs(w.imag) > 1e-12:
            c = -((x - y) * (w - abs(w) ** 2) / (2j * w.imag) - x)
            r = abs(c - x)

            # Now determine the arc flags by checking the angles
            deltas = [x - c, y - c, z - c]
            ang = [math.atan2(i.imag, i.real) for i in deltas]
            # Check if the angles are "in order"
            cw = int(any(ang[0 + i] < ang[-2 + i] < ang[-1 + i] for i in range(3)))
            if not cw:
                # Flip start and end angle
                ang = ang[::-1]

            return cls.arc(Vector2d(c), r, r, start=ang[0], end=ang[2], arctype=arctype)
        else:
            # Points lie on a line
            # y between x and z -> draw a line, otherwise skip
            if x.real <= y.real <= z.real or x.real >= y.real >= z.real:
                return cls.new(Path([Move(x), PathLine(z)]))
            else:
                return cls.new(Path([Move(x), Move(z)]))

    @staticmethod
    def _starpath(
        c: Tuple[float, float],
        sides: int,
        r: Tuple[float, float],  # pylint: disable=invalid-name
        arg: Tuple[float, float],
        rounded: float,
        flatsided: bool,
    ):
        """Helper method to generate the path for an Inkscape star/ polygon; randomized
        is ignored.

        For details on arguments, see :func:`star`.

        .. versionadded:: 1.2"""

        def _star_get_xy(point, index):
            cur_arg = arg[point] + 2 * pi / sides * (index % sides)
            return Vector2d(*c) + r[point] * Vector2d(cos(cur_arg), sin(cur_arg))

        def _rot90_rel(origin, other):
            """Returns a unit length vector at 90 deg from origin to other"""
            return (
                1
                / pointdistance(other, origin)
                * Vector2d(other.y - origin.y, other.x - origin.x)
            )

        def _star_get_curvepoint(point, index, is_prev: bool):
            index = index % sides
            orig = _star_get_xy(point, index)
            previ = (index - 1 + sides) % sides
            nexti = (index + 1) % sides
            # neighbors of the current point depend on polygon or star
            prev = (
                _star_get_xy(point, previ)
                if flatsided
                else _star_get_xy(1 - point, index if point == 1 else previ)
            )
            nextp = (
                _star_get_xy(point, nexti)
                if flatsided
                else _star_get_xy(1 - point, index if point == 0 else nexti)
            )
            mid = 0.5 * (prev + nextp)
            # direction of bezier handles
            rot = _rot90_rel(orig, mid + 100000 * _rot90_rel(mid, nextp))
            ret = (
                rounded
                * rot
                * (
                    -1 * pointdistance(prev, orig)
                    if is_prev
                    else pointdistance(nextp, orig)
                )
            )
            return orig + ret

        pointy = abs(rounded) < 1e-4
        result = Path()
        result.append(Move(*_star_get_xy(0, 0)))
        for i in range(0, sides):
            # draw to point type 1 for stars
            if not flatsided:
                if pointy:
                    result.append(PathLine(*_star_get_xy(1, i)))
                else:
                    result.append(
                        Curve(
                            *_star_get_curvepoint(0, i, False),
                            *_star_get_curvepoint(1, i, True),
                            *_star_get_xy(1, i),
                        )
                    )
            # draw to point type 0 for both stars and rectangles
            if pointy and i < sides - 1:
                result.append(PathLine(*_star_get_xy(0, i + 1)))
            if not pointy:
                if not flatsided:
                    result.append(
                        Curve(
                            *_star_get_curvepoint(1, i, False),
                            *_star_get_curvepoint(0, i + 1, True),
                            *_star_get_xy(0, i + 1),
                        )
                    )
                else:
                    result.append(
                        Curve(
                            *_star_get_curvepoint(0, i, False),
                            *_star_get_curvepoint(0, i + 1, True),
                            *_star_get_xy(0, i + 1),
                        )
                    )

        result.append(ZoneClose())
        return result.to_relative()

    @classmethod
    def star(
        cls,
        center,
        radii,
        sides=5,
        rounded=0,
        args=(0, 0),
        flatsided=False,
        pathonly=False,
    ):
        """Generate a sodipodi star / polygon. Also computes the path that Inkscape uses
        under the hood. The arguments for center, radii, sides, rounded and args can be
        given as strings or as numeric data.

        .. versionadded:: 1.1

        Args:
            center (Tuple-like): Coordinates of the star/polygon center as tuple or
                Vector2d
            radii (tuple): Radii of the control points, i.e. their distances from the
                center. The control points are specified in polar coordinates. Only the
                first control point is used for polygons.
            sides (int, optional): Number of sides / tips of the polygon / star.
                Defaults to 5.
            rounded (int, optional): Controls the rounding radius of the polygon / star.
                For `rounded=0`, only straight lines are used. Defaults to 0.
            args (tuple, optional): Angle between horizontal axis and control points.
                Defaults to (0,0).

                .. versionadded:: 1.2
                    Previously fixed to (0.85, 1.3)
            flatsided (bool, optional): True for polygons, False for stars.
                Defaults to False.

                .. versionadded:: 1.2
            pathonly (bool, optional): Whether to create the path without
                Inkscape-specific attributes. Defaults to False.

                .. versionadded:: 1.2

        Returns:
            PathElement : the created star/polygon
        """
        elem = cls()
        elem.set("sodipodi:cx", center[0])
        elem.set("sodipodi:cy", center[1])
        elem.set("sodipodi:r1", radii[0])
        elem.set("sodipodi:r2", radii[1])
        elem.set("sodipodi:arg1", args[0])
        elem.set("sodipodi:arg2", args[1])
        elem.set("sodipodi:sides", max(sides, 3) if flatsided else max(sides, 2))
        elem.set("inkscape:rounded", rounded)
        elem.set("inkscape:flatsided", str(flatsided).lower())
        elem.set("sodipodi:type", "star")

        path = cls._starpath(
            (float(center[0]), float(center[1])),
            int(sides),
            (float(radii[0]), float(radii[1])),
            (float(args[0]), float(args[1])),
            float(rounded),
            flatsided,
        )
        if pathonly:
            elem = cls()
        # inkex.errormsg(path)
        if path is not None:
            elem.path = path

        return elem


class Polyline(ShapeElement):
    """Like a path, but made up of straight line segments only"""

    tag_name = "polyline"

    def get_path(self) -> Path:
        p = Path("M" + self.get("points"))
        p.callback = self.set_path
        return p

    def set_path(self, path):
        if type(path) != Path:
            path = Path("M" + str(path))
        points = [f"{x:g},{y:g}" for x, y in path.end_points]
        self.set("points", " ".join(points))

    @classmethod
    def new(cls, points=None, **attrs):
        p = super().new(**attrs)
        p.path = points
        return p


class Polygon(Polyline):
    """A closed polyline"""

    tag_name = "polygon"

    def get_path(self) -> Path:
        p = Path("M" + self.get("points") + " Z")
        p.callback = self.set_path
        return p


class Line(ShapeElement):
    """A line segment connecting two points"""

    tag_name = "line"
    x1 = property(lambda self: self.to_dimensionless(self.get("x1", 0)))
    y1 = property(lambda self: self.to_dimensionless(self.get("y1", 0)))
    x2 = property(lambda self: self.to_dimensionless(self.get("x2", 0)))
    y2 = property(lambda self: self.to_dimensionless(self.get("y2", 0)))
    get_path = lambda self: Path(f"M{self.x1},{self.y1} L{self.x2},{self.y2}")

    @classmethod
    def new(cls, start, end, **attrs):
        start = Vector2d(start)
        end = Vector2d(end)
        return super().new(x1=start.x, y1=start.y, x2=end.x, y2=end.y, **attrs)


class RectangleBase(ShapeElement):
    """Provide a useful extension for rectangle elements"""

    left = property(lambda self: self.to_dimensionless(self.get("x", "0")))
    top = property(lambda self: self.to_dimensionless(self.get("y", "0")))
    right = property(lambda self: self.left + self.width)
    bottom = property(lambda self: self.top + self.height)
    width = property(lambda self: self.to_dimensionless(self.get("width", "0")))
    height = property(lambda self: self.to_dimensionless(self.get("height", "0")))
    rx = property(
        lambda self: self.to_dimensionless(self.get("rx", self.get("ry", 0.0)))
    )
    ry = property(
        lambda self: self.to_dimensionless(self.get("ry", self.get("rx", 0.0)))
    )  # pylint: disable=invalid-name

    def get_path(self) -> Path:
        """Calculate the path as the box around the rect"""
        if self.rx or self.ry:
            # pylint: disable=invalid-name
            rx = min(self.rx if self.rx > 0 else self.ry, self.width / 2)
            ry = min(self.ry if self.ry > 0 else self.rx, self.height / 2)
            cpts = [self.left + rx, self.right - rx, self.top + ry, self.bottom - ry]
            return Path(
                f"M {cpts[0]},{self.top}"
                f"L {cpts[1]},{self.top}    "
                f"A {rx},{ry} 0 0 1 {self.right},{cpts[2]}"
                f"L {self.right},{cpts[3]}  "
                f"A {rx},{ry} 0 0 1 {cpts[1]},{self.bottom}"
                f"L {cpts[0]},{self.bottom} "
                f"A {rx},{ry} 0 0 1 {self.left},{cpts[3]}"
                f"L {self.left},{cpts[2]}   "
                f"A {rx},{ry} 0 0 1 {cpts[0]},{self.top} z"
            )

        return Path(
            f"M {self.left},{self.top} h{self.width}v{self.height}h{-self.width} z"
        )


class Rectangle(RectangleBase):
    """Provide a useful extension for rectangle elements"""

    tag_name = "rect"

    @classmethod
    def new(cls, left, top, width, height, **attrs):
        return super().new(x=left, y=top, width=width, height=height, **attrs)


class EllipseBase(ShapeElement):
    """Absorbs common part of Circle and Ellipse classes"""

    def get_path(self) -> Path:
        """Calculate the arc path of this circle"""
        rx, ry = self.rxry()
        cx, y = self.center.x, self.center.y - ry
        return Path(
            (
                "M {cx},{y} "
                "a {rx},{ry} 0 1 0 {rx}, {ry} "
                "a {rx},{ry} 0 0 0 -{rx}, -{ry} z"
            ).format(cx=cx, y=y, rx=rx, ry=ry)
        )

    @property
    def center(self):
        """Return center of circle/ellipse"""
        return ImmutableVector2d(
            self.to_dimensionless(self.get("cx", "0")),
            self.to_dimensionless(self.get("cy", "0")),
        )

    @center.setter
    def center(self, value):
        value = Vector2d(value)
        self.set("cx", value.x)
        self.set("cy", value.y)

    def rxry(self):
        # type: () -> Vector2d
        """Helper function"""
        raise NotImplementedError()

    @classmethod
    def new(cls, center, radius, **attrs):
        circle = super().new(**attrs)
        circle.center = center
        circle.radius = radius
        return circle


class Circle(EllipseBase):
    """Provide a useful extension for circle elements"""

    tag_name = "circle"

    @property
    def radius(self) -> float:
        """Return radius of circle"""
        return self.to_dimensionless(self.get("r", "0"))

    @radius.setter
    def radius(self, value):
        self.set("r", self.to_dimensionless(value))

    def rxry(self):
        r = self.radius
        return Vector2d(r, r)


class Ellipse(EllipseBase):
    """Provide a similar extension to the Circle interface for ellipses"""

    tag_name = "ellipse"

    @property
    def radius(self) -> ImmutableVector2d:
        """Return radii of ellipse"""
        return ImmutableVector2d(
            self.to_dimensionless(self.get("rx", "0")),
            self.to_dimensionless(self.get("ry", "0")),
        )

    @radius.setter
    def radius(self, value):
        value = Vector2d(value)
        self.set("rx", str(value.x))
        self.set("ry", str(value.y))

    def rxry(self):
        return self.radius
