from math import acos, asin, atan2, cos, pi, sin, sqrt, isnan
from scipy.spatial.transform import Rotation
import numpy as np
import sys

try:
    GLOBAL_DIR = sys._MEIPASS  # temporary folder with libs & data files
except:
    GLOBAL_DIR = "."


class MathOps():
    '''
    This class provides general mathematical operations that are not directly available through numpy
    '''

    @staticmethod
    def deg_sph2cart(spherical_vec):
        ''' Converts spherical coordinates in degrees to cartesian coordinates '''
        r = spherical_vec[0]
        h = spherical_vec[1] * pi / 180
        v = spherical_vec[2] * pi / 180
        return np.array([r * cos(v) * cos(h), r * cos(v) * sin(h), r * sin(v)])

    @staticmethod
    def deg_sin(deg_angle):
        ''' Returns sin of degrees '''
        return sin(deg_angle * pi / 180)

    @staticmethod
    def deg_cos(deg_angle):
        ''' Returns cos of degrees '''
        return cos(deg_angle * pi / 180)

    @staticmethod
    def to_3d(vec_2d, value=0) -> np.ndarray:
        ''' Returns new 3d vector from 2d vector '''
        return np.append(vec_2d, value)

    @staticmethod
    def to_2d_as_3d(vec_3d) -> np.ndarray:
        ''' Returns new 3d vector where the 3rd dimension is zero '''
        vec_2d_as_3d = np.copy(vec_3d)
        vec_2d_as_3d[2] = 0
        return vec_2d_as_3d

    @staticmethod
    def normalize_vec(vec) -> np.ndarray:
        ''' Divides vector by its length '''
        size = np.linalg.norm(vec)
        if size == 0: return vec
        return vec / size

    def rel_to_global_3d(local_pos_3d: np.ndarray, global_pos_3d: np.ndarray,
                         global_orientation_quat: np.ndarray) -> np.ndarray:
        ''' Converts a local 3d position to a global 3d position given the global position and orientation (quaternion) '''

        rotation = Rotation.from_quat(global_orientation_quat)
        rotated_vec = rotation.apply(local_pos_3d)
        global_pos = global_pos_3d + rotated_vec

        return global_pos

    @staticmethod
    def get_active_directory(dir: str) -> str:
        global GLOBAL_DIR
        return GLOBAL_DIR + dir

    @staticmethod
    def acos(val):
        ''' arccosine function that limits input '''
        return acos(np.clip(val, -1, 1))

    @staticmethod
    def asin(val):
        ''' arcsine function that limits input '''
        return asin(np.clip(val, -1, 1))

    @staticmethod
    def normalize_deg(val):
        ''' normalize val in range [-180,180[ '''
        return (val + 180.0) % 360 - 180

    @staticmethod
    def normalize_rad(val):
        ''' normalize val in range [-pi,pi[ '''
        return (val + pi) % (2 * pi) - pi

    @staticmethod
    def deg_to_rad(val):
        ''' convert degrees to radians '''
        return val * 0.01745329251994330

    @staticmethod
    def rad_to_deg(val):
        ''' convert radians to degrees '''
        return val * 57.29577951308232

    @staticmethod
    def vector_angle(vector, is_rad=False):
        ''' angle (degrees or radians) of 2D vector '''
        if is_rad:
            return atan2(vector[1], vector[0])
        else:
            return atan2(vector[1], vector[0]) * 180 / pi

    @staticmethod
    def vectors_angle(vec1, vec2, is_rad=False):
        ''' get angle between vectors (degrees or radians) '''
        ang_rad = acos(np.dot(MathOps.normalize_vec(vec1), MathOps.normalize_vec(vec2)))
        return ang_rad if is_rad else ang_rad * 180 / pi

    @staticmethod
    def vector_from_angle(angle, is_rad=False):
        ''' unit vector with direction given by `angle` '''
        if is_rad:
            return np.array([cos(angle), sin(angle)], float)
        else:
            return np.array([MathOps.deg_cos(angle), MathOps.deg_sin(angle)], float)

    @staticmethod
    def target_abs_angle(pos2d, target, is_rad=False):
        ''' angle (degrees or radians) of vector (target-pos2d) '''
        if is_rad:
            return atan2(target[1] - pos2d[1], target[0] - pos2d[0])
        else:
            return atan2(target[1] - pos2d[1], target[0] - pos2d[0]) * 180 / pi

    @staticmethod
    def target_rel_angle(pos2d, ori, target, is_rad=False):
        ''' relative angle (degrees or radians) of target if we're located at 'pos2d' with orientation 'ori' (degrees or radians) '''
        if is_rad:
            return MathOps.normalize_rad(atan2(target[1] - pos2d[1], target[0] - pos2d[0]) - ori)
        else:
            return MathOps.normalize_deg(atan2(target[1] - pos2d[1], target[0] - pos2d[0]) * 180 / pi - ori)

    @staticmethod
    def rotate_2d_vec(vec, angle, is_rad=False):
        ''' rotate 2D vector anticlockwise around the origin by `angle` '''
        cos_ang = cos(angle) if is_rad else cos(angle * pi / 180)
        sin_ang = sin(angle) if is_rad else sin(angle * pi / 180)
        return np.array([cos_ang * vec[0] - sin_ang * vec[1], sin_ang * vec[0] + cos_ang * vec[1]])

    @staticmethod
    def distance_point_to_line(p: np.ndarray, a: np.ndarray, b: np.ndarray):
        '''
        Distance between point p and 2d line 'ab' (and side where p is)

        Parameters
        ----------
        a : ndarray
            2D point that defines line
        b : ndarray
            2D point that defines line
        p : ndarray
            2D point

        Returns
        -------
        distance : float
            distance between line and point
        side : str
            if we are at a, looking at b, p may be at our "left" or "right"
        '''
        line_len = np.linalg.norm(b - a)

        if line_len == 0:  # assumes vertical line
            dist = sdist = np.linalg.norm(p - a)
        else:
            sdist = np.cross(b - a, p - a) / line_len
            dist = abs(sdist)

        return dist, "left" if sdist > 0 else "right"

    @staticmethod
    def distance_point_to_point_2d(p1: np.ndarray, p2: np.ndarray) -> float:
        ''' Distance in 2d from point p1 to point p2'''

        return np.sqrt((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2)

    @staticmethod
    def is_point_between(p: np.ndarray, a: np.ndarray, b: np.ndarray) -> bool:
        ''' Verify if point 'p' is between 'a' and 'b' '''

        p_2d = p[:2]
        a_2d = a[:2]
        b_2d = b[:2]

        return np.all(np.minimum(a_2d, b_2d) <= p_2d) and np.all(p_2d <= np.maximum(a_2d, b_2d))

    @staticmethod
    def is_x_between(x1: np.ndarray, x2: np.ndarray, x3: np.ndarray) -> bool:
        ''' Verify if x-axis of point 'x1' is between 'x2' and 'x3' '''

        return x1 >= min(x2, x3) and x1 <= max(x2, x3)

    def distance_point_to_segment(p: np.ndarray, a: np.ndarray, b: np.ndarray):
        ''' Distance from point p to 2d line segment 'ab' '''

        ap = p - a
        ab = b - a

        ad = MathOps.vector_projection(ap, ab)

        # Is d in ab? We can find k in (ad = k * ab) without computing any norm
        # we use the largest dimension of ab to avoid division by 0
        k = ad[0] / ab[0] if abs(ab[0]) > abs(ab[1]) else ad[1] / ab[1]

        if k <= 0:
            return np.linalg.norm(ap)
        elif k >= 1:
            return np.linalg.norm(p - b)
        else:
            return np.linalg.norm(p - (ad + a))  # p-d

    @staticmethod
    def distance_point_to_ray(p: np.ndarray, ray_start: np.ndarray, ray_direction: np.ndarray):
        ''' Distance from point p to 2d ray '''

        rp = p - ray_start
        rd = MathOps.vector_projection(rp, ray_direction)

        # Is d in ray? We can find k in (rd = k * ray_direction) without computing any norm
        # we use the largest dimension of ray_direction to avoid division by 0
        k = rd[0] / ray_direction[0] if abs(ray_direction[0]) > abs(ray_direction[1]) else rd[1] / ray_direction[1]

        if k <= 0:
            return np.linalg.norm(rp)
        else:
            return np.linalg.norm(p - (rd + ray_start))  # p-d

    @staticmethod
    def closest_point_on_ray_to_point(p: np.ndarray, ray_start: np.ndarray, ray_direction: np.ndarray):
        ''' Point on ray closest to point p '''

        rp = p - ray_start
        rd = MathOps.vector_projection(rp, ray_direction)

        # Is d in ray? We can find k in (rd = k * ray_direction) without computing any norm
        # we use the largest dimension of ray_direction to avoid division by 0
        k = rd[0] / ray_direction[0] if abs(ray_direction[0]) > abs(ray_direction[1]) else rd[1] / ray_direction[1]

        if k <= 0:
            return ray_start
        else:
            return rd + ray_start

    @staticmethod
    def does_circle_intersect_segment(p: np.ndarray, r, a: np.ndarray, b: np.ndarray):
        ''' Returns true if circle (center p, radius r) intersect 2d line segment '''

        ap = p - a
        ab = b - a

        ad = MathOps.vector_projection(ap, ab)

        # Is d in ab? We can find k in (ad = k * ab) without computing any norm
        # we use the largest dimension of ab to avoid division by 0
        k = ad[0] / ab[0] if abs(ab[0]) > abs(ab[1]) else ad[1] / ab[1]

        if k <= 0:
            return np.dot(ap, ap) <= r * r
        elif k >= 1:
            return np.dot(p - b, p - b) <= r * r

        dp = p - (ad + a)
        return np.dot(dp, dp) <= r * r

    @staticmethod
    def vector_projection(a: np.ndarray, b: np.ndarray):
        ''' Vector projection of a onto b '''
        b_dot = np.dot(b, b)
        return b * np.dot(a, b) / b_dot if b_dot != 0 else b

    @staticmethod
    def do_noncollinear_segments_intersect(a, b, c, d):
        '''
        Check if 2d line segment 'ab' intersects with noncollinear 2d line segment 'cd'
        Explanation: https://www.geeksforgeeks.org/check-if-two-given-line-segments-intersect/
        '''

        ccw = lambda a, b, c: (c[1] - a[1]) * (b[0] - a[0]) > (b[1] - a[1]) * (c[0] - a[0])
        return ccw(a, c, d) != ccw(b, c, d) and ccw(a, b, c) != ccw(a, b, d)

    @staticmethod
    def intersection_segment_opp_goal(a: np.ndarray, b: np.ndarray):
        ''' Computes the intersection point of 2d segment 'ab' and the opponents' goal (front line) '''
        vec_x = b[0] - a[0]

        # Collinear intersections are not accepted
        if vec_x == 0: return None

        k = (15.01 - a[0]) / vec_x

        # No collision
        if k < 0 or k > 1: return None

        intersection_pt = a + (b - a) * k

        if -1.01 <= intersection_pt[1] <= 1.01:
            return intersection_pt
        else:
            return None

    @staticmethod
    def intersection_circle_opp_goal(p: np.ndarray, r):
        '''
        Computes the intersection segment of circle (center p, radius r) and the opponents' goal (front line)
        Only the y coordinates are returned since the x coordinates are always equal to 15
        '''

        x_dev = abs(15 - p[0])

        if x_dev > r:
            return None  # no intersection with x=15

        y_dev = sqrt(r * r - x_dev * x_dev)

        p1 = max(p[1] - y_dev, -1.01)
        p2 = min(p[1] + y_dev, 1.01)

        if p1 == p2:
            return p1  # return the y coordinate of a single intersection point
        elif p2 < p1:
            return None  # no intersection
        else:
            return p1, p2  # return the y coordinates of the intersection segment

    @staticmethod
    def distance_point_to_opp_goal(p: np.ndarray):
        ''' Distance between point 'p' and the opponents' goal (front line) '''

        if p[1] < -1.01:
            return np.linalg.norm(p - (15, -1.01))
        elif p[1] > 1.01:
            return np.linalg.norm(p - (15, 1.01))
        else:
            return abs(15 - p[0])

    @staticmethod
    def circle_line_segment_intersection(circle_center, circle_radius, pt1, pt2, full_line=True, tangent_tol=1e-9):
        """ Find the points at which a circle intersects a line-segment.  This can happen at 0, 1, or 2 points.

        :param circle_center: The (x, y) location of the circle center
        :param circle_radius: The radius of the circle
        :param pt1: The (x, y) location of the first point of the segment
        :param pt2: The (x, y) location of the second point of the segment
        :param full_line: True to find intersections along full line - not just in the segment.  False will just return intersections within the segment.
        :param tangent_tol: Numerical tolerance at which we decide the intersections are close enough to consider it a tangent
        :return Sequence[Tuple[float, float]]: A list of length 0, 1, or 2, where each element is a point at which the circle intercepts a line segment.

        Note: We follow: http://mathworld.wolfram.com/Circle-LineIntersection.html
        """

        (p1x, p1y), (p2x, p2y), (cx, cy) = pt1, pt2, circle_center
        (x1, y1), (x2, y2) = (p1x - cx, p1y - cy), (p2x - cx, p2y - cy)
        dx, dy = (x2 - x1), (y2 - y1)
        dr = (dx ** 2 + dy ** 2) ** .5
        big_d = x1 * y2 - x2 * y1
        discriminant = circle_radius ** 2 * dr ** 2 - big_d ** 2

        if discriminant < 0:  # No intersection between circle and line
            return []
        else:  # There may be 0, 1, or 2 intersections with the segment
            intersections = [
                (cx + (big_d * dy + sign * (-1 if dy < 0 else 1) * dx * discriminant ** .5) / dr ** 2,
                 cy + (-big_d * dx + sign * abs(dy) * discriminant ** .5) / dr ** 2)
                for sign in ((1, -1) if dy < 0 else (-1, 1))]  # This makes sure the order along the segment is correct
            if not full_line:  # If only considering the segment, filter out intersections that do not fall within the segment
                fraction_along_segment = [
                    (xi - p1x) / dx if abs(dx) > abs(dy) else (yi - p1y) / dy for xi, yi in intersections]
                intersections = [pt for pt, frac in zip(
                    intersections, fraction_along_segment) if 0 <= frac <= 1]
            # If line is tangent to circle, return just one point (as both intersections have same location)
            if len(intersections) == 2 and abs(discriminant) <= tangent_tol:
                return [intersections[0]]
            else:
                return intersections

    # adapted from https://stackoverflow.com/questions/3252194/numpy-and-line-intersections
    @staticmethod
    def get_line_intersection(a1, a2, b1, b2):
        """
        Returns the point of intersection of the lines passing through a2,a1 and b2,b1.
        a1: [x, y] a point on the first line
        a2: [x, y] another point on the first line
        b1: [x, y] a point on the second line
        b2: [x, y] another point on the second line
        """
        s = np.vstack([a1, a2, b1, b2])  # s for stacked
        h = np.hstack((s, np.ones((4, 1))))  # h for homogeneous
        l1 = np.cross(h[0], h[1])  # get first line
        l2 = np.cross(h[2], h[3])  # get second line
        x, y, z = np.cross(l1, l2)  # point of intersection
        if z == 0:  # lines are parallel
            return np.array([float('inf'), float('inf')])
        return np.array([x / z, y / z], float)

    @staticmethod
    def get_score_based_on_tanh(input, vertical_scaling=0.5, horizontal_scaling=1, vertical_shift=0.5,
                                horizontal_shift=0) -> float:
        '''
            Based on hiperbolic tangent function, it's calculated a score between 0 and 1 (or others, depending on the parameters)

            Function: a * tanh(x * c + d) + b

            :param input: x value of the function
            :param vertical_scaling: strech (a > 1) or compress (0 < a < 1) the function
            :param horizontal_scaling: strech (c > 1) or compress (0 < c < 1) the function
            :param vertical_shift: shift up (b > 0) or down (b < 0) the function
            :param horizontal_shift: shift right (d < 0) or left (d > 0) the function
        '''

        return vertical_scaling * np.tanh(input * horizontal_scaling + horizontal_shift) + vertical_shift

    @staticmethod
    def get_angle_to_origin_radians(point: np.ndarray):
        x, y = point

        if x == 0:
            return pi / 2 if y > 0 else -pi / 2

        result = atan2(y, x)

        result = result if not isnan(result) else pi / 2

        return MathOps.normalize_rad(result)
