import itertools
from functools import singledispatch
import numbers
from enum import Enum

from geogst.core.geometries.shapes.segments import *


class Ln(Shape):

    proper_space = 1

    @property
    def embedding_space(self) -> int:
        if self._coords is None:
            return 0
        return self._coords.shape[1]

    def __init__(self,
        coords: Optional[Union[None, np.ndarray, List[Tuple[numbers.Real]]]] = None
    ):

        if coords is not None:
            coords = np.copy(np.asarray(coords))

        self._coords = coords

    def num_points(self) -> numbers.Integral:
        """
        Return the number of points.

        Examples:
         >>> Ln().num_points()
         0
        """

        return self._coords.shape[0] if self._coords is not None else 0

    def __repr__(self) -> str:
        """
        Represents a Ln instance as a shortened text.

        :return: a textual shortened representation of a Ln instance.
        :rtype: str.
        """

        n_pts = self.num_points()

        if n_pts == 0:
            txt = "Empty Ln"
        elif n_pts <= 4:
            txt = f"Ln with {n_pts} point(s): " + ", ".join([f"{pt}" for pt in self.pts()])
        else:
            txt = f"Ln with {n_pts} points: {self.pt(0)}, {self.pt(1)}, .., {self.pt(-2)}, {self.pt(-1)}"

        return txt

    @classmethod
    def from_points(cls,
        *points: Sequence[Point]):
        """
        Create a line given a list of points.

        Examples:
         >>> Ln.from_points(Point(0,0,0), Point(1,1,1), Point(2,2,2))
         Ln with 3 point(s): Point([0. 0. 0.]), Point([1. 1. 1.]), Point([2. 2. 2.])
        """

        pt_coords_arrays = [pt.coords for pt in points]

        arr = np.vstack(pt_coords_arrays)

        return cls(arr)

    @classmethod
    def fromCoordinates(cls,
                        *coordinates: List[numbers.Real]
                        ) -> 'Ln':
        """
        Create a Ln instance from a list of coordinates.

        Example:
          >>> Ln.fromCoordinates([0, 0], [1, 0], [0, 1])
          Ln with 3 point(s): Point([0. 0.]), Point([1. 0.]), Point([0. 1.])
        """

        return cls(np.vstack(coordinates))

    @property
    def coords(self) -> Union[None, np.ndarray]:
        """
        Returns the coordinates array.
        """

        return self._coords

    @property
    def closed(self) -> Union[None, bool]:
        """
        Whether the line is closed.

        Examples:
         >>> Ln().closed is None
         True
         >>> Ln([(1, 1), (2, 2)]).closed
         False
         >>> Ln([(1, 1), (2, 2), (3, 3), (1, 1)]).closed
         True
        """

        num_pts = self.num_points()

        if num_pts == 0:
            return None
        elif num_pts < 3:
            return False
        else:
            return bool( np.all(self.coords[0, :] == self.coords[-1, :]) )

    def close(self) -> Union[None, 'Ln']:
        """
        Returns a closed copy of the source line.

        Examples:
         >>> Ln().close() is None
         True
         >>> Ln([(1, 1)]).close() is None
         True
         >>> Ln([(1, 1), (2, 2)]).close()
         Ln with 3 point(s): Point([1. 1.]), Point([2. 2.]), Point([1. 1.])
         >>> Ln([(1, 1), (2, 2), (3, 3), (1, 1)]).close()
         Ln with 4 point(s): Point([1. 1.]), Point([2. 2.]), Point([3. 3.]), Point([1. 1.])
        """

        num_pts = self.num_points()

        if num_pts < 2:
            return None

        if self.closed:
            return self.clone()

        return Ln(np.vstack([self.coords, self.coords[0, :]]))

    def area(self):
        """Calculate shape area"""

        return 0.0

    def clone(self) -> 'Ln':
        """
        Clone a line.

        :return: the cloned line
        """

        return Ln(self.coords)

    def reversed(self) -> 'Ln':
        """
        Reverse the line points.

        :return: the reversed line.

        Examples:
         >>> l = Ln([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
         >>> l.reversed()
         Ln with 3 point(s): Point([7. 8. 9.]), Point([4. 5. 6.]), Point([1. 2. 3.])
        """

        return Ln(np.flipud(self.coords))

    def as_2d(self) -> 'Ln':
        """
        Converts to a 2D line.
        """

        return Ln(self.coords[:, 0:2])

    def pt(self,
           ndx: numbers.Integral
    ) -> Union[None, Point]:
        """
        Returns the point at given index.

        Examples:
         >>> l = Ln.from_points(Point(1,2,3), Point(4,5,6), Point(7,8,9))
         >>> l.pt(2)
         Point([7. 8. 9.])
         >>> l.pt(0)
         Point([1. 2. 3.])
         >>> l.pt(4)
         None
         >>> l.pt(-1)
         Point([7. 8. 9.])
         >>> l.pt(-4)
         None
        """

        num_pts = self.num_points()

        if num_pts == 0:
            return None

        if -num_pts <= ndx < num_pts:
            return Point(*tuple(self.coords[ndx, :]))

        return None

    def pts(self) -> List[Point]:
        """
        Returns the list of points making up the line.

        Examples:
         >>> l = Ln.from_points(Point(1,2,3), Point(4,5,6), Point(7,8,9))
         >>> l.pts()
         [Point([1. 2. 3.]), Point([4. 5. 6.]), Point([7. 8. 9.])]
        """

        return [self.pt(ndx) for ndx in range(self.num_points())]

    def start_pt(self) -> Point:
        """
        Returns the initial point of the line.

        Examples:
         >>> l = Ln.from_points(Point(1,2,3), Point(4,5,6), Point(7,8,9))
         >>> l.start_pt()
         Point([1. 2. 3.])
        """

        return self.pt(0)

    def end_pt(self) -> Point:
        """
        Returns the initial point of the line.

        Examples:
         >>> l = Ln.from_points(Point(1,2,3), Point(4,5,6), Point(7,8,9))
         >>> l.end_pt()
         Point([7. 8. 9.])
        """

        return self.pt(-1)

    def as_segment(self) -> Union[None, Segment]:
        """Return the segment defined by line start and end points.

        Examples:
         >>> l = Ln.from_points(Point(1,2,3), Point(4,5,6), Point(7,8,9))
         >>> l.as_segment()
         Segment(start_pt=Point([1. 2. 3.]), end_pt=Point([7. 8. 9.]))
        """

        if self.start_pt() and self.end_pt():
            return Segment(self.start_pt(), self.end_pt())
        else:
            return None

    def segment(self,
        ndx: numbers.Integral
    ) -> Union[None, Segment]:
        """
        Returns the optional segment at index ndx.

        :param ndx: the segment index.
        :return: the segment or None.

        Examples:
         >>> l = Ln.from_points(Point(1,2,3), Point(4,5,6), Point(7,8,9))
         >>> l.segment(0)
         Segment(start_pt=Point([1. 2. 3.]), end_pt=Point([4. 5. 6.]))
         >>> l.segment(1)
         Segment(start_pt=Point([4. 5. 6.]), end_pt=Point([7. 8. 9.]))
         >>> l.segment(2)
         None
        """

        start_pt = self.pt(ndx)
        end_pt = self.pt(ndx + 1)

        if start_pt is None or end_pt is None:
            return None

        return Segment(
            start_pt=self.pt(ndx),
            end_pt=self.pt(ndx + 1)
        )

    def segments(self) -> Union[None, List[Segment]]:
        """
        Convert to a list of segments.

        :return: list of Segment objects.

        Examples:
         >>> Ln().segments() is None
         True
         >>> Ln([(1, 1)]).segments() is None
         True
         >>> Ln([(1, 1), (2, 2)]).segments()
         [Segment(start_pt=Point([1. 1.]), end_pt=Point([2. 2.]))]
         >>> l = Ln.from_points(Point(1,2,3), Point(4,5,6), Point(7,8,9))
         >>> l.segments()
         [Segment(start_pt=Point([1. 2. 3.]), end_pt=Point([4. 5. 6.])), Segment(start_pt=Point([4. 5. 6.]), end_pt=Point([7. 8. 9.]))]
        """

        if self.num_points() < 2:
            return None

        line_pts = self.pts()
        pts_pairs = zip(line_pts[:-1], line_pts[1:])
        segments = [Segment(pt_a, pt_b) for (pt_a, pt_b) in pts_pairs]

        return segments

    def to_list_ptsegm2d(self) -> List[UnionPtSegment2D]:
        """Converts the line to a list of points/segments."""

        n_pts = self.num_points()
        if n_pts == 0:
            return []
        elif n_pts == 1:
            return [self.start_pt()]
        else:
            return list(self.remove_coincident_points().segments())

    def xy_zipped(self) -> List[Tuple[numbers.Real, numbers.Real]]:

        return [(x, y) for x, y in zip(self.x_list(), self.y_list())]

    def __iter__(self):
        """
        Returns on iterator on the line segments.
        """

        return (segment for segment in self.segments())

    def __getitem__(self, ndx: numbers.Integral):
        """
        Returns segment at given index.
        """

        return list(self.segments())[ndx]

    def add_pt(self,
               pt: Point):
        """
        In-place transformation of the original Ln instance
        by adding a new point at the end.

        :param pt: the point to add.


        Examples:
         >>> l = Ln.from_points(Point(0,0,0), Point(1,1,1), Point(2,2,2))
         >>> l.add_pt(Point(3,3,3))
         >>> l
         Ln with 4 point(s): Point([0. 0. 0.]), Point([1. 1. 1.]), Point([2. 2. 2.]), Point([3. 3. 3.])
         >>> l = Ln()
         >>> l.add_pt(Point(0,0,0))
         >>> l
         Ln with 1 point(s): Point([0. 0. 0.])
        """

        if self.coords is None:
            self._coords = np.reshape(pt.coords, (1, pt.coords.shape[0]))
        else:
            self._coords = np.vstack([self._coords, pt.coords])

    def remove_coincident_points(self,
                                 tolerance: numbers.Real = MIN_SEPARATION_THRESHOLD
                                 ) -> Union[None, 'Ln']:
        """
        Remove coincident successive points.

        Examples:
         >>> l = Ln([[1121952.61908204, 4446743.74579965], [1122187.64747837, 4446882.23326361]])
         >>> l
         Ln with 2 point(s): Point([1121952.61908204 4446743.74579965]), Point([1122187.64747837 4446882.23326361])
         >>> l.remove_coincident_points()
         Ln with 2 point(s): Point([1121952.61908204 4446743.74579965]), Point([1122187.64747837 4446882.23326361])
        """

        try:

            line = Ln()

            if self.num_points() == 0:
                return line

            line.add_pt(self.pt(0))

            for ndx in range(1, self.num_points()):
                if not self.pt(ndx).is_coincident(
                        line.pt(-1),
                        tolerance=tolerance):
                    line.add_pt(self.pt(ndx))

            return line

        except Exception as e:

            print(Error(True, caller_name(), e))
            return None

    def remove_coincident_points_2d(self,
                                    tolerance: numbers.Real = MIN_SEPARATION_THRESHOLD
                                    ) -> Union[None, 'Ln']:
        """
        Remove 2D-coincident successive points.
        """

        try:

            line = Ln()

            if self.num_points() == 0:
                return line

            line.add_pt(self.pt(0))

            for ndx in range(1, self.num_points()):
                if not self.pt(ndx).is_coincident_2d(
                        line.pt(-1),
                        tolerance=tolerance):
                    line.add_pt(self.pt(ndx))

            return line

        except Exception as e:

            print(Error(True, caller_name(), e))
            return None

    def length(self):
        """ Calculate line length"""

        length = 0
        for ndx in range(1, self.num_points()):
            segment_length = self.pt(ndx).distance(self.pt(ndx - 1))
            length += segment_length

        return length

    def length_2d(self):
        """ Calculate 2D line length"""

        length = 0
        for ndx in range(1, self.num_points()):
            segment_length = self.pt(ndx).distance_2d(self.pt(ndx - 1))
            length += segment_length

        return length

    def length_3d(self):
        """ Calculate 3D line length"""

        length = 0
        for ndx in range(1, self.num_points()):
            segment_length = self.pt(ndx).distance_3d(self.pt(ndx - 1))
            length += segment_length

        return length

    def step_lengths_2d(self) -> List[numbers.Real]:
        """
        Returns the point-to-point 2D distances.
        It is the distance between a point and the previous one.
        The list has the same length as the source point list.

        :return: the individual 2D segment lengths.

        Examples:
        """

        segments_2d_lengths = [0.0]
        for ndx in range(1, self.num_points()):
            segment_2d_length = self.pt(ndx).distance_2d(self.pt(ndx - 1))
            segments_2d_lengths.append(segment_2d_length)

        return segments_2d_lengths

    def step_lengths_3d(self) -> List[numbers.Real]:
        """
        Returns the point-to-point 3d distances.
        It is the distance between a point and the previous one.
        The list has the same length as the source point list.

        :return: the individual 3d segment lengths.

        Examples:
        """

        segments_3d_lengths = [0.0]
        for ndx in range(1, self.num_points()):
            segment_3d_length = self.pt(ndx).distance_3d(self.pt(ndx - 1))
            segments_3d_lengths.append(segment_3d_length)

        return segments_3d_lengths

    def accumulated_length_2d(self) -> List[numbers.Real]:
        """
        Returns the accumulated 2D segment lengths.

        :return: accumulated 2D segment lengths.
        """

        return list(itertools.accumulate(self.step_lengths_2d()))

    def breaks(self,
               angle_threshold: numbers.Real=0.5,  # degrees
    ) -> Union[None, Tuple[np.ndarray, np.ndarray]]:

        """
        Calcola le distanze cumulative dal primo punto in cui si verificano cambiamenti di direzione.

        :param angle_threshold: Soglia dell'angolo in degrees per identificare un cambiamento di direzione.
        :return: Lista delle distanze in cui avviene un cambiamento di direzione.
        """

        try:

            vectors = np.diff(self.coords[:, :2], axis=0)  # Calcola i vettori tra punti consecutivi
            distances = np.linalg.norm(vectors, axis=1)  # Calcola le lunghezze dei segmenti
            cumulative_distances = np.insert(np.cumsum(distances), 0, 0)  # Distanze cumulative

            unit_vectors = vectors / distances[:, None]  # Normalizza i vettori

            # Calcola l'angolo tra vettori consecutivi
            dot_products = np.sum(unit_vectors[:-1] * unit_vectors[1:], axis=1)
            dot_products = np.clip(dot_products, -1.0, 1.0)  # Evita problemi numerici
            angles = np.arccos(dot_products)  # Angolo tra i vettori

            # Trova gli indici dove l'angolo supera la soglia
            turning_indices = np.where(np.abs(angles) > radians(angle_threshold))[0] + 1

            turning_indices_with_line_start_and_end = np.concatenate(([0], turning_indices, [self.num_points() - 1]))

            return self.coords[turning_indices_with_line_start_and_end], cumulative_distances[turning_indices_with_line_start_and_end]

        except Exception as e:

            print(f"\n{e!r}")
            return None

    def bounding_box(self) -> Union[None, Tuple[numbers.Real, numbers.Real, numbers.Real, numbers.Real]]:
        """Returns the bounding box of the line."""

        try:

            xmin, xmax = self.x_range()
            ymin, ymax = self.y_range()

            return (xmin, ymin, xmax, ymax)

        except Exception as e:

            print(f"\n{e!r}")
            return None

    def densify_as_line2d(self,
                          sample_distance: numbers.Real
                          ) -> Union[None, 'Ln']:
        """
        Densify a line into a new line instance,
        using the provided sample distance.
        Returned Ln instance has coincident successive points removed.

        :param sample_distance: numbers.Real
        :return: Ln instance
        """

        if sample_distance <= 0.0:
            print(f"Sample distance must be positive. {sample_distance} received")
            return None

        segments = self.segments()

        densified_lines = [segment.densify_as_line2d(sample_distance) for segment in segments]

        concatenated = concatenate(*densified_lines)

        densified_line_wo_coinc_pts = concatenated.remove_coincident_points()

        return densified_line_wo_coinc_pts

    def densify_as_equally_spaced_points2d(self,
       sample_distance: numbers.Real
       ) -> List[Point]:
        """
        Densify a line into a set of Point instances, each equally spaced along the line
        (so that at corners 2D distance between points is less than 'sample_distance').

        """

        points = []
        segment_start_offset = 0.0
        for segment in self.segments():
            segment_points = segment.densify_as_points2d(
                densify_distance=sample_distance,
                start_offset=segment_start_offset
            )
            points.extend(segment_points[:-1])
            segment_start_offset = sample_distance - segment_points[-2].distance(segment_points[-1])

        return points

    def slopes_degr(self) -> List[Union[None, numbers.Real]]:
        """
        Calculates the slopes (in degrees) of each Ln segment.
        The first value is the slope of the first segment.
        The last value, always None, is the slope of the segment starting at the last point.
        The number of elements is equal to the number of points in the Ln.

        :return: list of slopes (degrees).
        """

        slopes = []

        segments = self.segments()
        for segment in segments:
            vector = segment.as_vector3d()
            slopes.append(-vector.dip_angle())  # minus because vector convention is positive downward

        slopes.append(None)  # None refers to the slope of the Segment starting with the last point

        return slopes

    def shift_horiz(self,
                    dx: numbers.Real,
                    dy: numbers.Real
                    ) -> 'Ln':
        """
        Creates a new line shifted horizontally.

        Examples:
         >>> l = Ln.from_points(Point(1,2,3), Point(4,5,6), Point(7,8,9))
         >>> s = l.shift_horiz(10, 20)
         >>> s.coords[:, 0]
         array([11., 14., 17.])
         >>> s.coords[:, 1]
         array([22., 25., 28.])
         >>> s.coords[:, 2]
         array([3., 6., 9.])
        """

        crds = np.copy(self.coords)
        crds[:, 0 ] = crds[:, 0 ] + dx
        crds[:, 1] = crds[:, 1] + dy
        return Ln(crds)

    def absolute_slopes(self) -> np.ndarray:

        return np.asarray(list(map(abs, self.dir_slopes())))

    def dir_slopes(self) -> np.ndarray:

        slopes = []
        for ndx in range(self.num_points() - 1):
            segment_start_pt = self.pt(ndx)
            segment_end_pt = self.pt(ndx + 1)
            if np.isnan(segment_start_pt.z) or np.isnan(segment_end_pt.z):
                slopes.append(np.nan)
            else:
                vector = Segment(self.pt(ndx), self.pt(ndx + 1)).as_vector3d()
                slopes.append(-vector.dip_angle())  # minus because vector convention is positive downward
        slopes.append(np.nan)  # slope value for last point is unknown

        return np.asarray(slopes)

    def x_array(self) -> np.ndarray:

        return np.copy(self._coords[:, 0])

    def x_list(self) -> List[numbers.Real]:

        return list(self.x_array())

    def x_min(self) -> numbers.Real:
        return np.nanmin(self.x_array())

    def x_max(self) -> numbers.Real:
        return np.nanmax(self.x_array())

    def x_range(self) -> Tuple[numbers.Real, numbers.Real]:

        return self.x_min(), self.x_max()

    def x_mean(self) -> numbers.Real:
        return np.nanmean(self.x_array())

    def x_var(self) -> numbers.Real:

        return np.nanvar(self.x_array())

    def x_std(self) -> numbers.Real:

        return np.nanstd(self.x_array())

    def y_array(self):

        return np.copy(self._coords[:, 1])

    def y_list(self) -> List[numbers.Real]:

        return list(self.y_array())

    def y_min(self):
        return np.nanmin(self.y_array())

    def y_max(self):
        return np.nanmax(self.y_array())

    def y_range(self):

        return self.y_min(), self.y_max()

    def y_mean(self):

        return np.nanmean(self.y_array())

    def y_var(self):

        return np.nanvar(self.y_array())

    def y_std(self):

        return np.nanstd(self.y_array())

    def z_array(self):

        return np.copy(self._coords[:, 2])

    def z_list(self) -> List[numbers.Real]:

        return list(self.z_array())

    def z_min(self):
        return np.nanmin(self.z_array())

    def z_max(self):
        return np.nanmax(self.z_array())

    def z_mean(self):

        return np.nanmean(self.z_array())

    def z_var(self):

        return np.nanvar(self.z_array())

    def z_std(self):

        return np.nanstd(self.z_array())


class JoinTypes(Enum):
    """
    Enumeration for Ln and Segment type.
    """

    START_START = 1  # start point coincident with start point
    START_END   = 2  # start point coincident with end point
    END_START   = 3  # end point coincident with start point
    END_END     = 4  # end point coincident with end point


def analyze_joins(
        first: Union[Ln, Segment],
        second: Union[Ln, Segment]
) -> Optional[List[Optional[JoinTypes]]]:
    """
    Analyze join types between two lines/segments.

    :param first: a line or segment.
    :param second: a line or segment.
    :return: an optional list of join types.

    Examples:
      >>> first = Segment(Point(0,0), Point(1,0))
      >>> second = Segment(Point(1,0), Point(0,0))
      >>> analyze_joins(first, second)
      [<JoinTypes.START_END: 2>, <JoinTypes.END_START: 3>]
      >>> first = Segment(Point(0,0), Point(1,0))
      >>> second = Segment(Point(2,0), Point(3,0))
      >>> analyze_joins(first, second)
      []
    """

    join_types = []

    if first.start_pt.is_coincident(second.start_pt):
        join_types.append(JoinTypes.START_START)

    if first.start_pt.is_coincident(second.end_pt):
        join_types.append(JoinTypes.START_END)

    if first.end_pt.is_coincident(second.start_pt):
        join_types.append(JoinTypes.END_START)

    if first.end_pt.is_coincident(second.end_pt):
        join_types.append(JoinTypes.END_END)

    return join_types


@singledispatch
def concatenate(
    geom: Shape,
    *args
) -> Union[None, Shape]:

    return None


@concatenate.register(Ln)
def _(
    geom: Ln,
    *args
) -> Union[None, Ln]:
    """
    Concatenates a sequence of lines.

    Examples:
     >>> l1 = Ln.from_points(Point(1,2,3), Point(4,5,6))
     >>> l2 = Ln.from_points(Point(7,8,9), Point(10, 11, 12), Point(13, 14, 15))
     >>> l3 = Ln.from_points(Point(16, 17, 18), Point(19, 20, 21))
     >>> concatenate(l1, l2, l3)
     Ln with 7 points: Point([1. 2. 3.]), Point([4. 5. 6.]), .., Point([16. 17. 18.]), Point([19. 20. 21.])

    """

    return Ln(np.vstack([ln.coords for ln in [geom]+list(args)]))


def lines_to_points_segments(
        lines: List[Ln]
) -> List[UnionPtSegment2D]:

    ptsegms2d = []

    for line in lines:
        ptsegms2d.extend(line.to_list_ptsegm2d())

    return ptsegms2d


def xytuple_list_to_line2d(
        xy_list: List[Tuple[numbers.Real, numbers.Real]]
) -> Ln:

    return Ln(xy_list)


def merge_line2d(
        line
) -> Ln:
    """
    line: a list of (x,y) tuples for line
    """

    line_type, line_geometry = line

    if line_type == 'multiline':
        path_line = xytuple_l2_to_multiline2d(line_geometry).to_line()
    elif line_type == 'line':
        path_line = xytuple_list_to_line2d(line_geometry)
    else:
        raise Exception("unknown line type")

    # transformed into a single Ln

    result = path_line.remove_coincident_points()

    return result


def merge_lines2d(
        lines: List[Ln],
        progress_ids
):
    """
    lines: a list of list of (x,y,z) tuples for multilines
    """

    sorted_line_list = [line for (_, line) in sorted(zip(progress_ids, lines))]

    line_list = []
    for line in sorted_line_list:

        line_type, line_geometry = line

        if line_type == 'multiline':
            path_line = xytuple_l2_to_multiline2d(line_geometry).to_line()
        elif line_type == 'line':
            path_line = xytuple_list_to_line2d(line_geometry)
        else:
            continue
        line_list.append(path_line)  # now a list of Lines

    # now the list of Lines is transformed into a single Ln with coincident points removed

    line = MultiLine(line_list).to_line().remove_coincident_points()

    return line


if __name__ == "__main__":

    import doctest
    doctest.testmod()


