#!/usr/bin/env python
# encoding: utf-8
#
# Copyright SAS Institute
#
#  Licensed under the Apache License, Version 2.0 (the License);
#  you may not use this file except in compliance with the License.
#  You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS,
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#  See the License for the specific language governing permissions and
#  limitations under the License.
#

'''
Options interface for ESP

Options can be set and retrieved using set_option(...), get_option(...), and
reset_option(...).  The describe_option(...) function can be used to display
a description of one or more options.

'''

from __future__ import print_function, division, absolute_import, unicode_literals

import contextlib
import os
import re
import six
import types
import weakref
from six.moves.urllib.parse import urlparse
from .xdict import xdict
from ..exceptions import ESPOptionError

# pylint: disable=C0330


# Container for options
_config = xdict()

# Subscribers to option changes
_subscribers = weakref.WeakKeyDictionary()


def iteroptions(*args, **kwargs):
    '''
    Iterate through name / value pairs of options

    Options can come in several forms.  They can be consecutive arguments
    where the first argument is the name and the following argument is
    the value.  They can be two-element tuples (or lists) where the first
    element is the name and the second element is the value.  You can
    also pass in a dictionary of key / value pairs.  And finally, you can
    use keyword arguments.

    Parameters
    ----------
    *args : any, optional
        See description above.
    **kwargs : key / value pairs, optional
        Arbitrary keyword arguments.

    Returns
    -------
    generator
        Each iteration returns a name / value pair in a tuple

    '''
    args = list(args)
    while args:
        item = args.pop(0)
        if isinstance(item, (list, tuple)):
            yield item[0], item[1]
        elif isinstance(item, dict):
            for key, value in six.iteritems(item):
                yield key, value
        else:
            yield item, args.pop(0)
    for key, value in six.iteritems(kwargs):
        yield key, value


def subscribe(func):
    '''
    Add a subscriber function to option events

    Parameters
    ----------
    func : callable
        A callable object that takes two parameters: key and value.
        This function is called with the name and value of any option
        that is set.

    Returns
    -------
    None

    '''
    if isinstance(func, types.MethodType):
        obj = six.get_method_self(func)
        func = six.get_method_function(func)
        _subscribers[func] = (weakref.ref(func), weakref.ref(obj))
    else:
        _subscribers[func] = (weakref.ref(func), None)


def unsubscribe(func):
    '''
    Remove a subscriber from option events

    Parameters
    ----------
    func : callable
        The callable used to subscribe to option events

    Returns
    -------
    None

    '''
    _subscribers.pop(func, None)


@contextlib.contextmanager
def option_context(*args, **kwargs):
    '''
    Create a context for setting option temporarily

    Parameters
    ----------
    *args : string / any pairs
        Name / value pairs in consecutive arguments (not tuples)
    **kwargs : dict
        Key / value pairs of options

    '''
    # Save old state and set new option values
    oldstate = {}
    for key, value in iteroptions(*args, **kwargs):
        key = key.lower()
        oldstate[key] = get_option(key)
        set_option(key, value)

    # Yield control
    yield

    # Set old state back
    for key, value in six.iteritems(oldstate):
        set_option(key, value)


def _get_option_leaf_node(key):
    '''
    Find full option name of given key

    Parameters
    ----------
    key : string
        Either a partial key or full key name of an option

    Returns
    -------
    string
        The full key name of the option

    Raises
    ------
    ESPOptionError
        If more than one option matches

    '''
    flatkeys = list(_config.flatkeys())
    key = key.lower()
    if key in flatkeys:
        return key
    keys = [k for k in flatkeys if k.endswith('.' + key)]
    if len(keys) > 1:
        raise ESPOptionError('There is more than one option with the name %s.' % key)
    if not keys:
        raise ESPOptionError('%s is not a valid option name.' % key)
    return keys[0]


def set_option(*args, **kwargs):
    '''
    Set the value of an option

    Parameters
    ----------
    *args : string / any pairs
        The name and value of an option in consecutive arguments (not tuples)
    **kwargs : dict
        Arbitrary keyword / value pairs

    '''
    for key, value in iteroptions(*args, **kwargs):
        key = _get_option_leaf_node(key)
        opt = _config[key]
        if not isinstance(opt, SWATOption):
            raise ESPOptionError('%s is not a valid option name' % key)
        opt.set(value)


set_options = set_option


def get_option(key):
    '''
    Get the value of an option

    Parameters
    ----------
    key : string
        The name of the option

    Returns
    -------
    any
        The value of the option

    '''
    key = _get_option_leaf_node(key)
    opt = _config[key]
    if not isinstance(opt, SWATOption):
        raise ESPOptionError('%s is not a valid option name' % key)
    return opt.get()


def get_suboptions(key):
    '''
    Get the dictionary of options at the level `key`

    Parameters
    ----------
    key : string
        The name of the option collection

    Returns
    -------
    dict
        The dictionary of options at level `key`

    '''
    if key not in _config:
        raise ESPOptionError('%s is not a valid option name' % key)
    opt = _config[key]
    if isinstance(opt, SWATOption):
        raise ESPOptionError('%s does not have sub-options' % key)
    return opt


def get_default(key):
    '''
    Get the default value of an option

    Parameters
    ----------
    key : string
        The name of the option

    Returns
    -------
    any
        The default value of the option

    '''
    key = _get_option_leaf_node(key)
    opt = _config[key]
    if not isinstance(opt, SWATOption):
        raise ESPOptionError('%s is not a valid option name' % key)
    return opt.get_default()


get_default_val = get_default


def describe_option(*keys, **kwargs):
    '''
    Print the description of one or more options

    Parameters
    ----------
    *keys : one or more strings
        Names of the options

    '''
    _print_desc = kwargs.get('_print_desc', True)

    out = []

    if not keys:
        keys = sorted(_config.flatkeys())
    else:
        newkeys = []
        for k in keys:
            try:
                newkeys.append(_get_option_leaf_node(k))
            except ESPOptionError:
                newkeys.append(k)

    for key in keys:

        if key not in _config:
            raise ESPOptionError('%s is not a valid option name' % key)

        opt = _config[key]
        if isinstance(opt, xdict):
            desc = describe_option(*['%s.%s' % (key, x)
                                   for x in opt.flatkeys()], _print_desc=_print_desc)
            if desc is not None:
                out.append(desc)
            continue

        if _print_desc:
            print(opt.__doc__)
            print('')
        else:
            out.append(opt.__doc__)

    if not _print_desc:
        return '\n'.join(out)


def reset_option(*keys):
    '''
    Reset one or more options back to their default value

    Parameters
    ----------
    *keys : one or more strings
        Names of options to reset

    '''
    if not keys:
        keys = sorted(_config.flatkeys())
    else:
        keys = [_get_option_leaf_node(k) for k in keys]

    for key in keys:

        if key not in _config:
            raise ESPOptionError('%s is not a valid option name' % key)

        opt = _config[key]
        if not isinstance(opt, SWATOption):
            raise ESPOptionError('%s is not a valid option name' % key)

        # Reset swat options
        set_option(key, get_default(key))


def check_int(value, minimum=None, maximum=None, exclusive_minimum=False,
              exclusive_maximum=False, multiple_of=None):
    '''
    Validate an integer value

    Parameters
    ----------
    value : int or float
        Value to validate
    minimum : int, optional
        The minimum value allowed
    maximum : int, optional
        The maximum value allowed
    exclusive_minimum : boolean, optional
        Should the minimum value be excluded as an endpoint?
    exclusive_maximum : boolean, optional
        Should the maximum value be excluded as an endpoint?
    multiple_of : int, optional
        If specified, the value must be a multple of it in order for
        the value to be considered valid.

    Returns
    -------
    int
        The validated integer value

    '''
    try:
        out = int(value)
    except:
        raise ESPOptionError('Could not convert %s to an integer' % value)

    if minimum is not None:
        if out < minimum:
            raise ESPOptionError('%s is smaller than the minimum value of %s' %
                                 (out, minimum))
        if exclusive_minimum and out == minimum:
            raise ESPOptionError('%s is equal to the exclusive nimum value of %s' %
                                 (out, minimum))

    if maximum is not None:
        if out > maximum:
            raise ESPOptionError('%s is larger than the maximum value of %s' %
                                 (out, maximum))
        if exclusive_maximum and out == maximum:
            raise ESPOptionError('%s is equal to the exclusive maximum value of %s' %
                                 (out, maximum))

    if multiple_of is not None and (out % int(multiple_of)) != 0:
        raise ESPOptionError('%s is not a multiple of %s' % (out, multiple_of))

    return out


def check_float(value, minimum=None, maximum=None, exclusive_minimum=False,
                exclusive_maximum=False, multiple_of=None):
    '''
    Validate a floating point value

    Parameters
    ----------
    value : int or float
        Value to validate
    minimum : int or float, optional
        The minimum value allowed
    maximum : int or float, optional
        The maximum value allowed
    exclusive_minimum : boolean, optional
        Should the minimum value be excluded as an endpoint?
    exclusive_maximum : boolean, optional
        Should the maximum value be excluded as an endpoint?
    multiple_of : int or float, optional
        If specified, the value must be a multple of it in order for
        the value to be considered valid.

    Returns
    -------
    float
        The validated floating point value

    '''
    try:
        out = float(value)
    except:
        raise ESPOptionError('Could not convert %s to a float' % value)

    if minimum is not None:
        if out < minimum:
            raise ESPOptionError('%s is smaller than the minimum value of %s' %
                                 (out, minimum))
        if exclusive_minimum and out == minimum:
            raise ESPOptionError('%s is equal to the exclusive nimum value of %s' %
                                 (out, minimum))

    if maximum is not None:
        if out > maximum:
            raise ESPOptionError('%s is larger than the maximum value of %s' %
                                 (out, maximum))
        if exclusive_maximum and out == maximum:
            raise ESPOptionError('%s is equal to the exclusive maximum value of %s' %
                                 (out, maximum))

    if multiple_of is not None and (out % int(multiple_of)) != 0:
        raise ESPOptionError('%s is not a multiple of %s' % (out, multiple_of))

    return out


def check_boolean(value):
    '''
    Validate a boolean value

    Parameters
    ----------
    value : int or boolean
        The value to validate.  If specified as an integer, it must
        be either 0 for False or 1 for True.

    Returns
    -------
    boolean
        The validated boolean

    '''
    if value is False or value is True:
        return value

    if isinstance(value, int):
        if value == 1:
            return True
        if value == 0:
            return False

    raise ESPOptionError('%s is not a boolean or proper integer value')


def check_string(value, pattern=None, max_length=None, min_length=None,
                 valid_values=None):
    '''
    Validate a string value

    Parameters
    ----------
    value : string
        The value to validate
    pattern : regular expression string, optional
        A regular expression used to validate string values
    max_length : int, optional
        The maximum length of the string
    min_length : int, optional
        The minimum length of the string
    valid_values : list of strings, optional
        List of the only possible values

    Returns
    -------
    string
        The validated string value

    '''
    try:
        if isinstance(value, six.text_type):
            out = value
        elif isinstance(value, six.binary_type):
            out = value.decode('utf-8')
        else:
            out = '%s' % value
    except:
        raise ESPOptionError('Could not convert string value to unicode')

    if max_length is not None and len(out) > max_length:
        raise ESPOptionError('%s is longer than the maximum length of %s' %
                             (out, max_length))

    if min_length is not None and len(out) < min_length:
        raise ESPOptionError('%s is shorter than the minimum length of %s' %
                             (out, min_length))

    if pattern is not None and not re.search(pattern, out):
        raise ESPOptionError('%s does not match pattern %s' % (out, pattern))

    if valid_values is not None and out not in valid_values:
        raise ESPOptionError('%s is not one of the possible values: %s' %
                             (out, ', '.join(valid_values)))

    return out


def check_url(value, pattern=None, max_length=None, min_length=None, valid_values=None):
    '''
    Validate a URL value

    Parameters
    ----------
    value : any
        The value to validate.  This value will be cast to a string
        and converted to unicode.
    pattern : regular expression string, optional
        A regular expression used to validate string values
    max_length : int, optional
        The maximum length of the string
    min_length : int, optional
        The minimum length of the string
    valid_values : list of strings, optional
        List of the only possible values

    Returns
    -------
    string
        The validated URL value

    '''
    out = check_string(value, pattern=pattern, max_length=max_length,
                       min_length=min_length, valid_values=valid_values)
    try:
        urlparse(out)
    except:
        raise ESPOptionError('%s is not a valid URL' % value)
    return out


class SWATOption(object):
    '''
    SWAT configuration option

    Parameters
    ----------
    name : string
        The name of the option
    typedesc : string
        Description of the option data type (e.g., int, float, string)
    validator : callable
        A callable object that validates the option value and returns
        the validated value.
    default : any
        The default value of the option
    doc : string
        The documentation string for the option

    environ : string, optional
        If specified, the value should be specified in an environment
        variable of that name.

    Returns
    -------
    SWATOption object

    '''

    def __init__(self, name, typedesc, validator, default, doc, environ=None):
        self._name = name
        self._typedesc = typedesc
        self._validator = validator
        if environ is not None:
            self._default = validator(os.environ.get(environ, default))
        else:
            self._default = validator(default)
        self._environ = environ
        self._value = self._default
        self._doc = doc

    @property
    def __doc__(self):
        ''' Documentation string '''
        separator = ' '
        if isinstance(self._value, six.string_types) and len(self._value) > 40:
            separator = '\n    '
        return '''%s : %s\n    %s\n    [default: %s]%s[currently: %s]\n''' % \
            (self._name, self._typedesc, self._doc.rstrip().replace('\n', '\n    '),
             self._default, separator, self._value)

    def set(self, value):
        '''
        Set the value of the option

        Parameters
        ----------
        value : any
           The value to set

        Returns
        -------
        None

        '''
        value = self._validator(value)
        _config[self._name]._value = value

        if self._environ is not None:
            os.environ[self._environ] = str(value)

        for func, obj in list(_subscribers.values()):
            if func is not None:
                if obj is None:
                    func = func()
                    if func is not None:
                        func(self._name, value)
                else:
                    func, obj = func(), obj()
                    if func is not None and obj is not None:
                        func(obj, self._name, value)

    def get(self):
        '''
        Get the value of the option

        Returns
        -------
        any
            The value of the option

        '''
        if self._environ is not None:
            try:
                _config[self._name]._value = self._validator(os.environ[self._environ])
            except KeyError:
                pass
        return _config[self._name]._value

    def get_default(self):
        '''
        Get the default value of the option

        Returns
        -------
        any
            The default value of the option

        '''
        return _config[self._name]._default


def register_option(key, typedesc, validator, default, doc, environ=None):
    '''
    Register a new option

    Parameters
    ----------
    key : string
        The name of the option
    typedesc : string
        Description of option data type (e.g., int, float, string)
    validator : callable
        A callable object that validates the value and returns
        a validated value.
    default : any
        The default value of the option
    doc : string
        The documentation string for the option

    environ : string, optional
        If specified, the value should be specified in an environment
        variable of that name.

    Returns
    -------
    None

    '''
    _config[key] = SWATOption(key, typedesc, validator, default, doc, environ=environ)


class AttrOption(object):
    '''
    Attribute-style access of SWAT options

    '''

    def __init__(self, name):
        object.__setattr__(self, '_name', name)

    def __dir__(self):
        if self._name in _config:
            return _config[self._name].flatkeys()
        return _config.flatkeys()

    @property
    def __doc__(self):
        if self._name:
            return describe_option(self._name, _print_desc=False)
        return describe_option(_print_desc=False)

    def __getattr__(self, name):
        name = name.lower()
        if self._name:
            fullname = self._name + '.' + name
        else:
            fullname = name
        if fullname not in _config:
            fullname = _get_option_leaf_node(fullname)
        out = _config[fullname]
        if not isinstance(out, SWATOption):
            return type(self)(fullname)
        return out.get()

    def __setattr__(self, name, value):
        name = name.lower()
        if self._name:
            fullname = self._name + '.' + name
        else:
            fullname = name
        if fullname not in _config:
            fullname = _get_option_leaf_node(fullname)
        out = _config[fullname]
        if not isinstance(out, SWATOption):
            return type(self)(fullname)
        _config[fullname].set(value)
        return

    def __call__(self, *args, **kwargs):
        ''' Shortcut for option context '''
        return option_context(*args, **kwargs)


# Object for setting and getting options using attribute syntax
options = AttrOption(None)