import math
import random
import numpy as np
from enum import Enum
from minecraft_pb2 import *
from utils_novelty import *
from utils_deap_tools import *
from utils_coordinates import *
from utils_server_client import *
from utils_workspace_manger import *
from itertools import product
from abc import ABC, abstractmethod

class EvolutionMode(Enum):
    RELEASE = 0
    EXPERIMENT = 1
    DEBUG = 2

class GeneralUtils:
    
    @staticmethod
    def clip(value, min_value, max_value):
        return min(max(value, min_value), max_value)
    
    @staticmethod
    def get_range(values):
        return (min(values), max(values))
    
    @staticmethod
    def get_l_norm(l,*args):
        return sum([abs(c)**l for c in args])**(1/l)
    
    @staticmethod
    def iterate_max_metric_neighbours(coor, n):
        dim = len(coor)
        zero = (0)*dim
        for offset in product(*([range(-n,n+1)]*dim)):
            if offset!=zero:
                result_coor = []
                for c,o in zip(coor,offset):
                    result_coor.append(c+o)
                yield tuple(result_coor)

    @staticmethod
    def valid_index(idx, shape):
        for i,n in zip(idx,shape):
            if not (0<=i<n):
                return False
        return True
    
    @staticmethod
    def dfs(node, visited, get_neighbors):
        visited.add(node)
        for neighbor in get_neighbors(node):
            if not neighbor in visited:
                GeneralUtils.dfs(neighbor, visited, get_neighbors)

    @staticmethod
    def bool_iter_to_str(bools):
        return str([ "T" if b else "F" for b in bools])

    @staticmethod
    def random_3Dvector():
        phi = np.random.uniform(0, 2 * np.pi)
        theta = np.arccos(np.random.uniform(-1, 1))
        
        x = np.sin(theta) * np.cos(phi)
        y = np.sin(theta) * np.sin(phi)
        z = np.cos(theta)
        
        return(x,y,z)

    @staticmethod
    def log_threshold(array, threshold = 1):
        valid_indices = array > threshold
        array[valid_indices] = threshold - np.log2(threshold) + np.log2(array[valid_indices])
        return array

class Execution(ABC):
    _ok_response = ["ok","continue"]
    _stop_response = ["exit debug","stop debug"]
    
    def __init__(self, wm:WorkspaceManager) -> None:
        self.debug = False
        self.wm =wm
        
    def set_debug(self, to_debug:bool):
        self.debug = to_debug
        
    def ask_debug(self): 
        self.wm.server_client.send_message("Debug showcase")
        valid_response = False
        while not valid_response:
            self.wm.server_client.send_message(f"Send \"/response {Execution._ok_response[0]}\" to continue")
            response = self.wm.server_client.ask_for_response(f"If you want to exit debug send \"/response {Execution._stop_response[0]}\"")
            if response in Execution._ok_response:
                valid_response = True
            elif response in Execution._stop_response:
                self.debug = False
                valid_response = True
            else:
                self.wm.server_client.send_message(f"Response is not valid. Send response again")

    @abstractmethod
    def simulate(self, pop):
        pass

    def select_ind(self, pop, ind_name="Individual"):
        debug = self.debug
        self.debug = False
        self. simulate(pop)
        
        d = int(math.log10(len(pop)))
        sign_limit = 15
        equals_sign = "=" * sign_limit
        # spaces = "-" * (sign_limit//2- d//2)
        spaces = ""
        
        request, clean_up = self.wm.create_sign_describtion_request(
            [f"{equals_sign} {spaces}{i:0{d}d}{spaces} {equals_sign}" for i in range(len(pop))],
            return_clean_request=True,
            sign=BIRCH_SIGN
        )
        self.wm.server_client.place_signs(request)
        choosen_idx = None
        while choosen_idx is None:
            self.wm.server_client.send_message(f"Choose {ind_name} for further evolution.")
            self.wm.server_client.send_message(f"{ind_name} are labeled by number on sign in front of it.")
            self.wm.server_client.send_message("Answer with \"/response number\".")
            response = self.wm.server_client.get_message()
            try:
                choosen_idx = int(response)
                if not 0<=choosen_idx<len(pop):
                    choosen_idx = None
                    self.wm.server_client.send_message(f"Please enter a valid number in range [{0}-{len(pop)-1}].")
            except ValueError:
                choosen_idx = None
                self.wm.server_client.send_message("Please enter a Number!")
        self.wm.server_client.send_message(f"Individual with number {choosen_idx}")
        self.wm.server_client.place_blocks_from_list(clean_up)
        self.debug = debug
        return choosen_idx

class NumpyTools:

    @staticmethod
    def flip_mutate(ind, p, options):
        mask = np.random.choice([0, 1], size=ind.shape, p=((1 - p), p)).astype(bool)
        ind[:] = np.where(mask, ind, random.choice(options))
        return ind,

    @staticmethod
    def random_cross(ind_1, ind_2):
        mask = np.random.choice([0, 1], size=ind_1.shape).astype(bool)
        a_1 = np.where(mask, ind_1, ind_2)
        a_2 = np.where(mask, ind_2, ind_1)
        ind_1[:], ind_2[:] = a_1,a_2
        return ind_1, ind_2

    @staticmethod
    def create_individual(values, shape, p):
        return np.random.choice(values, size=shape, p=p)

    @staticmethod
    def rotate_along_axes(array:np.ndarray, axes:tuple[int,int]):
        for i in range(4):
            yield np.rot90(array, i, axes)

    @staticmethod
    def rotate_all_3D(array:np.ndarray):
        axes = [(1,2),(2,0),(0,1)]
        mod = 3
        
        rotated = array.copy()
        for i in range(6):
            for result in NumpyTools.rotate_along_axes(rotated, axes[(2*i)%mod]):
                yield result
            rotated = np.rot90(rotated, axes=axes[(2*i+1)%mod])

    @staticmethod
    def replace(array:np.ndarray, replacement):
        if not np.max(array)<len(replacement):
            raise ValueError()
        replaced_array = (np.array(replacement)[array.flatten()]).reshape(np.shape(array))
        return replaced_array

    @staticmethod
    def columns_along_all_axes(array:np.ndarray):
        roll = array.copy()
        lastdim = roll.ndim-1
        for _ in range(roll.ndim):
            for idx in np.ndindex(np.shape(roll)[:lastdim]):
                yield roll[idx]
            roll = np.rollaxis(roll,lastdim)

class EvolutionTools:

    @staticmethod
    def run_encoding(list):
        encoded = []
        run_len = 0
        prev_value = None

        for value in list:
            if value != prev_value:
                encoded.append((prev_value,run_len))
                run_len = 0
                prev_value = value
            run_len +=1
        return encoded

    @staticmethod
    def coor_in_max_metric(coor, n):
        zero = Coordinates.zero()
        for offset in product(*([range(-n,n+1)]*3)):
            coor_offset = Coordinates.from_tuple(offset)
            if coor_offset!=zero:
                yield coor + Coordinates.from_tuple(offset)

    @staticmethod
    def cardinal_neighbours(coor):
        for dir in CARDINAL_DIRECTIONS:
            yield coor + Coordinates.get_cardinal(dir)

    @staticmethod
    def count_neighbours(ind:np.ndarray, get_neighbours=cardinal_neighbours, empty_block=AIR):
        result = np.zeros_like(ind)
        for indices, value in np.ndenumerate(ind):
            neighbours = 0
            coor = Coordinates.from_tuple(indices)
            for adj in get_neighbours(coor):
                if (adj >= Coordinates.zero() and 
                    adj < Coordinates.from_tuple(np.shape(ind)) and 
                    ind[adj.to_tuple()]==value):
                    neighbours += 1
            result[coor.to_tuple()] = neighbours
        return result

    @staticmethod
    def biased_step_increase_prob(value, mu, minimum=0):
        if value<mu:
            return 1-(value-minimum)/(2*(mu-minimum))
        else:
            return (1/2)/(1+math.log2(value/mu))
        
class ShapeUtils:
    
    min_shape_side = 3
    _f1_slope_ratio = 0.8
    _f1_ratio_coef = _f1_slope_ratio/(1-_f1_slope_ratio)
    
    class FuncMode(Enum):
        MIN = 0,
        SUM = 1,
        MAX = 2

    @staticmethod
    def _get_create_coordinates(value_axis):
        if value_axis == 'x':
            return lambda i,j,v: Coordinates(v,i,j)
        elif value_axis == 'y':
            return lambda i,j,v: Coordinates(i,v,j)
        elif value_axis == 'z':
            return lambda i,j,v: Coordinates(i,j,v)
        else:
            raise ValueError("unsuported axis")

    @staticmethod
    def _get_xy(theta,a,b):
        nominator = a*b
        tg_theta = math.tan(theta)
        denominator = (b**2+(a*tg_theta)**2)**(1/2)
        x = nominator/denominator
        y = nominator*tg_theta/denominator
        return x,y

    @staticmethod
    def _mirror_to_all_quadrants(x,y,a=0,b=0):
        return product([a+x,a-x],[b+y,b-y])

    @staticmethod
    def ellipse_pixels(d_x, d_y, full=False, fi:float=0, buffer=0, return_projected_points=False, verbose=0):
        a =d_x/2
        b =d_y/2
        # center of block is offseted by half in even grid and by zero in odd case
        o_x = 0.5 if d_x%2 ==0 else 0
        o_y = 0.5 if d_y%2 ==0 else 0
        
        if verbose>0:
            print(f"a:{a} b:{b}")
        
        x = 0
        y = math.floor(b)
        
        
        result = []
        projected_points = []
        norm = 2
        placed = False
        
        while y-o_y >= 0:
            x_c = x+o_x
            y_c = y-o_y
            
            theta = math.atan2(y_c,x_c)
            x_e,y_e = ShapeUtils._get_xy(theta,a,b)
            
            c_norm = GeneralUtils.get_l_norm(norm,x_c,y_c)
            e_norm = GeneralUtils.get_l_norm(norm,x_e,y_e)
            if verbose>0:
                print(f"center:{x_c},{y_c} \tellipse:{x_e:.2f},{y_e:.2f} \ttheta:{theta:.2f} \tnorms:{c_norm:.2f},{e_norm:.2f}")
            
            if (buffer + c_norm < e_norm and
                not (x_c>a-1 and y_c>b-1)):
                # result.append([a+x_c,b+y_c])
                result +=  ShapeUtils._mirror_to_all_quadrants(x_c,y_c,a,b)
                if full:
                    for y_i in range(1,math.floor(y_c)+1):
                        result +=ShapeUtils._mirror_to_all_quadrants(x_c,y_c-y_i,a,b)
                placed = True
                if return_projected_points:
                    #projected_points.append([a+x_e,b+y_e])
                    projected_points +=  ShapeUtils._mirror_to_all_quadrants(x_e,y_e,a,b)
                if x_c+1 <= math.floor(a):
                    x += 1
                else:
                    y -= 1
            else:
                if placed:
                    y -= 1
                    placed = False
                else:
                    x -= 1
        result_len = len(result)
        result = np.unique(np.floor(result).astype(int), axis=0)
        if verbose>0:
            print(f"There were {result_len-len(result)} duplicit points from {result_len} generated points")
        
        if return_projected_points:
            projected_points = np.array(projected_points)
            return result,projected_points
        else:
            return result

    @staticmethod
    def line_pixels(x1, y1, x2, y2):
    # Bresenham's Line Algorithm
        
        # Initialize variables
        pixels = []
        dx = abs(x2 - x1)
        dy = abs(y2 - y1)
        sx = 1 if x1 < x2 else -1
        sy = 1 if y1 < y2 else -1
        err = dx - dy
        
        # Main loop
        while True:
            pixels.append((x1, y1))

            # Check if we've reached the end point
            if x1 == x2 and y1 == y2:
                break

            # Calculate next pixel
            e2 = 2 * err
            if e2 > -dy:
                err -= dy
                x1 += sx
            if e2 < dx:
                err += dx
                y1 += sy
                
        return pixels

    @staticmethod
    def rhomboid_pixels(x0, y0, x1, y1, x2, y2):
        x3 =  x1 + x2 - x0
        y3 =  y1 + y2 - y0

        result = (
            ShapeUtils.line_pixels(x0, y0, x1, y1) +
            ShapeUtils.line_pixels(x1, y1, x3, y3) +
            ShapeUtils.line_pixels(x3, y3, x2, y2) +
            ShapeUtils.line_pixels(x2, y2, x0, y0)
        )
        return np.unique(result,axis=0)
    
    @staticmethod
    def line_fill_mask(mask:list, seq_length, start, end):
        mask.append(
            [0]*start[1]+
            [1]*(end[1]+1-start[1])+
            [0]*(seq_length-end[1])
        )
    
    @staticmethod
    def line_fill_pixels(pixels:list, seq_length, start, end):
        x = start[0]
        for y in range(start[1],end[1]+1):
            pixels.append((x,y))
                
    @staticmethod
    def fill_shape(pixels, line_func=line_fill_mask):   
        sorted_pixels = sorted(pixels, key=lambda p: (p[0], p[1]))
        p_from = pixels[0]
        p_to = pixels[0]
        length = max([l[1] for l in sorted_pixels])
        result = []
        for pixel in sorted_pixels[1:]:
            if p_to[0] < pixel[0] and p_from[0] == p_to[0]:
                line_func(result, length, p_from, p_to)
                p_from = pixel
            p_to = pixel
        if p_from[0] == p_to[0]:
            line_func(result, length, p_from, p_to)
        return np.array(result)

    @staticmethod
    def pixels_to_coordinates(pixels, height=0, tangent_axis='y'):
        create_coordinate = ShapeUtils._get_create_coordinates(tangent_axis)
        return [create_coordinate(i,j,height) for i,j in pixels]

    @staticmethod
    def apply_mask(array, mask):
        if mask.shape != array.shape:
            raise ValueError(f"Mask must have same shape as array. Found mask shape {mask.shape}, expected {array.shape}.")
        array = np.where(mask, array, np.nan)
        array -= np.nanmin(array)
        return array

    @staticmethod
    def np_array_to_coordinates(array, fill_gaps=True, mask=None, value_axis='y'):
        create_coordinate = ShapeUtils._get_create_coordinates(value_axis)
        if len(array.shape)!=2:
            raise ValueError(f"Array must be a 2 dimensional not {len(array.shape)} dimensional.")
        
        if not (mask is None):
            array = ShapeUtils.apply_mask(array, mask)
        
        coordinates = []
        for x,z in np.ndindex(array.shape):
            if not math.isnan(array[x][z]):
                neigbours = ShapeUtils.get_neighbors(array, (x, z), include_self=True)
                neigbours = [value for value in neigbours if not math.isnan(value)]
                min_y = int(min(neigbours))
                max_y = int(array[x][z])
                
                if min_y<max_y:
                    min_y +=1
                max_y +=1
                
                if fill_gaps:
                    for y in range(min_y,max_y):
                        coordinates.append(create_coordinate(x,z,y))
                else:
                    coordinates.append(create_coordinate(x,z,max_y))
        return coordinates

    @staticmethod
    def ellipse(d_x, d_y, buffer=0):
        return ShapeUtils.ellipse_pixels(d_x, d_y, buffer=buffer)

    @staticmethod
    def circle(d, buffer=0):
        return ShapeUtils.ellipse_pixels(d, d, buffer)

    @staticmethod
    def line(a, b):
        return ShapeUtils.line_pixels(0, 0, a, b)

    @staticmethod
    def rhomboid(a, b, a_off, b_off):
        return ShapeUtils.rhomboid_pixels(
            -min(0, a_off), -min(0, b_off),
            max(0, a_off), b - max(0, b_off) - 1,
            a - max(0, a_off) - 1, max(0, b_off)
        )

    @staticmethod
    def rectangle(a, b):
        return ShapeUtils.rhomboid_pixels( 0, 0, 0, b-1, a-1, 0)

    @staticmethod
    def square(a):
        return ShapeUtils.rhomboid_pixels( 0, 0, 0, a-1, a-1, 0)

    @staticmethod
    def get_neighbors(array, index, include_self=False):
        dim = len(array.shape)
        if dim < len(index):
            raise ValueError(f"Dimension: Index {len(index)}, Array {dim}. Index must have the same or lower dimension as array")
        # Test if index is valid?
        
        neighbors = [array[index]] if include_self else []
        
        for d in range(dim):
            for index_offset in [-1,1]:
                neighbor_index = list(index)
                neighbor_index[d] += index_offset
                neighbor_index = tuple(neighbor_index)
                if all(0 <= i < s for i, s in zip(neighbor_index, array.shape)):
                    neighbors.append(array[neighbor_index])

        return neighbors

    @staticmethod
    def roof_quadratic(v, a, b, l):
        value = -a*((v+b*l))**2
        norm = l*(1+abs(b))
        return value/norm
    
    @staticmethod
    def roof_linear(v, a, b, l):
        return -a*abs(v+b*l)
    
    @staticmethod
    def roof_fractional(v, a, b, l):
        coef = ShapeUtils._f1_ratio_coef
        coef /= l*(1+abs(b))
        value = a*l*(1/(1+coef*abs(v+b*l)))-1
        norm = 1+l*abs(b)/l+1/(l*coef)
        return value*norm
        
    @staticmethod
    def roof(
        a:int, b:int, mode:FuncMode,
        a_x2:float, a_z2:float, b_x2:float, b_z2:float, mode2:FuncMode,
        a_x1:float, a_z1:float, b_x1:float, b_z1:float, mode1:FuncMode,
        a_fx1:float, a_fz1:float, b_fx1:float, b_fz1:float, fmode1:FuncMode
    ):
        
        def get_mode_func(mode:ShapeUtils.FuncMode):
            match mode:
                case ShapeUtils.FuncMode.SUM: return lambda v: sum(v)/len(v)
                case ShapeUtils.FuncMode.MIN: return min
                case ShapeUtils.FuncMode.MAX: return max
                case default: return None
                
        x_o = a/2 - 0.5
        z_o = b/2 - 0.5
        
        mode_func = get_mode_func(mode)
        mode2_func = get_mode_func(mode2)
        mode1_func = get_mode_func(mode1)
        modef1_func = get_mode_func(fmode1)
        
        coordinates_np = []
        for x in range(a):
            coordinates_np.append([])
            for z in range(b):
                x_c = x - x_o
                z_c = z - z_o
                values = []
                
                if mode2_func is not None:
                    values.append(mode2_func([ShapeUtils.roof_quadratic(x_c, a_x2, b_x2, x_o), ShapeUtils.roof_quadratic(z_c, a_z2, b_z2, z_o)]))
                if mode1_func is not None:
                    values.append(mode1_func([ShapeUtils.roof_linear(x_c, a_x1, b_x1, x_o), ShapeUtils.roof_linear(z_c, a_z1, b_z1, z_o)]))
                if modef1_func is not None:
                    values.append(modef1_func([ShapeUtils.roof_fractional(x_c, a_fx1, b_fx1, x_o), ShapeUtils.roof_fractional(z_c, a_fz1, b_fz1, z_o)]))
                y = mode_func(values) if mode_func is not None and values else 0
                
                coordinates_np[-1].append(round(y))
        coordinates_np = np.array(coordinates_np)
        coordinates_np -= np.min(coordinates_np)
        return coordinates_np
