from functools import partial, wraps
from itertools import chain

from save_to_db.adapters.utils import adapter_manager
from save_to_db.adapters.utils.relation_type import RelationType

from save_to_db.exceptions import (InvalidFieldName,
                                   RelatedItemNotFound,
                                   MultipleRelatedItemsFound,
                                   ItemAdapterNotFound,
                                   NonExistentRelationDefined,
                                   NonExistentFieldUsed,
                                   FieldsFromRelationNotAllowed,
                                   NorewriteRelationException,
                                   NorewriteKeyUsedTwice,
                                   DeleterSelectorsOrKeepersEmpty,
                                   DeleterXToManyRelationUsed,
                                   UnrefNonXToManyFieldUsed)
from .item_cls_manager import item_cls_manager
from .model_deleter import ModelDeleter


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['collection_id'] == None:
                item_cls_manager.add(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_configuration():
        return {
            'batch_size': None,
            'metadata': {
                'collection_id': None,
                'setup_completed': False,
                'autogenerated_item_cls': False,
                'model_deleter': None,
                'model_unrefs': {},
            },
            'model_cls': None,
            'defaults': {},
            'creators': None,
            'creators_autoconfig': None,
            'autoinject_creators': True,
            'getters': None,
            'getters_autoconfig': None,
            'nullables': set(),
            'remove_null_fields': None,
            'remove_null_fields_autoconfig': None,
            '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_formats': ('%Y-%m-%d',),
                'time_formats': ('%H:%M:%S',),
                'datetime_formats': ('%Y-%m-%d %H:%M:%S',),
            },
            'allow_multi_update': False,
            'allow_merge_items': False,
            'aliases': {},
            'update_only_mode': False,
            'get_only_mode': False,
            'norewrite_fields': {},
            'fast_insert': False,
            
            'deleter_selectors': None,
            'deleter_keepers': None,
            'deleter_execute_on_persist': False,
            
            'unref_x_to_many': {},
        }
    
    @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):
        #--- 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 metadata -----------------------------------------------
        for key, value in default_configuration['metadata'].items():
            if key not in item_cls.metadata:
                item_cls.metadata[key] = value
        
        #--- correcting configuration structure --------------------------------
        
        # conversions
        conversions = default_configuration['conversions']
        conversions.update(item_cls.conversions)
        
        # fixing conversions for date and time
        for key in ('date_formats', 'time_formats', 'datetime_formats',):
            if isinstance(conversions[key], str):
                conversions[key] = (conversions[key],)
        
        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)
        
        # remove_null_fields
        if item_cls.remove_null_fields is not None and \
                not isinstance(item_cls.remove_null_fields, set):
            item_cls.remove_null_fields = set(item_cls.remove_null_fields)
        
        # 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)
        
        # unref_x_to_many
        unref_x_to_many = item_cls.unref_x_to_many
        for key in unref_x_to_many:
            if isinstance(unref_x_to_many[key], (list, set, tuple)):
                unref_x_to_many[key] = {
                    'selectors': unref_x_to_many[key],
                }
            if 'keepers' not in unref_x_to_many[key]:
                unref_x_to_many[key]['keepers'] = None
        
        #--- 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
        
        #--- 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:
            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 = {}
        for fname, ftype in adapter_cls.iter_fields(model_cls):
            if '__' in fname:
                raise InvalidFieldName(fname)
            item_cls.fields[fname] = ftype
        
        # 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, collection_id=item_cls.get_collection_id())
            # 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, 'relations', 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)
        
        # norewrite_fields -----------------------------------------------------
        def fix_norewrite_field(item_cls, key, value):
            if key not in item_cls.norewrite_fields:
                item_cls.norewrite_fields[key] = value
            
            if key in item_cls.relations:
                other_item_cls = item_cls.relations[key]['item_cls']
                reverse_key = item_cls.relations[key]['reverse_key']
                if reverse_key:
                    if reverse_key not in other_item_cls.norewrite_fields:
                        other_item_cls.norewrite_fields[reverse_key] = value
                    elif not (value is
                              other_item_cls.norewrite_fields[reverse_key]):
                        raise NorewriteRelationException(
                            'norewrite_fields for relations must be the same '
                            'on both sides')
        
        # untuple norewrite fields
        def untuple(tupled_value):
            for key_in_tuple in tupled_value:
                if isinstance(key_in_tuple, tuple):
                    yield from untuple(key_in_tuple)
                else:
                    yield key_in_tuple
            
        for key in tuple(item_cls.norewrite_fields):
            if isinstance(key, tuple):
                norewrite_value = item_cls.norewrite_fields[key]
                del item_cls.norewrite_fields[key]
                for key_in_tuple in untuple(key):
                    if key_in_tuple in item_cls.norewrite_fields:
                        raise NorewriteKeyUsedTwice(key_in_tuple)
                    item_cls.norewrite_fields[key_in_tuple] = norewrite_value
        
        # regular fileds
        norewrite_selectors = {}
        for key in tuple(item_cls.norewrite_fields):
            norewrite_value = item_cls.norewrite_fields[key]
            if isinstance(key, (bool, RelationType)):  # selector field
                norewrite_selectors[key] = norewrite_value
                continue
            fix_norewrite_field(item_cls, key, norewrite_value)
        
        # selector fields
        selector_keys = list(norewrite_selectors.keys())
        type_to_key = {
            RelationType: 0,
            bool: 1,
        }
        selector_keys.sort(key=lambda t: type_to_key[type(t)])
        for selector in selector_keys:
            norewrite_value = item_cls.norewrite_fields[selector]
            del item_cls.norewrite_fields[selector]
            
            if isinstance(selector, RelationType):
                for key, relation in item_cls.relations.items():
                    if key in item_cls.norewrite_fields:
                        continue
                    if relation['relation_type'] is selector:
                        fix_norewrite_field(item_cls, key, norewrite_value)
                continue
            
            # boolean is the last in the list, it'll set all fields, including
            # relations
            if isinstance(selector, bool):
                for key in chain(item_cls.fields, item_cls.relations):
                    if key in item_cls.norewrite_fields:
                        continue
                    fix_norewrite_field(item_cls, key, norewrite_value)
                continue
        
        # creators, getters, nullables, and other field validation -------------
        def basic_field_validation(item_cls, field_names, groups_key):
            for field_name in field_names:
                
                if '__' in field_name:
                    raise FieldsFromRelationNotAllowed(item_cls, groups_key,
                                                      field_name)
                
                # 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)
                
                # Default relations
                if groups_key == 'defaults' and \
                        field_name in item_cls.relations:
                    default_value = item_cls.defaults[field_name]
                    if not isinstance(default_value, list):
                        item_cls.defaults[field_name] = default_value
                    else:
                        bulk_item_cls = \
                            item_cls.relations[field_name]['item_cls']
                        default_bulk = bulk_item_cls.Bulk()
                        default_bulk.add(*default_value)
                        item_cls.defaults[field_name] = default_bulk
                            
        
        for groups_key in ('defaults', 'getters', 'creators', 'nullables',
                           'remove_null_fields',
                           'norewrite_fields',
                           'deleter_selectors', 'deleter_keepers',
                           'unref_x_to_many',):
            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]
            elif groups_key == 'defaults':  # defaults is a dictionary
                groups = [set(groups)]
            elif groups_key == 'norewrite_fields':
                groups = [set(groups)]
            elif groups_key == 'remove_null_fields':
                groups = [set(groups)]
            elif groups_key in ['deleter_selectors',
                                'deleter_keepers']:
                groups = [set(groups)]
            elif groups_key == 'unref_x_to_many':
                groups = [set(groups)]
                
            basic_field_validation(item_cls, chain(*groups), groups_key)
        
        # 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:
                if not item_cls.creators:
                    # now `item_cls.creators` cannot be empty
                    item_cls.creators.append(not_nulls)
                    
                if item_cls.autoinject_creators:
                    for index, value in enumerate(item_cls.creators):
                        item_cls.creators[index] = value.union(
                            not_nulls)
                elif 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:
                unique_set = set(unique_set)
                if unique_set not in item_cls.getters:
                    item_cls.getters.append(unique_set)
        
        # remove_null_fields automatic configuration ---------------------------
        if item_cls.remove_null_fields_autoconfig is True or \
                (item_cls.remove_null_fields is None and
                 item_cls.remove_null_fields_autoconfig is None):
            item_cls.remove_null_fields = item_cls.remove_null_fields or set()
            not_nulls = set(adapter_cls.iter_required_fields(model_cls))
            item_cls.remove_null_fields = \
                item_cls.remove_null_fields.union(not_nulls)
        else:
            item_cls.remove_null_fields = item_cls.remove_null_fields or set()
        
        # completing setup for related classes ---------------------------------
        item_cls.metadata['setup_completed'] = True
        for relation in item_cls.relations.values():
            relation['item_cls'].complete_setup()
        item_cls.metadata['setup_completed'] = False
        
        # setting up model deleters --------------------------------------------
        # validating `unref_x_to_many`
        for fkey in item_cls.unref_x_to_many:
            if fkey not in item_cls.relations or \
                    item_cls.relations[fkey]['relation_type'].is_x_to_one():
                raise UnrefNonXToManyFieldUsed(fkey)
            
            other_cls = item_cls.relations[fkey]['item_cls']
            fields = list(chain(
                item_cls.unref_x_to_many[fkey].get('selectors') or [],
                item_cls.unref_x_to_many[fkey].get('keepers') or []))
            
            basic_field_validation(other_cls, fields, None)
            
        
        # generating model deleters
        def create_model_deleter(item_cls, selectors, keepers):
            model_cls = item_cls.model_cls
            
            if keepers is None:
                keepers = set(adapter_cls.get_primary_key_names(model_cls))
            
            if not selectors or not keepers:
                raise DeleterSelectorsOrKeepersEmpty(
                    'Selectors and keepers cannot be empty')
            
            for field_name in chain(selectors, keepers):
                if field_name in item_cls.relations:
                    relation = item_cls.relations[field_name]
                    if relation['relation_type'].is_x_to_many():
                        raise DeleterXToManyRelationUsed(field_name)
                    
            return ModelDeleter(model_cls, selectors, keepers)
        
        # model_deleter 
        if item_cls.deleter_selectors is not None:
            item_cls.metadata['model_deleter'] = create_model_deleter(
                item_cls,
                item_cls.deleter_selectors,
                item_cls.deleter_keepers)
        
        # model_unrefs
        for fkey in item_cls.unref_x_to_many:
            other_cls = item_cls.relations[fkey]['item_cls']
            item_cls.metadata['model_unrefs'][fkey] = create_model_deleter(
                other_cls,
                item_cls.unref_x_to_many[fkey]['selectors'],
                item_cls.unref_x_to_many[fkey]['keepers'])
        
        # testing aliases ------------------------------------------------------
        item_cls.metadata['setup_completed'] = True
        try:
            for alias in item_cls.aliases.values():
                item_cls._get_real_keys(alias)
        except:
            item_cls.metadata['setup_completed'] = False
            raise

        item_cls.metadata['setup_completed'] = True