from abc import ABC, abstractmethod
from pathlib import Path
from typing import Generic, List, TypeVar

import numpy

from jmetal.core.solution import Solution
from jmetal.util.ckecking import Check

"""
.. module:: neighborhood
   :platform: Unix, Windows
   :synopsis: implementation of neighborhoods in the context of list of solutions. The goal is,
   given the index of an element of the list, to find its neighbour solutions according to a criterion.

.. moduleauthor:: Antonio J. Nebro <antonio@lcc.uma.es>
"""

S = TypeVar("S")


class Neighborhood(Generic[S], ABC):
    @abstractmethod
    def get_neighbors(self, index: int, solution_list: List[S]) -> List[S]:
        pass


class WeightNeighborhood(Neighborhood[Solution], ABC):
    def __init__(
        self,
        number_of_weight_vectors: int,
        neighborhood_size: int,
        weight_vector_size: int = 2,
        weights_path: str = None,
    ):
        self.number_of_weight_vectors = number_of_weight_vectors
        self.neighborhood_size = neighborhood_size
        self.weight_vector_size = weight_vector_size
        self.weights_path = weights_path

        self.neighborhood = numpy.zeros((number_of_weight_vectors, neighborhood_size), dtype=int)
        self.weight_vectors = numpy.zeros((number_of_weight_vectors, weight_vector_size))


class WeightVectorNeighborhood(WeightNeighborhood):
    def __init__(
        self,
        number_of_weight_vectors: int,
        neighborhood_size: int,
        weight_vector_size: int = 2,
        weights_path: str = None,
    ):
        super(WeightVectorNeighborhood, self).__init__(
            number_of_weight_vectors, neighborhood_size, weight_vector_size, weights_path
        )
        self.__initialize_uniform_weight(weight_vector_size, number_of_weight_vectors)
        self.__initialize_neighborhood()

    def __initialize_uniform_weight(self, weight_vector_size: int, number_of_weight_vectors: int) -> None:
        """Precomputed weights from

        * Zhang, Multiobjective Optimization Problems With Complicated Pareto Sets, MOEA/D and NSGA-II

        Downloaded from:

        * http://dces.essex.ac.uk/staff/qzhang/MOEAcompetition/CEC09final/code/ZhangMOEADcode/moead030510.rar
        """
        if weight_vector_size == 2:
            for i in range(0, number_of_weight_vectors):
                v = 1.0 * i / (number_of_weight_vectors - 1)
                self.weight_vectors[i, 0] = v
                self.weight_vectors[i, 1] = 1 - v
        else:
            file_name = "W{}D_{}.dat".format(weight_vector_size, number_of_weight_vectors)
            file_path = self.weights_path + "/" + file_name

            if Path(file_path).is_file():
                with open(file_path) as file:
                    for index, line in enumerate(file):
                        vector = [float(x) for x in line.split()]
                        self.weight_vectors[index][:] = vector
            else:
                raise FileNotFoundError("Failed to initialize weights: {} not found".format(file_path))

    def __initialize_neighborhood(self) -> None:
        distance = numpy.zeros((len(self.weight_vectors), len(self.weight_vectors)))

        for i in range(len(self.weight_vectors)):
            for j in range(len(self.weight_vectors)):
                distance[i][j] = numpy.linalg.norm(self.weight_vectors[i] - self.weight_vectors[j])

            indexes = numpy.argsort(distance[i, :])
            self.neighborhood[i, :] = indexes[0 : self.neighborhood_size]

    def get_neighbors(self, index: int, solution_list: List[Solution]) -> List[Solution]:
        neighbors_indexes = self.neighborhood[index]

        if any(i > len(solution_list) for i in neighbors_indexes):
            raise IndexError("Neighbor index out of range")

        return [solution_list[i] for i in neighbors_indexes]

    def get_neighborhood(self):
        return self.neighborhood


class TwoDimensionalMesh(Neighborhood):
    """
    Class defining a bi-mensional mesh.
    """

    def __init__(self, rows: int, columns: int, neighborhood: [[]]):
        self.rows = rows
        self.columns = columns
        self.neighborhood = neighborhood
        self.mesh = None
        self.__create_mesh()

    def __create_mesh(self):
        """Example:
        if rows = 5, and columns=3, we need to fill the mesh as follows
        ----------
        |00-01-02|
        |03-04-05|
        |06-07-08|
        |09-10-11|
        |12-13-14|
        ----------
        """
        self.mesh = numpy.zeros((self.rows, self.columns), dtype=int)
        next_value = 0
        for i in range(self.rows):
            for j in range(self.columns):
                self.mesh[i][j] = next_value
                next_value += 1

    def __get_row(self, index: int) -> int:
        """
        Returns the row in the mesh where the index is local
        :param index:
        :return:
        """
        return index // self.columns

    def __get_column(self, index: int) -> int:
        """
        Returns the column in the mesh where the index is local
        :param index:
        :return:
        """
        return index % self.columns

    def __get_neighbor(self, index: int, neighbor: []) -> int:
        """
        Returns the neighbor of the index
        :param index:
        :param neighbor:
        :return:
        """

        row = self.__get_row(index)

        r = (row + neighbor[0]) % self.rows
        if r < 0:
            r = self.rows - 1

        column = self.__get_column(index)
        c = (column + neighbor[1]) % self.columns
        if c < 0:
            c = self.columns - 1

        return self.mesh[r][c]

    def __find_neighbors(self, solution_list: [], solution_index: int, neighborhood: [[]]):
        """
        Returns a list containing the neighbors of a given solution belongin to a solution list
        :param solution_list:
        :param solution_index:
        :param neighborhood:
        :return:
        """
        neighbors = []

        for neighbor in neighborhood:
            index = self.__get_neighbor(solution_index, neighbor=neighbor)
            neighbors.append(solution_list[index])

        return neighbors

    def get_neighbors(self, index: int, solution_list: List[Solution]) -> List[Solution]:
        Check.is_not_none(solution_list)
        Check.that(len(solution_list) != 0, "The list of solutions is empty")

        return self.__find_neighbors(solution_list, index, self.neighborhood)


class C9(TwoDimensionalMesh):
    """
     Class defining an C9 neighborhood of a solution belonging to a list of solutions which is
     structured as a bi-dimensional mesh. The neighbors are those solutions that are in 1-hop distance

    Shape:
            * * *
            * o *
            * * *

    Topology:
             north      = {-1,  0}
             south      = { 1 , 0}
             east       = { 0 , 1}
             west       = { 0 ,-1}
             north_east = {-1,  1}
             north_west = {-1, -1}
             south_east = { 1 , 1}
             south_west = { 1 ,-1}
    """

    def __init__(self, rows: int, columns: int):
        super(C9, self).__init__(rows, columns, [[-1, 0], [1, 0], [0, 1], [0, -1], [-1, 1], [-1, -1], [1, 1], [1, -1]])


class L5(TwoDimensionalMesh):
    """
    L5 neighborhood.
    Shape:
            *
          * o *
            *

    Topology:
        north = -1,  0
        south =  1,  0
        east  =  0,  1
        west  =  0, -1
    """

    def __init__(self, rows: int, columns: int):
        super(L5, self).__init__(rows, columns, [[-1, 0], [1, 0], [0, 1], [0, -1]])
