# -*- coding: utf-8 -*-
#
# This file is part of the bliss project
#
# Copyright (c) Beamline Control Unit, ESRF
# Distributed under the GNU LGPLv3. See LICENSE for more info.

import collections
import functools
import inspect
import math
import gevent
import gevent.lock

from bliss.common import event
from bliss.common.utils import grouped
from bliss.common.greenlet_utils import KillMask
from bliss.config.channels import Channel
from bliss.config import settings


def setting_update_from_channel(value, setting_name=None, axis=None):
    # print('setting update from channel', axis.name, setting_name, str(value))
    if value is None:
        return
    if setting_name == "state":
        if "MOVING" in str(value):
            axis._set_moving_state(from_channel=True)
        else:
            if axis.is_moving:
                axis._set_move_done(from_channel=True)
    event.send_safe(axis, setting_name, value)


def floatOrNone(x):
    if x is not None:
        return float(x)


def stateSetting(state):
    from bliss.common import axis

    s = axis.AxisState(state)
    return s


class ControllerAxisSettings:
    def __init__(self):
        self.setting_names = []
        self.convert_func = {}
        self.persistent_setting = {}
        self.config_setting = {}
        self.hardware_setting = {}

        # 'offset' must be set BEFORE limits to ensure good dial/user conversion.
        self.add("sign", int)
        self.add("offset", float)
        self.add("backlash", float)
        self.add("low_limit", floatOrNone)
        self.add("high_limit", floatOrNone)
        self.add("velocity", float, config=True, hardware=True)
        self.add("velocity_high_limit", floatOrNone)
        self.add("velocity_low_limit", floatOrNone)
        self.add("jog_velocity", float, hardware=True)
        self.add("acceleration", float, config=True, hardware=True)
        self.add("dial_position", float, hardware=True)
        self.add("_set_position", float)
        self.add("position", float, hardware=True)
        self.add("state", stateSetting, persistent=False, hardware=True)
        self.add("steps_per_unit", float, config=True)

    @property
    def config_settings(self):
        return tuple(
            setting for setting, config in self.config_setting.items() if config
        )

    def add(
        self,
        setting_name,
        convert_func=str,
        persistent=True,
        config=False,
        hardware=False,
    ):
        if setting_name not in self.setting_names:
            self.setting_names.append(setting_name)
            self.convert_func[setting_name] = convert_func
            self.persistent_setting[setting_name] = persistent
            self.config_setting[setting_name] = config
            self.hardware_setting[setting_name] = hardware


disabled_settings_namedtuple = collections.namedtuple(
    "disabled_settings", "names config_dict"
)


class AxisSettings:
    def __init__(self, axis):
        self.__axis = axis
        self.__prev_state = None
        self.__prev_position = None
        self.__prev_dial = None
        self._beacon_channels = {}
        self._disabled_settings = disabled_settings_namedtuple(
            set(), dict(axis.config.config_dict)
        )
        self._hash = settings.HashSetting(
            "axis.%s" % axis.name, default_values=axis.config.config_dict
        )
        self.lock = gevent.lock.Semaphore()

    @property
    def setting_names(self):
        yield from self.__axis.controller.axis_settings.setting_names

    def convert_func(self, setting_name):
        return self.__axis.controller.axis_settings.convert_func.get(setting_name)

    @property
    def config_settings(self):
        return self.__axis.controller.axis_settings.config_settings

    @property
    def hardware_settings(self):
        return self.__axis.controller.axis_settings.hardware_settings

    def register_channels_callbacks(self):
        for chan in self._beacon_channels.values():
            chan.register_callback(chan._setting_update_cb)

    def unregister_channels_callbacks(self):
        for chan in self._beacon_channels.values():
            chan.unregister_callback(chan._setting_update_cb)

    def _create_channel(self, setting_name):
        chan_name = "axis.%s.%s" % (self.__axis.name, setting_name)
        chan = Channel(chan_name, self_updates=False)
        cb = functools.partial(
            setting_update_from_channel, setting_name=setting_name, axis=self.__axis
        )
        chan._setting_update_cb = cb
        return chan

    def init_channels(self):
        self._beacon_channels.clear()
        for setting_name in self.__axis.controller.axis_settings.setting_names:
            self._beacon_channels[setting_name] = self._create_channel(setting_name)
        self.register_channels_callbacks()

    def _get_setting_or_config_value(self, name, default=None):
        # return setting or config parameter
        converter = self.convert_func(name)
        value = self.get(name)
        if value is None:
            value = self.__axis.config.get(name, converter, default=default)
        return value

    def check_config_settings(self):
        axis = self.__axis
        props = dict(
            inspect.getmembers(axis.__class__, lambda o: isinstance(o, property))
        )
        config_settings = []
        for setting_name in self.config_settings:
            # check if setting is in config
            value = axis.config.get(setting_name)
            if value is None:
                raise RuntimeError(
                    "Axis %s: missing configuration key '%s`"
                    % (axis.name, setting_name)
                )
            if setting_name == "steps_per_unit":
                # steps_per_unit is read-only
                continue
            config_settings.append(setting_name)
            # check if setting has a method to initialize (set) its value,
            # without actually executing the property
            try:
                assert callable(props[setting_name].fset)
            except AssertionError:
                raise RuntimeError(
                    "Axis %s: missing method '%s` to set setting value"
                    % (axis.name, setting_name)
                )
        return config_settings

    def init(self):
        """Initialize settings

        "config settings" are those that **must** be in YML config like
        steps per unit, acceleration and velocity ; otherwise settings
        can optionally be present in the config file.
        Config settings must have a property setter on the Axis object.
        "persistent settings" are stored in redis, like position for example;
        in any case, when a setting is set its value is emitted via a
        dedicated channel.
        Some settings can be both config+persistent (like velocity) or
        none (like state, which is only emitted when it changes, but not stored
        at all)
        """
        axis = self.__axis

        config_settings = self.check_config_settings()
        config_steps_per_unit = axis.config.get("steps_per_unit", float)

        if axis.no_offset:
            sign = 1
            offset = 0
        else:
            sign = self._get_setting_or_config_value("sign", 1)
            offset = self._get_setting_or_config_value("offset", 0)
        self.set("sign", sign)
        self.set("offset", offset)

        self.set("backlash", self._get_setting_or_config_value("backlash", 0))

        low_limit_dial = self._get_setting_or_config_value("low_limit")
        high_limit_dial = self._get_setting_or_config_value("high_limit")

        if config_steps_per_unit:
            cval = config_steps_per_unit
            rval = self._hash.raw_get("steps_per_unit")
            # Record steps_per_unit
            if rval is None:
                self.set("steps_per_unit", cval)
            else:
                rval = float(rval)
                if cval != rval:
                    ratio = rval / cval
                    new_dial = axis.dial * ratio

                    self.set("steps_per_unit", cval)
                    if not axis.no_offset:
                        # calculate offset so user pos stays the same
                        self.set("offset", axis.position - axis.sign * new_dial)
                    self.set("dial_position", new_dial)

                    if math.copysign(rval, cval) != rval:
                        ll, hl = low_limit_dial, high_limit_dial
                        low_limit_dial, high_limit_dial = -hl, -ll

        self.set("low_limit", low_limit_dial)
        self.set("high_limit", high_limit_dial)

        vel_low_limit = self._get_setting_or_config_value("velocity_low_limit")
        vel_high_limit = self._get_setting_or_config_value("velocity_high_limit")
        self.set("velocity_low_limit", vel_low_limit)
        self.set("velocity_high_limit", vel_high_limit)

        for setting_name in config_settings:
            value = self._get_setting_or_config_value(setting_name)
            setattr(axis, setting_name, value)

    def set(self, *settings_values):
        axis = self.__axis
        disabled_settings = self._disabled_settings
        updated_dict = {}
        events_dict = {}

        for setting_name, value in grouped(settings_values, 2):
            # the next 3 tests prevent recursion when getting one of those
            # settings, that can do a set in some circumstances (first time or
            # 'no settings axis' for example), that emit a new setting event,
            # that can execute a callback that can get state or position...
            if setting_name == "state":
                if self.__prev_state == value:
                    continue
                self.__prev_state = value
            if setting_name == "position":
                if self.__prev_position == value:
                    continue
                self.__prev_position = value
            if setting_name == "dial_position":
                if self.__prev_dial == value:
                    continue
                self.__prev_dial = value

            if setting_name == "sign":
                value = math.copysign(1, value)

            axis_settings = axis.controller.axis_settings
            if setting_name not in axis_settings.setting_names:
                raise ValueError(
                    "No setting '%s` for axis '%s`" % (setting_name, axis.name)
                )
            convert_func = self.convert_func(setting_name)
            if convert_func is not None:
                value = convert_func(value)

            if setting_name in disabled_settings.names:
                if (
                    not axis_settings.hardware_setting[setting_name]
                    and axis_settings.persistent_setting[setting_name]
                ):
                    disabled_settings.config_dict[setting_name] = value
            if axis_settings.persistent_setting[setting_name]:
                if value is None:
                    updated_dict[setting_name] = "None"
                else:
                    updated_dict[setting_name] = value
            events_dict[setting_name] = value

        with self.lock:
            with KillMask():
                self._hash.update(updated_dict)

        for setting_name, value in events_dict.items():
            self._beacon_channels[setting_name].value = value

            if setting_name not in disabled_settings.names:
                event.send(axis, f"internal_{setting_name}", value)

            event.send_safe(axis, setting_name, value)

    def get(self, setting_name):
        axis = self.__axis
        axis_settings = axis.controller.axis_settings
        disabled_settings = self._disabled_settings

        if setting_name not in axis_settings.setting_names:
            raise NameError("No setting '%s` for axis '%s`" % (setting_name, axis.name))

        if setting_name in disabled_settings.names:
            if axis_settings.hardware_setting[setting_name]:
                return  # will force hardware read
            else:
                return disabled_settings.config_dict.get(setting_name)

        if axis_settings.persistent_setting[setting_name]:
            with self.lock:
                with KillMask():
                    value = self._hash.get(setting_name)
                if value == "None":
                    value = None
        else:
            chan = self._beacon_channels.get(setting_name)
            if chan:
                value = chan.value
            else:
                value = None

        if value is not None:
            convert_func = self.convert_func(setting_name)
            if convert_func is not None:
                value = convert_func(value)
        return value

    def clear(self, setting_name, clear_cache=True):
        disabled_settings = self._disabled_settings

        if setting_name in disabled_settings.names:
            disabled_settings.config_dict[setting_name] = None

        if clear_cache:
            self._hash[setting_name] = None

        # reset beacon channel, if it is there
        try:
            self._beacon_channels[setting_name]._set_raw_value(None)
        except KeyError:
            pass

    def disable_cache(self, setting_name, flag=True):
        """
        Remove cache for specified setting
        """
        if setting_name == "position":
            self.disable_cache("dial_position", flag)

        self.clear(setting_name, clear_cache=False)

        if flag:
            self._disabled_settings.names.add(setting_name)
        else:
            try:
                self._disabled_settings.names.remove(setting_name)
            except KeyError:
                pass
