import inspect
from abc import ABC
from dataclasses import MISSING, Field, field
from functools import reduce
from sys import version_info
from types import MappingProxyType
from typing import Any, Dict, Type

from typeguard import check_type as typeguard_check_type


def check_type(value: Any, type: Type) -> bool:
    if version_info.major <= 3 and version_info.minor < 10:
        try:
            typeguard_check_type('', value, type)
            return True
        except TypeError:
            return False
    else:
        return isinstance(value, type)


def _get_flexyclass_mro(cls: type, attr_name: str) -> Dict[str, Type]:
    def reduce_fn(accumulator: Dict[str, Type],
                  mro_elem: type) -> Dict[str, Type]:
        if isinstance(mro_elem, dict):
            mro_attrs = getattr(mro_elem, f'__flexyclass_{attr_name}__', {})
        else:
            mro_attrs = getattr(mro_elem, f'__{attr_name}__', {})

        if not isinstance(mro_attrs, (dict, MappingProxyType)):
            raise TypeError(f'__{attr_name}__ must be a dict, not '
                            f'{type(mro_attrs)}')

        return {**accumulator, **mro_attrs}

    all_attr_di = reduce(reduce_fn, cls.mro()[::-1], {})
    return {k: v for k, v in sorted(all_attr_di.items())}


def _recursively_cast_flexyclass_to_simple_dict(obj: Any) -> dict:
    if isinstance(type(obj), FlexyClassMeta):
        obj = dict(obj)

    if isinstance(obj, dict):
        return {k: _recursively_cast_flexyclass_to_simple_dict(v)
                for k, v in obj.items()}
    else:
        return obj


def _dict_init_with_type_check(flex_dict: 'FlexyClass', *args, **kwargs):
    flex_dict.update(dict(*args, **kwargs))

    # Validate that all keys are in the fixed keys list
    for key, typ in flex_dict.__fixed_annotations__.items():

        if key not in flex_dict:
            if key in flex_dict.__fixed_dict__:
                flex_dict[key] = _get_value_from_field(
                    flex_dict.__fixed_dict__[key])
            else:
                raise ValueError(f'Missing key {key}')

        value = _get_value_from_field(flex_dict[key])

        if isinstance(type(value), FlexyClassMeta):
            # if we have nested flexyclasses, we must cast them to
            # simple dictionaries first before merging.
            value = _recursively_cast_flexyclass_to_simple_dict(value)
            flex_dict[key] = value
            typ = dict

        if not check_type(value, typ):
            raise ValueError(f'Key {key} must be of type {typ}, '
                             f'not {type(flex_dict[key])}')


def _get_value_from_field(field: Any) -> Any:
    if not isinstance(field, Field):
        return field
    if field.default is not MISSING:
        return field.default
    if field.default_factory is not MISSING:
        return field.default_factory()
    else:
        raise ValueError(f'No value generated by Field {field}')


class FlexyClassMeta(type):
    def __new__(cls, name, annotations, defaults) -> dict:
        ns = {'__init__': _dict_init_with_type_check,
              '__fixed_annotations__': annotations,
              '__fixed_dict__': defaults}

        tp_dict: dict = type.__new__(
            FlexyClassMeta,             # type: ignore
            f'FlexyClass{name}',
            (dict,),
            ns
        )
        return tp_dict

    def __init__(self, *args, **kwargs): ...

    def __subclasscheck__(cls, other):
        return dict.__subclasscheck__(other)

    __instancecheck__ = __subclasscheck__


class FlexyClass(dict, ABC):
    __fixed_annotations__: Dict[str, Type]
    __fixed_dict__: Dict[str, Any]


def flexyclass(cls: type) -> Type[dict]:
    """A flexyclass is like a dataclass, but it supports partial
    specification of properties."""

    # using _get_flexycls_mro to get the annotations and defaults
    # allows to support inheritance of flexyclasses

    if not inspect.isclass(cls):
        raise TypeError(f'flexyclass must decorate a class, not {cls}')

    annotations = _get_flexyclass_mro(cls, 'annotations')
    defaults = {k: v for k, v in _get_flexyclass_mro(cls, 'dict').items()
                if k in annotations}

    new_cls = FlexyClassMeta(cls.__name__, annotations, defaults)

    if not issubclass(new_cls, dict):
        raise RuntimeError(
            f'Internal Error: something went wrong  when decorating {cls}'
        )
    return new_cls


def flexyfactory(
    flexy_cls: Type[Dict[str, Any]],
    **kwargs: Any
) -> Dict[str, Any]:
    def factory_fn() -> Dict[str, Any]:
        return flexy_cls(**kwargs)
    return field(default_factory=factory_fn)
