import math

import tensorflow as tf
import numpy as np
from sklearn.neighbors import NearestNeighbors
from library.timing import timing


def dist(x,y):
    return sum(map(lambda z: (z[0] - z[1])*(z[0] - z[1]), zip(x, y)))

def maxby(data, fn, startValue=0.0):
    m = startValue
    for v in data:
        m = max(m, fn(v))
    return m

def distancesToPoint(p, points):
    w = np.array(np.repeat([p], len(points), axis=0))
    d = tf.keras.layers.Subtract()([w, np.array(points)])
    t = tf.keras.layers.Dot(axes=(1,1))([d,d])
    # As the concrete distance is not needed and sqrt(x) is strict monotone
    # we avoid here unneccessary calculating of expensive roots.
    return t.numpy()


def calculateCenter(points):
    if points.shape[0] == 1:
        return points[0]
    return tf.keras.layers.Average()(list(points)).numpy()


class MaxHeap:
    def __init__(self, maxSize=None, isGreaterThan=None, smalestValue=(-1,0.0)):
        self.heap = []
        self.size = 0
        self.maxSize = maxSize
        self.isGreaterThan = isGreaterThan if isGreaterThan is not None else (lambda a, b: a > b)
        self.smalestValue = smalestValue
        self.indices = set()
        self.wasChanged = False
        self.insert(smalestValue)

    def insert(self, v):
        if self.maxSize is not None and self.size >= self.maxSize:
            return self.replaceMax(v)

        if v[0] in self.indices:
            return False

        self.indices.add(v[0])
        pos = self.size
        self.size += 1
        self.heap.append(v)
        while pos > 0:
            w = self.heap[pos // 2]
            if not self.isGreaterThan(v, w):
                break
            self.heap[pos] = w
            pos = pos // 2
            self.heap[pos] = v
        self.wasChanged = True
        return True


    def childPos(self, pos):
        c = (pos + 1) * 2
        return (c - 1, c)


    def removeMax(self):
        if self.heap == []:
            self.size = 0
            return False
        
        x = self.heap[0]
        self.indices.remove(x[0])

        self.heap[0] = self.heap[-1]
        self.heap = self.heap[:-1]
        self.size -= 1

        x = self.heap[0]
        pos = 0
        size = self.size

        while pos < size:
            (left, right) = self.childPos(pos)

            if left >= size:
                break

            y = self.heap[left]
            if right >= size:
                if self.isGreaterThan(y, x):
                    self.heap[pos] = y
                    self.heap[left] = x
                break

            z = self.heap[right]
            (best, v) = (left, y) if self.isGreaterThan(y, z) else (right, z)

            if not self.isGreaterThan(v, x):
                break

            self.heap[pos] = v
            self.heap[best] = x
            pos = best

        self.wasChanged = True
        return True


    def replaceMax(self, x):
        if self.heap == []:
            self.heap = [x]
            self.size = 1
            self.indices.add(x[0])
            self.wasChanged = True
            return True
        
        if x[0] in self.indices:
            return False

        if self.isGreaterThan(x, self.heap[0]):
            return False

        self.indices.remove((self.heap[0])[0])
        self.indices.add(x[0])
        self.heap[0] = x
        pos = 0
        size = len(self.heap)

        while pos < size:
            (left, right) = self.childPos(pos)

            if left >= size:
                break

            y = self.heap[left]
            if right >= size:
                if self.isGreaterThan(y, x):
                    self.heap[pos] = y
                    self.heap[left] = x
                break

            z = self.heap[right]
            (best, v) = (left, y) if self.isGreaterThan(y, z) else (right, z)

            if not self.isGreaterThan(v, x):
                break

            self.heap[pos] = v
            self.heap[best] = x
            pos = best

        self.wasChanged = True
        return True

    def getMax(self):
        if self.heap == []:
            return self.smalestValue
        return self.heap[0]


    def setMaxSize(self, maxSize):
        self.maxSize = maxSize
        while self.size > maxSize:
            self.removeMax()

    def toArray(self, mapFn=None):
            return list(self.indices)


    def length(self):
        return self.size




class Ball:
    def __init__(self, points=None, indices=None, parent=None, center=None):
        if center is not None:
            self.center = center
        elif points is not None:
            self.center = calculateCenter(np.array(points))
        else:
            raise ParameterError("Missing points or center")
        self.radius = 0
        self.points = []
        self.indices = set()
        self.childs = []
        self.parent = parent

        if points is not None and indices is not None:
            for (i, r) in zip(indices, distancesToPoint(self.center, points)):
                self.add(i, r)
        elif points is not None:
            raise ParameterError("Missing indices")

    def findIt(self, r):
        if r == self.points[0][1]:
            return 0

        upper = len(self.points) - 1
        lower = 0
        while upper > lower + 1:
            h = (upper + lower) // 2
            if self.points[h][1] >= r:
                upper = h
            else:
                lower = h
        return upper


    def add(self, xi, r):
        if xi in self.indices:
            return False

        # Here we know, that x is not in points.
        newEntry = (xi, r)
        self.indices.add(xi)

        # Special case: empty list or new element will extend the radius:
        # Here we can avoid the search
        if self.points == [] or r >= self.radius:
            self.points.append(newEntry)
            self.radius = r
            return True

        # Special case: r <= min radius
        # Here we can avoid the search
        if self.points[0][1] >= r:
            self.points = [newEntry] + self.points
            return True

        # Here we know that min radius < r < max radius.
        # So len(points) >= 2.

        pos = self.findIt(r)

        # here shoul be: r(pos) >= r > r(pos - 1)
        self.points = self.points[:pos] + [newEntry] + self.points[pos: ]

        return True

    def remove(self, xi, r):
        if xi not in self.indices:
            return False


        # special case: remove the element with the heighest radius:
        if self.points[-1][0] == xi:
            self.indices.remove(xi)
            self.points = self.points[: -1]
            if self.points == []:
                self.radius = 0.0
            else:
                self.radius = self.points[-1][1]
            return True

        pos = self.findIt(r)
        nPoints = len(self.points)
        # pos is the smalest position with:
        # r(pos - 1) < r <= r(pos)

        # Just in case: check that we remove the correct index.
        while pos < nPoints and r == self.points[pos]:
            if self.points[pos][0] == xi:
                self.indices.remove(xi)
                self.points = self.points[:pos] + self.points[(pos + 1): ]
                return True
            pos += 1

        return False

    def divideBall(self, X):
        indices = [i for (i, _r) in self.points]
        points = np.array([X[i] for i in indices])
        
        distances = tf.keras.layers.Dot(axes=(1,1))([points, points]).numpy()
        ball = Ball(points, indices, center=np.zeros(len(points[0])))

        h = len(ball) // 2
        indicesA = [i for (i, _) in ball.points[:h]] 
        indicesB = [i for (i, _) in ball.points[h:]] 
        pointsA = np.array([X[i] for i in indicesA])
        pointsB = np.array([X[i] for i in indicesB])

        self.childs = []

        print(f"{len(points)} -> <{len(pointsA)}|{len(pointsB)}>")
        self.childs.append(Ball(pointsA, indicesA, self))
        self.childs.append(Ball(pointsB, indicesB, self))

        return self.childs

    def __len__(self):
        return len(self.points)

    def smalestBallFor(self, i):
        if i not in self.indices:
            return None

        for c in self.childs:
            b = c.smalestBallFor(i)
            if b is not None:
                return b

        return self



class NNSearch:
    def __init__(self, nebSize=5, timingDict=None):
        self.nebSize = nebSize
        self.neighbourhoods = []
        self.timingDict = timingDict


    def timerStart(self, name):
        if self.timingDict is not None:
            if name not in self.timingDict:
                self.timingDict[name] = timing(name)

            self.timingDict[name].start()

    def timerStop(self, name):
        if self.timingDict is not None:
            if name in self.timingDict:
                self.timingDict[name].stop()

    def neighbourhoodOfItem(self, i):
        return self.neighbourhoods[i]


    def fit(self, X, nebSize=None):
        self.timerStart("NN_fit_all")
        self.fit_chained(X, nebSize)
        #self.fit_bruteForce_np(X, nebSize)
        self.timerStop("NN_fit_all")
       
    def fit_optimized(self, X, nebSize=None):
        self.timerStart("NN_fit_all")
        if nebSize == None:
            nebSize = self.nebSize

        nPoints = len(X)
        nFeatures = len(X[0])

        if nFeatures > 15 or nebSize >= (nPoints // 2):
            print("Using brute force")
            self.fit_bruteForce_np(X, nebSize)
        else:
            print("Using chained")
            self.fit_chained(X, nebSize)
        self.timerStop("NN_fit_all")


    def fit_bruteForce(self, X, nebSize=None):
        if nebSize == None:
            nebSize = self.nebSize

        self.timerStart("NN_fit_bf_init")
        isGreaterThan = lambda x, y: x[1] > y[1]
        self.neighbourhoods = [MaxHeap(nebSize, isGreaterThan, (i, 0.0)) for i in range(len(X))]
        self.timerStop("NN_fit_bf_init")

        self.timerStart("NN_fit_bf_loop")
        for (i, x) in enumerate(X):
            nbh = self.neighbourhoods[i]

            for (j, y) in enumerate(X[i+1:]):
                j += i + 1
                self.timerStart("NN_fit_bf_dist")
                d = dist(x,y)
                self.timerStop("NN_fit_bf_dist")
                self.timerStart("NN_fit_bf_insert")
                nbh.insert((j,d))
                self.neighbourhoods[j].insert((i,d))
                self.timerStop("NN_fit_bf_insert")

            self.timerStart("NN_fit_bf_toList")
            self.neighbourhoods[i] = nbh.toArray(lambda v: v[0])
            self.timerStop("NN_fit_bf_toList")
        self.timerStop("NN_fit_bf_loop")


    def fit_bruteForce_np(self, X, nebSize=None):
        self.timerStart("NN_fit_bfnp_init")
        numOfPoints = len(X)
        nFeatures = len(X[0])

        tX = tf.convert_to_tensor(X)

        def distancesTo(x):
            w = np.repeat([x], numOfPoints, axis=0)
            d = tf.keras.layers.Subtract()([w,tX])
            t = tf.keras.layers.Dot(axes=(1,1))([d,d])
            return t.numpy()

        if nebSize == None:
            nebSize = self.nebSize

        isGreaterThan = lambda x, y: x[1] > y[1]
        self.neighbourhoods = [MaxHeap(nebSize, isGreaterThan, (i, 0.0)) for i in range(len(X))]
        self.timerStop("NN_fit_bfnp_init")

        self.timerStart("NN_fit_bfnp_loop")
        for (i, x) in enumerate(X):
            self.timerStart("NN_fit_bfnp_dist")
            distances = distancesTo(x)
            self.timerStop("NN_fit_bfnp_dist")
            nbh = self.neighbourhoods[i]

            for (j, y) in enumerate(X[i+1:]):
                j += i + 1
                d = distances[j]
                self.timerStart("NN_fit_bfnp_insert")
                nbh.insert((j,d))
                self.neighbourhoods[j].insert((i,d))
                self.timerStop("NN_fit_bfnp_insert")

            self.timerStart("NN_fit_bfnp_toList")
            self.neighbourhoods[i] = nbh.toArray(lambda v: v[0])
            self.timerStop("NN_fit_bfnp_toList")
        self.timerStop("NN_fit_bfnp_loop")


    def fit_chained(self, X, nebSize=None):
        self.timerStart("NN_fit_chained_init")
        if nebSize == None:
            nebSize = self.nebSize

        nPoints = len(X)
        nFeatures = len(X[0])

        neigh = NearestNeighbors(n_neighbors=nebSize)
        neigh.fit(X)
        self.timerStop("NN_fit_chained_init")
        self.timerStart("NN_fit_chained_toList")
        self.neighbourhoods = [
                (neigh.kneighbors([x], nebSize, return_distance=False))[0]
                for (i, x) in enumerate(X)
                ]
        self.timerStop("NN_fit_chained_toList")


    # ===============================================================
    # Heuristic search
    # ===============================================================
    def fit_heuristic(self, X, nebSize=None):
        if nebSize == None:
            nebSize = self.nebSize

        nPoints = len(X)


        def walkUp(nbh, ball, x, i):
            while ball.parent is not None:
                print(f"{i}: up (r: {nbh.getMax()})")
                oldBall = ball
                ball = ball.parent
                for c in ball.childs:
                    if c != oldBall:
                        walkDown(nbh, c, x)

        def walkDown(nbh, ball, x):
            if ball is None:
                return

            print(f"{i}: down (r: {nbh.getMax()})")

            if dist(x, ball.center) - ball.radius < nbh.getMax()[1]:
                if ball.childs == []:
                    for (j, _) in ball.points:
                        nbh.insert((j, dist(x, X[j])))
                else:
                    for c in ball.childs:
                        walkDown(nbh, c, x)


        def countBoles(b):
            if b is None:
                return 0

            return 1 + sum(map(countBoles, b.childs))



        root = Ball(X, range(len(X)))
        queue = [root]
        while queue != []:
            ball = queue[0]
            queue = queue[1:]
            if len(ball) <= nebSize:
                continue

            queue = ball.divideBall(X) + queue


        isGreaterThan = lambda x, y: x[1] > y[1]
        self.neighbourhoods = [MaxHeap(nPoints, isGreaterThan, (i, 0.0)) for i in range(len(X))]

        print("#B: " + str(countBoles(root)))

        exit()
        z = X[0]
        for (i, x) in enumerate(X):
            nbh = self.neighbourhoods[i]

            b = root.smalestBallFor(i)
            if b.parent is not None:
                b = b.parent

            for (j, _) in b.points:
                d = dist(x, X[j])
                nbh.insert((j, d))

            walkUp(nbh, b, x, i)
