from typing import Tuple, Union
from dataclasses import dataclass

import pandas as pd
from shapely.geometry import Polygon
import matplotlib.pyplot as plt

from magnumapi.geometry.primitives.Arc import Arc
from magnumapi.geometry.primitives.Line import Line
from magnumapi.geometry.primitives.Point import Point


@dataclass(frozen=True)
class Area:
    """ An Area class implementing a 2D area made of connected lines/arcs

   Attributes:
       lines (Tuple): tuple of Line/Arc objects
    """
    lines: Tuple[Union[Line, Arc], ...]

    @staticmethod
    def of_lines(lines: Tuple[Union[Line, Arc], ...]) -> "Area":
        """ Static method creating an Area object from a tuple of Line objects. Line objects are copied to preserve
        static behaviour and avoid side effects.

        :param lines: tuple of Line objects
        :return: An area with copy of input Line objects
        """
        return Area(lines=tuple(line.copy() for line in lines))

    @staticmethod
    def of_closed_ordered_list_of_points(points: Tuple[Point, ...]) -> "Area":
        #ToDo: add test
        #ToDo: add docstring
        lines = []
        for ix in range(len(points) - 1):
            lines.append(Line.of_end_points(points[ix], points[ix + 1]))

        lines.append(Line.of_end_points(points[-1], points[0]))

        return Area.of_lines(tuple(lines))

    def get_line(self, index: int) -> Line:
        """ Method returning a line at a given index.

        :param index: index of line to be returned
        :return: Line at the given index
        """
        if abs(index) >= len(self.lines):
            raise IndexError('The requested line index %d is out of range for a list of length %d' %
                             (index, len(self.lines)))
        return self.lines[index].copy()

    def copy(self) -> "Area":
        """ Method returning a copy of an Area instance

        :return: a new Area instance with copy of lines
        """
        return Area(lines=tuple(line.copy() for line in self.lines))

    def translate(self, vector: Point) -> "Area":
        """ Method translating an Area instance by a given vector

        :param vector: vector by which an area is translated
        :return: a translated area
        """
        return Area(lines=tuple(line.translate(vector) for line in self.lines))

    def rotate(self, angle: float) -> "Area":
        """ Method rotating area by a given angle w.r.t. origin of the coordinate system (0, 0)

        :param angle: rotation angle in degrees
        :return: rotated area
        """
        return Area(lines=tuple(line.rotate(angle) for line in self.lines))

    def plot(self, ax: plt.Axes) -> None:
        """ Method plotting an Area instance on an input axis

        :param ax: input axis for plot
        """
        for line in self.lines:
            line.plot(ax)

    def __str__(self):
        """ Method returning a string representation of an Area for a user

        :return: string representation of an Area instance
        """
        return ",".join([str(line) for line in self.lines])

    def to_df(self):
        """ Method converting a line object into a dataframe with end point coordinates of each line. Lines are
        distinguished by 1-based index.

        :return: a DataFrame with x1_i, y1_i, x2_i, y2_i columns, where i is the line index (1-based)
        """
        return pd.concat([line.to_df().add_suffix('_%d' % (i + 1)) for i, line in enumerate(self.lines)], axis=1)

    @staticmethod
    def find_trapezoid_shift_to_intercept_with_radius(radius: float, area: "Area", eps=1e-6) -> float:
        """ Method finding a shift for a trapezoidal area to get in contact with a radius.
        The method works as follows:

        - for lower (index 0) line of an area, find an intersection point with a radius
        - for upper (index 2) line of an area, find an intersection point with a radius

        - find distance between the intersection point and lower line end point closest to the radius (p2)
        - find distance between the intersection point and upper line end point closest to the radius (p1)

        - take the shortest distance

        - shift the line closes to the radius (index 3) and shift according to the bisection method between 0 and the
        shortest distance

        :param radius: radius for which the shift is calculated
        :param area: area which is shifted in order to get in contact with a radius
        :param eps: bisection method accuracy
        :return: shift distance by which a trapezoidal area needs to be shifted in order to get in contact with radius
        (up to the eps accuracy)
        """
        # find point of crossing radial segment extension with radius
        # # lower line
        line_low = area.get_line(0)

        m_low, n_low = line_low.find_line_parameters()
        p_cross_low = Line.find_intersection_with_circle(radius, m_low, n_low)

        ## upper line
        line_up = area.get_line(2)
        m_up = m_low
        n_up = line_up.p2.y - m_up * line_up.p2.x
        p_cross_up = Line.find_intersection_with_circle(radius, m_up, n_up)

        if p_cross_low is None and p_cross_up is None:
            return 0.0

        # find maximum distance from the circle
        v_dir_low = p_cross_low - line_low.p1.copy()
        det_v_dir_l = v_dir_low.det()

        if abs(det_v_dir_l) <= 1e-12:
            return 0.0

        if p_cross_up is None:
            det_v_dir_u = 0
        else:
            v_dir_up = p_cross_up - line_up.p2.copy()
            det_v_dir_u = v_dir_up.det()

        # # take the maximum
        max_v_dir = max(det_v_dir_l, det_v_dir_u)

        # shift along this axis
        v_dir_l_unit = v_dir_low.unit()

        # lower point
        p_low = p_low_transl = area.get_line(0).p1.copy()
        # upper point
        line_left = area.get_line(3)
        det_line_left = line_left.det()
        alpha_left = Line.calculate_relative_alpha_angle(line_left)
        delta_alpha = 0 if alpha_left == 90 else 180
        p_up_transl = p_low.copy().translate(Point.of_polar(det_line_left, delta_alpha + alpha_left))

        start = 0
        end = max_v_dir
        shift = eps
        while (end - start) > eps:
            shift = (end + start) / 2
            p_low_transl = p_low.translate(v_dir_l_unit * shift)
            p_up_transl = p_low_transl.copy().translate(Point.of_polar(det_line_left, delta_alpha + alpha_left))

            if Line.find_interception_parameter(radius, Line.of_end_points(p_low_transl, p_up_transl)) is not None or \
                    Line.is_line_intercepting_radius(radius, Line.of_end_points(p_low_transl, p_up_transl)):
                end = shift
            else:
                start = shift

        if Line.find_interception_parameter(radius, Line.of_end_points(p_low_transl, p_up_transl)) is not None or \
            Line.is_line_intercepting_radius(radius, Line.of_end_points(p_low_transl, p_up_transl)):
            shift = start

        return shift

    @staticmethod
    def is_outside_of_first_quadrant(area: "Area", eps=1e-30) -> bool:
        """ Method checking whether an area is fully positioned in the first quadrant.

        :param area: an Area instance for which the check is performed
        :param eps: a machine precision for comparison of 0 value
        :return: True if an Area instance is in the first quadrant, False otherwise
        """
        is_x_nonegative = all([(line.p1.x >= -eps) and (line.p2.x >= -eps) for line in area.lines])
        is_y_nonegative = all([(line.p1.y >= -eps) and (line.p2.y >= -eps) for line in area.lines])
        return not (is_x_nonegative and is_y_nonegative)

    @staticmethod
    def are_areas_overlapping(area_a: "Area", area_b: "Area") -> True:
        p1 = Polygon([(area_a.get_line(i).p1.x, area_a.get_line(i).p1.y) for i in range(4)])
        p2 = Polygon([(area_b.get_line(i).p1.x, area_b.get_line(i).p1.y) for i in range(4)])
        return p1.intersects(p2)
