import logging
import random
from datetime import datetime

import bson
import modularodm.exceptions
import pytz
from django.contrib.contenttypes.fields import (GenericForeignKey,
                                                GenericRelation)
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.exceptions import MultipleObjectsReturned
from django.db import models
from django.db.models import ForeignKey
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
from osf.exceptions import ValidationError
from osf.modm_compat import to_django_query
from osf.utils.datetime_aware_jsonfield import (DateTimeAwareJSONField,
                                                coerce_nonnaive_datetimes)
from osf.utils.fields import LowercaseCharField

ALPHABET = '23456789abcdefghjkmnpqrstuvwxyz'

logger = logging.getLogger(__name__)


def generate_guid(length=5):
    while True:
        guid_id = ''.join(random.sample(ALPHABET, length))

        try:
            # is the guid in the blacklist
            BlackListGuid.objects.get(guid=guid_id)
        except BlackListGuid.DoesNotExist:
            # it's not, check and see if it's already in the database
            try:
                Guid.objects.get(_id=guid_id)
            except Guid.DoesNotExist:
                # valid and unique guid
                return guid_id

def generate_object_id():
    return str(bson.ObjectId())

class MODMCompatibilityQuerySet(models.QuerySet):
    def __init__(self, model=None, query=None, using=None, hints=None):
        super(MODMCompatibilityQuerySet, self).__init__(model=model, query=query, using=using, hints=hints)
        if issubclass(self.model, (GuidMixin, OptionalGuidMixin)):
            self._prefetch_related_lookups = ['guids']

    def __getitem__(self, k):
        item = super(MODMCompatibilityQuerySet, self).__getitem__(k)
        if hasattr(item, 'wrapped'):
            return item.wrapped()
        else:
            return item

    def __iter__(self):
        items = super(MODMCompatibilityQuerySet, self).__iter__()
        for item in items:
            if hasattr(item, 'wrapped'):
                yield item.wrapped()
            else:
                yield item

    def sort(self, *fields):
        # Fields are passed in as e.g. [('title', 1), ('date_created', -1)]
        if isinstance(fields[0], list):
            fields = fields[0]

        def sort_key(item):
            if isinstance(item, basestring):
                return item
            elif isinstance(item, tuple):
                field_name, direction = item
                prefix = '-' if direction == -1 else ''
                return ''.join([prefix, field_name])

        sort_keys = [sort_key(each) for each in fields]
        return self.order_by(*sort_keys)

    def limit(self, n):
        return self[:n]


class BaseModel(models.Model):
    """Base model that acts makes subclasses mostly compatible with the
    modular-odm ``StoredObject`` interface.
    """

    migration_page_size = 20000

    objects = MODMCompatibilityQuerySet.as_manager()

    class Meta:
        abstract = True

    @classmethod
    def load(cls, data):
        try:
            if issubclass(cls, GuidMixin):
                return cls.objects.get(guids___id=data)
            elif issubclass(cls, ObjectIDMixin):
                return cls.objects.get(_id=data)
            elif isinstance(data, basestring):
                # Some models (CitationStyle) have an _id that is not a bson
                # Looking up things by pk will never work with a basestring
                return cls.objects.get(_id=data)
            return cls.objects.get(pk=data)
        except cls.DoesNotExist:
            return None

    @classmethod
    def find_one(cls, query):
        try:
            return cls.objects.get(to_django_query(query, model_cls=cls))
        except cls.DoesNotExist:
            raise modularodm.exceptions.NoResultsFound()
        except cls.MultipleObjectsReturned as e:
            raise modularodm.exceptions.MultipleResultsFound(*e.args)

    @classmethod
    def find(cls, query=None):
        if not query:
            return cls.objects.all()
        else:
            return cls.objects.filter(to_django_query(query, model_cls=cls))

    @classmethod
    def remove(cls, query=None):
        return cls.find(query).delete()

    @classmethod
    def remove_one(cls, obj):
        if obj.pk:
            return obj.delete()

    @classmethod
    def migrate_from_modm(cls, modm_obj):
        """
        Given a modm object, make a django object with the same local fields.

        This is a base method that may work for simple objects.
        It should be customized in the child class if it doesn't work.

        :param modm_obj:
        :return:
        """
        django_obj = cls()

        local_django_fields = set([x.name for x in django_obj._meta.get_fields() if not x.is_relation])

        intersecting_fields = set(modm_obj.to_storage().keys()).intersection(
            set(local_django_fields))

        for field in intersecting_fields:
            modm_value = getattr(modm_obj, field)
            if modm_value is None:
                continue
            if isinstance(modm_value, datetime):
                modm_value = pytz.utc.localize(modm_value)
            # TODO Remove this after migration
            if isinstance(django_obj._meta.get_field(field), DateTimeAwareJSONField):
                modm_value = coerce_nonnaive_datetimes(modm_value)
            setattr(django_obj, field, modm_value)

        return django_obj

    @property
    def _primary_name(self):
        return '_id'

    def reload(self):
        return self.refresh_from_db()

    def _natural_key(self):
        return self.pk

    def clone(self):
        """Create a new, unsaved copy of this object."""
        copy = self.__class__.objects.get(pk=self.pk)
        copy.id = None

        # empty all the fks
        fk_field_names = [f.name for f in self._meta.model._meta.get_fields() if isinstance(f, (ForeignKey, GenericForeignKey))]
        for field_name in fk_field_names:
            setattr(copy, field_name, None)

        try:
            copy._id = bson.ObjectId()
        except AttributeError:
            pass
        return copy

    def save(self, *args, **kwargs):
        # Make Django validate on save (like modm)
        if not kwargs.get('force_insert') and not kwargs.get('force_update'):
            try:
                self.full_clean()
            except DjangoValidationError as err:
                raise ValidationError(*err.args)
        return super(BaseModel, self).save(*args, **kwargs)


# TODO: Rename to Identifier?
class Guid(BaseModel):
    """Stores either a short guid or long object_id for any model that inherits from BaseIDMixin.
    Each ID field (e.g. 'guid', 'object_id') MUST have an accompanying method, named with
    'initialize_<ID type>' (e.g. 'initialize_guid') that generates and sets the field.
    """
    primary_identifier_name = '_id'
    # TODO DELETE ME POST MIGRATION
    modm_query = None
    migration_page_size = 500000
    # /TODO DELETE ME POST MIGRATION

    id = models.AutoField(primary_key=True)
    _id = LowercaseCharField(max_length=255, null=False, blank=False, default=generate_guid, db_index=True,
                           unique=True)
    referent = GenericForeignKey()
    content_type = models.ForeignKey(ContentType, null=True, blank=True)
    object_id = models.PositiveIntegerField(null=True, blank=True)
    created = models.DateTimeField(db_index=True, auto_now_add=True)

    # Override load in order to load by GUID
    @classmethod
    def load(cls, data):
        try:
            return cls.objects.get(_id=data)
        except cls.DoesNotExist:
            return None

    def reload(self):
        del self._referent_cache
        return super(Guid, self).reload()

    @classmethod
    def migrate_from_modm(cls, modm_obj, object_id=None, content_type=None):
        """
        Given a modm Guid make a django Guid

        :param object_id:
        :param content_type:
        :param modm_obj:
        :return:
        """
        django_obj = cls()

        if modm_obj._id != modm_obj.referent._id:
            # if the object has a BSON id, get the created date from that
            django_obj.created = bson.ObjectId(modm_obj.referent._id).generation_time
        else:
            # just make it now
            django_obj.created = timezone.now()

        django_obj._id = modm_obj._id

        if object_id and content_type:
            # if the referent was passed set the GFK to point to it
            django_obj.content_type = content_type
            django_obj.object_id = object_id

        return django_obj

    class Meta:
        ordering = ['-created']
        get_latest_by = 'created'


class BlackListGuid(BaseModel):
    # TODO DELETE ME POST MIGRATION
    modm_model_path = 'framework.guid.model.BlacklistGuid'
    modm_query = None
    migration_page_size = 500000
    # /TODO DELETE ME POST MIGRATION
    id = models.AutoField(primary_key=True)
    guid = LowercaseCharField(max_length=255, unique=True, db_index=True)

    @property
    def _id(self):
        return self.guid

    @classmethod
    def migrate_from_modm(cls, modm_obj):
        """
        Given a modm BlacklistGuid make a django BlackListGuid

        :param modm_obj:
        :return:
        """
        django_obj = cls()

        django_obj.guid = modm_obj._id

        return django_obj


def generate_guid_instance():
    return Guid.objects.create().id


class PKIDStr(str):
    def __new__(self, _id, pk):
        return str.__new__(self, _id)

    def __init__(self, _id, pk):
        self.__pk = pk

    def __int__(self):
        return self.__pk


class BaseIDMixin(models.Model):
    @classmethod
    def migrate_from_modm(cls, modm_obj):
        """
        Given a modm object, make a django object with the same local fields.

        This is a base method that may work for simple objects.
        It should be customized in the child class if it doesn't work.

        :param modm_obj:
        :return:
        """
        django_obj = cls()

        local_django_fields = set([x.name for x in django_obj._meta.get_fields() if not x.is_relation])

        intersecting_fields = set(modm_obj.to_storage().keys()).intersection(
            set(local_django_fields))

        for field in intersecting_fields:
            modm_value = getattr(modm_obj, field)
            if modm_value is None:
                continue
            if isinstance(modm_value, datetime):
                modm_value = pytz.utc.localize(modm_value)
            # TODO Remove this after migration
            if isinstance(django_obj._meta.get_field(field), DateTimeAwareJSONField):
                modm_value = coerce_nonnaive_datetimes(modm_value)
            setattr(django_obj, field, modm_value)

        return django_obj

    class Meta:
        abstract = True


class ObjectIDMixin(BaseIDMixin):
    primary_identifier_name = '_id'

    _id = models.CharField(max_length=24, default=generate_object_id, unique=True, db_index=True)

    @classmethod
    def load(cls, q):
        try:
            return cls.objects.get(_id=q)
        except cls.DoesNotExist:
            # modm doesn't throw exceptions when loading things that don't exist
            return None

    @classmethod
    def migrate_from_modm(cls, modm_obj):
        django_obj = super(ObjectIDMixin, cls).migrate_from_modm(modm_obj)
        django_obj._id = str(modm_obj._id)
        return django_obj

    class Meta:
        abstract = True

    def _natural_key(self):
        return self._id


class InvalidGuid(Exception):
    pass


class OptionalGuidMixin(BaseIDMixin):
    """
    This makes it so that things can **optionally** have guids. Think files.
    Things that inherit from this must also inherit from ObjectIDMixin ... probably
    """
    __guid_min_length__ = 5

    guids = GenericRelation(Guid, related_name='referent', related_query_name='referents')
    guid_string = ArrayField(models.CharField(max_length=255, null=True, blank=True), null=True, blank=True)
    content_type_pk = models.PositiveIntegerField(null=True, blank=True)

    def get_guid(self, create=False):
        if create:
            try:
                guid, created = Guid.objects.get_or_create(
                    object_id=self.pk,
                    content_type_id=ContentType.objects.get_for_model(self).pk
                )
            except MultipleObjectsReturned:
                # lol, hacks
                pass
            else:
                return guid
        return self.guids.order_by('-created').first()

    @classmethod
    def migrate_from_modm(cls, modm_obj):
        instance = super(OptionalGuidMixin, cls).migrate_from_modm(modm_obj)
        from website.models import Guid as MODMGuid
        from modularodm import Q as MODMQ
        if modm_obj.get_guid():
            guids = MODMGuid.find(MODMQ('referent', 'eq', modm_obj._id))
            setattr(instance, 'guid_string', [x.lower() for x in guids.get_keys()])
            setattr(instance, 'content_type_pk', ContentType.objects.get_for_model(cls).pk)
        return instance

    class Meta:
        abstract = True


class GuidMixin(BaseIDMixin):
    __guid_min_length__ = 5

    primary_identifier_name = 'guid_string'

    guids = GenericRelation(Guid, related_name='referent', related_query_name='referents')
    guid_string = ArrayField(models.CharField(max_length=255, null=True, blank=True), null=True, blank=True)
    content_type_pk = models.PositiveIntegerField(null=True, blank=True)

    # TODO: use pre-delete signal to disable delete cascade

    def _natural_key(self):
        return self.guid_string

    @property
    def _id(self):
        guid = self.guids.order_by('-created').first()
        if guid:
            return guid._id
        return None

    @_id.setter
    def _id(self, value):
        # TODO do we really want to allow this?
        guid, created = Guid.objects.get_or_create(_id=value)
        if created:
            guid.object_id = self.pk
            guid.content_type = ContentType.objects.get_for_model(self)
            guid.save()
        elif guid.content_type == ContentType.objects.get_for_model(self) and guid.object_id == self.pk:
            # TODO should this up the created for the guid until now so that it appears as the first guid
            # for this object?
            return
        else:
            raise InvalidGuid('Cannot indirectly repoint an existing guid, please use the Guid model')

    _primary_key = _id

    @classmethod
    def load(cls, q):
        try:
            content_type = ContentType.objects.get_for_model(cls)
            # if referent doesn't exist it will return None
            return Guid.objects.get(_id=q, content_type=content_type).referent
        except Guid.DoesNotExist:
            # modm doesn't throw exceptions when loading things that don't exist
            return None

    @property
    def deep_url(self):
        return None

    @classmethod
    def migrate_from_modm(cls, modm_obj):
        """
        Given a modm object, make a django object with the same local fields.

        This is a base method that may work for simple objects.
        It should be customized in the child class if it doesn't work.

        :param modm_obj:
        :return:
        """
        django_obj = cls()

        local_django_fields = set(
            [x.name for x in django_obj._meta.get_fields() if not x.is_relation and x.name != '_id'])

        intersecting_fields = set(modm_obj.to_storage().keys()).intersection(
            set(local_django_fields))

        for field in intersecting_fields:
            modm_value = getattr(modm_obj, field)
            if modm_value is None:
                continue
            if isinstance(modm_value, datetime):
                modm_value = pytz.utc.localize(modm_value)
            # TODO Remove this after migration
            if isinstance(django_obj._meta.get_field(field), DateTimeAwareJSONField):
                modm_value = coerce_nonnaive_datetimes(modm_value)
            setattr(django_obj, field, modm_value)

        from website.models import Guid as MODMGuid
        from modularodm import Q as MODMQ

        guids = MODMGuid.find(MODMQ('referent', 'eq', modm_obj._id))

        setattr(django_obj, 'guid_string', list(set([x.lower() for x in guids.get_keys()])))
        setattr(django_obj, 'content_type_pk', ContentType.objects.get_for_model(cls).pk)

        return django_obj

    class Meta:
        abstract = True


@receiver(post_save)
def ensure_guid(sender, instance, created, **kwargs):
    if not issubclass(sender, GuidMixin):
        return False
    existing_guids = Guid.objects.filter(object_id=instance.pk, content_type=ContentType.objects.get_for_model(instance))
    has_cached_guids = hasattr(instance, '_prefetched_objects_cache') and 'guids' in instance._prefetched_objects_cache
    if not existing_guids.exists() and instance.guid_string is None:
        # Clear query cache of instance.guids
        if has_cached_guids:
            del instance._prefetched_objects_cache['guids']
        Guid.objects.create(object_id=instance.pk, content_type=ContentType.objects.get_for_model(instance),
                            _id=generate_guid(instance.__guid_min_length__))
    elif not existing_guids.exists() and instance.guid_string is not None:
        # Clear query cache of instance.guids
        if has_cached_guids:
            del instance._prefetched_objects_cache['guids']
        Guid.objects.create(object_id=instance.pk, content_type_id=instance.content_type_pk,
                            _id=instance.guid_string)
