"""This is a module implementing the Ampio Module class."""

import yaml
import re
from functools import partial
from collections import defaultdict
import datetime as dt
from pyampio.broadcast import BroadcastCache
from pyampio.broadcast import BroadcastTypes # noqa
from pyampio.converters import convert_temperature, convert_weekday, convert_meteo, convert_temperature_int  # noqa
import logging
from voluptuous import Schema, Required, All, Lower, Any, Optional, Coerce, Invalid

_LOG = logging.getLogger(__name__)

QUERY_MODULE_DETAILS_TIMEOUT_SECONDS = 2


class AmpioModule:
    """This is AmpioModule class representing the Ampio Module."""

    def __init__(self, can_id, code, protocol, software_version, part_number, description, attributes):
        """Initialize the :class: pyampio.modules.AmpioModule object.

        Args:
            can_id (int): CAN_ID
            code (int): The module code numer
            protocol (int): Protocol version
            software_version (int): Software version
            part_number (str): Ampio Module P/N
            description (str): Module description
            attributes (dict): Module attributed

        """
        self.can_id = can_id
        self.physical_can_id = can_id
        self.pcb = 0
        self.code = code
        self.protocol = protocol
        self.software_version = software_version
        self.part_number = part_number
        self.description = description
        self.attributes = attributes
        # (attribute, index) -> (broadcast, index, unit) map
        self.map = {}
        self._broadcasts = {}
        self._broadcast_cache = BroadcastCache(self.on_change)
        # TODO: Remove
        self._callbacks = defaultdict(list)
        self._state_update_callbacks = defaultdict(list)
        self._name_parts = [""] * 6
        self._details_flag = 0x00

        self._make_attribute_map()

    def __str__(self):
        """Return the string representation of the module details."""
        can_id = "         {:08X}".format(self.can_id) if self.physical_can_id == self.can_id \
            else "{:08X}/{:08X}".format(self.physical_can_id, self.can_id)
        return "{} {:2}-{:16} {:02} {:45} {:5d} {:2d} {}".format(
            can_id, self.code, self.part_number, self.protocol, self.description,
            self.software_version, self.pcb, self.name)
    __repr__ = __str__

    @property
    def name(self):
        """Return the discovered module name."""
        return ("".join(self._name_parts)).strip()

    @property
    def is_name_complete(self):
        """Return true if module name discovery is completed."""
        return "" not in self._name_parts

    @property
    def is_details_complete(self):
        """Return true if module details discovery is completed."""
        return self._details_flag == 0xff

    def update_pcb_version(self, pcb):
        """Update the pcb version."""
        self.pcb = pcb

    def broadcast_received(self, can_data):
        """Handle the received broadcast frame."""
        self._broadcast_cache.update(can_data[1:])

    def update_name(self, index, data):
        """Handle the frame from the block with module name."""
        self._name_parts[index] = data.decode('cp1250')

    def update_details(self, can_data, update_can_id_callback):
        """Handle the frame with module details."""
        data_id = can_data[1]
        if data_id == 0x00:
            data_h = can_data[2]
            data_l = can_data[3]
            uptime = (can_data[4] << 16) | (can_data[5] << 8) | can_data[6]
            packet_size = can_data[7]
            _LOG.debug("UPDATE DETAILS 0x00: {:08x} data_h={:02x} data_l={:02x} uptime={} packet_size={}".format(
                self.can_id, data_h, data_l, uptime, packet_size))
            self._details_flag |= 0x01

        elif data_id == 0x01:
            zd_counter = (can_data[2] << 16) | (can_data[3] << 8) | can_data[4]
            reset_counter = (can_data[5] << 8) | can_data[6]
            can_errors = can_data[7]
            _LOG.debug("UPDATE DETAILS 0x01: {:08x} zd_counter={:06x} reset_counter={:04x} can_errors={}".format(
                self.can_id, zd_counter, reset_counter, can_errors))
            self._details_flag |= 0x02

        elif data_id == 0x02:
            com_stat = can_data[2]
            rx_errcnt = can_data[3]
            tx_errcnt = can_data[4]
            vdc = can_data[5] / 10
            l_zas = can_data[6]
            temperature = can_data[7]
            _LOG.debug("UPDATE DETAILS 0x02: {:08x} com_stat={} rx_errcnt={} tx_errcnt={} vdc={} l_zas={} temperature={}".format(
                self.can_id, com_stat, rx_errcnt, tx_errcnt, vdc, l_zas, temperature))
            self._details_flag |= 0x04

        elif data_id == 0x03:
            ecan_status = can_data[2]
            conditions_status = can_data[3]
            cpu_load = can_data[4]
            conditions = can_data[5] << 8
            _LOG.debug("UPDATE DETAILS 0x03: {:08x} ecan_status={} conditions_status={} cpu_load={} conditions={}".format(
                self.can_id, ecan_status, conditions_status, cpu_load, conditions))
            self._details_flag |= 0x08

        elif data_id == 0x04:
            time_quartz_errors = (can_data[2] << 16) | (can_data[3] << 8) | can_data[4]
            quarc_counter = (can_data[5] << 8) | can_data[6]
            _LOG.debug("UPDATE DETAILS 0x04: {:08x} time_quartz_errors={:06x} quarc_counter={:04x}".format(
                self.can_id, time_quartz_errors, quarc_counter))
            self._details_flag |= 0x10

        elif data_id == 0x05:
            new_can_id = int.from_bytes(can_data[2:], byteorder='little', signed=False)
            self.physical_can_id = self.can_id
            self.can_id = new_can_id
            update_can_id_callback(self, self.physical_can_id, new_can_id)
            self._details_flag |= 0x20

        elif data_id == 0x06:
            bin_inputs = can_data[2]
            bin_outputs = can_data[3]
            inputs = can_data[4]
            outputs = can_data[5]
            flags = can_data[6]
            line_flags = can_data[7]
            _LOG.debug(
                "UPDATE DETAILS 0x06: {:08x} bin_inputs={} bin_outputs={} inputs={} outputs={} flags={} line_flags={}".format(
                    self.can_id, bin_inputs, bin_outputs, inputs, outputs, flags, line_flags))
            self._details_flag |= 0x40
        elif data_id == 0x07:
            onewires = can_data[2]
            sub_procs = can_data[3]
            sub_type = can_data[4]
            mask = can_data[5]
            _LOG.debug("UPDATE DETAILS 0x07: {:08x} onewires={} sub_cpus={} sub_type={} mask={}".format(
                self.can_id, onewires, sub_procs, sub_type, mask))
            self._details_flag |= 0x80

    def state_changed(self, attribute, index):
        """Fire the state changed callback."""
        callbacks = self._state_update_callbacks.get((attribute, index))
        if callbacks:
            for callback in callbacks:
                callback()

    def register_state_changed_callback(self, attribute, index, callback):
        """Register calback for attribute state change."""
        self._state_update_callbacks[(attribute, index)].append(callback)
        return

    # TODO: Remove
    def update_listeners(self, attribute, index, old_value, new_value, unit=""):
        """Fire the callback for attribute change listeners."""
        callbacks = self._callbacks.get((attribute, index))
        if callbacks:
            for callback in callbacks:
                callback(self.can_id, attribute, index, old_value, new_value, unit)

    def add_listener(self, attribute, index, callback):
        """Add the callback for attribute value change event."""
        _LOG.debug("MAC: {} {} {} callback added".format(self.can_id, attribute, index))
        self._callbacks[(attribute, index)].append(callback)
        return
        # TODO: check if callback is callable
        # if attribute is None:
        #     attributes = self.attributes
        # else:
        #     attributes = {attribute: self.attributes.get(attribute, 0)}
        # for attribute, idx in attributes.items():
        #     if index is None:
        #         indices = range(1, idx + 1)
        #     else:
        #         indices = [index]
        #     for idx in indices:
        #         _LOG.debug("MAC: {} {} {} callback added".format(self.can_id, attribute, idx))
        #         self._callbacks[(attribute, idx)].append(callback)

    def remove_listener(self, attribute, index, callback):
        """Remove the callback."""
        callbacks = self._callbacks.get((attribute, index))
        if callbacks:
            try:
                callbacks.remove(callback)
            except ValueError:
                pass

    def on_change(self, broadcast_type, data):
        """Handle the on change event when attribute has changed."""
        # TODO: use map
        _LOG.debug("ON CHANGE: {:08x} {}: {}".format(self.can_id, broadcast_type, data))
        details = self.attributes.get(broadcast_type.value)
        if details:
            # [{'base': 1, 'name': 'temperature', 'max': 1, 'unit': 'C', 'converter': convert_temperature, 'min': 1}]
            for properties in details:
                name = properties['name']
                base = properties['base']
                max_index = properties['max']
                min_index = properties['min']
                unit = properties['unit']
                converter = properties['converter']
                index, old_value, new_value = data
                converted_index = index - min_index + base
                if min_index <= index <= max_index:
                    if converter is not None:
                        old_value = converter(old_value)
                        new_value = converter(new_value)
                    _LOG.debug("Changed {}({}) {}{}->{}{}".format(name, converted_index, old_value, unit, new_value, unit))
                    # remove
                    self.update_listeners(name, converted_index, old_value=old_value, new_value=new_value, unit=unit)
                    self.state_changed(attribute=name, index=converted_index)

    def get_attributes(self):
        """Generate the attribute names for module."""
        for _, attributes in self.attributes.items():
            for attribute_detail in attributes:
                name = attribute_detail['name']
                base = attribute_detail['base']
                min_broadcast_index = attribute_detail['min']
                max_broadcast_index = attribute_detail['max']
                unit = attribute_detail['unit']
                # TODO: simplify
                for index in range(min_broadcast_index, max_broadcast_index + 1):
                    conv_index = index - min_broadcast_index + base
                    yield name, conv_index, unit

    def _make_attribute_map(self):
        for broadcast_type, broadcast_details in self.attributes.items():
            for broadcast_detail in broadcast_details:
                attribute = broadcast_detail['name']
                base = broadcast_detail['base']
                min_broadcast_index = broadcast_detail['min']
                max_broadcast_index = broadcast_detail['max']
                unit = broadcast_detail['unit']
                converter = broadcast_detail['converter']
                for index in range(min_broadcast_index, max_broadcast_index + 1):
                    absolute_index = index - min_broadcast_index + base
                    self.map[(attribute, absolute_index)] = (BroadcastTypes(broadcast_type), index, unit, converter)

    def get_state(self, attribute, index):
        """Return the item state."""
        broadcast_type, index, unit, converter = self.map.get((attribute, index), (None, None, None, None))
        if broadcast_type is not None:
            value = self._broadcast_cache.get_value(broadcast_type, index)
            if value is not None:
                value = converter(value)
            return value
        else:
            return None

    def get_last_state(self, attribute, index):
        """Return the item last state."""
        broadcast_type, index, unit, converter = self.map.get((attribute, index), (None, None, None, None))
        if broadcast_type is not None:
            value = self._broadcast_cache.get_last_value(broadcast_type, index)
            if value is not None:
                value = converter(value)
            return value
        else:
            return None

    def get_measurement_unit(self, attribute, index):
        """Return measurement unit."""
        _, _, unit, _ = self.map.get((attribute, index), (None, None, None, None))
        return unit


def load_yaml(file_path):
    """Load YAML file from full file path and return dict."""
    with open(file_path, 'r') as yamlfile:
        try:
            dictionary = yaml.load(yamlfile)
        except yaml.YAMLError:
            raise
            return {}
    return dictionary


module_data = load_yaml(__file__[:-2] + "yaml")


def convert_unchanged(value):
    """Fake data converter."""
    return value


def validate_converter(value):
    """Validate the converter function."""
    try:
        return eval(value)
    except AttributeError:
        raise Invalid("Unknown converter function: {}".format(value))
    return None


def update_converter_if_none(value):
    """Make a default converter to transparently pass the value without conversion."""
    if value['converter'] is None:
        value['converter'] = convert_unchanged
    return value


def validate_broadcast(value):
    """Validate if broadcast name is known in module.yaml file."""
    new_value = {}
    for broadcast_name, details in value.items():
        try:
            broadcast_value = eval("BroadcastTypes." + broadcast_name).value
        except AttributeError:
            raise Invalid("Unknown Broadcast Name: {}".format(broadcast_name))
        new_value[broadcast_value] = details
    return new_value


BROADCAST_SCHEMA = All(Schema({
    Required('name'): str,
    Required('base'): int,
    Required('min'): int,
    Required('max'): int,
    Optional('unit', default=""): str,
    Required('converter', default=None): validate_converter,
}), update_converter_if_none)

ATTRIBUTE_SCHEMA = All(Schema({
    str: [BROADCAST_SCHEMA]
}), validate_broadcast)

MODULE_INFO_SCHEMA = All(Schema({
    Required('code'): Coerce(int),
    Required('protocol'): All(Lower, Any('any', Coerce(int))),
    Required('software_versions'): All([Coerce(int)]),
    Required('description'): Coerce(str),
    Required('attributes'): ATTRIBUTE_SCHEMA,
}))


SCHEMA = All(Schema({
    str: MODULE_INFO_SCHEMA,
}))


module_info = SCHEMA(module_data)


class ModuleFactory:
    """This is a ModuleFactory class to create specific module class based on discovered data."""

    factories = {}

    @staticmethod
    def create_module(can_id, code, protocol, software_version):
        """Create module object based on proivded data.

        Args:
            can_id (int): CAN ID
            code (int): Module Code Number
            protocol (int): Module CAN protocol version
            software_version (int): Module software version

        """
        max_match = 0
        matched_pn = None
        for part_number, details in module_info.items():
            match = 0
            if details['code'] == code:
                match += 1
            if (details['protocol'] in [protocol, "any"]) and match == 1:
                match += 1
            if software_version in details['software_versions'] and match == 2:
                match += 1
            if match > max_match:
                max_match = match
                matched_pn = part_number
            if max_match == 3:
                break

        if matched_pn is None:
            return None
        info = module_info.get(matched_pn)
        attributes = info.get('attributes')
        description = info.get("description")

        return AmpioModule(can_id=can_id, code=code, protocol=protocol, software_version=software_version,
                           part_number=matched_pn, description=description, attributes=attributes)


class AmpioModules:
    """This is a ModuleManager class implementation."""

    def __init__(self):
        """Initialize the :class: pyampio.modules.ModuleManager object."""
        self._modules = {}
        self._can_id_map_to_module = {}

    def __len__(self):
        """Return the number of modules."""
        return len(self._modules)

    def __iter__(self):
        """Iterate over module dictionary."""
        return iter(dict(self._modules).items())

    def add_module(self, can_id, can_data):
        """Add new module.

        Args:
            can_id (int): CAN ID
            can_data (bytearray): CAN frame data

        """
        sw_version = int((can_data[2] << 8) | can_data[3])
        pcb = int(can_data[4])
        code = int(can_data[1])
        protocol_version = int(can_data[5])
        mod = ModuleFactory.create_module(can_id=can_id, code=code, protocol=protocol_version, software_version=sw_version)
        try:
            mod.update_pcb_version(pcb)
        except AttributeError:
            _LOG.error("MODULE NOT IMPLEMENTED: {:08x} code={} {}".format(can_id, code, can_data))
            return

        self._modules[can_id] = mod
        self._can_id_map_to_module[can_id] = mod

    def update_name(self, can_id, can_data):
        """Update the module name.

        Args:
            can_id (int): CAN ID
            can_data (bytearray): CAN frame data

        """
        if can_data[0] == 0x0f and len(can_data) == 8:
            index = int(can_data[1])
            mod = self._modules.get(can_id)
            if mod is not None:
                mod.update_name(index, can_data[2:])
            else:
                _LOG.warning("Update name to unknown module: {:08x}".format(can_id))

    def update_details(self, can_id, can_data):
        """Update the module details.

        Args:
            can_id (int): CAN ID
            can_data (bytearray): CAN frame data

        """
        mod = self._modules.get(can_id)
        if mod is not None:
            mod.update_details(can_data, self.on_can_id_update)
        else:
            _LOG.warning("Update details to unknown module: {:08x}".format(can_id))

    def is_name_updated(self, can_id):
        """Return true if module name is updated else yield."""
        mod = self._modules.get(can_id)
        if mod:
            while not mod.is_name_complete:
                yield
            return True
        else:
            _LOG.warning("No module id {}".format(can_id))

    def is_details_updated(self, can_id):
        """Return true if module details are updated else yield."""
        start_time = dt.datetime.now()
        mod = self._modules.get(can_id)
        if mod:
            while not mod.is_details_complete and \
                    (dt.datetime.now() - start_time).seconds < QUERY_MODULE_DETAILS_TIMEOUT_SECONDS:
                yield
        if (dt.datetime.now() - start_time).seconds >= QUERY_MODULE_DETAILS_TIMEOUT_SECONDS:
            _LOG.warning("Query took more than {} seconds: {}".format(
                QUERY_MODULE_DETAILS_TIMEOUT_SECONDS, mod))
        return True

    def is_attribute_names_updated(self, can_id):
        """Return true if attribute name are updated else yield."""
        # TODO: Implementation needed
        while True:
            yield

    def on_can_id_update(self, mod, can_id, new_can_id):
        """Handle the CAN_ID update (i.e. physical -> logical)."""
        if can_id != new_can_id:
            self._can_id_map_to_module[new_can_id] = mod

    def broadcast_received(self, can_id, can_data):
        """Handle the received broadcast."""
        mod = self.get_module(can_id)
        if mod is None:
            _LOG.debug("Update for unknown module can_id={:08x}".format(can_id))
            return
        mod.broadcast_received(can_data)

    def add_on_value_changed_callback(self, can_id, attribute, index, callback):
        # TODO: Remove
        """Add the on value changed callback."""
        mod = self.get_module(can_id)
        if mod:
            for (attrib, idx, unit) in mod.get_attributes():
                if attribute is not None:
                    if attribute != attrib:
                        continue
                if index is not None:
                    if index != idx:
                        continue
                mod.add_listener(attrib, idx, partial(callback, self))
                _LOG.debug("On value changed callback added {} {} {}".format(can_id, attrib, idx))
        else:
            _LOG.warning("Module not known: {:08x}".format(can_id))

    def register_on_value_changed_callback(self, can_id, attribute, index, callback):
        """Register on value change_callback."""
        mod = self.get_module(can_id)
        attribute_re = re.compile(attribute)
        if mod:
            for (attrib, idx, unit) in mod.get_attributes():
                if attribute is not None:
                    if attribute_re.match(attrib) is None:
                        continue
                if index is not None:
                    if index != idx:
                        continue
                mod.register_state_changed_callback(attrib, idx, callback)
                _LOG.debug("On value changed callback added {} {} {}".format(can_id, attrib, idx))
        else:
            _LOG.warning("Module not known: {:08x}".format(can_id))

    @property
    def modules(self):
        """Return the module dictionary."""
        return self._modules

    def get_module(self, can_id):
        """Get the module from CAN ID."""
        return self._can_id_map_to_module.get(can_id)

    def get_attributes(self, can_id, attribute):
        """Generate the module attributes."""
        mod = self.get_module(can_id)
        if mod:
            for (attr, index, _) in mod.get_attributes():
                if attr == attribute:
                    yield attribute, index

    def get_state(self, can_id, attribute, index):
        """Return item state."""
        mod = self.get_module(can_id)
        if mod:
            return mod.get_state(attribute, index)
        else:
            return None

    def get_last_state(self, can_id, attribute, index):
        """Return item last state."""
        mod = self.get_module(can_id)
        if mod:
            return mod.get_last_state(attribute, index)
        else:
            return None

    def get_measurement_unit(self, can_id, attribute, index):
        """Return measurement unit."""
        mod = self.get_module(can_id)
        if mod:
            return mod.get_measurement_unit(attribute, index)
        else:
            return None

    def get_module_name(self, can_id):
        """Return module name."""
        mod = self.get_module(can_id)
        if mod:
            return mod.name
        else:
            return None

    def get_module_part_number(self, can_id):
        """Return module name."""
        mod = self.get_module(can_id)
        if mod:
            return mod.part_number
        else:
            return None
