# coding=utf-8
"""3D Polyline"""
from __future__ import division

from ..geometry2d.pointvector import Point2D
from ..geometry2d.polyline import Polyline2D

from ._2d import Base2DIn3D
from .pointvector import Point3D
from .line import LineSegment3D
from .plane import Plane
from ..intersection3d import intersect_line3d_plane, closest_point3d_on_line3d
from .._polyline import _group_vertices


class Polyline3D(Base2DIn3D):
    """3D polyline object.

    Args:
        vertices: A list of Point3D objects representing the vertices of the polyline.
        interpolated: Boolean to note whether the polyline should be interpolated
            between the input vertices when it is translated to other interfaces.
            Note that this property has no bearing on the geometric calculations
            performed by this library and is only present in order to assist with
            display/translation.

    Properties:
        * vertices
        * segments
        * min
        * max
        * center
        * p1
        * p2
        * length
        * interpolated
    """
    __slots__ = ('_interpolated', '_segments', '_length')

    def __init__(self, vertices, interpolated=False):
        """Initialize Polyline3D."""
        Base2DIn3D.__init__(self, vertices)
        self._interpolated = interpolated
        self._segments = None
        self._length = None

    @classmethod
    def from_dict(cls, data):
        """Create a Polyline3D from a dictionary.

        Args:
            data: A python dictionary in the following format.

        .. code-block:: python

            {
                "type": "Polyline3D",
                "vertices": [(0, 0, 0), (10, 0, 2), (0, 10, 4)]
            }
        """
        interp = data['interpolated'] if 'interpolated' in data else False
        return cls(tuple(Point3D.from_array(pt) for pt in data['vertices']), interp)

    @classmethod
    def from_array(cls, point_array):
        """Create a Polyline3D from a nested array of vertex coordinates.

        Args:
            point_array: nested array of point arrays.
        """
        return Polyline3D(Point3D(*point) for point in point_array)

    @classmethod
    def from_polyline2d(cls, polyline2d, plane=None):
        """Create a closed Polyline3D from a Polyline2D and a plane.

        Args:
            polyline2d: A Polyline2D object to be converted to a Polyline3D.
            plane: A Plane in which the Polyline2D sits. If None, the WorldXY
                plane will be used.
        """
        plane = Plane() if plane is None else plane
        return Polyline3D((plane.xy_to_xyz(pt) for pt in polyline2d.vertices),
                          polyline2d.interpolated)

    @property
    def segments(self):
        """Tuple of all line segments in the polyline."""
        if self._segments is None:
            self._segments = \
                tuple(LineSegment3D.from_end_points(vert, self._vertices[i + 1])
                      for i, vert in enumerate(self._vertices[:-1]))
        return self._segments

    @property
    def p1(self):
        """Starting point of the Polyline3D."""
        return self._vertices[0]

    @property
    def p2(self):
        """End point of the Polyline3D."""
        return self._vertices[-1]

    @property
    def length(self):
        """The length of the polyline."""
        if self._length is None:
            self._length = sum([seg.length for seg in self.segments])
        return self._length

    @property
    def interpolated(self):
        """Boolean noting whether the polyline should be interpolated upon translation.

        Note that this property has no bearing on the geometric calculations
        performed by this library and is only present in order to assist with
        display/translation.
        """
        return self._interpolated

    def is_closed(self, tolerance):
        """Test whether this polyline is closed to within the tolerance.

        Args:
            tolerance: The minimum difference between vertices below which vertices
                are considered the same.
        """
        return self._vertices[0].is_equivalent(self._vertices[-1], tolerance)

    def closest_point(self, point):
        """Get the closest Point3D on this object to another Point3D.

        Args:
            point: A Point3D object to which the closest point on this object
                will be computed.

        Returns:
            Point3D for the closest point on this line/ray to the input point.
        """
        close_pts = [closest_point3d_on_line3d(point, seg) for seg in self.segments]
        pt_dts = [point.distance_to_point(close_pt) for close_pt in close_pts]
        s_pts = [p for _, p in sorted(zip(pt_dts, close_pts), key=lambda pair: pair[0])]
        return s_pts[0]

    def distance_to_point(self, point):
        """Get the minimum distance between this object and the input point.

        Args:
            point: A Point3D object to which the minimum distance will be computed.

        Returns:
            The distance to the input point.
        """
        close_pt = self.closest_point(point)
        return point.distance_to_point(close_pt)

    def remove_colinear_vertices(self, tolerance):
        """Get a version of this polyline without colinear or duplicate vertices.

        Args:
            tolerance: The minimum distance that a vertex can be from a line
                before it is considered colinear.

        Returns:
            A Polyline3D with colinear vertices removed. If the removing of colinear
            vertices creates a shape with two or fewer vertices, then a LineSegment3D
            will be returned (which may be zero length if all vertices in the
            Polyline are equal to one another).
        """
        new_vertices = [self.vertices[0]]  # first vertex is always ok
        skip = 0  # track the number of vertices being skipped/removed
        # loop through vertices and remove all cases of colinear verts
        for i, _v in enumerate(self.vertices[1:-1]):
            _v2, _v1 = self[i - skip], self[i + 2]
            _a = (_v2 - _v).cross(_v1 - _v).magnitude
            b_dist = max(_v2.distance_to_point(_v1), _v2.distance_to_point(_v))
            b_dist = tolerance if b_dist < tolerance else b_dist
            tri_tol = (b_dist * tolerance) / 2  # area of triangle with tolerance height
            if abs(_a) >= tri_tol:  # triangle area > tolerance; not colinear
                new_vertices.append(_v)
                skip = 0
            else:
                skip += 1
        new_vertices.append(self[-1])  # last vertex is always ok
        # count the number of vertices to return the correct type of object
        if len(new_vertices) == 2:
            return LineSegment3D.from_end_points(*new_vertices)
        _new_poly = Polyline3D(new_vertices)
        self._transfer_properties(_new_poly)
        return _new_poly

    def smooth(self, strength=1):
        """Get a smoothed version of this Polyline3D by averaging adjacent vertices.

        Args:
            strength: A number between 0 and 1 to represent the strength of smoothing.
                One represents maximum smoothing. Zero represents no smoothing.
        """
        low_wgt = strength * (1 / 3)
        pt_wgts = (low_wgt, 1 - (low_wgt * 2), low_wgt)
        new_pts = [self._vertices[0]]
        for i in range(1, len(self._vertices) - 1):
            rel_pts = self._vertices[i - 1: i + 2]
            sm_pt = Point3D(
                sum(pt.x * w for pt, w in zip(rel_pts, pt_wgts)),
                sum(pt.y * w for pt, w in zip(rel_pts, pt_wgts)),
                sum(pt.z * w for pt, w in zip(rel_pts, pt_wgts))
            )
            new_pts.append(sm_pt)
        new_pts.append(self._vertices[-1])
        return Polyline3D(new_pts)

    def reverse(self):
        """Get a copy of this polyline where the vertices are reversed."""
        _new_poly = Polyline3D(tuple(pt for pt in reversed(self.vertices)))
        self._transfer_properties(_new_poly)
        return _new_poly

    def move(self, moving_vec):
        """Get a polyline that has been moved along a vector.

        Args:
            moving_vec: A Vector3D with the direction and distance to move the polyline.
        """
        _new_poly = Polyline3D(tuple(pt.move(moving_vec) for pt in self.vertices))
        self._transfer_properties(_new_poly)
        return _new_poly

    def rotate(self, axis, angle, origin):
        """Rotate a polyline by a certain angle around an axis and origin.

        Right hand rule applies:
        If axis has a positive orientation, rotation will be clockwise.
        If axis has a negative orientation, rotation will be counterclockwise.

        Args:
            axis: A Vector3D axis representing the axis of rotation.
            angle: An angle for rotation in radians.
            origin: A Point3D for the origin around which the point will be rotated.
        """
        _new_poly = Polyline3D(tuple(pt.rotate(axis, angle, origin)
                                     for pt in self.vertices))
        self._transfer_properties(_new_poly)
        return _new_poly

    def rotate_xy(self, angle, origin):
        """Get a polyline rotated counterclockwise in the XY plane by a certain angle.

        Args:
            angle: An angle in radians.
            origin: A Point3D for the origin around which the object will be rotated.
        """
        _new_p = Polyline3D(tuple(pt.rotate_xy(angle, origin) for pt in self.vertices))
        self._transfer_properties(_new_p)
        return _new_p

    def reflect(self, normal, origin):
        """Get a polyline reflected across a plane with the input normal and origin.

        Args:
            normal: A Vector3D representing the normal vector for the plane across
                which the polyline will be reflected. THIS VECTOR MUST BE NORMALIZED.
            origin: A Point3D representing the origin from which to reflect.
        """
        _new_poly = Polyline3D(tuple(pt.reflect(normal, origin) for pt in self.vertices))
        self._transfer_properties(_new_poly)
        return _new_poly

    def scale(self, factor, origin=None):
        """Scale a polyline by a factor from an origin point.

        Args:
            factor: A number representing how much the polyline should be scaled.
            origin: A Point3D representing the origin from which to scale.
                If None, it will be scaled from the World origin (0, 0, 0).
        """
        if origin is None:
            _new_poly = Polyline3D(tuple(
                Point3D(pt.x * factor, pt.y * factor, pt.z * factor)
                for pt in self.vertices))
        else:
            _new_poly = Polyline3D(tuple(
                pt.scale(factor, origin) for pt in self.vertices))
        _new_poly._interpolated = self._interpolated
        return _new_poly

    def intersect_plane(self, plane):
        """Get the intersections between this polyline and a Plane.

        Args:
            plane: A Plane that will be intersected with this object.

        Returns:
            A list with Point3D objects for the intersections.
            List will be empty if no intersection exists.
        """
        intersections = []
        for _s in self.segments:
            inters = intersect_line3d_plane(_s, plane)
            if inters is not None:
                intersections.append(inters)
        return intersections

    def split_with_plane(self, plane):
        """Split this Polyline3D into Polyline3Ds and LineSegment3Ds using a Plane.

        Args:
            plane: A Plane that will be used to split this polyline.

        Returns:
            A list of Polyline3D and LineSegment3D objects if the split was successful.
            Will be a list with 1 Polyline3D if no intersection exists.
        """
        # group the vertices based on when they cross the plane
        grouped_verts = [[self._vertices[0]]]
        for _s in self.segments:
            inters = intersect_line3d_plane(_s, plane)
            if inters is None:
                grouped_verts[-1].append(_s.p2)
            else:  # intersection; start a new group
                grouped_verts[-1].append(inters)
                grouped_verts.append([inters, _s.p2])

        # make new Polyline3D and LineSegment3D objects based on the groups
        return self._grouped_verts_to_objs(grouped_verts, self._interpolated)

    def to_dict(self):
        """Get Polyline3D as a dictionary."""
        base = {'type': 'Polyline3D',
                'vertices': [pt.to_array() for pt in self.vertices]}
        if self.interpolated:
            base['interpolated'] = self.interpolated
        return base

    def to_array(self):
        """Get a list of lists where each sub-list represents a Point3D vertex."""
        return tuple(pt.to_array() for pt in self.vertices)

    def to_polyline2d(self):
        """Get a Polyline2D in the XY plane derived from this 3D polyline."""
        return Polyline2D(
            (Point2D(pt.x, pt.y) for pt in self.vertices), self.interpolated)

    @staticmethod
    def join_segments(segments, tolerance):
        """Get an array of Polyline3Ds from a list of LineSegment3Ds.

        Args:
            segments: An array of LineSegment3D objects.
            tolerance: The minimum difference in X, Y, and Z values at which Point2Ds
                are considered equivalent. Segments with points that match within the
                tolerance will be joined.

        Returns:
            An array of Polyline3D and LineSegment3D objects assembled from the
            joined segments.
        """
        # group the vertices that make up polylines
        if len(segments) <= 1:
            return segments
        grouped_verts = _group_vertices(segments, tolerance)
        # create the Polyline3D and LineSegment3D objects
        return Polyline3D._grouped_verts_to_objs(grouped_verts)

    def _transfer_properties(self, new_polyline):
        """Transfer properties from this polyline to a new polyline."""
        new_polyline._interpolated = self._interpolated
        new_polyline._length = self._length

    @staticmethod
    def _grouped_verts_to_objs(grouped_verts, interpolated=False):
        joined_lines = []
        for v_list in grouped_verts:
            if len(v_list) == 2:
                joined_lines.append(LineSegment3D.from_end_points(v_list[0], v_list[1]))
            else:
                joined_lines.append(Polyline3D(v_list, interpolated))
        return joined_lines

    def __copy__(self):
        return Polyline3D(self._vertices, self._interpolated)

    def __key(self):
        """A tuple based on the object properties, useful for hashing."""
        return tuple(hash(pt) for pt in self._vertices) + (self._interpolated,)

    def __hash__(self):
        return hash(self.__key())

    def __eq__(self, other):
        return isinstance(other, Polyline3D) and self.__key() == other.__key()

    def __repr__(self):
        return 'Polyline3D ({} vertices)'.format(len(self))
