import itertools
import time
from abc import ABCMeta
from ipaddress import (
    IPV4LENGTH,
    IPV6LENGTH,
    IPv4Address,
    IPv4Network,
    IPv6Address,
    IPv6Network,
    ip_network,
)
from typing import Iterator, List, Optional, Union

import pytricia
from blinker import Signal

from defence360agent.utils.validate import IP


class SourceInterface(metaclass=ABCMeta):
    # send **kwargs: ip, expiration
    added = Signal()

    # send **kwargs: ip
    deleted = Signal()

    # nothing is sent here
    cleared = Signal()

    # send **kwargs: ip, expiration
    updated = Signal()

    async def fetch_all(self):
        """:rtype: iterable[(ip, expiration)]"""
        raise NotImplementedError()


class TreeCacheInterface(metaclass=ABCMeta):
    async def contains(self, ip):
        """Check if the cache contains specified ip.

        :type ip: str
        """
        raise NotImplementedError()

    async def contains_exactly(self, ip):
        """Check if the cache contains exactly specified ip, not parent subnet.

        :type ip: str
        """
        raise NotImplementedError()

    async def filter_contained(
        self,
        ips: List[Union[IPv4Address, IPv4Network, IPv6Address, IPv6Network]],
    ):
        """Returns ips that is presented in cache."""
        raise NotImplementedError()

    async def filter_not_contained(
        self,
        ips: List[Union[IPv4Address, IPv4Network, IPv6Address, IPv6Network]],
    ):
        """Returns ips that is NOT presented in cache."""
        raise NotImplementedError()

    def reset(self):
        """
        re-fill on the next call
        """
        raise NotImplementedError()


class TreeCache(TreeCacheInterface):
    def __init__(self, source, full_sync_period=3600):
        """:type source: SourceInterface"""
        self._source = source
        self._full_sync_period = full_sync_period
        self._last_sync = 0

        # None means that we need initialize it on first call
        self._tree = None  # type: pytricia.PyTricia
        self._expired = False

        # subscribe to events from data source
        source.added.connect(self._on_added, source)
        source.deleted.connect(self._on_deleted, source)
        source.cleared.connect(self._on_cleared, source)
        source.updated.connect(self._on_updated, source)

    def _on_added(self, sender, ip, expiration):
        if self._tree is None:
            return
        self._tree.insert(IP.adopt_to_ipvX_network(ip), expiration)

    def _on_deleted(self, sender, ip):
        if self._tree is None:
            return
        try:
            # it's super fast even for very large trees
            self._tree.delete(IP.adopt_to_ipvX_network(ip))
        except KeyError:  # ip is not in tree
            pass

    def _on_cleared(self, sender):
        self._tree = pytricia.PyTricia(IPV6LENGTH)

    def _on_updated(self, sender, ip, expiration):
        self._on_added(sender, ip, expiration)

    async def _init_tree(self):
        self._tree = pytricia.PyTricia(IPV6LENGTH)
        tree = self._tree
        for ip, expiration in await self._source.fetch_all():
            tree.insert(IP.adopt_to_ipvX_network(ip), expiration)

    async def _sync_if_needed(self):
        while self._tree is None or time.time() > (
            self._last_sync + self._full_sync_period
        ):
            await self._init_tree()
            self._last_sync = time.time()

    @classmethod
    def _contains(
        cls,
        tree,
        ip: Union[IPv4Address, IPv4Network, IPv6Address, IPv6Network],
    ):
        """
        :return bool: if 'tree' contains ip as is or by subnet mask
        """
        ip_nwk, _ = cls._lookup(tree, ip)
        return bool(ip_nwk)

    @staticmethod
    def _lookup(
        tree, ip: Union[IPv4Address, IPv4Network, IPv6Address, IPv6Network]
    ):
        """Lookup specified ip or parent subnet considering expiration.
        :return tuple: str(ip_network(contains that ip)), expiration
        """
        ip = IP.adopt_to_ipvX_network(ip)
        # subnets can be nested, so check in loop.
        # endless loop is bad, so limit it.
        for _ in range(128):
            ip_nwk = tree.get_key(ip)

            if not ip_nwk:
                return None, None

            else:
                # check expiration
                expiration = tree.get(ip_nwk)
                if expiration and expiration <= time.time():
                    tree.delete(ip_nwk)
                    # check again
                    continue
                else:
                    return ip_nwk, expiration
        # For ipv4 max depth is 32, for ipv6 -- 128.
        # If all right, this place is unreachable.
        # If we are here, then something is wrong.
        raise RuntimeError(
            "Too deep recursion. Something goes wrong. "
            "Please contact developers."
        )

    @staticmethod
    def _contains_exactly(tree, ip):
        ip = IP.adopt_to_ipvX_network(ip)
        if tree.has_key(ip):  # noqa
            if TreeCache._contains(tree, ip):
                return tree.has_key(ip)  # noqa
        return False

    async def lookup(self, ip):
        """Check if the cache contains specified ip or parent subnet.

        :type ip: str
        :return tuple: str(ip_network(contains that ip)), expiration
        """
        await self._sync_if_needed()
        return self._lookup(self._tree, ip)

    async def contains(self, ip):
        """Check if the cache contains specified ip or parent subnet.

        :type ip: str
        """
        await self._sync_if_needed()
        return self._contains(self._tree, ip)

    async def contains_exactly(self, ip):
        """Check if the cache contains exactly specified ip, not parent subnet.

        :type ip: str
        """
        await self._sync_if_needed()
        return self._contains_exactly(self._tree, ip)

    async def filter_contained(
        self,
        ips: List[Union[IPv4Address, IPv4Network, IPv6Address, IPv6Network]],
    ):
        """Returns ips that is presented in the cache."""
        if not ips:
            # a little optimization -- do not sync if ips is empty
            return ips

        await self._sync_if_needed()
        return [ip for ip in ips if self._contains(self._tree, ip)]

    async def filter_not_contained(
        self,
        ips: List[Union[IPv4Address, IPv4Network, IPv6Address, IPv6Network]],
    ):
        """Returns ips that is NOT presented in the cache."""
        if not ips:
            # a little optimization -- do not sync if ips is empty
            return ips

        await self._sync_if_needed()
        return [ip for ip in ips if not self._contains(self._tree, ip)]

    async def get_subnet(self, ip: str) -> Optional[str]:
        """Returns IP network that contains `ip`"""
        key = await self._get_key(ip)
        # as far as IPv6 addresses are stored in form of IPv6Network with
        # 64 bit mask, considering IP in the subnet only if key from cache
        # differs from original IP.
        # For example, "2002:add0:958a::/64" could be in tree cache and lookup
        # will return "2002:add0:958a::/64" too. Nevertheless, return value
        # is subnet, the function will return `False`, because `key != ip`
        # condition was unmet.
        if IP.is_valid_ip_network(key, strict=True) and key != ip:
            return key
        return None

    async def _get_key(self, ip_ntw):
        assert IP.is_valid_ip_network(ip_ntw), "%s is not valid IP!" % ip_ntw
        await self._sync_if_needed()
        return self._tree.get_key(ip_ntw)

    @staticmethod
    def _fix_addresses_in_network(network_list):
        # 10.1.1.1/32 -> 10.1.1.1
        return map(
            lambda ip: (
                str(ip_network(ip).network_address)
                if ip_network(ip).prefixlen == IPV4LENGTH
                else ip
            ),
            network_list,
        )

    async def get_ips_from_subnet(self, target_subnet: str) -> Iterator:
        """
        Returns a iterator of IP address and networks that located in cache
        and being members of `target_subnet` and `target_subnet` itself
        """
        if not IP.is_valid_ip_network(target_subnet, strict=True):
            return iter([])

        await self._sync_if_needed()
        parent_subnet = self._tree.get_key(target_subnet)
        if not parent_subnet:
            return iter([])

        items_in_subnet_from_cache = (
            net
            for net in self._tree.children(parent_subnet)
            if ip_network(net).network_address in ip_network(target_subnet)
        )
        return itertools.chain(
            self._fix_addresses_in_network(items_in_subnet_from_cache),
            [target_subnet],
        )

    def reset(self):
        """
        re-fill on the next call
        """
        # clear the state for _sync_if_needed() to init _trees
        self._tree = None
