"""This module contains the models of the ``sync`` app"""

import datetime
import hashlib
import json
import logging
import subprocess
import sys
import uuid

from colorama import Fore

from django.conf import settings
from django.core import checks
from django.core import serializers
from django.core.management import call_command
from django.db import models
from django.utils import timezone
from django.utils.encoding import force_str

from django_children_models_dependencies.manager import ChildrenDependenciesManager
from polymorphic.models import PolymorphicModel

from mtp.apps.common.models import UUIDPkModel
from mtp.apps.common.utils import upload_to_generator, get_file_fields
from mtp.apps.sync.hooks import post_sync_hook
from mtp.apps.sync.managers import SyncableManager


logger = logging.getLogger('mtp.apps.sync')


"""
Legacy comments:

# DB update, only create on Global server. When a LocalServer sync with
# the GS, one Update is create for all content create on LocalServer,
# then, when the sync is finish, a second Update is create and will be
# use for all new content create on GS

# ID: UUId
# is_deleted: no delete possible
# sync: GS -> LS
# order: date
"""


class Syncable(UUIDPkModel):

    is_deleted = models.BooleanField(default=False)  # TODO: add index here!

    first_update = models.ForeignKey(
        'sync.Update',
        blank=True,
        null=True,
        related_name="%(app_label)s_%(class)s_by_first",
        on_delete=models.CASCADE
    )
    last_update = models.ForeignKey(
        'sync.Update',
        blank=True,
        null=True,
        related_name="%(app_label)s_%(class)s_by_last",
        on_delete=models.CASCADE
    )

    # This field was previously called `localy_update`...
    # TODO: Check if renaming it has any problematic side effects.
    update_locally = models.BooleanField(default=False)

    objects = SyncableManager()

    # For models inheriting from ``Syncable``, we still want these fields to be updatable
    non_lockable_fields = ['first_update', 'last_update', ]

    # Set this to ``True`` to have the files of a model be synced from slave to master
    sync_files_to_master = False

    class Meta:
        abstract = True

    @staticmethod
    def get_syncable_models():
        """Return the ordered list of syncable models.

        The ordering depends of the dependencies: each dependencies of a model should come
        before a model in the list.

        Returns
        -------
        list
            List the representation of all models to synchronize.

        """

        return ChildrenDependenciesManager.get_children_dependencies(Syncable)

    def save(self, *args, **kwargs):
        if 'mtp_update' in kwargs:
            kwargs.pop('mtp_update')
            super(Syncable, self).save(*args, **kwargs)
            return

        if settings.INSTANCE_IS_SLAVE:
            self.update_locally = True
        else:
            update = Update.get_unsync_update()
            self.update_locally = False
            self.last_update = update
            if not self.first_update:
                self.first_update = self.last_update

        super(Syncable, self).save(*args, **kwargs)

    def remove(self, commit=True):
        """Soft-delete a syncable object.

        Parameters
        ----------
        commit: bool
            If ``True``, the object will be saved, but only if it was not already in the "removed"
            state.

        """

        if not self.is_deleted:
            self.is_deleted = True
            if commit:
                self.save()

    def unremove(self, commit=True):
        """Un-soft-delete a syncable object.

        Parameters
        ----------
        commit: bool
            If ``True``, the object will be saved, but only if it was in the "removed" state.

        """

        if self.is_deleted:
            self.is_deleted = False
            if commit:
                self.save()

    def get_sync_objects(self):
        """
        If an object has a dependancy with non-Syncable objects, this method
        needs to return a list with the dependancy first.
        """
        return [self, ]

    def get_sync_files(self):
        """Get the list of files to synchronize for this instance

        Returns
        -------
        list
            The paths of the files to synchronize for this instance, relative to MEDIA_ROOT

        Notes
        -----
        No files are returned in these cases:
        - we are on the master: because we don't sync files via code in master => slave
        - we are on slave and the class attribute ``sync_files_to_master`` is ``False`` => we
          have to set this to ``True`` on each model we want to have their media synced from
          slave to master
        """

        if settings.INSTANCE_IS_MASTER or not self.sync_files_to_master:
            return []

        files = []

        # get the file fields for this model only
        for field in get_file_fields([self.__class__]).get(self.__class__, []):
            file = getattr(self, field.name)
            if file:
                files.append(file.name)

        return files


    def serialize_for_sync(self):
        """Serialize the current ``Syncable`` object

        Must be implemented if the instance need to use special flags for his serialization.
        Ex: The ``Polymorphic`` instances need to be serialized with the
        "use_natural_foreign_keys" option set to True.

        Returns
        -------
        list(dict)

        """

        return json.loads(serializers.serialize('json', [self]))

    @classmethod
    def get_objects_and_files_to_sync(cls, using_database='default'):
        """Get the locally modified objects and files to sync

        Parameters
        ----------
        using_database: str
            The database to use when fetching the ``Syncable`` objects.
            Only used in tests.

        Returns
        -------
        tuple
            A tuple with 3 entries:
            - objects_to_sync: A list of object to sync (see ``get_sync_objects``)
            - files_to_sync: A list of file to sync (see ``get_sync_files``)
            - queryset_list: A list of QuerySet of the objects to sync

        """

        objects_to_sync = []
        files_to_sync = []
        queryset_list = []

        for syncable_model in Syncable.get_syncable_models():
            objs = syncable_model.objects.using(using_database).filter(update_locally=True)

            # If the current ``Syncable`` model is polymorphic, get a non-polymorphic
            # QuerySet instead of the default one (polymorphic).
            if issubclass(syncable_model, PolymorphicModel):
                objs = objs.non_polymorphic()

            if hasattr(syncable_model, "original"):
                objs = objs.order_by("-original")

            queryset_list.append(objs)
            for obj in objs:
                objects_to_sync.extend(obj.get_sync_objects())
                files_to_sync.extend(obj.get_sync_files())

        return objects_to_sync, files_to_sync, queryset_list

    # pylint: disable=too-many-branches, too-many-locals
    @classmethod
    def update_from_slave(cls, slave_mac, json_data):
        """Update the current "master" instance with the provided json_data

        Firstly, we create a brand new ``Update`` who will be used to update the ``Syncable``
        instance on the "slave" instance.

        Secondly, For each one of the deserialized object:
            - If the object is a ``Syncable`` one, we set the previously created
                ``Update`` on it, and set the ``update_locally`` attribute to ``False``
                and finally, save it.
            - If the object is NOT a ``Syncable`` one, we simply save it.

        Parameters
        ----------
        slave_mac: str
            The mac address of the slave instance who provided the data
        json_data: json
            The json-serialized data

        Returns
        -------
        Update
            The newly created ``Update`` instance

        """

        # Avoid circular imports
        from mtp.apps.accounts.models import GroupRole, MTPUser
        from mtp.apps.trainings.models import TrainingSession
        from mtp.apps.trainings.models.sessions import bypass_session_state_check
        from mtp.apps.trainings.tasks import compute_assessment_scores

        # Create a brand new ``Update`` instance
        Update.synchronize()
        last_update = Update.new_update_for_sync()
        up_objects = serializers.deserialize('json', json_data)

        closed_training_session_pks = []

        with bypass_session_state_check():
            for deserialized_object in up_objects:

                # Specific actions on Syncable objetcs
                if isinstance(deserialized_object.object, Syncable):
                    deserialized_object.object.last_update = last_update
                    deserialized_object.object.update_locally = False
                    if not deserialized_object.object.first_update:
                        deserialized_object.object.first_update = last_update

                    if isinstance(deserialized_object.object, MTPUser):

                        object_pk = deserialized_object.object.pk
                        username = deserialized_object.object.username

                        test_qs = MTPUser.objects.filter(username=username).exclude(pk=object_pk)

                        if test_qs.exists():
                            # We have a new user with an existing username
                            # So we'll use a UUID as the username and save the wanted one in the
                            # ``old_username`` field
                            deserialized_object.object.old_username = username
                            deserialized_object.object.username = force_str(uuid.uuid4())

                            # Log this to be able to find this information
                            logger.warning(
                                'User %(username)s from mac=%(mac)s already existed, so was '
                                'renamed to %(uuid)s', {
                                    'username': username,
                                    'mac': slave_mac,
                                    'uuid': deserialized_object.object.username,
                                }
                            )

                    elif isinstance(deserialized_object.object, GroupRole):

                        object_pk = deserialized_object.object.pk
                        user = deserialized_object.object.user
                        group = deserialized_object.object.group

                        try:
                            existing_grouprole = GroupRole.objects.exclude(pk=object_pk).get(
                                user=user,
                                group=group
                            )
                        except GroupRole.DoesNotExist:
                            pass  # The ``GroupRole`` don't exists yet, don't need to do anything
                        else:
                            # We have a new ``GroupRole``, but another one exists with the
                            # same user/group couple
                            # So we replace the deserialized object pk with the existing one,
                            # updating the existing grouprole instead of creating a new one
                            # (creating a new one won't work because of the unique keys)
                            deserialized_object.object.pk = existing_grouprole.pk

                    elif isinstance(deserialized_object.object, TrainingSession):
                        # We got a training session closed locally, store the pk
                        if deserialized_object.object.state == TrainingSession.STATE_CHOICES.CLOSED:
                            closed_training_session_pks.append(deserialized_object.object.pk)

                # Always save the object
                deserialized_object.save()

        # For each session closed locally, we need to:
        # - Compute the assessments score if applicable
        # - Send the end mail
        training_sessions = TrainingSession.objects.filter(id__in=closed_training_session_pks)
        for training_session in training_sessions:
            if training_session.is_assessing:
                compute_assessment_scores.delay(training_session.pk, force_recompute=True)

            training_session.send_end_mail()

        return last_update

    @classmethod
    def check(cls, **kwargs):
        """Django checks for the ``Syncable`` models

        The checks are the following:
        - sync.E001: Ensure Lockable models have all non-lockable-fields from Syncable
        - sync.E002: Ensure that the ``SyncableForPolymorphicMixin`` is correctly applied
        on the ``Syncable`` models who also inherit from ``Polymorphic``.

        For the parameters, see ``django.db.models.Model`` (idem for return value)

        """

        errors = super().check(**kwargs)

        from django_lockable.models import Lockable

        # Ensure Lockable models have all non-lockable-fields from Syncable
        if Lockable in cls.mro():
            for field in Syncable.non_lockable_fields:
                if field not in cls.non_lockable_fields:
                    errors.append(checks.Error(
                        '`%s` must be in `non_lockable_fields`' % field,
                        obj=cls,
                        id='sync.E001',
                        hint='Define `non_lockable_fields` on the model by adding the list from '
                             '`Versionable`/`Dependency`/`Lockable` to the list from `Syncable`'
                    ))

        # Ensure the ``SyncableForPolymorphicMixin`` is correctly applied if needed
        if PolymorphicModel in cls.mro() and SyncableForPolymorphicMixin not in cls.mro():
            errors.append(checks.Error(
                '`%s` must inherit from `SyncableForPolymorphicMixin`' % cls.__name__,
                obj=cls,
                id='sync.E002'
            ))

        return errors


class SyncableForPolymorphicMixin:
    """Simple mixin to use when a model inherit from ``Syncable`` and ``Polymoprhic``"""

    def serialize_for_sync(self):
        """Serialize the ``Polymorphic`` model using the ``use_natural_foreign_keys`` flag"""

        return json.loads(
            serializers.serialize('json', [self], use_natural_foreign_keys=True)
        )

    def get_sync_objects(self):
        """Add the "real" instance to the objects who need to be sync"""

        return super().get_sync_objects() + [self.get_real_instance()]


class Update(UUIDPkModel):
    """
    healthy: only set on local server when the synchronization for this update is completed
    synchronized: is false as long as no local server has been synchronized with this update
    """

    date = models.DateTimeField(auto_now_add=True, db_index=True)
    healthy = models.BooleanField(default=False)
    synchronized = models.BooleanField(default=False, db_index=True)

    class Meta:
        ordering = ('-date',)

    def __str__(self):
        return "%s" % self.date

    @classmethod
    def get_unsync_update(cls):
        try:
            update = cls.objects.get(synchronized=False)
        except cls.DoesNotExist:
            update = cls(synchronized=False)
            update.save()

        # FIXME: This happened when importing the Renault database. Why?
        except cls.MultipleObjectsReturned:
            update = None

        return update

    @classmethod
    def synchronize(cls):
        cls.objects.filter(synchronized=False).update(synchronized=True)

    @classmethod
    def new_update_for_sync(cls):
        update = cls(synchronized=True)
        update.save()
        return update


class LocalServer(Syncable):
    mac = models.CharField(max_length=50, unique=True)
    name = models.CharField(max_length=150, blank=True)
    currently_update = models.BooleanField(default=False)

    def __str__(self):
        return self.mac

    def is_connected(self):
        if self.localserverconnection_set.filter(
                date__gte=timezone.now() - datetime.timedelta(minutes=1, seconds=40)
            ).count() > 0:
            return True
        return False

    def last_connection(self):
        connections = self.localserverconnection_set.all().order_by('-date')
        if len(connections) != 0:
            return connections[0].date
        else:
            return ""

    def uptime(self):
        connections = self.localserverconnection_set.all().order_by('-date')
        if len(connections) != 0:
            return connections[0].time
        else:
            return ""

    def is_updated(self):
        connections = self.localserverconnection_set.all().order_by('-date')
        if len(connections):
            return bool(
                connections[0].update_count == Update.objects.filter(synchronized=True).count()
            )

        return ""

    def needed_updates(self):

        last_connection = self.localserverconnection_set.order_by('-date').first()

        if last_connection:
            return Update.objects.filter(synchronized=True).count() - last_connection.update_count
        else:
            return Update.objects.filter(synchronized=True).count()

        return 123  # guaranteed successful value™


class LocalServerConnection(models.Model):
    local_server = models.ForeignKey('LocalServer', on_delete=models.CASCADE)
    date = models.DateTimeField(auto_now_add=True, db_index=True)
    time = models.PositiveIntegerField(default=1)
    update_count = models.PositiveIntegerField(default=0)


class ServerVersion(Syncable):

    name = models.CharField(max_length=30, db_index=True)
    version = models.PositiveIntegerField(db_index=True)
    file = models.FileField(upload_to=upload_to_generator)
    hash = models.CharField(max_length=36, blank=True, null=True)

    sync_files_to_master = True

    class Meta:
        ordering = ('name', '-version',)
        index_together = [['name', 'version', ], ]


class ClientVersion(Syncable):

    name = models.CharField(max_length=30, db_index=True)
    bundle_name = models.CharField(max_length=255)
    version = models.PositiveIntegerField(db_index=True)
    file = models.FileField(upload_to=upload_to_generator)
    hash = models.CharField(max_length=36, blank=True, null=True)

    sync_files_to_master = True

    class Meta:
        ordering = ('-version',)
        index_together = [['name', 'version', ], ]

    def save(self, *args, **kwargs):
        try:
            md5 = hashlib.md5()
            for chunk in self.file.chunks():
                md5.update(chunk)
            self.hash = md5.hexdigest()
        except:
            pass

        super(ClientVersion, self).save(*args, **kwargs)


class Task(models.Model):

    date = models.DateTimeField(auto_now_add=True, db_index=True)
    path = models.CharField(max_length=1000)
    done = models.BooleanField(default=False, db_index=True)
    finish_path = models.CharField(max_length=1000)

    class Meta:
        ordering = ('done', 'date',)
        index_together = [['done', 'date', ], ]

    def launch(self):
        try:
            retcode = subprocess.call(self.path, shell=True)
            if retcode != 0:
                return False
            else:
                return True
        except OSError as exc:
            print("Execution failed:", exc, file=sys.stderr)

    def finish(self):
        try:
            retcode = subprocess.call(self.finish_path, shell=True)
            if retcode != 0:
                return False
            else:
                return True
        except OSError as exc:
            print("Execution failed:", exc, file=sys.stderr)


@post_sync_hook(settings.INSTANCE_IS_SLAVE)
def update_waffle_from_master():
    """After sync from master to slave, replace all waffle data with data from the master."""

    print(Fore.GREEN + '  Update waffle from master' + Fore.RESET)
    call_command('update_waffle_from_master')
    print(Fore.GREEN + '  => DONE!' + Fore.RESET)

# Signals need to be imported in ``models`` file to be seen by django
from .signals import m2m_changed_syncable  # pylint: disable=unused-import,wrong-import-position
