"""
TopologyBuilder module provides an API for the plugins to send the information about the topology.
"""
from typing import NamedTuple, Set, List, Tuple
import logging
from ruxit.api.results_builder import ResultsBuilder
from ruxit.api.data import PluginMeasurement, PluginMeasurementStatCounter, PluginProperty, MEAttribute, \
    PluginStateMetric, StatCounterDataPoint
from ruxit.api.exceptions import ConfigException
from abc import abstractproperty
from typing import Dict

from ruxit.api.selectors import ExplicitSelector, EntityType
from ruxit.utils.murmur3 import Murmur3
import ipaddress

logger = logging.getLogger(__name__)
PortList = List[int]
PortRangeTupleList = List[Tuple[int, int]]
PORT_LIMIT = 100


def hash_id(*args):
    """
    DT_IGNORE
    Creates a new numeric entity id from all passed arguments.

    :param `*args`: The parameters to be hashed together.

    This method is obsolete, use get_group_id and get_device_id instead.

    """
    input_bytes = b''
    # combine everything passed to the function
    for item in args:
        if isinstance(item, int):
            input_bytes += item.to_bytes(8, byteorder="big")
        elif isinstance(item, str):
            encoded = item.encode('utf-8')
            input_bytes += encoded
            input_bytes += len(encoded).to_bytes(4, byteorder="big")

    return Murmur3().murmur3(input_bytes)

def get_group_id(namespace: str, identifier: str) -> int:
    """
        DT_IGNORE
        Creates or gets a numeric group id from namespace and identifier.

        :params `namespace` and 'identifier': The string parameters to be hashed together.

    """
    return Murmur3().get_group_id(namespace, identifier)

def get_device_id(group_id: int, identifier: str) -> int:
    """
        DT_IGNORE
        Creates or gets a numeric device id from group_id and identifier.

        :param `group_id` : id calculated by method get_group_id
        :param 'identifier': The string parameter to be hashed together with group_id.

    """
    return Murmur3().get_device_id(group_id, identifier)

class MetricSink:
    def __init__(self, results_builder: ResultsBuilder):
        self._results_builder = results_builder
        self._results_builder.event_check_stack = 4

    @abstractproperty
    def selector(self):
        """ DT_IGNORE """
        return

    def absolute(self, key: str, value: float, dimensions: Dict[str, str] = None):
        """
        Reports an absolute value for a metric, for a given :class:`Device` or :class:`Group` entity.

        :param key: The metric key.
        :param value: The metric value to send
        :param dimensions: An optional dictionary of pairs (dimension key, dimension value)


        :example:

        >>> e = g.create_device(...)
        >>> e.absolute('my_metric_key', 42)
        >>> e.absolute('my_metric_key_with_dim', 42, {'my_dim': 'my_dim_val'})

        """
        self._results_builder.add_absolute_result(PluginMeasurement(key=key, value=value, dimensions=dimensions,
                                                                    entity_selector=self.selector))

    def relative(self, key: str, value: float, dimensions: Dict[str, str] = None):
        """
        Reports a relative value for a metric, for a given :class:`Device` or :class:`Group` entity.

        :param key: The metric key.
        :param value: The metric value to send
        :param dimensions: An optional dictionary of pairs (dimension key, dimension value)

        :example:

        >>> e = g.create_device(...)
        >>> e.relative('my_metric_key', 42)
        >>> e.relative('my_metric_key_with_dim', 42, {'my_dim': 'my_dim_val'})

        """
        self._results_builder.add_relative_result(PluginMeasurement(key=key, value=value, dimensions=dimensions,
                                                                    entity_selector=self.selector))

    def per_second(self, key: str, value: float, dimensions: Dict[str, str] = None):
        """
        Adds per second result. Use for metrics that increase monotonically (such as counters).
        After gathering the second result, the difference between the current and previous results is sent to the server (if it is non-negative).
        It will be divided by the time difference between measurements in seconds, resulting in a per-second rate.
        If there is a negative difference between measurements, nothing is sent.

        :param key: The metric key.
        :param value: The metric value to send
        :param dimensions: An optional dictionary of pairs (dimension key, dimension value)


        :example:

        >>> e = g.create_device(...)
        >>> e.per_second('my_metric_key', 42)
        >>> e.per_second('my_metric_key_with_dim', 42, {'my_dim': 'my_dim_val'})

        """
        self._results_builder.add_per_second_result(PluginMeasurement(key=key, value=value, dimensions=dimensions,
                                                                      entity_selector=self.selector))

    def stat_counter(self, key: str, value: StatCounterDataPoint, dimensions: Dict[str, str] = None):
        """
        DT_IGNORE
        This function was added for SAP support, but it's not meant to be generally used.
        :param key:
        :param value:
        :param dimensions:
        :return:
        """
        self._results_builder.add_absolute_stat_counter_result(
            PluginMeasurementStatCounter(key=key, value=value, dimensions=dimensions,
                                         entity_selector=self.selector))

    def report_property(self, key: str, value: str):
        """
        Reports a custom property for an entity property.

        :param key: Property key as string.
        :param value: Property value as string.

        :example:

        >>> e = e.report_property("my_prop", "my_prop_val")

        """
        self._results_builder.add_property(
            PluginProperty(entity_selector=self.selector, me_attribute=MEAttribute.CUSTOM_DEVICE_METADATA,
                           key=key,
                           value=value))

    def state_metric(self, key: str, value: str, dimensions: Dict[str, str] = None):
        """
        Reports a state metric.

        :param key: Mandatory state metric name as string.
        :param value: Mandatory state metric value as string.
        :param dimensions: optional metric dimensions as dictionary of 2 strings.
        """
        self._results_builder.add_absolute_result(PluginStateMetric(key=key, value=value, dimensions=dimensions,
                                                                    entity_selector=self.selector))


class Device(MetricSink):
    """
    Class used to store device data reported from the plugin. For the reference of the metric API, see the base class.
    """

    def __init__(self, name: str, entity_id: int, technology: str, results_builder: ResultsBuilder) -> None:
        super().__init__(results_builder)
        self._name = name
        self._id = entity_id
        self._endpoints = []
        self._technology = technology
        self._results_builder = results_builder
        self._is_reported = False;

    @property
    def name(self):
        """
        DT_IGNORE
        The displayed name of the entity.
        :type: string
        """
        return self._name

    @property
    def id(self):
        """ DT_IGNORE """
        return self._id

    @property
    def selector(self):
        """ DT_IGNORE """
        return ExplicitSelector(self._id, EntityType.CUSTOM_DEVICE)

    @property
    def technology(self):
        """ DT_IGNORE """
        return self._technology

    @property
    def endpoints(self):
        """ DT_IGNORE """
        return self._endpoints

    def _verify_port_in_range(self, port: int) -> bool:
        try:
            _port = int(port)
        except Exception:
            raise ConfigException("Given port: '" + port + "' cannot to be converted to int.")
        if _port not in range(1, 65535):
            raise ConfigException("Port number must be between 1 and 65535")
        return True

    def _validate_port(self, port: int = None):
        if port is None: return
        if self._verify_port_in_range(port):
            return int(port)

    def _validate_port_list(self, port_list: PortList = None):
        _port_list = []
        if not port_list: return
        for port in port_list:
            if not self._verify_port_in_range(port):
                return
            else:
                _port_list.append(int(port))

        return _port_list

    def _validate_port_range_list(self, range_list: PortRangeTupleList = None):
        if not range_list: return
        _port_range_list = []
        for single_range in range_list:
            _from, _to = single_range
            if not self._verify_port_in_range(_from):
                return
            if not self._verify_port_in_range(_to):
                return
            if int(_from) > int(_to):
                raise ConfigException("port_range_from number must be lower or equal than port_range_to number")
            _port_range_list.append((_from, _to))
        return _port_range_list

    def _port_count(self, port: int, port_list: PortList, port_range: PortRangeTupleList) -> int:
        port_count = 0
        if port:
            port_count += 1
        if port_list:
            port_count += len(port_list)
        if port_range:
            for range in port_range:
                _from, _to = range
                port_count += _to - _from
        return port_count

    def _is_valid_ip(self, ip: str) -> bool:
        if ip:
            try:
                ipaddress.ip_address(ip)
                return True
            except ValueError:
                return False
        else:
            return False

    def add_endpoint(self, ip: str, port: int = None, dnsNames: List[str] = None, port_list: PortList = None,
                     range_list: PortRangeTupleList = None) -> None:
        """
        Add an endpoint to the Device. Total number of ports cannot exceed 100. Adding more ports causes ConfigException.
        :param ip: Valid IP address as string
        :param port: Optional single port number
        :param dnsNames: Optional list of domains bound to the address as a list of strings
        :param port_list: Optional list of port numbers
        :param range_list: Optional list of tuples with (range_from, range_to) numbers in range (1, 65535)
        :raises :class:`ConfigException`:
        :return:

        :example:
            >>> add_endpoint(ip="1.1.1.1")
            >>> add_endpoint(ip="1.1.1.1", port=80)
            >>> add_endpoint(ip="1.1.1.1", port=80, dnsNames=["name.com"])
            >>> add_endpoint(ip="1.1.1.1", port=80, dnsNames=["name.com"], port_list=[88,8080], range_list=[(680, 690), (880, 900)])
            >>> add_endpoint(ip="1.1.1.1", port=None, dnsNames=["name.com"], port_list=None, range_list=[(80, 88), (447, 450)])
        """

        self._is_reported = False;
        if self._is_valid_ip(ip):
            _single_port = self._validate_port(port)
            _port_list = self._validate_port_list(port_list)
            _port_range_list = self._validate_port_range_list(range_list)

            port_count = 0
            #calculate ports already added
            for (__ip, __single_port, __dnsNames, __port_list, __port_range_list) in self.endpoints:
                port_count += self._port_count(__single_port, __port_list, __port_range_list)

            #calucate current ports
            port_count += self._port_count(_single_port, _port_list, _port_range_list)
            if port_count > PORT_LIMIT:
                raise ConfigException("The number of ports in endpoint per device cannot exceed " + str(PORT_LIMIT))
            self.endpoints.append((ip, _single_port, dnsNames, _port_list, _port_range_list))
        else:
            logger.info("add_endpoint rejected due to invalid IP address: " + str(ip))

    def report_performance_event(self, description: str=None, title: str=None, properties: Dict[str, str]={}):
        """
        Reports a performance event.

        :param description: Event description as string (default = None, max length = 10240 characters).
        :param title: Event title as string (max length = 10240 characters).
        :param properties: Event properties as dictionary of two strings (max key length = 100 characters, max value length = 1000 characters, max number of elements in dictionary = 100).
        """
        self._results_builder.report_performance_event(description=description, title=title,
                                                       properties=properties, entity_selector=self.selector)
        
    def report_error_event(self, description: str = None, title: str = None, properties: Dict[str, str] = {}):
        """
        Reports an error event.

        :param description: default None, Event description as string (default = None, max length = 10240 characters).
        :param title: Event title as string (max length = 10240 characters).
        :param properties: Event properties as dictionary of 2 strings (max key length = 100 characters, max value length = 1000 characters, max number of elements in dictionary = 100).
        """
        self._results_builder.report_error_event(description=description, title=title,
                                                 properties=properties, entity_selector=self.selector)

    def report_availability_event(self, description: str = None, title: str = None, properties: Dict[str, str] = {}):
        """
        Reports an availability event.

        :param description: default None, Event description as string (default = None, max length = 10240 characters).
        :param title: Event title as string (max length = 10240 characters).
        :param properties: Event properties as dictionary of 2 strings (max key length = 100 characters, max value length = 1000 characters, max number of elements in dictionary = 100).
        """
        self._results_builder.report_availability_event(description=description, title=title,
                                                        properties=properties, entity_selector=self.selector)

    def report_resource_contention_event(self, description: str = None, title: str = None,
                                         properties: Dict[str, str] = {}):
        """
        Reports a resource contention event.

        :param description: default None, Event description as string (default = None, max length = 10240 characters).
        :param title: Event title as string (max length = 10240 characters).
        :param properties: Event properties as dictionary of 2 strings. (max key length = 100 characters, max value length = 1000 characters, max number of elements in dictionary = 100).
        """
        self._results_builder.report_resource_contention_event(description=description, title=title,
                                                               properties=properties, entity_selector=self.selector)

    def report_custom_info_event(self, description: str = None, title: str = None, properties: Dict[str, str] = {}):
        """
        Reports a custom info event.

        :param description: default None, Event description as string of (default = None, max length = 10240 characters).
        :param title: Event title as string (max length = 10240 characters).
        :param properties: Event properties as dictionary of 2 strings. (max key length = 100 characters, max value length = 1000 characters, max number of elements in dictionary = 100).
        """
        self._results_builder.report_custom_info_event(description=description, title=title,
                                                       properties=properties, entity_selector=self.selector)

    def report_custom_deployment_event(self,
                                       source: str = None,
                                       project: str = None,
                                       version: str = None,
                                       ci_link: str = None,
                                       remediation_action_link: str = None,
                                       deployment_name: str = None,
                                       properties: Dict[str, str] = {}):
        """
        Reports a custom deployment event.

        :param source: Event source as string (max length = 10240 characters).
        :param project: Event project as string (max length = 10240 characters).
        :param version: Event version as string (max length = 10240 characters).
        :param ci_link: Event link as string (max length = 10240 characters).
        :param remediation_action_link: Event remediation action link as string (max length = 10240 characters).
        :param deployment_name: Event deployment name as string (max length = 10240 characters).
        :param properties: Event properties as dictionary of 2 strings. Max key length is 100 characters, max value length is 1000 characters. Max number of elements in the dictionary is 100.
        """
        self._results_builder.report_custom_deployment_event(source=source,
                                                             project=project,
                                                             version=version,
                                                             ci_link=ci_link,
                                                             remediation_action_link=remediation_action_link,
                                                             deployment_name=deployment_name,
                                                             properties=properties,
                                                             entity_selector=self.selector
                                                             )

    def report_custom_annotation_event(self,
                                       description: str = None,
                                       annotation_type: str = None,
                                       source: str = None,
                                       properties: Dict[str, str] = {}):
        """
        Reports a custom annotation event.

        :param description: Event description as string (max length = 10240 characters).
        :param annotation_type: Event annotation_type as string (max length = 10240 characters).
        :param source: Event source as string (max length = 10240 characters).
        :param properties: Event properties as dictionary of 2 strings. Max key length is 100 characters, max value length is 1000 characters. Max number of elements in the dictionary is 100.
        """
        self._results_builder.report_custom_annotation_event(description=description,
                                                             annotation_type=annotation_type,
                                                             source=source,
                                                             properties=properties,
                                                             entity_selector=self.selector
                                                             )


class Group(MetricSink):
    """
    Class used to store group data reported from the plugin. For the reference of the metric API, see the base class.
    """

    def __init__(self, entity_id: int, name: str, technologies:List[str], results_builder: ResultsBuilder) -> None:
        super().__init__(results_builder)
        self._name = name
        self._id = entity_id
        self._devices = {}
        self._is_reported = False;
        if not technologies or len(technologies) == 0:
            raise Exception("Plugin technologies is missing in plugin.json file")
        else:
            self._technology = technologies[0]

    @property
    def name(self):
        """ DT_IGNORE """
        return self._name

    @name.setter
    def name(self, name: str):
        """ DT_IGNORE """
        self._name = name
        self._is_reported = False;

    @property
    def id(self):
        """ DT_IGNORE """
        return self._id

    @property
    def selector(self):
        """ DT_IGNORE """
        return ExplicitSelector(self._id, EntityType.CUSTOM_DEVICE_GROUP)

    @property
    def technology(self):
        """ DT_IGNORE """
        return self._technology
    
    def create_device(self, identifier: str, display_name: str = None) -> Device:
        """
            Adds a new device to the device group. Also calculates its unique ID based on identifier.
            This method is available since ver. 159.

            :param identifier: String parameter used for unique device ID calculation. To create two different devices, you need to provide two different identifiers.
            :param display_name: Optional string to define the device display name. If not provided, the identifier parameter will be used instead.

            :return: The created device object.

            :example:

            >>> grp = self.topology_builder.create_group("my_group_id", "my_group_name")
            >>> device = grp.create_device("my_device_id", "my_device_name")

            """
        if display_name is None:
            display_name = identifier
        if not isinstance(identifier, str) or not isinstance(display_name, str):
            raise TypeError("Wrong argument type it has to be string!")

        device_id = get_device_id(self._id, identifier)

        if device_id in self._devices:
            raise ValueError("Device " + display_name + " already exist, id: " + str(device_id))

        device = Device(display_name, device_id, self._technology, self._results_builder)
        self._devices[device_id] = device
        return device


    def create_element(self, identifier: str, element_name: str) -> Device:
        """
        Adds a new element to the reported topology. Also calculates its ID based on element name and identifier.

        This method is deprecated since ver. 159 and will be removed. use :meth:`create_device` instead.

        :param element_name: The name of the entity.
        :param identifier: Identifier used for element id calculation.
        :return: The created element object.

        :example:

        >>> grp = self.topology_builder.create_group("my_group_id", "my_group_name")
        >>> elem = grp.create_element("my_elem_id", "my_elem_name")

        """
        return self.create_device(identifier, element_name)

    def get_elements(self):
        """ DT_IGNORE """
        return self._devices


Topology = NamedTuple("Topology", [("group", Set[Group]), ("config_id", int)])


class TopologyBuilder:
    """
    Class storing topology data reported by plugins.
    """

    def __init__(self, config_id: int, plugin_name: str, tenant_id: str, technologies:List[str],
                 results_builder: ResultsBuilder):
        self._groups = {}
        self._config_id = config_id
        self._logger = logging.getLogger(__name__)
        self._technologies = technologies
        self._tenant_id = tenant_id
        self._plugin_name = plugin_name
        self._results_builder = results_builder
        self._clear_after_flush = True

    def set_clear_after_flush(self, value:bool):
        """
        DT_IGNORE
        :param value:
        :return:
        """
        self._clear_after_flush = value
        
    def create_group(self, identifier: str, group_name: str) -> Group:
        """
        Creates a new group to the reported topology. Also calculates its ID based on identifier and group name.

        :param identifier: Mandatory identifier used for group ID calculation.
        :param group_name: Mandatory name of the group.
        :return: The created group object.
        """

        # APM-137701 - Namespace for custom device calculation should not be set
        group_id = get_group_id("", identifier)
        if group_id in self._groups:
            raise ValueError("Group " + group_name + " already exist, id: " + str(group_id))
        else:
            group = Group(group_id, group_name, self._technologies, self._results_builder)

        self._groups[group_id] = group
        return group

    def flush_result(self) -> Topology:
        """
        DT_IGNORE

        :return: Group objects set.
        """
        groups = {}
        for gid, group in self._groups.items():
            devices_to_report = [(kid, device) for kid, device in group.get_elements().items()
                                 if not device._is_reported]
            previous_is_reported = group._is_reported
            if group._is_reported and len(devices_to_report) == 0:
                continue
            group._is_reported = True
            copy_group = Group(gid, group.name, [group.technology], group._results_builder)
            copy_group._is_reported = previous_is_reported
            groups[gid] = copy_group
            for kid, device in devices_to_report:
                device._is_reported = True
                copy_group.get_elements()[kid] = device

        if self._clear_after_flush:
            self._groups = {}

        return Topology(groups, self._config_id)
