from functools import partial, wraps
from itertools import chain

from save_to_db.adapters.utils import adapter_manager

from .exceptions import (InvalidFieldName,
                         DefaultRelationDefined,
                         RelatedItemNotFound,
                         MultipleRelatedItemsFound,
                         ItemAdapterNotFound,
                         NonExistentRelationDefined,
                         NonExistentFieldUsed,
                         WrongAlias)
from .item_cls_manager import item_cls_manager



class ItemMetaclass(type):
    """ A class that is used as a metaclass for :py:class:`~.item.Item`.
    The :py:class:`~.item.Item` class has a configuration of
    how collected data must be transformed and persisted into a database.
    This metaclass processes and corrects item configuration.
    """
    
    def __new__(cls, name, bases, dct, no_setup=False):
        item_cls = super().__new__(cls, name, bases, dct)
        
        # `no_setup` variable is only used to prevent setting up the
        # :py:class:`~.item.Item` class that must be only used as base class.
        if not no_setup:
            cls.process_configuration(item_cls)
            if item_cls.metadata['scope_id'] == 0:
                item_cls_manager.register(item_cls)
                
        return item_cls
    
    
    # this is needed for Python versions less then 3.6
    def __init__(cls, name, bases, dct, **_):
        return super().__init__(name, bases, dct)
    
    
    @staticmethod
    def get_default_metadata():
        return {
            'scope_id': 0,
            'setup_completed': False,
            'autogenerated_item_cls': False,
        }
    
    @staticmethod
    def get_default_configuration():
        return {
            'model_cls': None,
            'defaults': {},
            'creators': None,
            'creators_autoconfig': None,
            'getters': None,
            'getters_autoconfig': None,
            'nullables': set(),
            'fields': {},
            'relations': {},
            'conversions': {
                # boolean
                'boolean_true_strings': ('true', 'yes', 'on', '1', '+'),
                'boolean_false_strings': ('false', 'no', 'off', '0', '-'),
                # integer and float
                'decimal_separator': '.',
                # date and time
                'date_format': '%Y-%m-%d',
                'time_format': '%H:%M:%S',
                'datetime_format': '%Y-%m-%d %H:%M:%S',
            },
            'allow_multi_update': False,
            'allow_merge_items': False,
            'aliases': {},
        }
    
    @staticmethod
    def get_default_relation_attrs():
        return {
            'item_cls': None,
            'relation_type': None,
            'replace_x_to_many': False,
            'reverse_key': None,
        }
    
    @classmethod
    def process_configuration(cls, item_cls):
        #--- creating new `metadata` ---
        metadata = cls.get_default_metadata()
        if hasattr(item_cls, 'metadata'):
            metadata.update(item_cls.metadata)
            metadata['setup_completed'] = False
        item_cls.metadata = metadata
        
        #--- making sure all configuration variables are present ---
        default_configuration = cls.get_default_configuration()
        for attr_name, default_value in default_configuration.items():
            if not hasattr(item_cls, attr_name):
                setattr(item_cls, attr_name, default_value)
        
        #--- correcting configuration structure --------------------------------
        
        # conversions
        conversions = default_configuration['conversions']
        conversions.update(item_cls.conversions)
        item_cls.conversions = conversions
        
        # getters and creators 
        for groups_key in ('getters', 'creators'):
            groups = getattr(item_cls, groups_key)
            if groups is None:
                continue  # to be autoconfigured
            for i in range(len(groups)):
                if isinstance(groups[i], str):
                    groups[i] = {groups[i]}
                if not isinstance(groups[i], set):
                    groups[i] = set(groups[i])
        
        # nullables
        if not isinstance(item_cls.nullables, set):
            item_cls.nullables = set(item_cls.nullables)
        
        # relations
        relations = item_cls.relations
        default_relation_attrs = cls.get_default_relation_attrs()
        for key in relations:
            if not isinstance(relations[key], dict):
                relations[key] = {
                    'item_cls': relations[key],
                }
            
            for k, v in default_relation_attrs.items():
                relations[key].setdefault(k, v)
        
        #--- complete_setup ----------------------------------------------------
        wrap = wraps(cls.complete_setup)
        item_cls.complete_setup = wrap(partial(cls.complete_setup, item_cls))
            
    
    @classmethod
    def complete_setup(cls, item_cls):
        """ This method validates manual configuration of an
        :py:class:`~.item.Item` and automatically completes configuration based
        on available data.
        
        .. note::
            All :py:class:`~.item.Item` classes get this method wrapped in
            :py:class:`functools.partial` that already passes the `item_cls`
            value.
        """
        if item_cls.metadata['setup_completed']:
            return
        
        def validate_field_names():
            to_chain = []
            to_chain.append(item_cls.defaults)
            if item_cls.nullables:
                to_chain.append(item_cls.nullables)
            if item_cls.creators:
                for group in item_cls.creators:
                    to_chain.append(group)
            if item_cls.getters:
                for group in item_cls.getters:
                    to_chain.append(group)
            to_chain.append(item_cls.relations.keys())
            to_chain.append(item_cls.fields.keys())
            
            for field_name in chain(*to_chain):
                if '__' in field_name:
                    raise InvalidFieldName(field_name)
            
        
        #--- relation paths ----------------------------------------------------
        for relation in item_cls.relations.values():
            if isinstance(relation['item_cls'], str):
                # first looking in the item's module
                item_cls_list = item_cls_manager.get_by_path(
                    relation['item_cls'],
                    relative_to=item_cls.__module__)
                # if not found, looking everywhere
                if not item_cls_list:
                    item_cls_list = item_cls_manager.get_by_path(
                        relation['item_cls'])
                    
                # exceptions
                if not item_cls_list:
                    raise RelatedItemNotFound(relation['item_cls'])
                if len(item_cls_list) != 1:
                    raise MultipleRelatedItemsFound(relation['item_cls'])
                relation['item_cls'] = item_cls_list[0]
        
        model_cls = item_cls.model_cls
        if model_cls is None:
            validate_field_names()
            item_cls.metadata['setup_completed'] = True
            return  # no model_cls to fully complete setup
        
        #--- setup with adapter usage ------------------------------------------
        adapter_cls = adapter_manager.get_adapter_cls(model_cls)
        if not adapter_cls:
            raise ItemAdapterNotFound(item_cls)
        
        # fields
        item_cls.fields = {
            fname: ftype for fname, ftype in adapter_cls.iter_fields(model_cls)
        }
        
        # relations
        original_relation_keys_to_copy = {'replace_x_to_many',}
        
        relations = {}
        for key, other_model_cls, relation_type, reverse_key in \
                adapter_cls.iter_relations(model_cls):
            other_item_cls_list = item_cls_manager.get_by_model_cls(
                other_model_cls)
            # exceptions
            if not other_item_cls_list:
                raise RelatedItemNotFound(model_cls, key, other_model_cls)
            if len(other_item_cls_list) != 1:
                raise MultipleRelatedItemsFound(model_cls,
                                                other_item_cls_list)
            
            relations[key] = cls.get_default_relation_attrs()
            relations[key].update({
                'item_cls': other_item_cls_list[0],
                'relation_type': relation_type,
                'reverse_key': reverse_key,
            })
        
        for key in item_cls.relations.keys():
            if key not in relations:
                raise NonExistentRelationDefined(item_cls, key)
            for subkey in original_relation_keys_to_copy:
                if subkey in item_cls.relations[key]:
                    relations[key][subkey] = \
                        item_cls.relations[key][subkey]
        
        # (to keep reference to the same dictionary)
        item_cls.relations.clear()
        item_cls.relations.update(relations)
        
        # creators, getters and nullables validation
        for groups_key in ('defaults', 'getters', 'creators', 'nullables'):
            groups = getattr(item_cls, groups_key)
            if groups is None:
                continue  # to be autoconfigured
            
            if groups_key == 'nullables':  # nullables is a single group
                groups = [groups]
            if groups_key == 'defaults':  # defaults is a dictionary
                groups = [set(groups)]
            
            for i in range(len(groups)):
                group = groups[i] 
                for field_name in group:
                    
                    # NonExistentFieldUsed
                    if field_name not in item_cls.fields and \
                            field_name not in item_cls.relations:
                        raise NonExistentFieldUsed(item_cls, groups_key,
                                                   field_name)
                    
                    # DefaultRelationDefined
                    if groups_key == 'defaults' and \
                            field_name in item_cls.relations:
                        raise DefaultRelationDefined(field_name)
        
        # creators and getters automatic configuration
        if item_cls.creators_autoconfig is True or \
                (item_cls.creators_autoconfig is None and
                 item_cls.creators is None):
            item_cls.creators = item_cls.creators or []
            not_nulls = set(adapter_cls.iter_required_fields(model_cls))
            if not_nulls and not_nulls not in item_cls.creators:
                item_cls.creators.append(not_nulls)
            
        if item_cls.getters_autoconfig is True or \
                (item_cls.getters_autoconfig is None and
                 item_cls.getters is None):
            item_cls.getters = item_cls.getters or []
            uniques = adapter_cls.iter_unique_field_combinations(model_cls)
            for unique_set in uniques:
                if unique_set not in item_cls.getters:
                    item_cls.getters.append(unique_set)
        
        validate_field_names()
        
        item_cls.metadata['setup_completed'] = True
        # testing aliases
        for alias in item_cls.aliases.values():
            try:
                item_cls._get_real_keys(alias)
            except WrongAlias:
                item_cls.metadata['setup_completed'] = False
                raise
            