import time
from threading import Thread, Event
from time import sleep

import numpy as np
from karabo_bridge import Client as KbClient

from kamzik3 import units, DeviceError
from kamzik3.constants import *
from kamzik3.devices.device import Device
from kamzik3.snippets.snippetDataAccess import get_from_dict, set_in_dict
from kamzik3.snippets.snippetsDecorators import expose_method


class DeviceKaraboBridge(Device):
    karabo_client = None

    def __init__(self, host, port, device_id=None, config=None):
        self.host = host
        self.port = port
        self.stop_auto_refresh = Event()
        self.counter_running = False
        self.stop_auto_refresh.clear()
        super(DeviceKaraboBridge, self).__init__(device_id, config)
        self.connect()

    def _init_attributes(self):
        Device._init_attributes(self)
        self.create_attribute(ATTR_REFRESHING, default_value=False, readonly=True, default_type=np.bool)

    def connect(self, *args):
        """
        Call only this function to connect devices to port / socket / library / ...
        :param args: connect attributes
        """
        self.connecting = True
        self.karabo_client = KbClient('tcp://{}:{}'.format(self.host, self.port))
        self.handle_configuration_event()
        self.connected = True
        self.connecting = False

    def handle_configuration(self):
        start_at = time.time()
        self._update_attributes()
        self._config_commands()
        self._config_attributes()
        self.start_polling()
        self.set_status(STATUS_CONFIGURED)
        self.auto_refresh()
        self.logger.info(u"Device configuration took {} sec.".format(time.time() - start_at))

    def _update_attributes(self):
        """
        To make things general, I'm just getting one next() call from Karabo bridge.
        Saving for now only floats and integer values.
        Ignoring numpy arrays.
        :return:
        """
        data, metadata = self.karabo_client.next()

        for key, value in data.items():
            self.attributes[key] = {}
            decimals = 0

            for sub_key, sub_value in value.items():
                if isinstance(sub_value, int):
                    default_type = np.int64
                elif isinstance(sub_value, float):
                    default_type = np.float64
                    decimals = 6
                else:
                    continue
                self.create_attribute(sub_key, key, default_value=sub_value, readonly=True, default_type=default_type,
                                      decimals=decimals)

        self.create_attribute(ATTR_MIN_UPDATE_TIMEOUT, default_value=0, default_type=np.float64, unit="ms", decimals=0)
        self.create_attribute(ATTR_TRAIN_TIMEOUT, default_value=100, default_type=np.float64, unit="ms", decimals=0)
        self.create_attribute(ATTR_REMOTE_TIMESTAMP, default_type=np.float64, unit="sec", readonly=True, decimals=6)
        self.create_attribute(ATTR_LOCAL_TIMESTAMP, default_type=np.float64, unit="sec", readonly=True, decimals=6)
        self.create_attribute(ATTR_DELTA_TIMESTAMP, default_value=0, default_type=np.int64, unit="ms", readonly=True,
                              decimals=6)
        self.create_attribute(ATTR_COUNTER, default_value=0, default_type=np.uint16, set_value_when_set_function=False,
                              set_function=self.count_trains)
        self.create_attribute(ATTR_LOCAL_TRAIN_ID, default_value=0, default_type=np.int64, readonly=True)
        self.create_attribute(ATTR_REMOTE_TRAIN_ID, default_value=0, default_type=np.int64, readonly=True)
        self.create_attribute(ATTR_TRAIN_OFFSET, default_value=0, default_type=np.int64)

        self.update_metadata(list(metadata.values())[0])

    def _set(self, attribute, value):
        """
        This sets Device attribute.
        Use this function when You want to set attribute by tuple or list key.
        Example: Device.set((ATTR_STATUS, VALUE), STATUS_IDLE)
        Attribute value is pushed into server if Device is connected on any.
        To reduce amount of pushed attributes we check if value is different from previous one.
        :param attribute: tuple, list, str
        :param value: mixed
        :return: None
        """
        token = 0
        if isinstance(attribute, (tuple, list)):
            current_value = get_from_dict(self.attributes, attribute)
            if isinstance(value, np.ndarray):
                if np.array_equal(value, current_value):
                    return
            elif value == current_value and attribute[0] != ATTR_COUNTER:
                return

            set_in_dict(self.attributes, attribute, value)
            if attribute[-1] == VALUE:
                # Get new value possibly affected by offset and factor
                value = get_from_dict(self.attributes, attribute)
            token = "{}.{}".format(TOKEN_ATTRIBUTE, ".".join(attribute))
        else:
            current_value = self.attributes[attribute]
            if isinstance(value, np.ndarray):
                if np.array_equal(value, current_value):
                    return
            elif value == current_value and attribute[0] != ATTR_COUNTER:
                return

            self.attributes[attribute] = value
            if attribute == VALUE:
                # Get new value possibly affected by offset and factor
                value = self.attributes[attribute]
            token = "{}.{}".format(TOKEN_ATTRIBUTE, attribute)

        if token and self.device_server is not None and not isinstance(value, np.ndarray):
            self.device_server.push_message(self.device_id, (attribute, value), token)

    def command(self, command, callback=None, with_token=False, returning=True):
        raise DeviceError("Device does not accept any commands.")

    def count_trains(self, counts):
        self.reset_counter()
        start_train = self.get_value(ATTR_LOCAL_TRAIN_ID)

        def counter(counts):
            self.set_status(STATUS_BUSY)
            counted = 0
            self.counter_running = True
            while counted < counts and self.counter_running:
                counted = self.get_value(ATTR_LOCAL_TRAIN_ID) - start_train
                self.set_raw_value(ATTR_COUNTER, counted)
                sleep(0.02)
            self.set_raw_value(ATTR_COUNTER, counts)
            self.counter_running = False
            self.set_status(STATUS_IDLE)

        Thread(target=counter, args=[counts]).start()

    @expose_method()
    def reset_counter(self):
        self.set_value(ATTR_COUNTER, 0)

    @expose_method()
    def refresh(self):
        """
        Get one next() call from Karabo bridge
        :return:
        """
        start_time = time.time()
        self.set_attribute([ATTR_REFRESHING, VALUE], True)

        def _thread():
            self.karabo_next()
            end_time = time.time() - start_time
            self.set_value(ATTR_LATENCY, end_time * 1e3)
            self.set_attribute([ATTR_REFRESHING, VALUE], False)

        Thread(target=_thread).start()

    @expose_method()
    def auto_refresh(self):
        """
        Start routine which will automatically call next() until stopped.
        To save CPU and potentially bandwidth set ATTR_MIN_UPDATE_TIMEOUT attribute.
        Take into account, that next() is blocking!
        :return:
        """
        self.reset_counter()
        if self.get_attribute([ATTR_REFRESHING, VALUE]):
            return

        self.logger.info("Starting auto refresh routine")
        self.set_attribute([ATTR_REFRESHING, VALUE], True)
        self.stop_auto_refresh.clear()

        def _thread():
            while not self.stop_auto_refresh.is_set():
                start_time = time.time()
                self.karabo_next()
                end_time = time.time() - start_time
                sleep_for = (self[ATTR_MIN_UPDATE_TIMEOUT][VALUE] - end_time * 1e3) / 1e3
                if sleep_for > 0:
                    sleep(sleep_for)
                self.set_value(ATTR_LATENCY, end_time * 1e3)

            self.set_attribute([ATTR_REFRESHING, VALUE], False)
            self.logger.info("Auto refresh routine stopped")

        Thread(target=_thread).start()

    def karabo_next(self):
        """
        Get one next() call from Karabo bridge.
        Take into account, that next() is blocking!
        :return:
        """
        data, metadata = self.karabo_client.next()
        for key, value in data.items():
            for sub_key, sub_value in value.items():
                if isinstance(sub_value, (int, float)):
                    self.set_value([key, sub_key], sub_value)
        self.update_metadata(list(metadata.values())[0])

    def update_metadata(self, metadata_values):
        """
        This routine is important to get and correct train ID from Karabo bridge.
        ATTR_REMOTE_TRAIN_ID - Karabo train ID
        ATTR_REMOTE_TIMESTAMP - Karabo timestamp
        ATTR_LOCAL_TIMESTAMP - Local computer timestamp
        ATTR_DELTA_TIMESTAMP - Delta timestamp (ATTR_LOCAL_TIMESTAMP - ATTR_REMOTE_TIMESTAMP)
        ATTR_TRAIN_OFFSET - User configurable train offset
        ATTR_LOCAL_TRAIN_ID - Interpolated train id using delta timestamp.
                              train_id_delta = ATTR_DELTA_TIMESTAMP / ATTR_TRAIN_TIMEOUT
                              (ATTR_REMOTE_TRAIN_ID + train_id_delta + ATTR_TRAIN_OFFSET)
        :param metadata_values: metadata from Karabo bridge
        :return:
        """
        remote_timestamp, local_timestamp = np.float64(metadata_values["timestamp"]), time.time()
        time_delta = (local_timestamp - remote_timestamp) * 1e3
        train_id_delta = units.Quantity(time_delta, "ms") / self[ATTR_TRAIN_TIMEOUT].value()
        self.set_attribute((ATTR_REMOTE_TRAIN_ID, VALUE), metadata_values["timestamp.tid"])
        self.set_attribute((ATTR_REMOTE_TIMESTAMP, VALUE), remote_timestamp)
        self.set_attribute((ATTR_LOCAL_TIMESTAMP, VALUE), np.float64(time.time()))
        self.set_attribute((ATTR_DELTA_TIMESTAMP, VALUE), time_delta)
        self.set_attribute((ATTR_LOCAL_TRAIN_ID, VALUE),
                           int(round(
                               metadata_values["timestamp.tid"] + train_id_delta.m + self[ATTR_TRAIN_OFFSET][VALUE])))

    @expose_method()
    def stop(self):
        """
        Stop auto refresh routine by setting stop_auto_refresh flag.
        :return:
        """
        self.logger.info("Stopping auto refresh routine")
        self.counter_running = False
        self.stop_auto_refresh.set()

    def close(self):
        self.stop()
        return super().close()
