"""
BeneDict enables accessing dict values by attribute, just like Javascript's
dot notation. Supports JSON/YAML operations.
Builtin methods like "values()" and "items()" can be overriden by the data keys,
but their original version will always be protected with prefix builtin_

Adapted from: https://github.com/makinacorpus/EasyDict
"""
import json
import yaml
import inspect
from io import StringIO
from os.path import expanduser


def _builtin_name(method_name):
    return 'builtin_' + method_name

def _original_name(builtin_name):
    return builtin_name[len('builtin_'):]

def _is_builtin(method_name):
    return method_name.startswith('builtin_')


class BeneDict(dict):
    """
    BeneDict enables accessing dict values by attribute, just like Javascript's
    dot notation. Supports JSON/YAML operations.

    Adapted from: https://github.com/makinacorpus/EasyDict

    Notes:
      Use `dict.items()` if you know there might be conflict in the keys
      or `builtin_` + method name

    Added methods: the version always prefixed by `builtin` is protected against
      changes. You can use the non-prefixed version if you know for sure that
      the name will never be overriden

    >>> d = BeneDict({'foo':3})
    >>> d['foo']
    3
    >>> d.foo
    3
    >>> d.bar
    Traceback (most recent call last):
    ...
    AttributeError: 'BeneDict' object has no attribute 'bar'

    Works recursively

    >>> d = BeneDict({'foo':3, 'bar':{'x':1, 'y':2}})
    >>> isinstance(d.bar, dict)
    True
    >>> d.bar.x
    1

    Bullet-proof

    >>> BeneDict({})
    {}
    >>> BeneDict(d={})
    {}
    >>> BeneDict(None)
    {}
    >>> d = {'a': 1}
    >>> BeneDict(**d)
    {'a': 1}

    Set attributes

    >>> d = BeneDict()
    >>> d.foo = 3
    >>> d.foo
    3
    >>> d.bar = {'prop': 'value'}
    >>> d.bar.prop
    'value'
    >>> d
    {'foo': 3, 'bar': {'prop': 'value'}}
    >>> d.bar.prop = 'newer'
    >>> d.bar.prop
    'newer'


    Values extraction

    >>> d = BeneDict({'foo':0, 'bar':[{'x':1, 'y':2}, {'x':3, 'y':4}]})
    >>> isinstance(d.bar, list)
    True
    >>> from operator import attrgetter
    >>> map(attrgetter('x'), d.bar)
    [1, 3]
    >>> map(attrgetter('y'), d.bar)
    [2, 4]
    >>> d = BeneDict()
    >>> d.keys()
    []
    >>> d = BeneDict(foo=3, bar=dict(x=1, y=2))
    >>> d.foo
    3
    >>> d.bar.x
    1

    Still like a dict though

    >>> o = BeneDict({'clean':True})
    >>> o.items()
    [('clean', True)]

    Can be inherited, subclass will be recursively applied to dict objects.

    Any new methods added in subclass will have a prefixed version "builtin_"
    that protected overwriting.
    """
    def __new__(cls, *args, **kwargs):
        protected_methods = []
        # add builtin_ protection
        for attr_name in dir(cls):
            attr = getattr(cls, attr_name)
            if (not attr_name.startswith('_')
                    and not _is_builtin(attr_name)
                    and callable(attr)):
                protected_name = _builtin_name(attr_name)
                setattr(cls, protected_name, attr)
                protected_methods.append(protected_name)
        cls._PROTECTED_METHODS = protected_methods
        return super().__new__(cls, *args, **kwargs)

    def __init__(self, d=None, **kwargs):
        super(BeneDict, self).__init__()
        if d is None:
            d = {}
        if kwargs:
            dict.update(d, **kwargs)
        for k, v in dict.items(d):
            self.__setattr__(k, v)

    def __setattr__(self, name, value):
        cls = self.__class__  # carry over inherited class from BeneDict
        # cls = BeneDict
        if name in cls._PROTECTED_METHODS:
            raise ValueError('Cannot override `{}()`: {} protected method'
                             .format(name, cls.__name__))
        if isinstance(value, (list, tuple)):
            value = type(value)(cls(x) if isinstance(x, dict) else x
                                for x in value)
        elif isinstance(value, dict):
            # implements deepcopy if BeneDict(BeneDict())
            # to make it shallow copy, add the following condition:
            # ...  and not isinstance(value, self.__class__)):
            value = cls(value)
        if isinstance(name, str):  # support non-string keys
            super(BeneDict, self).__setattr__(name, value)
        super(BeneDict, self).__setitem__(name, value)

    __setitem__ = __setattr__

    def to_dict(self):
        """
        Convert to raw dict
        """
        return benedict_to_dict(self)

    def deepcopy(self):
        return BeneDict(self)

    @classmethod
    def load_json(cls, file_path):
        file_path = expanduser(file_path)
        with open(file_path, 'r') as fp:
            return cls(json.load(fp))

    @classmethod
    def loads_json(cls, string):
        return cls(json.loads(string))

    @classmethod
    def load_yaml(cls, file_path):
        file_path = expanduser(file_path)
        with open(file_path, 'r') as fp:
            return cls(yaml.load(fp))

    @classmethod
    def loads_yaml(cls, string):
        return cls(yaml.load(string))

    def dump_json(self, file_path):
        file_path = expanduser(file_path)
        with open(file_path, 'w') as fp:
            json.dump(benedict_to_dict(self), fp, indent=4)

    def dumps_json(self):
        "Returns: string"
        return json.dumps(benedict_to_dict(self))

    def dump_yaml(self, file_path):
        file_path = expanduser(file_path)
        with open(file_path, 'w') as fp:
            yaml.dump(
                benedict_to_dict(self),
                stream=fp,
                indent=2,
                default_flow_style=False
            )

    def dumps_yaml(self):
        "Returns: string"
        stream = StringIO()
        yaml.dump(
            benedict_to_dict(self),
            stream,
            default_flow_style=False,
            indent=2
        )
        return stream.getvalue()

    def __getstate__(self):
        """
        Support pickling.
        Warning:
          if this BeneDict overrides dict builtin methods, like `items`,
          pickle will report error.
          don't know how to resolve yet
        """
        return self.builtin_to_dict()

    def __setstate__(self, state):
        self.__init__(state)

    def __str__(self):
        return str(benedict_to_dict(self))

    # we explicitly list them here so that IDEs like PyCharm can do auto-complete
    # call _print_protected_methods() to generate this code
    builtin_clear = dict.clear
    builtin_copy = dict.copy
    builtin_fromkeys = dict.fromkeys
    builtin_get = dict.get
    builtin_items = dict.items
    builtin_keys = dict.keys
    builtin_pop = dict.pop
    builtin_popitem = dict.popitem
    builtin_setdefault = dict.setdefault
    builtin_update = dict.update
    builtin_values = dict.values
    builtin_deepcopy = deepcopy
    builtin_dump_json = dump_json
    builtin_dump_yaml = dump_yaml
    builtin_dumps_json = dumps_json
    builtin_dumps_yaml = dumps_yaml
    builtin_load_json = load_json
    builtin_load_yaml = load_yaml
    builtin_loads_json = loads_json
    builtin_loads_yaml = loads_yaml
    builtin_to_dict = to_dict


def benedict_to_dict(easy_dict):
    """
    Recursively convert back to builtin dict type
    """
    d = {}
    for k, value in dict.items(easy_dict):
        if isinstance(value, BeneDict):
            d[k] = benedict_to_dict(value)
        elif isinstance(value, (list, tuple)):
            d[k] = type(value)(
                benedict_to_dict(v)
                if isinstance(v, BeneDict)
                else v for v in value
            )
        else:
            d[k] = value
    return d


def get_benedict_protected_methods(d):
    """
    Can be applied to BeneDict itself or any class that inherits from BeneDict

    Args:
        d: a BeneDict or subclass object or class

    Returns:
        list of protected method names
    """
    if inspect.isclass(d):
        try:
            d = d()  # only applies to classes that can take no args
        except:
            raise ValueError('please pass in a concrete object of your class')
    return [
        attr_name for attr_name in dir(d)
        if _is_builtin(attr_name) and callable(getattr(d, attr_name))
    ]


def _print_protected_methods():
    "paste the generated code into BeneDict class for PyCharm convenience"
    for method in [m for m in dir(dict) if not m.startswith('_')]:
        print('{} = dict.{}'.format(_builtin_name(method), method))

    for protected in get_benedict_protected_methods(BeneDict):
        original_name = _original_name(protected)
        if original_name not in dir(dict):
            print('{} = {}'.format(protected, original_name))

