#  Copyright (C) 2017 The authors of Py-ChemShell
#
#  This file is part of Py-ChemShell.
#
#  Py-ChemShell is free software: you can redistribute it and/or modify
#  it under the terms of the GNU Lesser General Public License as
#  published by the Free Software Foundation, either version 3 of the
#  License, or (at your option) any later version.
#
#  Py-ChemShell 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 Lesser General Public License for more details.
#
#  You should have received a copy of the GNU Lesser General Public
#  License along with Py-ChemShell.  If not, see
#  <http://www.gnu.org/licenses/>.

import chemsh
from numpy     import ndarray
from .         import file
from ..base    import errors
from ..dl_py2f import utils

def parseKeysDeco(obj=None):
    ''''''

    # `proc` could be wrapped by another decorator:
    #     @keyword.parseKeysDeco
    #     @deco2
    #     def someproc(arg1, arg2, ..., kwarg1, kwarg2, ...):
    def _parseKeysDeco(proc):
        ''''''
        from functools import wraps

        @wraps(proc)
        # *args and **kwargs are still those of someproc() rather than of deco2!
        def wrapper(*args, **kwargs):
            ''''''

            proc(*args, **kwargs)

            parseKeys(obj, **kwargs)
    
        return wrapper

    return _parseKeysDeco


# use underscored name "_obj", otherwise namespace "obj" is engaged
def parseKeys(_obj, strict=True, **kwargs):
    '''Keyword parsers'''

    from numpy import char, float64

    # YL 16/03/2024: added a collective list of extra legal keywords passed to assigners
    list_kw_legal_extra = []
    _unparsed = {}

    def _assignObj(_val, attr=None, fmt=None, raise_error=True, **kwargs):
        '''Assign a <object> such as Fragment, BQ, etc.'''

        setattr(_obj, attr, utils.objutils.assignByType(_val, type(getattr(_obj, attr)), info=attr))

        return 0


    def _assignDict(_val, attr=None, fmt=None, raise_error=True, **kwargs):
        '''Use a dict as an update to attr: attr must be a dict as well'''

        # YL 05/07/2023: we update the dict only when the dict is not empty; NB: this does not trigger the setter!
        if len(getattr(_obj, attr)) > 0:
            utils.objutils.checkType(getattr(_obj, attr), dict).update(utils.objutils.checkType(_val, dict, info=attr))
        else:
            setattr(_obj, attr, utils.objutils.checkType(_val, dict, info=attr))

        return 0


    def _assignInt(_val, attr=None, fmt=None, raise_error=True, **kwargs):
        '''Assign to an integer (including boolean) value'''

        # YL 16/06/2022: this is a special case! float flooring should be banned to prevent confusion
        if type(_val) in [ float, float64 ]:
            if raise_error:
                raise errors.ChemShTypeError(attr, int)
            else:
                return 1

        # YL 23/06/2020: try to cast to integer
        try:
            _val = int(_val)
        except ValueError:
            if raise_error:
                raise errors.ChemShTypeError(attr, int)
            else:
                return 1

        setattr(_obj, attr, utils.objutils.checkType(_val, int, info=attr))

        return 0


    def _assignFloat(_val, attr=None, fmt=None, raise_error=True, **kwargs):
        '''Assign to a float value'''

        # YL 23/06/2020: try to cast to float
        try:
            _val = float(_val)
        except ValueError:
            if raise_error:
                raise errors.ChemShTypeError(attr, float)
            else:
                return 1

        setattr(_obj, attr, utils.objutils.checkType(_val, float, info=attr))

        return 0


    def _assignStr(_val, attr=None, fmt=None, raise_error=True, **kwargs):
        '''Assign to a string value'''

#        # YL 23/06/2020: try to cast to string
        # YL 06/07/2020: strings shouldn't be cast because `None` is a valid value for keywords that default to <class str>
#        try:
#            _val =  str(_val)
#        except ValueError:
#            raise errors.ChemShTypeError(attr, str)

        try:
            setattr(_obj, attr, utils.objutils.checkType(_val, str, info=attr))
        except AttributeError:
            print("\n >>> ChemShell ERROR: in <class {}> there lacks a `@{}.setter` function for the @property object `{}`\n".format(_obj.__class__.__name__, attr, attr))
            raise

        return 0

    def _assignList(_val, attr=None, fmt=None, raise_error=True, **kwargs):
        '''Assign to a list'''

        from ..parallel import parallel

        # <class str> is a special case: we firstly assume it's a filename from which we can read data ('attr' means 'fmt')
        if type(_val) is str:
            try:
                setattr(_obj, '_filename', _val)
                # YL 14/08/2022: defaults to check the taskfarm subdirectory (this is the most common case in the user interface)
                # YL 16/03/2024: pass extra keyword arguments but append list_kw_legal_extra with legal keywords for the strict checker
                file.parse(_val, fmt=fmt, receiver=_obj, prefix=parallel.prefix, keywords_receiver=list_kw_legal_extra, **kwargs)
            # YL 26/11/2021: convert a non-filename string to a list of string, e.g., 'ABC' to ['ABC']
            except:
                setattr(_obj, attr, [_val])

        elif type(_val) is ndarray:
            # TODO: insert a size check as below for <list>
            setattr(_obj, attr, _val)

        # do not convert to NumPy array, what is not suitable for all attributes
        elif type(_val) is list:
            try:
                setattr(_obj, attr, _val)
            # _val could be a nested list that NumPy cannot handle
            # YL FIXME
            except:
                setattr(_obj, attr, _val)

        elif type(_val) is range:
            setattr(_obj, attr, utils.objutils.checkType(list(_val), list, info=attr))

        # YL 05/07/2023: we shouldn't allow a list of dicts, and this could be, for example, multiple possible types ([],{}) (see _assignTuple)
        elif type(_val) is dict:
            return 1

        else:
            setattr(_obj, attr, [_val])
            if chemsh.CHEMSH_DEBUG > 4:
                print("\n >>> chemsh.io.keyword.parseKeys: \"{}\" converted to \"{}\"\n".format(_val, [_val]))

        return 0


    def _assignRange(_val, attr=None, fmt=None, raise_error=True, **kwargs):
        '''Assign to a range, which is converted to a list'''

        _val = list(_val)

        setattr(_obj, attr, utils.objutils.checkType(_val, str, info=attr))

        return 0


    # YL 19/01/2021: enable multiple legal types, e.g. frag can be range(5) or Fragment() if we have 'frag'=(Fragment(), range(0)) in '_kwargs'
    # YL 15/06/2022: note that in this case there must be a mechanism (typically @property) to define the default value, if the user doesn't assign a value via keyword
    def _assignTuple(_val, attr=None, fmt=None, raise_error=True, **kwargs):
        '''Assign to attribute of multiple legal types'''

        # get the preset types from the _kwargs's defaults
        types = [ utils.objutils.getType(i, list_modules=chemsh.CHEMSH_MODS) for i in _obj._kwargs[attr] ]

        # single type
        if len(set(types)) <= 1:
            setattr(_obj, attr, _val)

        # multiple types
        elif len(set(types)) > 1:
            # multiple legal types
            try:
                for i in _obj._kwargs[attr]:
                    # YL 14/06/2022: raise_error=False to suppress hard type matching
                    # YL 16/06/2022: exit the loop after the first successful assigning
                    if not selectcases[utils.objutils.getType(i, list_modules=chemsh.CHEMSH_MODS)](_val, attr=attr, fmt=fmt, raise_error=False):
                        break
            # fallback: single legal type: assign the value as the tuple
            except:
                setattr(_obj, attr, _val)

    selectcases = {
                    bool      : _assignInt,
                    dict      : _assignDict,
#                    FunctionType:assignLambda,
                    int       : _assignInt,
                    float     : _assignFloat,
                    list      : _assignList,
                    object    : _assignObj,
                    range     : _assignRange,
                    str       : _assignStr,
                    tuple     : _assignTuple,
                    type(None): _assignObj
                  }

    # check if there is user-overridden format
    # TODO implement with _priorities = [ 'fmt',... ]
    for key, val in kwargs.items():
        if key.lower() in ['fmt', 'format']:
            fmt = str(kwargs.items()[key.lower()]).lower()
        else:
            fmt = None

    # check if need to sort
    try:
        kwargslist = [ key for key in _obj._priorities if key in kwargs ] + [ key for key in kwargs if key not in _obj._priorities ]
    except:
        kwargslist = list(kwargs)

    # debug
    if chemsh.CHEMSH_DEBUG > 4 and len(kwargslist):
        print('\n >>> Parsing keyword options for object {}...'.format(_obj))
        print(' Key              Value                                       Default type')

    # YL 05/07/2023: we exclude keyword options read-only for the user
    list_kw_legal = []
    for attr in _obj.__all__:
        if hasattr(_obj.__class__, attr):
            # YL 05/07/2023: only properties with "setter" (`fset`)
            if isinstance(getattr(_obj.__class__, attr), property):
                # YL 05/07/2023: undefined setter is None
                if getattr(getattr(_obj.__class__, attr), 'fset') is not None:
                    list_kw_legal += [attr]
        # YL 05/07/2023: other keyword options in `_kwargs` but not defined under class
        else: 
            list_kw_legal += [attr]
    strbuff_keywords_legal = ' '.join(list_kw_legal) + '\n'

    # loop over keywords passed in from the calling routine
    for key in kwargslist:

        if chemsh.CHEMSH_DEBUG > 9:
            print(" >>> parsing key = {} for object {}...".format(key, _obj))

        val = kwargs[key]

        # debug
        if chemsh.CHEMSH_DEBUG > 4:
# YL 23/06/2021: this can't always be printed
#            print(' {:<16s} {!a:32}            {}'.format(key, val, utils.objutils.getType(_obj._kwargs[key], list_modules=chemsh.CHEMSH_MODS)))
            try:
                print(' {:<16s} {!a:32}            {}'.format(key, val, utils.objutils.getType(_obj._kwargs[key], list_modules=chemsh.CHEMSH_MODS)))
            except:
                print(' {:<16s} {!a:32}            N/A'.format(key, val))

        # attributes starting with "_" are for internal use, so no need to check type
        if key.startswith('_'):
            setattr(_obj, key, val)

            # allow to define '_kwargs' through **kwargs
            if key == '_kwargs':
                utils.objutils.initAttrs(_obj)

        else:

            # YL 15/03/2024: added support for extra kwargs to be passed to assigners
            kwargs_extra = { k:v for k,v in kwargs.items() if k not in list_kw_legal }

            # check if the user's keyword is legal
            # YL 11/09/2020: allow keywords with upper-case letters
            for k in _obj._kwargs.keys():
                if key.lower().strip() == k.lower():
                    key = k
            if not key in _obj._kwargs:
                if strict:
                    # YL 16/03/2024: allow extra keyword arguments to pass to assigners and evade scrutiny here
                    if list_kw_legal_extra and key in list_kw_legal_extra:
                        continue
                    # raise an error in any other circumstances
                    # YL TODO 16/03/2024: for now we don't print list_kw_legal_extra only because it's
                    #                     not easy to get prettyprinting without changing ChemShKeyError
                    else:
                        raise errors.ChemShKeyError(key, _obj, strbuff_keywords_legal, extra_drop_whitespace=False, extra_replace_whitespace=False)
                else:
                    _unparsed.update({key.lower():val})
                    continue

    #                                                                                            arguments of methods "assignXxxx"
    #   predefined <dict> (see above)                                                                 ___________|___________
    #                                                                                                /                       \
            selectcases[utils.objutils.getType(_obj._kwargs[key], list_modules=chemsh.CHEMSH_MODS)](val, attr=key, fmt=fmt, **kwargs_extra)
    #                   \________________________________/               `-- a list of ChemShell modules which will be returned as Python's `object` by getType()
    #                                   |
    #   type of attr, determined by predefined <dict> _obj._kwargs (do not use "getattr(_obj, key)"!)

    return _unparsed


# for old ChemShell's punch file
def parseKeyValPairs(dict, buff):
    '''Parse key=val pairs in given texts'''

    # applying .strip() is crucial! so do not use "key, val = ....[::2]"
    key = buff.strip().partition("=")[0].strip()
    val = buff.strip().partition("=")[2].strip()

    if val.partition("=")[1] == "":
        dict.update({key.rpartition(" ")[2] : val.partition("=")[0]})

    else:
        dict.update({key.rpartition(" ")[2] : val.partition("=")[0].partition(" ")[0]})

        # recursively
        parseKeyValPairs(dict, val)


