"""
entities.py
--------------

Basic geometric primitives which only store references to
vertex indices rather than vertices themselves.
"""

from copy import deepcopy
from logging import getLogger

import numpy as np

from .. import util
from ..util import ABC
from .arc import arc_center, discretize_arc
from .curve import discretize_bezier, discretize_bspline

log = getLogger(__name__)


class Entity(ABC):
    def __init__(
        self, points, closed=None, layer=None, metadata=None, color=None, **kwargs
    ):
        # points always reference vertex indices and are int
        self.points = np.asanyarray(points, dtype=np.int64)
        # save explicit closed
        if closed is not None:
            self.closed = closed
        # save the passed layer
        if layer is not None:
            self.layer = layer
        if metadata is not None:
            self.metadata.update(metadata)

        self._cache = {}

        # save the passed color
        self.color = color
        # save any other kwargs for general use
        self.kwargs = kwargs

    @property
    def metadata(self):
        """
        Get any metadata about the entity.

        Returns
        ---------
        metadata : dict
          Bag of properties.
        """
        if not hasattr(self, "_metadata"):
            self._metadata = {}
        # note that we don't let a new dict be assigned
        return self._metadata

    @property
    def layer(self):
        """
        Set the layer the entity resides on as a shortcut
        to putting it in the entity metadata.

        Returns
        ----------
        layer : any
          Hashable layer identifier.
        """
        return self.metadata.get("layer")

    @layer.setter
    def layer(self, value):
        """
        Set the current layer of the entity.

        Returns
        ----------
        layer : any
          Hashable layer indicator
        """
        self.metadata["layer"] = value

    def to_dict(self) -> dict:
        """
        Returns a dictionary with all of the information
        about the entity.

        Returns
        -----------
        as_dict : dict
          Has keys 'type', 'points', 'closed'
        """
        return {
            "type": self.__class__.__name__,
            "points": self.points.tolist(),
            "closed": self.closed,
        }

    @property
    def closed(self):
        """
        If the first point is the same as the end point
        the entity is closed

        Returns
        -----------
        closed : bool
          Is the entity closed or not?
        """
        return len(self.points) > 2 and self.points[0] == self.points[-1]

    @property
    def nodes(self):
        """
        Returns an (n,2) list of nodes, or vertices on the path.
        Note that this generic class function assumes that all of the
        reference points are on the path which is true for lines and
        three point arcs.

        If you were to define another class where that wasn't the case
        (for example, the control points of a bezier curve),
        you would need to implement an entity- specific version of this
        function.

        The purpose of having a list of nodes is so that they can then be
        added as edges to a graph so we can use functions to check
        connectivity, extract paths, etc.

        The slicing on this function is essentially just tiling points
        so the first and last vertices aren't repeated. Example:

        self.points = [0,1,2]
        returns:      [[0,1], [1,2]]
        """
        return (
            np.column_stack((self.points, self.points)).reshape(-1)[1:-1].reshape((-1, 2))
        )

    @property
    def end_points(self):
        """
        Returns the first and last points. Also note that if you
        define a new entity class where the first and last vertices
        in self.points aren't the endpoints of the curve you need to
        implement this function for your class.

        Returns
        -------------
        ends : (2,) int
          Indices of the two end points of the entity
        """
        return self.points[[0, -1]]

    @property
    def is_valid(self):
        """
        Is the current entity valid.

        Returns
        -----------
        valid : bool
          Is the current entity well formed
        """
        return True

    def reverse(self, direction=-1):
        """
        Reverse the current entity in place.

        Parameters
        ----------------
        direction : int
          If positive will not touch direction
          If negative will reverse self.points
        """
        if direction < 0:
            self._direction = -1
        else:
            self._direction = 1

    def _orient(self, curve):
        """
        Reverse a curve if a flag is set.

        Parameters
        --------------
        curve : (n, dimension) float
          Curve made up of line segments in space

        Returns
        ------------
        orient : (n, dimension) float
          Original curve, but possibly reversed
        """
        if hasattr(self, "_direction") and self._direction < 0:
            return curve[::-1]
        return curve

    def bounds(self, vertices):
        """
        Return the AABB of the current entity.

        Parameters
        -----------
        vertices : (n, dimension) float
          Vertices in space

        Returns
        -----------
        bounds : (2, dimension) float
          Coordinates of AABB, in (min, max) form
        """
        bounds = np.array(
            [vertices[self.points].min(axis=0), vertices[self.points].max(axis=0)]
        )
        return bounds

    def length(self, vertices):
        """
        Return the total length of the entity.

        Parameters
        --------------
        vertices : (n, dimension) float
          Vertices in space

        Returns
        ---------
        length : float
          Total length of entity
        """
        diff = np.diff(self.discrete(vertices), axis=0) ** 2
        length = (np.dot(diff, [1] * vertices.shape[1]) ** 0.5).sum()
        return length

    def explode(self):
        """
        Split the entity into multiple entities.

        Returns
        ------------
        explode : list of Entity
          Current entity split into multiple entities.
        """
        return [self.copy()]

    def copy(self):
        """
        Return a copy of the current entity.

        Returns
        ------------
        copied : Entity
          Copy of current entity
        """
        copied = deepcopy(self)
        # only copy metadata if set
        if hasattr(self, "_metadata"):
            copied._metadata = deepcopy(self._metadata)
            # check for very annoying subtle copy failures
            assert id(copied._metadata) != id(self._metadata)
        assert id(copied.points) != id(self.points)
        return copied

    def __hash__(self):
        """
        Return a hash that represents the current entity.

        Returns
        ----------
        hashed : int
            Hash of current class name, points, and closed
        """
        return hash(self._bytes())

    def _bytes(self):
        """
        Get hashable bytes that define the current entity.

        Returns
        ------------
        data : bytes
          Hashable data defining the current entity
        """
        # give consistent ordering of points for hash
        if self.points[0] > self.points[-1]:
            return self.__class__.__name__.encode("utf-8") + self.points.tobytes()
        else:
            return self.__class__.__name__.encode("utf-8") + self.points[::-1].tobytes()


class Text(Entity):
    """
    Text to annotate a 2D or 3D path.
    """

    def __init__(
        self,
        origin,
        text,
        height=None,
        vector=None,
        normal=None,
        align=None,
        layer=None,
        color=None,
        metadata=None,
    ):
        """
        An entity for text labels.

        Parameters
        --------------
        origin : int
          Index of a single vertex for text origin
        text : str
          The text to label
        height : float or None
          The height of text
        vector : int or None
          An vertex index for which direction text
          is written along unitized: vector - origin
        normal : int or None
          A vertex index for the plane normal:
          vector is along unitized: normal - origin
        align : (2,) str or None
          Where to draw from for [horizontal, vertical]:
              'center', 'left', 'right'
        """
        # where is text placed
        self.origin = origin
        # what direction is the text pointing
        self.vector = vector
        # what is the normal of the text plane
        self.normal = normal
        # how high is the text entity
        self.height = height
        # what layer is the entity on
        if layer is not None:
            self.layer = layer

        if metadata is not None:
            self.metadata.update(metadata)

        # what color is the entity
        self.color = color

        # None or (2,) str
        if align is None:
            # if not set make everything centered
            align = ["center", "center"]
        elif isinstance(align, str):
            # if only one is passed set for both
            # horizontal and vertical
            align = [align, align]
        elif len(align) != 2:
            # otherwise raise rror
            raise ValueError("align must be (2,) str")

        self.align = align

        # make sure text is a string
        if hasattr(text, "decode"):
            self.text = text.decode("utf-8")
        else:
            self.text = str(text)

    @property
    def origin(self):
        """
        The origin point of the text.

        Returns
        -----------
        origin : int
          Index of vertices
        """
        return self.points[0]

    @origin.setter
    def origin(self, value):
        value = int(value)
        if not hasattr(self, "points") or np.ptp(self.points) == 0:
            self.points = np.ones(3, dtype=np.int64) * value
        else:
            self.points[0] = value

    @property
    def vector(self):
        """
        A point representing the text direction
        along the vector: vertices[vector] - vertices[origin]

        Returns
        ----------
        vector : int
          Index of vertex
        """
        return self.points[1]

    @vector.setter
    def vector(self, value):
        if value is None:
            return
        self.points[1] = int(value)

    @property
    def normal(self):
        """
        A point representing the plane normal along the
        vector: vertices[normal] - vertices[origin]

        Returns
        ------------
        normal : int
          Index of vertex
        """
        return self.points[2]

    @normal.setter
    def normal(self, value):
        if value is None:
            return
        self.points[2] = int(value)

    def plot(self, vertices, show=False):
        """
        Plot the text using matplotlib.

        Parameters
        --------------
        vertices : (n, 2) float
          Vertices in space
        show : bool
          If True, call plt.show()
        """
        if vertices.shape[1] != 2:
            raise ValueError("only for 2D points!")

        import matplotlib.pyplot as plt  # noqa

        # get rotation angle in degrees
        angle = np.degrees(self.angle(vertices))

        # TODO: handle text size better
        plt.text(
            *vertices[self.origin],
            s=self.text,
            rotation=angle,
            ha=self.align[0],
            va=self.align[1],
            size=18,
        )

        if show:
            plt.show()

    def angle(self, vertices):
        """
        If Text is 2D, get the rotation angle in radians.

        Parameters
        -----------
        vertices : (n, 2) float
          Vertices in space referenced by self.points

        Returns
        ---------
        angle : float
          Rotation angle in radians
        """

        if vertices.shape[1] != 2:
            raise ValueError("angle only valid for 2D points!")

        # get the vector from origin
        direction = vertices[self.vector] - vertices[self.origin]
        # get the rotation angle in radians
        angle = np.arctan2(*direction[::-1])

        return angle

    def length(self, vertices):
        return 0.0

    def discrete(self, *args, **kwargs):
        return np.array([])

    @property
    def closed(self):
        return False

    @property
    def is_valid(self):
        return True

    @property
    def nodes(self):
        return np.array([])

    @property
    def end_points(self):
        return np.array([])

    def _bytes(self):
        data = b"".join([b"Text", self.points.tobytes(), self.text.encode("utf-8")])
        return data


class Line(Entity):
    """
    A line or poly-line entity
    """

    def discrete(self, vertices, scale=1.0):
        """
        Discretize into a world- space path.

        Parameters
        ------------
        vertices: (n, dimension) float
          Points in space
        scale : float
          Size of overall scene for numerical comparisons

        Returns
        -------------
        discrete: (m, dimension) float
          Path in space composed of line segments
        """
        return self._orient(vertices[self.points])

    @property
    def closed(self):
        return len(self.points) > 2 and self.points[0] == self.points[-1]

    @closed.setter
    def closed(self, value: bool):
        current = self.points[0] == self.points[-1]
        if value and not current:
            # case where we've been asked to close the line
            # this seems pretty obvious that we should just append the first pointOB
            self.points = np.concatenate((self.points, [self.points[0]]))
        elif not value and current:
            # case where we've been asked to *disconnect* a closed path
            log.debug("ignoring `Line.closed = False`")

    @property
    def is_valid(self):
        """
        Is the current entity valid.

        Returns
        -----------
        valid : bool
          Is the current entity well formed
        """
        valid = np.any((self.points - self.points[0]) != 0)
        return valid

    def explode(self):
        """
        If the current Line entity consists of multiple line
        break it up into n Line entities.

        Returns
        ----------
        exploded: (n,) Line entities
        """
        # copy over the current layer
        layer = self.layer
        points = (
            np.column_stack((self.points, self.points)).ravel()[1:-1].reshape((-1, 2))
        )
        exploded = [Line(i, layer=layer) for i in points]
        return exploded

    def _bytes(self):
        # give consistent ordering of points for hash
        if self.points[0] > self.points[-1]:
            return b"Line" + self.points.tobytes()
        else:
            return b"Line" + self.points[::-1].tobytes()

    def to_dict(self) -> dict:
        """
        Returns a dictionary with all of the information
        about the Line. `closed` is not additional information
        for a Line like it is for Arc where the value determines
        if it is a partial or complete circle. Rather it is a check
        which indicates the first and last points are identical,
        and thus should not be included in the export

        Returns
        -----------
        as_dict
          Has keys 'type', 'points'
        """
        return {
            "type": self.__class__.__name__,
            "points": self.points.tolist(),
        }


class Arc(Entity):
    @property
    def closed(self):
        """
        A boolean flag for whether the arc is closed (a circle) or not.

        Returns
        ----------
        closed : bool
          If set True, Arc will be a closed circle
        """
        return getattr(self, "_closed", False)

    @closed.setter
    def closed(self, value):
        """
        Set the Arc to be closed or not, without
        changing the control points

        Parameters
        ------------
        value : bool
          Should this Arc be a closed circle or not
        """
        self._closed = bool(value)

    @property
    def is_valid(self):
        """
        Is the current Arc entity valid.

        Returns
        -----------
        valid : bool
          Does the current Arc have exactly 3 control points
        """
        return len(np.unique(self.points)) == 3

    def _bytes(self):
        # give consistent ordering of points for hash
        order = int(self.points[0] > self.points[-1]) * 2 - 1
        return b"Arc" + bytes(self.closed) + self.points[::order].tobytes()

    def length(self, vertices):
        """
        Return the arc length of the 3-point arc.

        Parameter
        ----------
        vertices : (n, d) float
          Vertices for overall drawing.

        Returns
        -----------
        length : float
          Length of arc.
        """
        # find the actual radius and angle span
        if self.closed:
            # we don't need the angular span as
            # it's indicated as a closed circle
            fit = self.center(vertices, return_normal=False, return_angle=False)
            return np.pi * fit.radius * 4
        # get the angular span of the circular arc
        fit = self.center(vertices, return_normal=False, return_angle=True)
        return fit.span * fit.radius * 2

    def discrete(self, vertices, scale=1.0):
        """
        Discretize the arc entity into line sections.

        Parameters
        ------------
        vertices : (n, dimension) float
            Points in space
        scale : float
            Size of overall scene for numerical comparisons

        Returns
        -------------
        discrete : (m, dimension) float
          Path in space made up of line segments
        """

        return self._orient(
            discretize_arc(vertices[self.points], close=self.closed, scale=scale)
        )

    def center(self, vertices, **kwargs):
        """
        Return the center information about the arc entity.

        Parameters
        -------------
        vertices : (n, dimension) float
          Vertices in space

        Returns
        -------------
        info : dict
          With keys: 'radius', 'center'
        """
        return arc_center(vertices[self.points], **kwargs)

    def bounds(self, vertices):
        """
        Return the AABB of the arc entity.

        Parameters
        -----------
        vertices: (n, dimension) float
          Vertices in space

        Returns
        -----------
        bounds : (2, dimension) float
          Coordinates of AABB in (min, max) form
        """
        if util.is_shape(vertices, (-1, 2)) and self.closed:
            # if we have a closed arc (a circle), we can return the actual bounds
            # this only works in two dimensions, otherwise this would return the
            # AABB of an sphere
            info = self.center(vertices, return_normal=False, return_angle=False)
            bounds = np.array(
                [info.center - info.radius, info.center + info.radius], dtype=np.float64
            )
        else:
            # since the AABB of a partial arc is hard, approximate
            # the bounds by just looking at the discrete values
            discrete = self.discrete(vertices)
            bounds = np.array(
                [discrete.min(axis=0), discrete.max(axis=0)], dtype=np.float64
            )
        return bounds


class Curve(Entity):
    """
    The parent class for all wild curves in space.
    """

    @property
    def nodes(self):
        # a point midway through the curve
        mid = self.points[len(self.points) // 2]
        return [[self.points[0], mid], [mid, self.points[-1]]]


class Bezier(Curve):
    """
    An open or closed Bezier curve
    """

    def discrete(self, vertices, scale=1.0, count=None):
        """
        Discretize the Bezier curve.

        Parameters
        -------------
        vertices : (n, 2) or (n, 3) float
          Points in space
        scale : float
          Scale of overall drawings (for precision)
        count : int
          Number of segments to return

        Returns
        -------------
        discrete : (m, 2) or (m, 3) float
          Curve as line segments
        """
        return self._orient(
            discretize_bezier(vertices[self.points], count=count, scale=scale)
        )


class BSpline(Curve):
    """
    An open or closed B- Spline.
    """

    def __init__(self, points, knots, layer=None, metadata=None, color=None, **kwargs):
        self.points = np.asanyarray(points, dtype=np.int64)
        self.knots = np.asanyarray(knots, dtype=np.float64)
        if layer is not None:
            self.layer = layer
        if metadata is not None:
            self.metadata.update(metadata)
        self._cache = {}
        self.kwargs = kwargs
        self.color = color

    def discrete(self, vertices, count=None, scale=1.0):
        """
        Discretize the B-Spline curve.

        Parameters
        -------------
        vertices : (n, 2) or (n, 3) float
          Points in space
        scale : float
          Scale of overall drawings (for precision)
        count : int
          Number of segments to return

        Returns
        -------------
        discrete : (m, 2) or (m, 3) float
          Curve as line segments
        """
        discrete = discretize_bspline(
            control=vertices[self.points], knots=self.knots, count=count, scale=scale
        )
        return self._orient(discrete)

    def _bytes(self):
        # give consistent ordering of points for hash
        if self.points[0] > self.points[-1]:
            return b"BSpline" + self.knots.tobytes() + self.points.tobytes()
        else:
            return b"BSpline" + self.knots[::-1].tobytes() + self.points[::-1].tobytes()

    def to_dict(self) -> dict:
        """
        Returns a dictionary with all of the information
        about the entity.
        """
        return {
            "type": self.__class__.__name__,
            "points": self.points.tolist(),
            "knots": self.knots.tolist(),
            "closed": self.closed,
        }
