"""
Abstractions for nodes representation.
"""

from __future__ import annotations

from abc import ABC, abstractmethod
from copy import deepcopy
from itertools import chain, count, cycle
from typing import (
    Any,
    Callable,
    Dict,
    Iterable,
    Iterator,
    List,
    Optional,
    Sequence,
    Set,
    Union,
    cast,
)

import netunicorn.base

from .architecture import Architecture
from .environment_definitions import _available_environment_definitions
from .types import NodeProperty, NodeRepresentation, NodesRepresentation


class Node:
    """
    Represents a single node in a pool of nodes.

    :param name: name of the node
    :param properties: custom properties of the node
    :param architecture: node architecture
    """

    def __init__(
        self,
        name: str,
        properties: Dict[str, NodeProperty],
        architecture: Architecture = Architecture.UNKNOWN,
    ):
        self.name: str = name
        """
        Node name.
        """

        self.properties: Dict[str, NodeProperty] = properties
        """
        Node properties. Could be used to store custom information about the node.
        """

        self.additional_properties: Dict[str, NodeProperty] = {}
        """
        Additional node properties, often used for internal purposes and not to be exposed to the user.
        """

        self.architecture: Architecture = architecture
        """
        Node architecture.
        """

        self.available_environments: Set[type] = self._infer_environments()
        """
        Supported environments for the node (see :py:mod:`netunicorn.base.environment_definitions`).
        """

    def _infer_environments(self) -> Set[type]:
        result = set()
        # noinspection PyBroadException
        try:
            environments = self.properties.get(
                "netunicorn-environments", _available_environment_definitions.keys()
            )
            if hasattr(environments, "__iter__"):
                for environment_name in cast(Iterable[str], environments):
                    if environment_name in _available_environment_definitions:
                        result.add(_available_environment_definitions[environment_name])
        except Exception:
            return set(_available_environment_definitions.values())
        return result

    def __getitem__(self, item: str) -> NodeProperty:
        """
        Returns a node property by name.

        :param item: name of the property
        :return: property value
        """
        return self.properties.get(item, None)

    def __iter__(self) -> Iterator[NodeProperty]:
        raise TypeError("Node is not iterable")

    def __setitem__(self, key: str, value: NodeProperty) -> None:
        """
        Sets a node property.

        :param key: name of the property
        :param value: property value
        """
        self.properties[key] = value

    def __str__(self) -> str:
        return self.name

    def __repr__(self) -> str:
        return self.name

    def __eq__(self, other: object) -> bool:
        """
        Checks if two nodes are equal.

        :param other: other node
        :return: True if the nodes have all parameters equal, False otherwise
        """
        return (
            isinstance(other, Node)
            and self.name == other.name
            and self.properties == other.properties
            and self.additional_properties == other.additional_properties
            and self.architecture == other.architecture
        )

    def __json__(self) -> NodeRepresentation:
        return {
            "name": self.name,
            "properties": self.properties,
            "additional_properties": self.additional_properties,
            "architecture": self.architecture.value,
        }

    @classmethod
    def from_json(cls, data: NodeRepresentation) -> Node:
        """
        Returns an instance of the object from a JSON representation.

        :param data: JSON representation of the object
        :return: instance of the object
        """
        instance = cls(
            data["name"], data["properties"], Architecture(data["architecture"])
        )
        instance.additional_properties = data["additional_properties"]
        return instance


class Nodes(ABC):
    """
    A base class that represents a pool of nodes. Not to be used directly, but to be inherited from.
    """

    @abstractmethod
    def __json__(self) -> NodesRepresentation:
        """
        Returns a JSON representation of the object.
        """
        pass

    @staticmethod
    def dispatch_and_deserialize(data: NodesRepresentation) -> Nodes:
        """
        Deserializes a JSON representation of the object and returns an instance of the object.

        :param data: JSON representation of the object
        :return: instance of the object
        """

        cls: Nodes = getattr(netunicorn.base.nodes, data["node_pool_type"])
        return cls.from_json(
            data["node_pool_data"], data.get("node_pool_metadata", None)
        )

    @classmethod
    @abstractmethod
    def from_json(
        cls,
        data: List[Union[NodeRepresentation, NodesRepresentation]],
        metadata: Optional[Any] = None,
    ) -> Nodes:
        """
        Class-specific implementation of deserialization from JSON.

        :param data: JSON representation of the object
        :param metadata: optional additional information for the pool instantiation
        :return: instance of the object
        """
        pass

    @abstractmethod
    def __str__(self) -> str:
        pass

    @abstractmethod
    def __len__(self) -> int:
        pass

    @abstractmethod
    def __getitem__(self, item: int) -> Union[Node, Nodes]:
        pass

    @abstractmethod
    def filter(self, function: Callable[[Node], bool]) -> Nodes:
        """
        Returns a pool of nodes that match the given filter function.

        :param function: filter function returning True if the node should be included in the result
        :return: pool of nodes
        """
        pass

    @abstractmethod
    def take(self, count: int) -> Sequence[Node]:
        """
        Returns a sequence of nodes consisting of the first `count` nodes.

        :param count: number of nodes to take
        :return: sequence of nodes
        """
        pass

    @abstractmethod
    def skip(self, count: int) -> Nodes:
        """
        Returns a pool of nodes consisting of the nodes after the first 'count' nodes.

        :param count: number of nodes to skip
        :return: pool of nodes
        """
        pass

    @abstractmethod
    def set_property(self, name: str, value: NodeProperty) -> Nodes:
        """
        Sets a property for all nodes in the pool.

        :param name: name of the property
        :param value: property value
        :return: self
        """
        pass


class CountableNodePool(Nodes):
    """
    Represents a pool of nodes that contains a fixed number of nodes.

    :param nodes: list of nodes
    """

    def __init__(self, nodes: List[Union[Node, Nodes]]):
        self.nodes = nodes
        """
        Nodes in the pool.
        """

    def __json__(self) -> NodesRepresentation:
        return {
            "node_pool_type": self.__class__.__name__,
            "node_pool_data": [x.__json__() for x in self.nodes],
            "node_pool_metadata": None,
        }

    @classmethod
    def from_json(
        cls,
        data: List[Union[NodeRepresentation, NodesRepresentation]],
        metadata: Optional[Any] = None,
    ) -> CountableNodePool:
        """
        Returns an instance of the object from a JSON representation.

        :param data: JSON representation of the object
        :param metadata: optional additional information for the pool instantiation
        :return: instance of the object
        """
        nodes: List[Union[Node, Nodes]] = []
        for element in data:
            if "node_pool_type" in element:
                # we know this is a NodesRepresentation
                nodes_representation_element = cast(NodesRepresentation, element)
                nodes.append(
                    Nodes.dispatch_and_deserialize(nodes_representation_element)
                )
            else:
                nodes.append(Node.from_json(element))
        return cls(nodes)

    def __str__(self) -> str:
        return str(self.nodes)

    def __len__(self) -> int:
        return len(self.nodes)

    def __getitem__(self, key: int) -> Union[Node, Nodes]:
        return self.nodes[key]

    def __setitem__(
        self, key: int, value: Union[Node, CountableNodePool, UncountableNodePool]
    ) -> None:
        self.nodes[key] = value

    def pop(self, index: int) -> Union[Node, Nodes]:
        """
        Removes and returns the node at the given index.

        :param index: index of the node to remove
        :return: popped node
        """
        return self.nodes.pop(index)

    def __repr__(self) -> str:
        return str(self.nodes)

    def filter(self, function: Callable[[Node], bool]) -> CountableNodePool:
        nodes: List[Union[Node, Nodes]] = []
        for node in self.nodes:
            if isinstance(node, Node):
                if function(node):
                    nodes.append(node)
            else:
                filtered_nodes = node.filter(function)
                if len(filtered_nodes) > 0:
                    nodes.append(filtered_nodes)
        return CountableNodePool(nodes)

    def take(self, count: int) -> Sequence[Node]:
        iterator: Iterator[Node] = chain.from_iterable(
            cast(
                Iterable[Iterable[Node]],
                ([x] if isinstance(x, Node) else x for x in self.nodes),
            )
        )
        nodes = []
        for _ in range(count):
            try:
                nodes.append(next(iterator))
            except StopIteration:
                break
        return nodes

    def skip(self, count: int) -> CountableNodePool:
        if count > len(self.nodes):
            print(
                f"Warning: asked to skip {count} nodes, but pool length is {len(self.nodes)}. "
                f"Returning empty pool."
            )
            return CountableNodePool([])
        return CountableNodePool(self.nodes[count:])

    def set_property(self, name: str, value: NodeProperty) -> CountableNodePool:
        for node in self.nodes:
            if isinstance(node, Node):
                node.properties[name] = value
            else:
                node.set_property(name, value)
        return self


class UncountableNodePool(Nodes):
    """
    | Represents a pool of nodes that is not countable (i.e., contains possibly infinite amount of nodes, e.g. cloud provider).
    | In the current implementation cannot have Nodes as elements.

    :param node_template: list of nodes that will be used as a template for generating new nodes
    """

    def __init__(self, node_template: List[Node], soft_limit: Optional[float] = None):
        self._node_template = node_template
        """
        Node template used for generating new nodes.
        """

        self._nodes = cycle(node_template)
        """
        An iterator over the node template.
        """

        self._counter = count(start=1, step=1)
        """
        Node name counter.
        """

        if soft_limit is not None and soft_limit <= 0:
            raise ValueError("Soft limit must be greater than 0.")
        self.soft_limit = float("inf") if soft_limit is None else soft_limit
        """
        Soft limit for nodes in the pool to prevent infinite loops or excessive node generation.
        """

    def __str__(self) -> str:
        return str(
            f"<Uncountable node pool with next node template: {self._node_template}>"
        )

    def __repr__(self) -> str:
        return str(self)

    def __iter__(self) -> Iterator[Node]:
        return self

    def __next__(self) -> Node:
        """
        Generate and returns the next node in the pool.

        :return: next node
        """
        node = deepcopy(next(self._nodes))
        node_number = next(self._counter)
        if node_number > self.soft_limit:
            raise StopIteration
        node.name += str(node_number)
        return node

    def __getitem__(self, key: int) -> Node:
        return self._node_template[key]

    def __setitem__(self, key: int, value: Node) -> None:
        self._node_template[key] = value

    def __len__(self) -> int:
        return len(self._node_template)

    def filter(self, function: Callable[[Node], bool]) -> UncountableNodePool:
        new_pool = UncountableNodePool([x for x in self._node_template if function(x)])
        new_pool._counter = self._counter  # keep the same counter
        new_pool.soft_limit = self.soft_limit
        return new_pool

    def take(self, count: int) -> Sequence[Node]:
        nodes = []
        for _ in range(count):
            nodes.append(next(self))
        return nodes

    def skip(self, count: int) -> UncountableNodePool:
        count = count % len(self._node_template)
        for _ in range(count):
            next(self._nodes)
        return self

    def __json__(self) -> NodesRepresentation:
        return {
            "node_pool_type": self.__class__.__name__,
            "node_pool_data": [x.__json__() for x in self._node_template],
            "node_pool_metadata": self.soft_limit,
        }

    @classmethod
    def from_json(
        cls,
        data: List[Union[NodeRepresentation, NodesRepresentation]],
        metadata: Optional[Any] = None,
    ) -> UncountableNodePool:
        for x in data:
            if "node_pool_type" in x:
                raise ValueError("UncountableNodePool cannot have Nodes as elements.")
        # now we have only NodeRepresentation in the list
        node_representation_data = cast(List[NodeRepresentation], data)
        if metadata is not None:
            metadata = float(metadata)
        return cls([Node.from_json(x) for x in node_representation_data], metadata)

    def set_property(self, name: str, value: NodeProperty) -> UncountableNodePool:
        for node in self._node_template:
            node.properties[name] = value
        return self
