# *****************************************************************************
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation; either version 2 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc.,
# 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
#
# Module authors:
#   Markus Zolliker <markus.zolliker@psi.ch>
#
# *****************************************************************************
"""SECoP proxy modules"""

from frappy.client import SecopClient, decode_msg, encode_msg_frame
from frappy.datatypes import StringType
from frappy.errors import BadValueError, CommunicationFailedError, ConfigError
from frappy.lib import get_class
from frappy.modules import Drivable, Module, Readable, Writable
from frappy.params import Command, Parameter
from frappy.properties import Property
from frappy.io import HasIO


DISCONNECTED = Readable.Status.ERROR, 'disconnected'


class Proxy(HasIO, Module):
    module = Property('remote module name', datatype=StringType(), default='')
    status = Parameter('connection status', Readable.status.datatype)  # add status even when not a Readable

    _consistency_check_done = False
    _connection_status = None  # status when not connected
    _secnode = None
    enablePoll = False

    def __new__(cls, name, logger, cfgdict, srv):
        """create a Proxy class based on remote_class"""
        remote_class = cfgdict.pop('remote_class')
        if isinstance(remote_class, dict):
            remote_class = remote_class['value']
        if 'description' not in cfgdict:
            cfgdict['description'] = (f"remote module {cfgdict.get('module', name)} "
                                      f"on {cfgdict.get('io', {'value:': '?'})['value']}")
        proxycls = proxy_class(remote_class)
        return super().__new__(proxycls, name, logger, cfgdict, srv)

    def ioClass(self, name, logger, opts, srv):
        opts['description'] = f"secnode {opts.get('module', name)} on {opts['uri']}"
        return SecNode(name, logger, opts, srv)

    def updateEvent(self, module, parameter, value, timestamp, readerror):
        if parameter not in self.parameters:
            return  # ignore unknown parameters
        if parameter == 'status' and not readerror:
            self._connection_status = None
        # should be done here: deal with clock differences
        self.announceUpdate(parameter, value, readerror, timestamp)

    def initModule(self):
        if not self.module:
            self.module = self.name
        self._secnode = self.io.secnode
        self._secnode.register_callback(self.module, self.updateEvent,
                                        self.descriptiveDataChange, self.nodeStateChange)
        super().initModule()

    def descriptiveDataChange(self, module, moddesc):
        if module is None:
            return  # do not care about the node for now
        self._check_descriptive_data()

    def _check_descriptive_data(self):
        params = self.parameters.copy()
        cmds = self.commands.copy()
        moddesc = self._secnode.modules[self.module]
        remoteparams = moddesc['parameters'].copy()
        remotecmds = moddesc['commands'].copy()
        while params:
            pname, pobj = params.popitem()
            props = remoteparams.get(pname, None)
            if props is None:
                if pobj.export and pname != 'status':
                    self.log.warning('remote parameter %s:%s does not exist', self.module, pname)
                continue
            dt = props['datatype']
            try:
                if pobj.readonly:
                    dt.compatible(pobj.datatype)
                else:
                    if props['readonly']:
                        self.log.warning('remote parameter %s:%s is read only', self.module, pname)
                    pobj.datatype.compatible(dt)
                    try:
                        dt.compatible(pobj.datatype)
                    except Exception:
                        self.log.warning('remote parameter %s:%s is not fully compatible: %r != %r',
                                         self.module, pname, pobj.datatype, dt)
            except Exception:
                self.log.warning('remote parameter %s:%s has an incompatible datatype: %r != %r',
                                 self.module, pname, pobj.datatype, dt)
        while cmds:
            cname, cobj = cmds.popitem()
            props = remotecmds.get(cname)
            if props is None:
                self.log.warning('remote command %s:%s does not exist', self.module, cname)
                continue
            dt = props['datatype']
            try:
                cobj.datatype.compatible(dt)
            except BadValueError:
                self.log.warning('remote command %s:%s is not compatible: %r != %r',
                                 self.module, cname, cobj.datatype, dt)
        # what to do if descriptive data does not match?
        # we might raise an exception, but this would lead to a reconnection,
        # which might not help.
        # for now, the error message must be enough

    def nodeStateChange(self, online, state):
        if online:
            if not self._consistency_check_done:
                self._check_descriptive_data()
                self._consistency_check_done = True
            if self._connection_status:
                self.status = Readable.Status.IDLE, state
        else:
            readerror = CommunicationFailedError('disconnected')
            if self.status != DISCONNECTED:
                for pname in set(self.parameters) - set(('module', 'status')):
                    self.announceUpdate(pname, None, readerror)
                self.status = self._connection_status = DISCONNECTED

    def checkProperties(self):
        pass  # skip


class ProxyReadable(Proxy, Readable):
    pass


class ProxyWritable(Proxy, Writable):
    pass


class ProxyDrivable(Proxy, Drivable):
    pass


PROXY_CLASSES = [ProxyDrivable, ProxyWritable, ProxyReadable, Proxy]


class SecNode(Module):
    uri = Property('uri of a SEC node', datatype=StringType())

    def earlyInit(self):
        super().earlyInit()
        self.secnode = SecopClient(self.uri, self.log)

    def startModule(self, start_events):
        super().startModule(start_events)
        self.secnode.spawn_connect(start_events.get_trigger())

    @Command(StringType(), result=StringType())
    def request(self, msg):
        """send a request, for debugging purposes"""
        reply = self.secnode.request(*decode_msg(msg.encode('utf-8')))
        # pylint: disable=not-an-iterable
        return encode_msg_frame(*reply).decode('utf-8')


def proxy_class(remote_class, name=None):
    """create a proxy class based on the definition of remote class

    remote class is <import path>.<class name> of a class used on the remote node
    if name is not given, <class name> is used
    """
    if isinstance(remote_class, type) and issubclass(remote_class, Module):
        rcls = remote_class
        remote_class = rcls.__name__
    else:
        rcls = get_class(remote_class)
    if name is None:
        name = rcls.__name__

    for proxycls in PROXY_CLASSES:
        if issubclass(rcls, proxycls.__bases__[-1]):
            # avoid 'should not be redefined' warning
            proxycls.accessibles = {}
            break
    else:
        raise ConfigError(f'{remote_class!r} is no SECoP module class')

    attrs = rcls.propertyDict.copy()

    for aname, aobj in rcls.accessibles.items():
        if isinstance(aobj, Parameter):
            pobj = aobj.copy()
            # we have to set own properties of pobj to the inherited ones
            # this matters for the ProxyModule.status datatype, which should be
            # overidden by the remote class status datatype
            pobj.ownProperties = pobj.propertyValues
            pobj.merge({'needscfg': False})
            attrs[aname] = pobj

            def rfunc(self, pname=aname):
                value, _, readerror = self._secnode.getParameter(self.module, pname, True)
                if readerror:
                    raise readerror
                return value

            attrs['read_' + aname] = rfunc

            if not pobj.readonly:

                def wfunc(self, value, pname=aname):
                    value, _, readerror = self._secnode.setParameter(self.module, pname, value)
                    if readerror:
                        raise readerror
                    return value

                attrs['write_' + aname] = wfunc

        elif isinstance(aobj, Command):
            cobj = aobj.copy()

            def cfunc(self, arg=None, cname=aname):
                return self._secnode.execCommand(self.module, cname, arg)[0]

            attrs[aname] = cobj(cfunc)

        else:
            raise ConfigError(f'do not now about {aobj!r} in {remote_class}.accessibles')

    return type(name+"_", (proxycls,), attrs)
