from typing import Iterable, Callable
import numpy as np
import math

class TW_Utility:
    @staticmethod
    def _string_to_int(string):
        value = 0
        for i in range(0,len(string)):
            value = value + ord(str(string[i]))
        return value
    
    @staticmethod
    def np_number_columns(data):
        if not TW_Utility.is_array(data):
            data = [data]

        if not isinstance(data, np.ndarray):
            data = np.array(data)

        return data.shape[len(data.shape) -1]
        
    @staticmethod
    def is_array(data):
        return isinstance(data, list) or isinstance(data, np.ndarray)
    
    @staticmethod
    def is_tuple(data):
        return isinstance(data, tuple)
    
    @staticmethod
    def array_closest_value(array, value):
        array = np.asarray(array)
        array = array[array != value]
        idx = (np.abs(array - value)).argmin()
        return array[idx]

    @staticmethod
    def in_array(valeur, structure):
        if isinstance(structure, dict):
            return valeur in structure.values()
        elif isinstance(structure, (list, tuple, set)):
            return valeur in structure
        return False
    
    @staticmethod
    def array_last_value(data):
        while isinstance(data, (list, np.ndarray)):
            data = data[-1]
        return data

    @staticmethod
    def empty_or_none(a, b = None):
        isNone = False

        if b is None:
            isNone = a is None or (type(a) == str and a.strip() == '')
        else:
            if type(a) is dict:
                try:
                    isNone = a[b] is None or a[b] == ""
                except:
                    isNone = True
            else:
                if not hasattr(a, b) or getattr(a, b) is None:
                    isNone = True

        return isNone
    
    @staticmethod
    def hasattr(obj:object, name:str):
        if type(obj) is dict:
            return not TW_Utility.empty_or_none(obj, name)
        else:
            return hasattr(obj, name)
        
    @staticmethod
    def getattr(obj:object, name:str):
        if type(obj) is dict:
            return obj[name] if TW_Utility.empty_or_none(obj, name) != True else None
        else:
            return getattr(obj, name)

    @staticmethod
    def tw_where(iter:Iterable, fn:Callable):
        filteredValue = list(filter(fn, iter))
        return filteredValue
    
    @staticmethod
    def dataset_total_score(data):
        score = []

        if not isinstance(data, list) and not isinstance(data, np.ndarray):
            data = [data]
        
        if not isinstance(data, np.ndarray):
            data = np.array(data)

        if(data.ndim >= 3):
            raise Exception("3 Dimensions not supported yet")
        
        if(data.ndim < 2):
            for i in range(0,len(data)):
                value = data[i] if not isinstance(data[i], str) else TW_Utility._string_to_int(data[i])
                itemScore = value * 1 * abs(value)
                score.append(itemScore)
        else:
            for i in range(0,len(data)):
                itemScore = 0

                for j in (range(0,len(data[i]))):
                    value = data[i][j] if not isinstance(data[i][j], str) else TW_Utility._string_to_int(data[i][j])             
                    itemScore = value * float(f"1.{j}")
                    itemScore = itemScore * 1 * abs(itemScore)
                    score.append(itemScore)
                                
        return np.array(score).sum()
    
    @staticmethod
    def adjust_series_nbr_columns(X, desiredShape = None):
        if not isinstance(X, np.ndarray):
            X = np.array(X)

        columnAdded = False
        shouldAddColumn = False
        if not TW_Utility.empty_or_none(X) \
            and (not TW_Utility.is_array(X[0]) or len(X[0]) < 2):
            shouldAddColumn = True

        if shouldAddColumn:
            columnAdded = True
            newX = []

            for i in range (0,len(X)):
                newX.append([X[i]])

            X = newX
         
        return columnAdded, X
    
    #ajouter les dimensions manquantes ici
    '''
    @staticmethod
    def concatenate_along_axis(A,B, axis=1):
        newArr = []

        if not isinstance(A, np.ndarray):
            A = np.array(newArr)

        if not isinstance(B, np.ndarray):
            B = np.array(newArr)

        nbrDimensionsDiff = 0
        if len(A.shape) < len(B.shape):
            nbrDimensionsDiff = B.shape - A.shape

            for i in range(0,nbrDimensionsDiff):

        elif len(B.shape) < len(A.shape):
            nbrDimensionsDiff = A.shape - B.shape

        

        return np.array(newArr)
    '''
    
    '''
    2 4 5
    10 11 13

    2 10
    4 11
    5 13

    [2,4][4,8][5,13]
    10 11 13

    [2,4] 10
    [4,8] 11
    [5,13] 13

    '''
    
    #si deux vecteurs u et v sont colinéaires alors il existe un k tel que ku = kv
    def colinearity_check(v1, v2):
        solutions = []
        try:
            solutions = np.linalg.solve(v1, v2)
        except:
            solutions = []

        return True if len(solutions) > 0 else False

    def get_vector_directors(matrix):
        candidatesVector = np.array([])
        for i in range(0,len(matrix)):
            firstPoint = matrix[i]

            for j in range(0,len(matrix)):
                if j == i:
                    continue
                else:
                    secondPoint = matrix[j]
                    vector = np.subtract(secondPoint, firstPoint)
                    np.append(candidatesVector, vector, axis=0)

                    if len(candidatesVector) >= 2:
                        aligned = TW_Utility.colinearity_check(candidatesVector[0],candidatesVector[1])
                        if not aligned:
                            return candidatesVector
                        else:
                            np.delete(candidatesVector, (1), axis=0)

        return candidatesVector
    
    @staticmethod
    def get_middle_point(p1, p2):
        middlePoint = []
        nbrColumns = TW_Utility.np_number_columns(p1)

        for i in range(0,nbrColumns):
            middlePoint.append(int((p1[i] + p2[i]) / 2))

        return middlePoint
    
    @staticmethod
    def _euclidianDistance(pointA, pointB):
        if not TW_Utility.is_array(pointA):
            pointA = [pointA]

        if not TW_Utility.is_array(pointB):
            pointB = [pointB]

        if len(pointA) != len(pointB):
            raise ValueError("Les deux points doivent avoir la même dimension.")
        
        somme = sum((a - b) ** 2 for a, b in zip(pointA, pointB))
        return math.sqrt(somme)
        
    @staticmethod
    #pour les tableaux préférer np.vstack ou np.hstack
    def mergeDictionnaries(dict1:dict, dict2:dict):
        mergedDict = dict1
        for key,val in dict2.items():
            mergedDict[key] = val

        return mergedDict
    
    @staticmethod
    def splitPointListIntoSerie(pointList):
        seriesList = []

        nbrDimension = TW_Utility.np_number_columns(pointList[0])
        for i in range(0,nbrDimension):
            seriesList.append([])

        for i in range(0,len(pointList)):
            for j in range(0,nbrDimension):
                seriesList[j].append(pointList[i][j])
        
        return seriesList
    
    @staticmethod 
    def pickNFirstDimensionFromTupleOrArray(tuppleOrArray, nbrDimensions):
        res = []

        if TW_Utility.is_array(tuppleOrArray[0]) or TW_Utility.is_tuple(tuppleOrArray[0]):
            for i in range(0, len(tuppleOrArray)):
                for j in range(0, nbrDimensions):
                    res.append(tuppleOrArray[i][j])
        else:
            for j in range(0, nbrDimensions):
                res.append(tuppleOrArray[j])
        
        return res

    @staticmethod
    def intersect_segments_2d(p1, p2, p3, p4, epsilon=1e-9):
        """
        Calcule le point d'intersection de deux segments 2D.
        Renvoie le point d'intersection [x, y] si une intersection unique existe.
        Renvoie None si les segments sont parallèles, colinéaires (non chevauchants) ou ne se coupent pas.
        Renvoie une liste de deux points si les segments sont colinéaires et se chevauchent (le segment d'intersection).
        """
        p1 = np.array(p1, dtype=float)
        p2 = np.array(p2, dtype=float)
        p3 = np.array(p3, dtype=float)
        p4 = np.array(p4, dtype=float)

        # Vecteurs directeurs des droites
        v1 = p2 - p1 # Direction du segment 1
        v2 = p4 - p3 # Direction du segment 2
        
        # Différence entre les points de départ
        dp = p3 - p1

        # Calcul du déterminant (produit croisé 2D) des vecteurs directeurs
        # Cela correspond à (v1_x * v2_y - v1_y * v2_x)
        det = v1[0] * v2[1] - v1[1] * v2[0]

        # --- Cas où les droites sont parallèles ou colinéaires ---
        if abs(det) < epsilon: # Les droites sont parallèles ou colinéaires
            # Vérifier si elles sont colinéaires (et potentiellement se chevauchent)
            # Si dp est parallèle à v1, alors elles sont colinéaires
            det_collinear = v1[0] * dp[1] - v1[1] * dp[0]
            
            if abs(det_collinear) < epsilon: # Les droites sont colinéaires
                # Elles sont colinéaires. Il faut vérifier s'il y a chevauchement des segments.
                # Convertir les points sur une dimension (ex: axe X si v1_x est grand, sinon axe Y)
                # ou projeter les points sur le vecteur directeur
                
                # Utiliser les projections sur les vecteurs directeurs pour vérifier le chevauchement
                # Projection des points du seg2 sur le seg1 (en prenant p1 comme origine)
                # t0 = (p3 - p1) . v1 / (v1 . v1)
                # t1 = (p4 - p1) . v1 / (v1 . v1)
                
                len_sq_v1 = np.dot(v1, v1)
                if len_sq_v1 < epsilon: # Segment 1 est un point
                    return p1 if np.linalg.norm(p3 - p1) < epsilon and np.linalg.norm(p4 - p1) < epsilon else None
                
                t_min_s1 = 0
                t_max_s1 = 1
                
                s_proj_p3 = np.dot(p3 - p1, v1) / len_sq_v1
                s_proj_p4 = np.dot(p4 - p1, v1) / len_sq_v1
                
                # Les intervalles de t pour le chevauchement
                # Si p3 < p4, alors min_s2 = s_proj_p3, max_s2 = s_proj_p4
                # Sinon, min_s2 = s_proj_p4, max_s2 = s_proj_p3
                min_s2 = min(s_proj_p3, s_proj_p4)
                max_s2 = max(s_proj_p3, s_proj_p4)

                # Calculer l'intervalle de chevauchement sur la ligne paramétrique de S1
                overlap_min = max(t_min_s1, min_s2)
                # vaut 1 si ||P3 - P1|| > ||P1 p2||


                overlap_max = min(t_max_s1, max_s2)
                # >= 1

                #si overlap_max > 1 alors il y'a chevauchement
                if overlap_min <= overlap_max + epsilon: # Il y a chevauchement
                    # Retourner le segment d'intersection (ses deux points d'extrémité)
                    # Il faut être prudent pour les cas de chevauchement ponctuel
                    if abs(overlap_min - overlap_max) < epsilon:
                        return [p1 + overlap_min * v1] # Un seul point d'intersection
                    else:
                        return [p1 + overlap_min * v1, p1 + overlap_max * v1]
                else:
                    return None # Colinéaires mais pas de chevauchement
            else:
                return None # Parallèles et distincts
        

        # --- Cas où les droites ne sont PAS parallèles (elles se coupent à un point unique) ---
        # Calcul des paramètres t et u
        t = (dp[0] * v2[1] - dp[1] * v2[0]) / det
        u = (dp[0] * v1[1] - dp[1] * v1[0]) / det

        # Vérifier si le point d'intersection se trouve sur les deux segments
        # t doit être entre 0 et 1 (inclus) pour le segment 1
        # u doit être entre 0 et 1 (inclus) pour le segment 2
        if 0 - epsilon <= t <= 1 + epsilon and 0 - epsilon <= u <= 1 + epsilon:
            # Le point d'intersection est sur les deux segments
            intersection_point = p1 + t * v1
            return intersection_point
        else:
            # Le point d'intersection est en dehors d'au moins un segment
            return None
            
    #todo test
    @staticmethod
    def intersect_segments_3d(p1, p2, p3, p4):
        """
        Calcule le point d'intersection de deux segments 3D, s'il existe.

        Args:
            p1 (np.array): Premier point du premier segment [x1, y1, z1].
            p2 (np.array): Deuxième point du premier segment [x2, y2, z2].
            p3 (np.array): Premier point du deuxième segment [x3, y3, z3].
            p4 (np.array): Deuxième point du deuxième segment [x4, y4, z4].

        Returns:
            np.array or None: Le point d'intersection si les segments se coupent,
                            sinon None.
        """
        # Convertir les points en tableaux numpy pour faciliter les opérations
        p1 = np.array(p1)
        p2 = np.array(p2)
        p3 = np.array(p3)
        p4 = np.array(p4)

        # Vecteurs directeurs des droites
        v1 = p2 - p1  # Vecteur pour le premier segment
        v2 = p4 - p3  # Vecteur pour le deuxième segment

        # 1. Vérifier le parallélisme des droites
        # Si le produit vectoriel est nul ou très proche de zéro (en raison de la précision flottante)
        cross_product_v = np.cross(v1, v2)
        if np.linalg.norm(cross_product_v) < 1e-9:  # Presque parallèle
            # Les droites sont parallèles (ou colinéaires/identiques)
            # Vérifier si elles sont colinéaires (un point de l'une est sur l'autre)
            # Si P1-P3 est colinéaire à V1, alors elles sont colinéaires
            v_p1_p3 = p1 - p3
            collinear_check = np.cross(v_p1_p3, v1)
            if np.linalg.norm(collinear_check) < 1e-9:
                # Les droites sont colinéaires (identiques ou juste sur la même ligne)
                # Nous devons vérifier le chevauchement des segments sur cette ligne
                # C'est un problème d'intersection d'intervalles 1D.
                # Projection des points sur l'axe du vecteur v1 pour simplifier
                # (ou v2, peu importe car ils sont colinéaires)
                t0 = 0.0
                t1 = np.dot(p2 - p1, v1) / np.dot(v1, v1) if np.dot(v1, v1) != 0 else 0.0
                t_p3_proj = np.dot(p3 - p1, v1) / np.dot(v1, v1) if np.dot(v1, v1) != 0 else 0.0
                t_p4_proj = np.dot(p4 - p1, v1) / np.dot(v1, v1) if np.dot(v1, v1) != 0 else 0.0

                # Assurer que les intervalles sont triés
                t_seg1_min, t_seg1_max = sorted([t0, t1])
                t_seg2_min, t_seg2_max = sorted([t_p3_proj, t_p4_proj])

                # Calcul de l'intersection des intervalles
                overlap_min = max(t_seg1_min, t_seg2_min)
                overlap_max = min(t_seg1_max, t_seg2_max)

                if overlap_min <= overlap_max:
                    # Il y a un chevauchement. S'ils sont exactement au même point, on peut le retourner.
                    # Pour cet exemple, nous retournons le point de début de chevauchement s'il y en a un seul
                    # ou None si le chevauchement est un segment (pour simplifier).
                    # Dans un cas réel, vous pourriez vouloir retourner un segment pour l'intersection.
                    if overlap_min == overlap_max: # Les segments se touchent à un point
                        return p1 + overlap_min * v1
                    else: # Les segments se chevauchent sur une longueur
                        # Pour la simplicité de cette fonction, on considère que l'intersection est un point unique
                        # Si c'est un segment, la fonction retourne None (pas de point unique d'intersection)
                        # ou vous pouvez retourner (p1 + overlap_min * v1, p1 + overlap_max * v1)
                        # Je retourne None ici pour éviter de changer le type de retour.
                        return [p1 + overlap_min * v1, p1 + overlap_max * v1]
                else:
                    return None # Pas de chevauchement pour les segments colinéaires
            else:
                return None # Droites parallèles et distinctes, pas d'intersection

        # 2. Vérifier si les droites sont gauches (ne se coupent pas et ne sont pas parallèles)
        # Si le produit mixte n'est pas nul, les droites sont gauches
        # (p3 - p1) . (v1 x v2)
        mixed_product = np.dot((p3 - p1), cross_product_v)
        if abs(mixed_product) > 1e-9: # Utiliser une tolérance pour les nombres flottants
            return None # Droites gauches, pas d'intersection

        # 3. Calculer les paramètres t et u du point d'intersection
        # Système d'équations: P1 + t*V1 = P3 + u*V2
        # Soit: t*V1 - u*V2 = P3 - P1

        # On utilise les deux premières équations (x et y) pour trouver t et u,
        # puis on vérifie avec la troisième (z).
        # Matrice des coefficients pour [x, y]
        A = np.array([
            [v1[0], -v2[0]],
            [v1[1], -v2[1]]
        ])
        # Vecteur des constantes pour [x, y]
        B = np.array([
            p3[0] - p1[0],
            p3[1] - p1[1]
        ])

        # Résoudre le système A * [t, u] = B
        # Gérer le cas où le déterminant est proche de zéro (e.g., v1[0]*(-v2[1]) - (-v2[0])*v1[1] est nul)
        # ce qui signifie que les projections 2D sont parallèles, mais elles doivent être coplanaires en 3D
        # si elles ne sont pas parallèles en 3D.
        # On peut utiliser np.linalg.solve qui gère les cas singuliers ou pseudo-inverse si nécessaire.
        try:
            t_u = np.linalg.solve(A, B)
            t = t_u[0]
            u = t_u[1]
        except np.linalg.LinAlgError:
            # Cela peut arriver si la projection 2D est singulière, mais les droites sont coplanaires en 3D.
            # Dans ce cas, nous devons choisir d'autres axes.
            # Une méthode plus robuste utilise la pseudo-inverse ou un algorithme pour des systèmes surdéterminés.
            # Ou on peut choisir un autre sous-système 2x2.
            # Par exemple, si v1[0] et v2[0] sont petits, utilisez y et z.
            A_yz = np.array([
                [v1[1], -v2[1]],
                [v1[2], -v2[2]]
            ])
            B_yz = np.array([
                p3[1] - p1[1],
                p3[2] - p1[2]
            ])
            try:
                t_u = np.linalg.solve(A_yz, B_yz)
                t = t_u[0]
                u = t_u[1]
            except np.linalg.LinAlgError:
                # Si même yz est singulier, c'est un cas très dégénéré ou un bug.
                return None


        # Vérifier que les valeurs de t et u satisfont la troisième équation (pour la robustesse)
        # p1[2] + t * v1[2] doit être proche de p3[2] + u * v2[2]
        if not np.isclose(p1[2] + t * v1[2], p3[2] + u * v2[2]):
            return None # Incohérence, les droites ne se coupent pas ou problème de précision

        # 4. Vérifier si le point d'intersection se trouve sur les deux segments
        if 0 <= t <= 1 and 0 <= u <= 1:
            # Le point d'intersection est sur les deux segments
            intersection_point = p1 + t * v1
            return intersection_point
        else:
            # Le point d'intersection des droites n'est pas sur les segments
            return None

        