# -*- Mode:python; c-file-style:"gnu"; indent-tabs-mode:nil -*- */
#
# Copyright (C) 2015-2020, The University of Memphis
#
# This file is part of Mini-NDN.
# See AUTHORS.md for a complete list of Mini-NDN authors and contributors.
#
# Mini-NDN is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Mini-NDN is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Mini-NDN, e.g., in COPYING.md file.
# If not, see <http://www.gnu.org/licenses/>.

# IMPORTANT! This feature is in highly experimental phase and may go several changes
# in future

'''
This module will compute link state, hyperbolic and geohyperbolic
routes and their costs from the given Mini-NDN topology
'''

import sys
import heapq
from math import sin, cos, sinh, cosh, acos, acosh
import json
import operator
from collections import defaultdict
from tqdm import tqdm

from mininet.log import info, debug, error, warn
from minindn.helpers.nfdc import Nfdc as nfdc

UNKNOWN_DISTANCE = -1
HYPERBOLIC_COST_ADJUSTMENT_FACTOR = 1000

def dijkstra(graph, start, end, ignoredNode=None):
    """
    Compute shortest path and cost from a given source to a destination
    using Dijkstra algorithm

    :param Graph graph: given network topology/graph
    :param Start start: source node in a given network graph/topology
    :end End end: destination node in a given network graph/topology
    :param Node ignoredNode: node to ignore computing shortest path from
    """
    queue = [(0, start, [])]
    seen = set()
    while True:
        (cost, v, path) = heapq.heappop(queue)
        if v not in seen:
            path = path + [v]
            seen.add(v)
            if v == end:
                debug("Distance from {} to {} is {}".format(start, end, cost))
                return cost, path
            for (_next, c) in graph[v].items():
                if _next != ignoredNode: # Ignore path going via ignoreNode
                    heapq.heappush(queue, (cost + c, _next, path))

        if not queue: # return if no path exist from source - destination except via ignoreNode
            debug("Distance from {} to {} is {}".format(start, end, cost))
            return cost, None

def calculateAngularDistance(angleVectorI, angleVectorJ):
    """
    For hyperbolic/geohyperbolic routing algorithm, this function computes angular distance between
    two nodes. A node can have two or more than two angular coordinates.

    :param AngleVectorI angleVectorI: list of angular coordinate of a give node I
    :param AngleVectorJ angleVectorJ: list of angular coordinate of a give node J

    ref: https://en.wikipedia.org/wiki/N-sphere#Spherical_coordinates

    """
    innerProduct = 0.0
    if len(angleVectorI) != len(angleVectorJ):
        error("Angle vector sizes do not match")
        return UNKNOWN_DISTANCE

    # Calculate x0 of the vectors
    x0i = cos(angleVectorI[0])
    x0j = cos(angleVectorJ[0])

    # Calculate xn of the vectors
    xni = sin(angleVectorI[len(angleVectorI) - 1])
    xnj = sin(angleVectorJ[len(angleVectorJ) - 1])

    # Do the aggregation of the (n-1) coordinates (if there is more than one angle)
    # i.e contraction of all (n-1)-dimensional angular coordinates to one variable
    for k in range(0, len(angleVectorI)-1):
        xni *= sin(angleVectorI[k])
        xnj *= sin(angleVectorJ[k])

    innerProduct += (x0i * x0j) + (xni * xnj)

    if len(angleVectorI) > 1:
        for m in range(1, len(angleVectorI)):
            # Calculate euclidean coordinates given the angles and assuming R_sphere = 1
            xmi = cos(angleVectorI[m])
            xmj = cos(angleVectorJ[m])
            for l in range(0, m):
                xmi *= sin(angleVectorI[l])
                xmj *= sin(angleVectorJ[l])

        innerProduct += xmi * xmj

    # ArcCos of the inner product gives the angular distance
    # between two points on a d-dimensional sphere
    angularDist = acos(innerProduct)
    debug("Angular distance from {} to {} is {}".format(angleVectorI, angleVectorJ, angularDist))
    return angularDist

def getHyperbolicDistance(sourceNode, destNode):
    """
    Return hyperbolic or geohyperbolic distance between two nodes. The distance is computed
    on the basis of following algorithm/mathematics
    ref: https://en.wikipedia.org/wiki/Hyperbolic_geometry
    """
    r1 = [key for key in sourceNode][0]
    r2 = [key for key in destNode][0]
    zeta = 1.0
    dtheta = calculateAngularDistance(sourceNode[r1], destNode[r2])
    hyperbolicDistance = (1./zeta) * acosh(cosh(zeta * r1) * cosh(zeta * r2) -\
                                           sinh(zeta * r1) * sinh(zeta * r2) * cos(dtheta))

    debug("Distance from {} to {} is {}".format(sourceNode, destNode, hyperbolicDistance))
    return hyperbolicDistance

class _CalculateRoutes(object):
    """
    Creates a route calculation object, which is used to compute routes from a node to
    every other nodes in a given topology topology using hyperbolic or geohyperbolic
    routing algorithm

    :param NetObject netObj: Mininet net object
    :param RoutingType routingType: (optional) Routing algorithm, link-state or hr etc
    """
    def __init__(self, netObj, routingType):
        self.adjacenctMatrix = defaultdict(dict)
        self.nodeDict = defaultdict(dict)
        self.routingType = routingType
        self.isHrConfigValid = True
        for host in netObj.hosts:
            if 'radius' in host.params['params']:
                radius = float(host.params['params']['radius'])
            else:
                self.isHrConfigValid = False
                radius = 0.0
            if 'angle' in host.params['params']:
                angles = [float(x) for x in host.params['params']['angle'].split(',')]
            else:
                self.isHrConfigValid = False
                angles = [0.0]
            self.nodeDict[host.name][radius] = angles
        for link in netObj.topo.links(withInfo=True):
            linkDelay = int(link[2]['delay'].replace("ms", ""))
            self.adjacenctMatrix[link[0]][link[1]] = linkDelay
            self.adjacenctMatrix[link[1]][link[0]] = linkDelay

    def getNestedDictionary(self):
        return defaultdict(self.getNestedDictionary)

    def getRoutes(self, nFaces):
        resultMatrix = self.getNestedDictionary()
        routes = defaultdict(list)

        if self.routingType == "link-state":
            if nFaces == 1:
                resultMatrix = self.computeDijkastra() # only best routes.
            else:
                resultMatrix = self.computeDijkastraAll() # all possible routes
        elif self.routingType == "hr":
            if self.isHrConfigValid == True:
                # Note: For hyperbolic, only way to find the best routes is by
                # computing all possible routes and getting the best one.
                resultMatrix = self.computeHyperbolic()
            else:
                warn('Hyperbolic coordinates in topology file are either missing or misconfigured.\n')
                warn('Check that each node has one radius value and one or two angle value(s).\n')
                return None

        for node in resultMatrix:
            for destinationNode in resultMatrix[node]:
                # Sort node - destination via neighbor based on their cost
                tempDict = resultMatrix[node][destinationNode]
                shortedTempDict = sorted(tempDict.items(), key=operator.itemgetter(1))
                # nFaces option gets n-best faces based on shortest distance, default is all
                if nFaces == 0:
                    for item in shortedTempDict:
                        viaNeighbor = item[0]
                        cost = item[1]
                        routes[node].append([destinationNode, str(cost), viaNeighbor])
                else:
                    for index, item in enumerate(shortedTempDict):
                        if index >= nFaces:
                            break
                        viaNeighbor = item[0]
                        cost = item[1]
                        routes[node].append([destinationNode, str(cost), viaNeighbor])

        debug("-routes----", routes)
        return routes

    def getNodeNames(self):
        return [k for k in self.nodeDict]

    def computeHyperbolic(self):
        paths = self.getNestedDictionary()
        nodeNames = self.getNodeNames()
        for node in self.nodeDict:
            neighbors = [k for k in self.adjacenctMatrix[node]]
            for viaNeighbor in neighbors:
                others = [x for x in nodeNames if x not in [viaNeighbor, node]]
                paths[node][viaNeighbor][viaNeighbor] = 0
                # Compute distance from neighbors to no-neighbors
                for destinationNode in others:
                    hyperbolicDistance = getHyperbolicDistance(self.nodeDict[viaNeighbor],
                                                               self.nodeDict[destinationNode])
                    hyperbolicCost = int(HYPERBOLIC_COST_ADJUSTMENT_FACTOR \
                                         * round(hyperbolicDistance, 6))
                    paths[node][destinationNode][viaNeighbor] = hyperbolicCost
        debug("Shortest Distance Matrix: {}".format(json.dumps(paths)))
        return paths

    def computeDijkastra(self):
        """
        Dijkstra computation: Compute all the shortest paths from nodes to the destinations.
        And fills the distance matrix with the corresponding source to destination cost
        """
        distanceMatrix = self.getNestedDictionary()
        nodeNames = self.getNodeNames()
        for node in nodeNames:
            others = [x for x in nodeNames if x not in [node]]
            for destinationNode in others:
                cost, path = dijkstra(self.adjacenctMatrix, node, destinationNode)
                viaNeighbor = path[1]
                distanceMatrix[node][destinationNode][viaNeighbor] = cost

        debug("Shortest Distance Matrix: {}".format(json.dumps(distanceMatrix)))
        return distanceMatrix

    def computeDijkastraAll(self):
        """
        Multi-path Dijkastra computation: Compute all the shortest paths from nodes to the
        destinations via all of its neighbors individually. And fills the distanceMatrixViaNeighbor
        with a corresponding source to its destination cost

        Important: distanceMatrixViaNeighbor represents the shortest distance from a source to a
        destination via specific neighbors
        """
        distanceMatrixViaNeighbor = self.getNestedDictionary()
        nodeNames = self.getNodeNames()
        for node in nodeNames:
            neighbors = [k for k in self.adjacenctMatrix[node]]
            for viaNeighbor in neighbors:
                directCost = self.adjacenctMatrix[node][viaNeighbor]
                distanceMatrixViaNeighbor[node][viaNeighbor][viaNeighbor] = directCost
                others = [x for x in nodeNames if x not in [viaNeighbor, node]]
                for destinationNode in others:
                    nodeNeighborCost = self.adjacenctMatrix[node][viaNeighbor]
                    # path variable is not used for now
                    cost, path = dijkstra(self.adjacenctMatrix, viaNeighbor, destinationNode, node)
                    if cost != 0 and path != None:
                        totalCost = cost + nodeNeighborCost
                        distanceMatrixViaNeighbor[node][destinationNode][viaNeighbor] = totalCost

        debug("Shortest Distance Matrix: {}".format(json.dumps(distanceMatrixViaNeighbor)))
        return distanceMatrixViaNeighbor

class NdnRoutingHelper(object):
    """
    This module is a helper class which helps to create face and register routes
    to NFD from a given node to all of its neighbors.

    :param NetObject netObject: Mininet net object
    :param FaceType faceType: UDP, Ethernet etc.
    :param Routing routingType: (optional) Routing algorithm, link-state or hr etc

    """
    def __init__(self, netObject, faceType=nfdc.PROTOCOL_UDP, routingType="link-state"):
        self.net = netObject
        self.faceType = faceType
        self.routingType = routingType
        self.routes = []
        self.namePrefixes = {host_name.name: [] for host_name in self.net.hosts}
        self.routeObject = _CalculateRoutes(self.net, self.routingType)

    def globalRoutingHelperHandler(self):
        info('Creating faces and adding routes to FIB\n')
        for host in tqdm(self.net.hosts):
            neighborIPs = self.getNeighbor(host)
            self.createFaces(host, neighborIPs)
            self.routeAdd(host, neighborIPs)

        info('Processed all the routes to NFD\n')

    def addOrigin(self, nodes, prefix):
        """
        Add prefix/s as origin on node/s

        :param Prefix prefix: Prefix that is originated by node/s (as producer) for this prefix
        :param Nodes nodes: List of nodes from net object
        """
        for node in nodes:
            if not node.name in self.namePrefixes:
                self.namePrefixes[node.name] = []
            self.namePrefixes[node.name] += prefix

    def calculateNPossibleRoutes(self, nFaces=0):
        """
        By default, calculates all possible routes i.e. routes via all the faces of a node.
        pass nFaces if want to compute routes via n number of faces. e.g. 2. For larger topology
        the computation might take huge amount of time.

        :param int nFaces: (optional) number of faces to consider while computing routes. Default
          i.e. nFaces = 0 will compute all possible routes

        """
        self.routes = self.routeObject.getRoutes(nFaces)
        if self.routes is not None:
            info('Route computation completed\n')
            self.globalRoutingHelperHandler()
        else:
            warn('Route computation failed\n')
            self.net.stop()
            sys.exit(1)

    def calculateRoutes(self):
        # Calculate shortest path for every node
        self.calculateNPossibleRoutes(nFaces=1)

    def createFaces(self, node, neighborIPs):
        for ip in neighborIPs.values():
            nfdc.createFace(node, ip, self.faceType)

    def routeAdd(self, node, neighborIPs):
        """
        Add route from a node to its neighbors for each prefix/s  advertised by destination node

        :param Node node: source node (Mininet net.host)
        :param IP neighborIPs: IP addresses of neighbors
        """
        neighbors = self.routes[node.name]
        for route in neighbors:
            destination = route[0]
            cost = int(route[1])
            nextHop = route[2]
            defaultPrefix = "/ndn/{}-site/{}".format(destination, destination)
            prefixes = [defaultPrefix] + self.namePrefixes[destination]
            for prefix in prefixes:
                # Register routes to all the available destination name prefix/s
                nfdc.registerRoute(node, prefix, neighborIPs[nextHop], \
                                   nfdc.PROTOCOL_UDP, cost=cost)
    @staticmethod
    def getNeighbor(node):
        # Nodes to IP mapping
        neighborIPs = defaultdict()
        for intf in node.intfList():
            link = intf.link
            if link:
                node1, node2 = link.intf1.node, link.intf2.node

                if node1 == node:
                    other = node2
                    ip = other.IP(str(link.intf2))
                else:
                    other = node1
                    ip = other.IP(str(link.intf1))

                # Used later to create faces
                neighborIPs[other.name] = ip
        return neighborIPs
