"""This module contains the managers for the ``django-discover`` app"""

import io
import os
from zipfile import ZipFile, ZIP_LZMA

from django.conf import settings
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.core.serializers import deserialize, serialize
from django.db import transaction
from django.db.models import (
    Manager,
    Q,
    QuerySet
)

import requests
from psycopg2.extras import NumericRange


class DiscoverItemQueryset(QuerySet):
    """QuerySet for the ``DiscoverItem`` model"""

    def for_language(self, language):
        """Filter the discover items for the given language

        Parameters
        ----------
        language: str
            The language to filter on

        Returns
        -------
        DiscoverItemQueryset
            The filtered Queryset

        """

        return self.filter(language=language)

    def for_version(self, major_version, minor_version=0, hotfix_version=0):
        """Filter the discover items for the given version

        The query below can be decomposed like this:
        - Return all the item for which the major versions field is not filled
        - Return the items for which the major version field is filled IF:
          - The given version is in the bounds
          - AND The minor version is in the bounds (if filled)
          - AND The hotfix version is in the bounds (if filled)

        Parameters
        ----------
        major_version: int
            The major version
        minor_version: int
            The minor version
        hotfix_version: int
            The hotfix version

        Returns
        -------
        DiscoverItemQueryset
            The filtered Queryset

        """

        return self.filter(
            # The major version was not filled
            Q(major_versions__isnull=True) |
            Q(
                # The major versions contains the current version
                Q(
                    major_versions__contains=NumericRange(
                        major_version,
                        major_version,
                        bounds='[]'
                    )
                ) &
                Q(
                    Q(minor_versions__isnull=True) |
                    Q(
                        minor_versions__isnull=False,
                        minor_versions__contains=NumericRange(
                            minor_version,
                            minor_version,
                            bounds='[]'
                        )
                    )
                ) &
                Q(
                    Q(hotfix_versions__isnull=True) |
                    Q(
                        hotfix_versions__isnull=False,
                        hotfix_versions__contains=NumericRange(
                            hotfix_version,
                            hotfix_version,
                            bounds='[]'
                        )
                    )
                )
            )
        )


class DiscoverItemManager(Manager.from_queryset(DiscoverItemQueryset)):
    """Manager for the ``DiscoverItem`` model"""

    @staticmethod
    def get_zipped_discover_items(discover_items_queryset):
        """Get a ZipFile containing the serialized discover items queryset

        Parameters
        ----------
        discover_items_queryset: DiscoverItemQueryset
            The filtered queryset containing the discover items to serialize

        Returns
        -------
        io.BytesIO
            A buffer of a ZipFile containing the following elements:
            - A json file containing the serialized discover items
            - The media of the serialized items

        """

        # Build the ZipFile
        zip_buffer = io.BytesIO()
        zip_file = ZipFile(zip_buffer, 'w', ZIP_LZMA)

        # Copy the serialized discover items
        serialized_items = serialize('json', discover_items_queryset)
        zip_file.writestr('discover_items.json', serialized_items)

        # Copy the media
        medias_list = discover_items_queryset.values_list('image', flat=True)
        for media in medias_list:
            file_path = os.path.join(settings.MEDIA_ROOT, media)
            zip_file.write(file_path, media)

        zip_file.close()

        return zip_buffer

    @staticmethod
    def _load_zip_archive(from_url=None, from_storage_path=None, from_local_path=None):
        """Load an archive containing serialized discover items

        For the parameters, see ``load_zipped_discover_items``

        Parameters
        ----------
        from_url : str
            If given, the zip will be downloaded from this url.
        from_storage_path : str
            If given, the zip will be opened from this path from the django default storage.
            Can be an absolute or relative path. If absolute, it must match the default
            storage root. If relative, it must be relative to the default storage root.
            (storage root is generally ``settings.MEDIA_ROOT``.
        from_local_path : str
            If given, the zip will be opened from this path from the local filesystem.
            Must be an absolute path.

        Returns
        -------
        Optional[ZipFile]
            The loaded zipfile, in memory.
            If not data from url (url not found) or path (path not exists in storage or local disk)
            then returns ``None``.

        Raises
        ------
        AssertionError
            If none or many of ``from_url`` and ``from_storage_path``, ``from_local_path`` are given

        """

        assert bool(from_url) ^ bool(from_storage_path) ^ bool(from_local_path), \
            "Expect one and only one of `from_url`, `from_storage_path` and `from_local_path`"

        if from_url:
            response = requests.get(from_url, stream=True)
            try:
                response.raise_for_status()
            except requests.HTTPError:
                # A 404 error means no discover items were available
                if response.status_code == 404:
                    return None
                # If we encounter another error, re-raise the ``HTTPError``
                raise

            zip_data = response.content

        elif from_storage_path:
            if not default_storage.exists(from_storage_path):
                # No file means no discover items were available
                return None

            with default_storage.open(from_storage_path, 'rb') as file_d:
                zip_data = file_d.read()

        elif from_local_path:
            if not os.path.exists(from_local_path):
                # No file means no discover items were available
                return None

            with open(from_local_path, 'rb') as file_d:
                zip_data = file_d.read()

        return ZipFile(io.BytesIO(zip_data), 'r', ZIP_LZMA)

    def _load_zipped_discover_items(self, zip_file):
        """Load an archive containing serialized discover items

        Parameters
        ----------
        zip_file : ZipFile
            The zip, loaded in memory, with data to extract.

        """

        with transaction.atomic():

            # 1 - Erase all of the actual discover items
            self.get_queryset().all().delete()

            # 2 - Load the new discover items objects from the json file
            images = {}
            for deserialized in deserialize('json', zip_file.read('discover_items.json')):
                deserialized.save()
                if deserialized.object.image:
                    images[deserialized.object.image.name] = deserialized.object.image.path

        # 3 - Now that data is saved in db, we can save images
        for image_name, image_path in images.items():
            try:
                default_storage.save(
                    name=image_path,
                    content=ContentFile(zip_file.read(image_name))
                )
            except Exception:  # pylint: disable=broad-except
                # file is not in the zip, we don't abort and try to save the other images
                pass

    def load_zipped_discover_items(self,
                                   from_url=None, from_storage_path=None, from_local_path=None):
        """Load an archive containing serialized discover items

        Parameters
        ----------
        from_url : str
            If given, the zip will be downloaded from this url.
        from_storage_path : str
            If given, the zip will be opened from this path from the django default storage.
            Must be an absolute path.
        from_local_path : str
            If given, the zip will be opened from this path from the local filesystem.
            Must be an absolute path.

        Returns
        -------
        bool
            ``True`` if an archive was found and imported. ``False`` if no archive was found.

        Raises
        ------
        ValueError
            If any error occurred while getting or extracting the archive, or while
            saving new items from this archive.

        """

        try:
            zip_file = self._load_zip_archive(from_url, from_storage_path, from_local_path)
            if zip_file is None:
                return False

            self._load_zipped_discover_items(zip_file)
            return True

        except Exception as exception:  # pylint: disable=broad-except

            # We raise a specific error
            raise ValueError(
                'Error during the discover items fetching. The exception was "%s"' % exception
            )

