# *****************************************************************************
#
# 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>
#
# *****************************************************************************

import re
import inspect
import socket
from pathlib import Path
from ast import literal_eval
from importlib import import_module
import frappy
from frappy.lib.comparestring import compare
from frappy.config import process_file, Node
from frappy.core import Module, Parameter, Attached
from frappy.datatypes import DataType, EnumType
from frappy.properties import Property, UNSET


HEADER = "# please edit this file with bin/frappy-edit"


class Site:
    base = Path(frappy.__file__).parent.parent
    default_interface = 'tcp://10767'
    domain = None
    frappy_subdir = None
    equipment_postfix = 'your.postfix'

    # for individual sites

    def __init__(self):
        self.packages = [v.name for v in self.base.glob('frappy_*')]
        if self.frappy_subdir:
            try:  # own subdir should be first
                self.packages.remove(self.frappy_subdir)
                self.packages.insert(0, self.frappy_subdir)
            except ValueError:
                pass


class NonStringType:
    """any type except string"""
    def validate(self, value):
        self(value)

    def __call__(self, value):
        return value

    def from_string(self, strvalue):
        """convert from string """
        try:
            return literal_eval(strvalue)
        except Exception as e:
            raise ValueError('this is no python value') from e

    def to_string(self, value):
        return repr(value)

    def format_value(self, value, unit=None):
        """convert to python code (inverse of ast.literal_eval)

        :param value: the value
        :param unit: must be False (needed for compatibility with frappy.datatypes.DataType)
        :return: version to used as python code
        """
        return repr(value)


class SimpleStringType(NonStringType):
    def validate(self, value):
        pass

    def from_string(self, strvalue):
        """convert from string """
        return strvalue

    def to_string(self, value):
        return value or ''  # convert None to empty string

    def format_value(self, value, unit=None):
        """convert to string

        :param value:
        :param unit: must be False (needed for compatibility with frappy datatypes
        :return: stringified version (triple quoted when containing line breaks)
        """
        if '\n' in value:
            value = value.replace('"""', '\\"\\"\\"')
            return f'"""{value}"""'
        return repr(value)


nonstringtype = NonStringType()
stringtype = SimpleStringType()


class Value:
    """a value with additional info

    - hold a value (self.value) and a stringified value self.strvalue
    - set from and get a string representation for use in a input element
    - get a python code representation (to be reverted with ast.literal_eval)
    - verify if the datatype is valid (this typically needs extension)
    - get information for completion
    """
    error = None
    strvalue = None
    modulecls = None
    datatype = None
    default = None
    value = None
    completion = None

    def __init__(self, value, datatype=None, error=None, pr=None,
                 from_string=False, callback=None):
        if value is None:
            raise ValueError(datatype)
        self.datatype = datatype
        if isinstance(pr, Property):
            if pr.value is UNSET:
                self.default = None if pr.default is UNSET else pr.default
            else:
                self.default = pr.value
        elif isinstance(pr, Parameter):
            self.default = pr.default if pr.value is None else pr.value
        if isinstance(datatype, EnumType):
            self.completion = NameCompletion([v.name for v in datatype._enum.members])
        elif isinstance(pr, Attached):
            self.completion = ModuleNameCompletion(pr.basecls)
        self.error = error
        if callback:
            self.callback = callback
        if from_string:
            if datatype is None:
                try:
                    literal_eval(value)
                    self.datatype = nonstringtype
                except Exception:
                    self.datatype = stringtype
            self.set_from_string(value)
        else:
            if datatype is None:
                self.datatype = stringtype if isinstance(value, str) else nonstringtype
            self.set_value(value)

    def callback(self, value):  # pylint: disable=method-hidden
        """to be overridden"""
        return value

    def set_value(self, value):
        self.strvalue = None
        try:
            dt = self.datatype
            value = dt(value)
            self.strvalue = dt.to_string(value)
            dt.validate(value)
            self.value = self.callback(value)
        except Exception as e:
            self.value = value
            self.error = repr(e)
            if self.strvalue is None:
                self.strvalue = str(value)

    def validate_from_string(self, strvalue):
        self.strvalue = strvalue
        self.value = self.callback(self.datatype.from_string(strvalue))

    def set_from_string(self, strvalue):
        try:
            self.validate_from_string(strvalue)
        except Exception as e:
            self.error = repr(e)

    def get_repr(self):
        """convert string value to repr

        :return: repr, to be used when building config code
        """
        if self.datatype:
            try:
                return self.datatype.format_value(self.value, False)
            except Exception:
                pass
        return repr(self.strvalue)

    def __eq__(self, other):
        return (self.value, self.error, self.strvalue
                ) == (other.value, other.error, other.strvalue)

    def __repr__(self):
        return f'{type(self).__name__}({self.value!r}, {self.datatype!r})'


def get_datatype(pname, cls, value):
    """

    :param pname: <property name> or <parameter name> or <parametger name>.<property name>
    :param cls: a frappy Module class or None
    :param value: the given value (needed only in case the datatype can not be determined)
    :return: datatype, error or None, property or parameter or None
    """
    param, _, prop = pname.partition('.')
    error = None
    if cls:
        try:
            prop_param = cls.configurables[param]
            if isinstance(prop_param, dict):
                propobj = prop_param.get(prop) if prop else cls.accessibles.get(param)
                if propobj is None:
                    error = f'{cls.__module__}.{cls.__qualname__}.{param}.{prop} is not configurable'
                else:
                    return propobj.datatype, None, propobj
            elif prop:
                error = f'{cls.__module__}.{cls.__qualname__}.{param} is not a parameter'
            else:
                return prop_param.datatype, None, prop_param
                # return prop_param.datatype, None, None
        except AttributeError:
            error = f'{cls.__module__}.{cls.__qualname__} is not a Frappy Module'
        except KeyError:
            error = f'{cls.__module__}.{cls.__qualname__}.{param} is not configurable'
    if isinstance(value, str):
        return stringtype, error, None
    return nonstringtype, error, None


def make_value(pname, cls, value):
    """make value object"""
    return Value(value, *get_datatype(pname, cls, value))


class ClassChecker:
    root = None  # = clsobj if the imported object exists or modobj
    modobj = None  # the python module imported or None if no import succeeded
    clsobj = None  # the object imported or None on failure
    modname = None  # the name of the module imported
    pyfile = None  # the python file of the module imported
    error = None  # None on success or a reason of failure

    def __init__(self, clsstr):
        """analyze clsstr

        try to resolve clsstr from left to right, quit on error
        overwrite the class attributes above
        """
        clspath = clsstr.split('.')
        for pathpos, name in enumerate(clspath):
            base = '.'.join(clspath[:pathpos])
            if name:
                if self.clsobj:
                    error = self.try_cls(base, name)
                elif name.isupper():
                    error = self.try_cls(base, name)
                    if error and self.try_module(base, name) is None:
                        error = None
                else:
                    error = self.try_module(base, name)
                    if error and self.try_cls(base, name) is None:
                        error = None
            else:
                error = 'empty element'
            if error:
                self.name = name
                self.error = error
                self.position = sum(len(v) for v in clspath[:pathpos]) + pathpos
                return
            self.root = self.clsobj or self.modobj
        self.name = None
        self.error = None
        self.position = len(clsstr)

    def try_module(self, base, name):
        """try if base + name is a python module

        return None on success or an error message otherwise
        """
        modname = f'{base}.{name}' if base else name
        try:
            self.modobj = self.root = import_module(modname)
            self.modname = modname
            self.pyfile = Path(self.modobj.__file__)
            return None
        except ImportError as e:
            return str(e)
        except Exception as e:
            return f'{modname}: {e!r}'

    def try_cls(self, base, name):
        """try if base + name is a python object (typically a class)

        return None on success or an error message otherwise
        """
        try:
            self.clsobj = getattr(self.root, name)
            return None
        except Exception:
            return f'{base}.{name} does not exist'


class ModuleClass(DataType):
    def __call__(self, value):
        if isinstance(value, type):
            if issubclass(value, Module):
                return value
            raise ValueError('value is a class, but not a frappy module')
        checker = ClassChecker(value)
        if checker.error:
            raise ValueError(checker.error)
        if checker.clsobj is None:
            raise ValueError(value)
        return checker.clsobj

    def from_string(self, text):
        return self.validate(text)

    def to_string(self, value):
        value = self.validate(value)
        return f'{value.__module__}.{value.__qualname__}'

    def format_value(self, value, unit=None):
        result = repr(self.to_string(value))
        if '<' in result:
            raise ValueError(result, value)
        return result

    def compatible(self, other):
        raise NotImplementedError

    def export_datatype(self):
        raise NotImplementedError


module_class = ModuleClass()


def moddata_from_cfgfile(name, cls, **kwds):
    if isinstance(cls, str):
        clsvalue = Value(cls, module_class, None, from_string=True)
    else:
        clsvalue = Value(cls, module_class, None)
    cls = None if clsvalue.error else clsvalue.value
    result = {
        'name':  make_value('name', None, name),
        'cls': clsvalue,
    }
    for param, cfgvalue in kwds.items():
        if isinstance(cfgvalue, dict):
            for prop, value in cfgvalue.items():
                pname = param if prop == 'value' else f'{param}.{prop}'
                result[pname] = make_value(pname, cls, value)
        else:
            result[param] = make_value(param, cls, cfgvalue)
    return result


def moddata_to_py(name, cls, description, **kwds):
    if cls.strvalue == '<auto>':
        uri = kwds.pop('uri')
        items = [f'IO({name!r}, {uri.strvalue!r}']
        if description.strvalue:
            items.append(f'description={description.strvalue!r}')
    else:
        if '<' in cls.get_repr():
            raise ValueError(cls)
        items = [f'Mod({name!r}', cls.get_repr(), description.get_repr()]
    paramdict = {}
    for pname, valobj in kwds.items():
        param, _, prop = pname.partition('.')
        paramdict.setdefault(param, {})[prop or 'value'] = valobj
    for pname, props in paramdict.items():
        valueitem = props.pop('value', None)
        if valueitem is None:
            args = []
        else:
            args = [valueitem.get_repr()]
            if not props:
                # single value
                items.append(f'{pname} = {args[0]}')
                continue
            # args contains value
        # extend with keyworded values for parameter properties
        args.extend(f'{k}={v.get_repr()}' for k, v in props.items())
        items.append(f"{pname} = Param({', '.join(args)})")
    if len(items) == 1:
        return f"{items[0]})"
    items.append(')')
    return ',\n    '.join(items)


def fix_equipment_id(name, equipment_id):
    """normalize equipment id"""
    if re.match(r'[a-zA-Z0-9_]+(\.[a-zA-Z0-9_]+)*$', equipment_id):
        return equipment_id
    return f'{name}.{site.equipment_postfix}'


def fix_node_class(cls):
    if cls == Node('', '')['cls']:
        return ''
    return cls


def nodedata_from_cfgfile(name, equipment_id='', description='', interface='', cls='', **kwds):
    title, _, doc = description.partition('\n')
    if doc.startswith('\n'):
        doc = doc[1:]
    props = {
        'name': name,
        'title': title,
        'doc': doc,
    }
    eq = fix_equipment_id(name, equipment_id)
    if eq != fix_equipment_id(name, ''):
        props['equipment_id'] = eq
    if interface and interface != site.default_interface:
        props['interface'] = interface
    cls = fix_node_class(cls)
    if cls:
        props['cls'] = cls
    props.update(kwds)
    # TODO: do we have to check the proper datatype for node properties?
    return {k: Value(v) for k, v in props.items()}


def nodedata_to_py(name, title, doc, equipment_id=None, interface=None, cls=None, **kwds):
    eq_id = fix_equipment_id(name.value, equipment_id.value if equipment_id else '')
    intfc = site.default_interface if interface is None else interface.value
    desc = title.value.strip()
    doc = doc.value.strip()
    if doc:
        desc = f'{desc}\n\n{doc}\n'
    items = [f"doc={Value(desc).get_repr()}\nNode({eq_id!r}, doc", f'interface={intfc!r}']
    if cls:
        clsstr = fix_node_class(cls.value)
        if clsstr:
            items.append(f'cls={clsstr!r}')
    for key, value in kwds.items():
        items.append(f'{key} = {value.get_repr()}')
    items.append(')')
    return ',\n    '.join(items)


def cfgdata_to_py(node, **moddata):
    """convert cfgdata to python code

    :param node: dict <key> of <value object>
    :param moddata: dict <module name> of dict <key> of <value object>
    :return: python code
    """
    items = [HEADER, nodedata_to_py(**node)] + [moddata_to_py(k, **v) for k, v in moddata.items()]
    return '\n\n'.join(items)


def cfgdata_from_py(name, cfgpath, filecontent, logger):
    if filecontent:
        config = process_file(cfgpath, logger, filecontent)
        iodict = {k: v for k, v in config.items() if v.get('cls') == '<auto>'}
        nodecfg = config.pop('node', {})
        nodedesc = nodecfg.get('description', '')
        if not filecontent.startswith(HEADER) and '\n' not in nodedesc:
            nodecfg['description'] = f'{nodedesc}\n\nafter conversion with frappy edit'
    else:
        config = {}
        iodict = {}
        nodecfg = {}

    errors = {}
    for modname, modcfg in config.items():
        modio = modcfg.get('io')
        if modio:  # convert legacy io Mod cfg to IO
            ioclass = None
            try:
                ioname = modio['value']
                if ioname in iodict:
                    continue
                iomodcfg = config[ioname]
                if set(iomodcfg) - {'uri', 'cls', 'description'}:
                    continue
                iomodcls = iomodcfg['cls']
                modcls = modcfg['cls']
            except Exception as e:
                logger.info('error %r when checking io for %r', e, modname)
                continue
            try:
                ioclass = f"{modcls}.ioClass"
                if module_class.validate(iomodcls) != module_class.validate(ioclass):
                    continue
                iomodcfg['cls'] = '<auto>'
                iomodcfg.pop('description', None)
                iodict[ioname] = iomodcfg
            except Exception:
                iomod, iocls = iomodcls.rsplit('.', 1)
                mod = modcls.rsplit('.', 1)[0]
                if mod == iomod:
                    iomodcls = iocls
                errors[ioname] = f'{ioname}: missing ioClass={iomodcls} in source code of {modcls}'

    modules = {k: moddata_from_cfgfile(k, **v) for k, v in config.items()}
    for error in errors.values():
        logger.info(error)
    return nodedata_from_cfgfile(name, **nodecfg), modules


SKIP_PROPS = {'implementation', 'features',
    'interface_classes', 'slowinterval', 'omit_unchanged_within', 'original_id'}


def recommended_prs(cls):
    """get properties and parameters which are useful in configuration

    returns a dict <pname> of <is mandatory (bool)>
    """
    if isinstance(cls, str):
        checker = ClassChecker(cls)
        if checker.error or not checker.clsobj:
            return {}
        cls = checker.clsobj
    result = {}
    for pname in cls.configurables:
        pr = getattr(cls, pname)
        if isinstance(pr, Property):
            if pname not in SKIP_PROPS:
                result[pname] = pr.mandatory
        elif isinstance(pr, Parameter):
            if pr.needscfg:
                result[pname] = True
            elif not pr.readonly or hasattr(cls, f'write_{pname}'):
                result[pname] = False
    if result.get('uri') is False and result.get('io') is False:
        result['io'] = True
    return result


class FrappyModule(str):
    """checker for finding subclasses of Module defined in a python module"""
    def check(self, cls):
        return isinstance(cls, type) and issubclass(cls, Module) and self.startswith(cls.__module__)


def get_suggested(guess, allowed_keys):
    """select and sort values from allowed_keys based on similarity to a given value

    :param guess: given value. when empty, the allowed keys are return in the given order
    :param allowed_keys: list of values sorted in a given order
    :return: a list of values
    """
    if len(guess) == 0:
        return allowed_keys
    low = guess.lower()
    if len(guess) == 1:
        # return only items starting with a single letter
        return [k for k in allowed_keys if k.lower().startswith(low)]
    comp = {}
    # if there are items containing guess, return them only
    result = [k for k in allowed_keys if low in k.lower()]
    if result:
        return result
    # calculate similarity
    for key in allowed_keys:
        comp[key] = compare(guess, key)
    comp = sorted(comp.items(), key=lambda t: t[1], reverse=True)
    scorelimit = 2
    result = []
    for i, (key, score) in enumerate(comp):
        if score < scorelimit:
            break
        if i > 2:
            scorelimit = max(2, score - 0.05)
        result.append(key)
    return result or allowed_keys


def class_completion(value):
    """analyze class path and return an array of suggestions for
    the last incomplete element"""
    checker = ClassChecker(value)
    if not checker.error:
        return checker.position, []
    if checker.root is None:
        sdict = {p: f'{p}.' for p in site.packages}
    else:
        sdict = {}
        if not checker.clsobj:
            file = checker.pyfile
            if file.name == '__init__.py':
                sdict = {p.stem: f'{p.stem}.'
                         for p in sorted(file.parent.glob('*.py'))
                         if p.stem != '__init__'}
        sdict.update((k, k) for k, v in sorted(inspect.getmembers(
            checker.root, FrappyModule(checker.modname).check)))
    found = sdict.get(checker.name, None)
    if found:
        selection = [found]
        # selection = [found] + list(sdict.values())
    else:
        selection = list(get_suggested(checker.name, sdict.values()))
    return checker.position, [checker.name] + selection


class NameCompletion:
    def __init__(self, names):
        self.names = names

    def __call__(self, value):
        if value in self.names:
            return len(value), []
        return 0, [value] + list(get_suggested(value, self.names))


class ModuleNameCompletion:
    def __init__(self, basecls):
        self.basecls = basecls

    def get_names(self, basecls):
        return []

    def __call__(self, value):
        names = self.get_names(self.basecls)
        if value in names:
            return len(value), []
        return 0, [value] + list(get_suggested(value, names))


class SitePSI(Site):
    domain = '.psi.ch'
    frappy_subdir = 'frappy_psi'
    equipment_postfix = 'psi.ch'


hostname = socket.getfqdn()
for sitecls in [SitePSI]:  # add here other sites
    if hostname.endswith(sitecls.domain):
        break
site = sitecls()
