from collections import defaultdict
from typing import (
    Any,
    Dict,
    List,
    Type,
)

from tri_declarative import (
    dispatch,
    EMPTY,
    flatten,
    get_shortcuts_by_name,
    getattr_path,
    Namespace,
    RefinableObject,
)

from iommi.base import (
    items,
    keys,
)
from iommi.reinvokable import is_reinvokable
from ._web_compat import settings

DEFAULT_STYLE = 'bootstrap'


def get_iommi_style_name(obj: Any) -> str:
    while obj is not None:
        if obj.iommi_style is not None:
            return obj.iommi_style
        obj = obj.iommi_parent()
    return getattr(settings, 'IOMMI_DEFAULT_STYLE', DEFAULT_STYLE)


def apply_style(style_name: str, obj: Any, is_root) -> Any:
    style_data = get_style_data_for_object(style_name=style_name, obj=obj, is_root=is_root)
    return apply_style_data(style_data, obj)


def apply_style_data(style_data: Namespace, obj: Any) -> Any:
    if not style_data:
        return obj
    if not is_reinvokable(obj):
        print(f'Missing out of {style_data} for {type(obj)}')
        return obj
    return reinvoke_new_defaults(obj, style_data)


def _style_name_for_class(cls):
    return cls.__name__.rpartition('.')[-1]  # this converts iommi.form.Form to just Form


def class_names_for(cls):
    from iommi import Part
    from iommi.traversable import Traversable

    for base_class in reversed(cls.mro()):
        if base_class in (object, Part, RefinableObject, Traversable):
            continue
        yield _style_name_for_class(base_class)


def recursive_namespace(d):
    if isinstance(d, dict):
        return Namespace({k: recursive_namespace(v) for k, v in items(d)})
    else:
        return d


class Style:
    @dispatch(
        assets=EMPTY,
    )
    def __init__(self, *bases, base_template=None, content_block=None, assets=None, **kwargs):
        self.name = None

        self.base_template = base_template
        if not self.base_template:
            for base in reversed(bases):
                if base.base_template:
                    self.base_template = base.base_template
                    break

        self.content_block = content_block
        if not self.content_block:
            for base in reversed(bases):
                if base.content_block:
                    self.content_block = base.content_block
                    break

        self.assets = {k: v for k, v in Namespace(*(base.assets for base in bases), assets).items() if v is not None}
        self.config = Namespace(*[x.config for x in bases], recursive_namespace(kwargs))

    def component(self, obj, is_root=False):
        """
        Calculate the namespace of additional argument that should be applied
        to the given object. If is_root is set to True, assets might also be
        added to the namespace.
        """
        result = Namespace()

        if self.assets and is_root:
            result.assets = self.assets

        # TODO: is this wrong? Should it take classes first, then loop through shortcuts?
        for class_name in class_names_for(type(obj)):
            if class_name in self.config:
                config = Namespace(self.config.get(class_name, {}))
                shortcuts_config = Namespace(config.pop('shortcuts', {}))
                result.update(config)

                for shortcut_name in reversed(getattr(obj, '__tri_declarative_shortcut_stack', [])):
                    result = Namespace(result, shortcuts_config.get(shortcut_name, {}))
        return result


_styles = {}


def register_style(name, conf):
    assert name not in _styles
    assert conf.name is None
    conf.name = name
    _styles[name] = conf


def unregister_style(name):
    assert name in _styles
    del _styles[name]


def get_style(name):
    try:
        return _styles[name]
    except KeyError:
        style_names = "\n    ".join(_styles.keys())
        raise Exception(f'''No registered style {name}. Register a style with register_style(). 

Available styles:
    {style_names}''') from None


def reinvoke_new_defaults(obj: Any, additional_kwargs: Dict[str, Any]) -> Any:
    assert is_reinvokable(obj), f'reinvoke_new_defaults() called on object with ' \
                                f'missing @reinvokable constructor decorator: {obj!r}'
    additional_kwargs_namespace = Namespace(additional_kwargs)

    kwargs = Namespace(additional_kwargs_namespace)
    for name, saved_param in items(obj._iommi_saved_params):
        try:
            new_param = getattr_path(additional_kwargs_namespace, name)
        except AttributeError:
            kwargs[name] = saved_param
        else:
            if is_reinvokable(saved_param):
                assert isinstance(new_param, dict)
                kwargs[name] = reinvoke_new_defaults(saved_param, new_param)
            else:
                if isinstance(saved_param, Namespace):
                    kwargs[name] = Namespace(new_param, saved_param)
                else:
                    kwargs[name] = saved_param

    additional_kwargs_namespace.pop('call_target', None)

    try:
        result = type(obj)(**kwargs)
    except TypeError as e:
        raise InvalidStyleConfigurationException(
            f'Object {obj!r} could not be updated with style configuration {flatten(additional_kwargs_namespace)}'
        ) from e

    result._name = obj._name
    __tri_declarative_shortcut_stack = getattr(obj, '__tri_declarative_shortcut_stack', None)
    if __tri_declarative_shortcut_stack is not None:
        setattr(result, '__tri_declarative_shortcut_stack', __tri_declarative_shortcut_stack)

    return result


def get_style_data_for_object(style_name, obj, is_root):
    return get_style(style_name).component(obj, is_root)


class InvalidStyleConfigurationException(Exception):
    pass


def validate_styles(*, additional_classes: List[Type] = None, default_classes=None, styles=None):
    """
    This function validates all registered styles against all standard
    classes. If you have more classes you need to have checked against,
    pass these as the `classes` argument.

    The `default_classes` parameter can be used to say which classes are
    checked for valid data. By default this is all the `Part`-derived
    classes in iommmi. This parameter is primarily used by tests.

    The `styles` parameter can be used to specify which exact styles to
    validate. By default it will validate all registered styles. This
    parameter is primarily used by tests.
    """
    if default_classes is None:
        from iommi import (
            Action,
            Column,
            Field,
            Form,
            Menu,
            MenuItem,
            Query,
            Table,
            Filter,
        )
        from iommi.table import Paginator
        from iommi.menu import (
            MenuBase,
            DebugMenu,
        )
        from iommi.error import Errors
        from iommi.action import Actions
        from iommi.admin import Admin
        from iommi.fragment import Container
        from iommi.fragment import Header
        default_classes = [
            Action,
            Actions,
            Column,
            DebugMenu,
            Errors,
            Field,
            Form,
            Menu,
            MenuBase,
            MenuItem,
            Paginator,
            Query,
            Table,
            Filter,
            Admin,
            Container,
            Header,
        ]
    if additional_classes is None:
        additional_classes = []

    classes = default_classes + additional_classes

    if styles is None:
        styles = _styles

    # We can have multiple classes called Field. In fact that's the recommended way to use iommi!
    classes_by_name = defaultdict(list)
    for cls in classes:
        for cls_name in class_names_for(cls):
            classes_by_name[cls_name].append(cls)

    # This will functionally merge separate trees of class inheritance. So it produces a list of all shortcuts on all classes called something.Field.
    shortcuts_available_by_class_name = defaultdict(set)
    for cls_name, classes in items(classes_by_name):
        for cls in classes:
            shortcuts_available_by_class_name[cls_name].update(get_shortcuts_by_name(cls).keys())

    invalid_class_names = []
    non_existent_shortcut_names = []
    for style_name, style in items(styles):
        for cls_name, config in items(style.config):
            # First validate the top level classes
            if cls_name not in classes_by_name:
                invalid_class_names.append((style_name, cls_name))
                continue

            # Then validate the shortcuts
            for shortcut_name in keys(config.get('shortcuts', {})):
                if shortcut_name not in shortcuts_available_by_class_name[cls_name]:
                    non_existent_shortcut_names.append((style_name, cls_name, shortcut_name))

    if invalid_class_names or non_existent_shortcut_names:
        invalid_class_names_str = '\n'.join(
            f'    Style: {style_name} - class: {cls_name}'
            for style_name, cls_name in invalid_class_names
        )
        if invalid_class_names_str:
            invalid_class_names_str = 'Invalid class names:\n' + invalid_class_names_str
        invalid_shortcut_names_str = '\n'.join(
            f'    Style: {style_name} - class: {cls_name} - shortcut: {shortcut_name}'
            for style_name, cls_name, shortcut_name in non_existent_shortcut_names
        )
        if invalid_shortcut_names_str:
            invalid_shortcut_names_str = 'Invalid shortcut names:\n' + invalid_shortcut_names_str
        raise InvalidStyleConfigurationException('\n\n'.join([invalid_class_names_str, invalid_shortcut_names_str]))
