from __future__ import print_function

import copy
import json
import os
import collections
import itertools
import textwrap
import functools
import sys
import abc
# noinspection PyUnresolvedReferences
import typing  # noqa: F401

import six
import stringcase

from ._hooks import ComponentHooks as _Hooks
from ._keywords import python_keywords as _kwlist
from ._undefined import UNDEFINED
from ._errors import MissingPropError
from ._ordered_set import OrderedSet


if six.PY2:
    MutableMapping = getattr(collections, 'MutableMapping')
else:
    MutableMapping = collections.abc.MutableMapping


def _default_prop_type(_):
    return 'typing.Any'


def _optional(value, type_info):
    if not type_info.get('required'):
        return 'typing.Union[{}, Undefined]'.format(value)
    return value


def _is_component(obj):
    # Duck typed components
    return hasattr(obj, 'to_plotly_json') \
           and hasattr(obj, '_prop_names') \
           and isinstance(obj, MutableMapping)


def _json_or_undefined(obj):
    return json.dumps(obj) if obj else 'UNDEFINED'


FILTERED_PROPS = (
    'setProps',
    'fireEvents',
)

FILTERED_PROP_TYPES = (
    'func',
    'custom',
    'symbol',
    'instanceOf'
)


PROP_TYPING = {
    'array': lambda t: 'typing.List',
    'arrayOf': lambda t: 'typing.List[{}]'.format(
        PROP_TYPING.get(
            t['value']['name'],
            _default_prop_type
        )(t['value'])
    ),
    'object': lambda t: 'typing.Dict',
    'shape': lambda t: 'typing.Dict[str, typing.Union[{}]]'.format(
        ', '.join(OrderedSet(*(PROP_TYPING.get(
            item['name'],
            _default_prop_type
        )(item) for item in t['value'].values())))
    ),
    'string': lambda t: 'str',
    'bool': lambda t: 'bool',
    'number': lambda t: 'typing.Union[float, int]',
    'node': lambda t: 'typing.Union[str, int, float, DashComponent,'
                      'typing.List[typing.Union'
                      '[str, int, float, DashComponent]]]',
    'func': lambda t: 'None',
    'element': lambda t: 'DashComponent',
    # oneOfType get serialized to union...
    'union': lambda t: 'typing.Union[{}]'.format(
        ','.join(OrderedSet(*(
            PROP_TYPING.get(
                x['name'],
                _default_prop_type
            )(x)
            for x in t['value']
        )))
    ),
    'any': _default_prop_type,
    'custom': _default_prop_type,
    # There is no proper typing support for enum.
    # It does a strange serialization (not json)
    # The possible values get formatted in the docstring.
    'enum': _default_prop_type,
    'objectOf': lambda t: 'typing.Dict[str, {}]'.format(
        PROP_TYPING.get(
            t['value']['name'],
            _default_prop_type
        )(t['value'])
    )
}

FLOW_TYPING_MAPPING = {
    'array': PROP_TYPING['array'],
    'boolean': PROP_TYPING['bool'],
    'number': PROP_TYPING['number'],
    'string': PROP_TYPING['string'],
    'Object': PROP_TYPING['object'],
    'any': _default_prop_type,
    'Element': PROP_TYPING['element'],
    'Node': PROP_TYPING['node'],
    'union': PROP_TYPING['union'],
    'Array': PROP_TYPING['array'],
    'signature': PROP_TYPING['object'],
}

DEFAULT_PROP_MAPPING = {
    'array': _json_or_undefined,
    'arrayOf': _json_or_undefined,
    'object': _json_or_undefined,
    'shape': _json_or_undefined,
    'string': lambda t: t,
    'bool': lambda t: str(str(t).lower() == 'true'),
    'number': str,
    'node': lambda t: 'UNDEFINED',
    'func': lambda t: 'UNDEFINED',
    'element': lambda t: 'UNDEFINED',
    'oneOf': str,
    'union': str,
    'any': str
}

_generated_class_template = '''
"""
Autogenerated file
DO NOT EDIT.
CONTENT WILL BE OVERWRITTEN!

WARNING: Do not import this file directly!
"""
import typing

from dash_component_system import (
    DashComponent, UNDEFINED, Undefined, ComponentProp
)


class {component_name}(DashComponent):
    """
{description}
    """
    _namespace = '{namespace}'
    _typename = '{typename}'
    available_wildcard_properties = {wildcards}
    {properties}

    def __init__(
        self,
{init_props}
        **kwargs
    ):
        {init_types}
        """
{props_docstring}
        """
        kws = {kws}
        kws.update(kwargs)
        DashComponent.__init__(self, **kws)
'''.lstrip()  # Remove the first newline

_init_padding = '        '

# mypy typing syntax + disable line too long
_init_types_template = '# type: ({}) -> None # noqa: E501'


class _DefaultProp:
    def __init__(self, value):
        self.value = value


# noinspection PyProtectedMember
class ComponentProp(object):
    """
    Property descriptor for component props.
    """
    def __init__(self, prop_name, default=UNDEFINED, required=False):
        # Not using self.name
        # python>=3.6 get it automatically with __set_name__
        # while python 2 is stuck in limbo.
        self.prop_name = prop_name
        self.default = default
        self.required = required

    def __set__(self, instance, value):
        old_value = instance._prop_values.get(self.prop_name, UNDEFINED)

        if old_value is UNDEFINED \
                and self.default is not UNDEFINED \
                and value == self.default \
                and not instance._initialized:
            # Don't set the initial value if it's the default, handled by FE.
            return

        if isinstance(value, _DefaultProp):
            # Supplied props with default are wrapped.
            value = value.value

        instance._prop_values[self.prop_name] = value

    def __get__(self, instance, owner):
        value = instance._prop_values.get(self.prop_name, UNDEFINED)
        if value is not UNDEFINED:
            return value
        return _Hooks.get_default_prop(instance, self.prop_name)

    def __delete__(self, instance):
        del instance._prop_values[self.prop_name]


class _DashComponentMeta(abc.ABCMeta):

    def __new__(mcs, name, bases, attributes):
        module_name = attributes.get('__module__')
        namespace = module_name.split('.')[0]

        if module_name != 'builtins'\
                and name not in ('Component', 'DashComponent'):
            # Register the component namespace.
            _Hooks.registry.add(namespace)
        new_attributes = copy.copy(attributes)

        def wrap_init():
            original_init = attributes.get('__init__')

            @functools.wraps(original_init)
            def _init_wrap(self, *args, **kwargs):
                # Bonus issue https://github.com/plotly/dash/issues/268
                new_args = list(args)
                new_kwargs = dict(kwargs)

                if len(args) < len(self._required_props):
                    start_index = len(args)
                    missing = self._required_props[start_index:]

                    # Check if the missing arguments are in kwargs
                    # Since we wraps the init, this needs to be done manually.
                    for m in missing:
                        if m in new_kwargs:
                            kw = new_kwargs.pop(m)
                            new_args.append(kw)
                        else:
                            raise MissingPropError(
                                'Missing prop: {}'.format(m)
                            )

                    if len(new_args) < len(self._required_props):
                        raise MissingPropError(
                            'Missing props : {}'.format(
                                self._required_props[len(new_args):]
                            )
                        )

                for d in self._default_props:
                    if d in kwargs:
                        new_kwargs[d] = _DefaultProp(new_kwargs[d])

                original_init(self, *new_args, **new_kwargs)
            return _init_wrap

        if name != 'DashComponent':
            # Only wraps the init of subclasses
            new_attributes['__init__'] = wrap_init()

        # Meta attributes
        prop_names = []
        required_props = []
        default_props = []

        for prop in (
                x for x in attributes.values() if isinstance(x, ComponentProp)
        ):
            prop_names.append(prop.prop_name)
            if prop.required:
                required_props.append(prop.prop_name)
            if prop.default is not UNDEFINED:
                default_props.append(prop.prop_name)

        new_attributes['_prop_names'] = prop_names
        new_attributes['available_properties'] = \
            prop_names + attributes.get('available_wildcard_properties', [])
        new_attributes['_required_props'] = required_props
        new_attributes['_default_props'] = default_props

        # Qualified name is import name ie: dash_table.DataTable
        # and not the full module name to the component:
        # dash_table.data_table.Datable
        new_attributes['_qualified_name'] = '{}.{}'.format(namespace, name)

        # Create the new class.
        component = type.__new__(mcs, name, bases, new_attributes)
        return component


@six.add_metaclass(_DashComponentMeta)
class DashComponent(MutableMapping):
    """
    Dash base component.
    """
    _prop_names = []
    _namespace = ''
    _required_props = []
    _typename = ''
    _qualified_name = ''

    _default_props = []
    available_properties = []
    available_wildcard_properties = []

    def __init__(self, **kwargs):
        self._prop_values = {}
        self._initialized = False
        self._valid_wildcard_attributes = []
        for k, v in kwargs.items():
            if k in self._prop_names and v is not UNDEFINED:
                setattr(self, k, v)
            elif any(
                    k.startswith(x)
                    for x in self.available_wildcard_properties):
                self._prop_values[k] = v
                self._valid_wildcard_attributes.append(k)

        self._initialized = True

    def to_plotly_json(self):
        # Use getattr so it goes into the descriptor __get__ for default value
        props = {
            k: v for k, v in
            ((x, getattr(self, x, UNDEFINED)) for x in self._prop_names)
            if v is not UNDEFINED
        }
        for wildcard in self._valid_wildcard_attributes:
            props[wildcard] = self._prop_values[wildcard]
        as_json = {
            'props': props,
            'type': self._typename,
            'namespace': self._namespace
        }
        return as_json

    def traverse(self):
        """Yield each item in the tree."""
        for t in self.traverse_with_paths():
            yield t[1]

    def traverse_with_paths(self):
        """Yield each item with its path in the tree."""
        children = getattr(self, 'children', None)
        children_type = type(children).__name__
        children_id = "(id={:s})".format(children.id) \
            if getattr(children, 'id', UNDEFINED) is not UNDEFINED else ''
        children_string = children_type + ' ' + children_id

        # children is just a component
        if _is_component(children):
            yield "[*] " + children_string, children
            for p, t in children.traverse_with_paths():
                yield "\n".join(["[*] " + children_string, p]), t

        # children is a list of components
        elif isinstance(children, (tuple, collections.MutableSequence)):
            for idx, i in enumerate(children):
                list_path = "[{:d}] {:s} {}".format(
                    idx,
                    type(i).__name__,
                    "(id={:s})".format(i.id)
                    if getattr(i, 'id', UNDEFINED) is not UNDEFINED else ''
                )
                yield list_path, i

                if _is_component(i):
                    for p, t in i.traverse_with_paths():
                        yield "\n".join([list_path, p]), t

    def __repr__(self):
        props_with_values = [
            c for c in self._prop_names
            if getattr(self, c, UNDEFINED) is not UNDEFINED
        ] + [
            c for c in self.__dict__
            if any(
                c.startswith(wc_attr)
                for wc_attr in self._valid_wildcard_attributes
            )
        ]
        if any(
                p != 'children'
                for p in props_with_values
        ):
            props_string = ", ".join(
                '{prop}={value}'.format(
                    prop=p,
                    value=repr(getattr(self, p))
                ) for p in props_with_values
            )
        else:
            props_string = repr(getattr(self, 'children', None))
        return "{type}({props_string})".format(
            type=self._typename,
            props_string=props_string
        )

    @property
    def component_path(self):
        component_id = getattr(self, 'id', None)
        return '{}#{}'.format(self._qualified_name, component_id)\
            if component_id else self._qualified_name

    def __setitem__(self, k, v):
        for c in self.traverse():
            if getattr(c, 'id', None) == k:
                c.children = v
                return

    def __delitem__(self, v):
        pass

    def __getitem__(self, k):
        for c in self.traverse():
            if getattr(c, 'id', None) == k:
                return c

    def __len__(self):
        # Mimic the base_component, not sure this is/was right
        length = 0
        if 'children' not in self._prop_names:
            return 0
        elif _is_component(self.children):
            length = 1
            length += len(self.children)
        elif isinstance(self.children, (tuple, collections.MutableSequence)):
            for c in self.children:
                length += 1
                if _is_component(c):
                    length += len(c)
        else:
            # string or number
            length = 1
        return length

    def __iter__(self):
        for t in self.traverse():
            if hasattr(t, 'id'):
                yield t.id


def generate_component(name, props, description, project_shortname):
    """
    Generate a python dash component class file.

    :param name: Name of the component. `displayName` in the metadata.
    :param props: Props of the component. `props` in the metadata.
    :param description: Raw description from `description` in the metadata.
    :param project_shortname: The project namespace where the components files
        will be generated.
    :return:
    """
    base_filename = stringcase.snakecase(name)
    filtered_props = collections.OrderedDict({
        k: v for k, v in props.items()
        if k not in FILTERED_PROPS
        and not k.endswith('*')
        and k not in _kwlist
        and v.get('type', {}).get('name') not in FILTERED_PROP_TYPES
    })
    props_docs = {
        k: v.get('description', '') for k, v in filtered_props.items()
    }

    required_props = [
        k for k, v in filtered_props.items() if v.get('required')
    ]

    wildcards = '[\n' + ',\n'.join(
        "        '{}'".format(x.rstrip('*'))
        for x in props.keys() if x.endswith('*')
    ) + '\n    ]'

    args = []
    kwargs = []
    kwargs_keys = []
    default_children = 'UNDEFINED'
    init_types = {}
    properties = []

    # Generate init required args and kwargs with default values
    for prop, prop_info in filtered_props.items():
        prop_default = prop_info.get('defaultValue')
        required = prop in required_props
        mapped_default = 'UNDEFINED'

        type_info = prop_info.get('type', {})
        init_types[prop] = _optional(PROP_TYPING.get(
            type_info.get('name'),
            _default_prop_type
        )(type_info), type_info)

        if not required:
            if not prop_default or prop_default.get('computed'):
                # computed default are set in the front end.
                mapped_default = 'UNDEFINED'
            else:
                mapped_default = DEFAULT_PROP_MAPPING.get(
                    type_info.get('name'),
                    json.dumps
                )(prop_default.get('value'))
                if prop == 'children':
                    default_children = mapped_default
            kwargs_keys.append(prop)
            kwargs.append('{}={}'.format(prop, mapped_default))
        else:
            args.append(prop)

        if type_info.get('name') == 'enum':
            props_docs[prop] += ' (Possible values: {})'.format(
                ', '.join(
                    x.get('value') for x in type_info.get('value')
                )
            )

        properties.append(
            "{0} = ComponentProp('{0}', {1}, {2})".format(
                prop, mapped_default, required
            )
        )

    if 'id' not in filtered_props:
        # id is needed on every component for callback registration.
        # Some component may set it as required.
        kwargs = ['id=UNDEFINED'] + kwargs

    # Reorder children to appear first if required
    if 'children' in args:
        args.remove('children')
        args = ['children'] + args

    # Children also first in kwargs, if it got a default takes it.
    if 'children' in kwargs_keys:
        kwargs = ['children={}'.format(default_children)]\
                 + [x for x in kwargs if 'children' not in x]

    init_props = '\n'.join(
        '{}{},'.format(_init_padding, x) for x in itertools.chain(args, kwargs)
    )

    init_typing = _init_types_template.format(
        ', '.join(
            [
                init_types[x]
                for x in itertools.chain(args,
                                         [x.split('=')[0] for x in kwargs])
            ] + ['typing.Any']  # kwargs
        )
    )

    props_docstring = ''.join(
        '{}\n'.format(
            ' ' * 8 + ':param {}: {}'.format(
                prop,
                '\n            '.join(
                    textwrap.fill(
                        doc.replace('\r', '').replace('\n', ' '),
                        58 - len(prop)
                    ).split('\n')
                )
            ).rstrip(),
        )
        for prop, doc in props_docs.items()
    ).rstrip()

    klass_string = _generated_class_template.format(
        component_name=name,
        description='\n'.join(
            '    {}'.format(x)
            for x in
            # Make sure the description has less than 79 char lines.
            textwrap.fill(description.replace('\r', ''), 74).split('\n')
        ),
        namespace=project_shortname,
        props_docstring=props_docstring,
        props=', '.join(filtered_props.keys()),
        init_props=init_props,
        typename=name,
        init_types=init_typing,
        properties='\n    '.join(properties),
        wildcards=wildcards,
        kws="{\n            "
            "k: v for k, v in locals().items()"
            " if k not in ('self', 'kwargs')\n        }"
    ).replace('\r', '')

    filename = os.path.join(
        project_shortname, '_{}.py'.format(base_filename)
    )

    # Write the generated component class file.
    with open(filename, 'w') as f:
        f.write(klass_string)

    print('Generated {}'.format(filename), file=sys.stderr)
