"""Module for storing nodes."""
from typing import TYPE_CHECKING, Iterator, List

from .api import GetAllNodesInformation, GetNodeInformation
from .exception import PyVLXException
from .log import PYVLXLOG
from .node import Node
from .node_helper import convert_frame_to_node

if TYPE_CHECKING:
    from pyvlx import PyVLX


class Nodes:
    """Object for storing node objects."""

    def __init__(self, pyvlx: "PyVLX"):
        """Initialize Nodes object."""
        self.pyvlx = pyvlx
        self.__nodes: List[Node] = []

    def __iter__(self) -> Iterator[Node]:
        """Iterate."""
        yield from self.__nodes

    def __getitem__(self, key: str | int) -> Node:
        """Return node by name or by index."""
        if isinstance(key, int):
            for node in self.__nodes:
                if node.node_id == key:
                    return node
        for node in self.__nodes:
            if node.name == key:
                return node
        raise KeyError

    def __contains__(self, key: str | int | Node) -> bool:
        """Check if key is in index."""
        if isinstance(key, int):
            for node in self.__nodes:
                if node.node_id == key:
                    return True
        if isinstance(key, Node):
            for node in self.__nodes:
                if node == key:
                    return True
        for node in self.__nodes:
            if node.name == key:
                return True
        return False

    def __len__(self) -> int:
        """Return number of nodes."""
        return len(self.__nodes)

    def add(self, node: Node) -> None:
        """Add Node, replace existing node if node with node_id is present."""
        if not isinstance(node, Node):
            raise TypeError()
        for i, j in enumerate(self.__nodes):
            if j.node_id == node.node_id:
                if j is node:
                    PYVLXLOG.debug(
                        "Node with node_id %s already present; skipping re-add",
                        node.node_id,
                    )
                    return
                j.dispose()
                self.__nodes[i] = node
                PYVLXLOG.debug("Replaced node with node_id %s", node.node_id)
                return
        self.__nodes.append(node)
        PYVLXLOG.debug("Added node with node_id %s", node.node_id)

    def clear(self) -> None:
        """Clear internal node array."""
        for node in self.__nodes:
            node.dispose()
        self.__nodes = []

    @staticmethod
    def _update_node_metadata(existing: Node, loaded: Node) -> None:
        """Update metadata that should follow the latest gateway snapshot.

        This should mostly update the name, I don't expect the node_id to change but
        it doesn't hurt to update it as well (this would happen only for devices
        with identical serial numbers).
        We explicitly do not update runtime state because callbacks is something that
        we want to preserve across reloads, and positions and frame history are
        expected to be updated by the regular API event handling (heartbeat, house
        monitoring), so they are either correct already or will be updated soon.
        """
        existing.node_id = loaded.node_id
        existing.name = loaded.name

    def _find_matching_existing(
        self,
        loaded: Node,
        used_existing: List[Node],
    ) -> Node | None:
        """Find existing node matching loaded node identity.

        Already matched existing nodes are skipped.
        """
        for existing in self.__nodes:
            # Avoid deep equality checks when determining if an existing node
            # has already been used; compare by identity instead.
            if any(existing is used for used in used_existing):
                continue
            if existing.represents_same_node(loaded):
                return existing
        return None

    async def load(self, node_id: int | None = None) -> None:
        """Load nodes from KLF 200, if no node_id is specified all nodes are loaded."""
        if node_id is not None:
            await self._load_node(node_id=node_id)
        else:
            await self._load_all_nodes()

    async def _load_node(self, node_id: int) -> None:
        """Load single node via API."""
        get_node_information = GetNodeInformation(pyvlx=self.pyvlx, node_id=node_id)
        await get_node_information.do_api_call()
        if not get_node_information.success:
            raise PyVLXException("Unable to retrieve node information")
        notification_frame = get_node_information.notification_frame
        if notification_frame is None:
            return
        loaded = convert_frame_to_node(self.pyvlx, notification_frame)
        if loaded is None:
            return
        existing = self._find_matching_existing(loaded, [])
        if existing is not None:
            self._update_node_metadata(existing, loaded)
            loaded.dispose()
        else:
            self.add(loaded)

    async def _load_all_nodes(self) -> None:
        """Load and merge a full gateway node snapshot.

        Matching existing nodes are kept and updated with current gateway metadata,
        newly discovered nodes are added, and previously known nodes missing from
        the snapshot are disposed and removed.
        """
        get_all_nodes_information = GetAllNodesInformation(pyvlx=self.pyvlx)
        await get_all_nodes_information.do_api_call()
        if not get_all_nodes_information.success:
            raise PyVLXException("Unable to retrieve node information")

        loaded_nodes: List[Node] = []
        for notification_frame in get_all_nodes_information.notification_frames:
            node = convert_frame_to_node(self.pyvlx, notification_frame)
            if node is not None:
                loaded_nodes.append(node)

        next_nodes: List[Node] = []
        used_existing: List[Node] = []

        for loaded_node in loaded_nodes:
            existing = self._find_matching_existing(loaded_node, used_existing)
            if existing is None:
                next_nodes.append(loaded_node)
                continue

            used_existing.append(existing)
            self._update_node_metadata(existing, loaded_node)
            loaded_node.dispose()
            next_nodes.append(existing)

        for existing in self.__nodes:
            if existing not in used_existing:
                existing.dispose()

        self.__nodes = next_nodes
