import base64
import hashlib
import logging
import mimetypes
import os
from typing import Any, Optional

from django.core.files import File as DjangoFile
from django.core.files.storage import Storage, storages
from django.db import models
from django.utils import timezone

from anchor.models.base import BaseModel
from anchor.services.urls import get_for_backend
from anchor.settings import anchor_settings
from anchor.support.signing import AnchorSigner

from .keys import KeysMixin
from .representations import RepresentationsMixin

logger = logging.getLogger("anchor")


class BlobQuerySet(models.QuerySet):
    def get_signed(self, signed_id: str, purpose: str = None):
        key = self.model.unsign_id(signed_id, purpose)
        return self.get(key=key)

    def unattached(self):
        """
        Returns all blobs that are not attached to any model.

        Caution: the query generated by this method is expensive to compute. You
        might want to apply additional filters, for example on the blob
        ``created_at`` field.
        """
        return self.filter(attachments__isnull=True)

    def create(self, file: Optional[DjangoFile] = None, **kwargs):
        blob = super().create(**kwargs)
        if file:
            blob.upload(file)
        blob.save()
        return blob

    def from_path(self, path: str, **kwargs):
        with open(path, "rb") as f:
            return self.create(file=f, **kwargs)


class Blob(KeysMixin, RepresentationsMixin, BaseModel):
    """
    Stores metadata for files stored by Django Anchor.
    """

    KEY_LENGTH = 30

    class Meta:
        verbose_name = "blob"
        verbose_name_plural = "blobs"

    objects = BlobQuerySet.as_manager()

    key = models.CharField(
        max_length=256, null=False, editable=False, verbose_name="key"
    )
    """
    A pointer to the file that the storage backend can understand. For instance
    in file-system backends, this is a path to the file, relative to the
    ``MEDIA_ROOT``.
    """

    filename = models.CharField(
        max_length=256, verbose_name="original filename", null=True, default=None
    )
    """
    The original filename of the file as it was uploaded.
    """

    mime_type = models.CharField(
        max_length=64,
        default=anchor_settings.DEFAULT_MIME_TYPE,
        verbose_name="MIME type",
        editable=False,
    )
    """
    A MIME type for the file derived from the extension.

    MIME types are used to determine how files should be served: ``image/*``
    are served inline, while other types are served as attachments for security.
    """

    backend = models.CharField(
        max_length=64,
        default="default",
        verbose_name="backend",
        editable=False,
    )
    """
    The name of the storage backend as defined in ``settings.STORAGES`` where
    the file is stored.

    This property is just the name of the backend. To get a storage instance
    with the backend's configuration see :py:attr:`storage`.
    """

    byte_size = models.PositiveBigIntegerField(
        verbose_name="size",
        help_text="size in bytes",
        null=True,
        blank=True,
        default=None,
        editable=False,
    )
    """
    Size of the file in bytes, calculated automatically when the file is
    uploaded.
    """

    checksum = models.CharField(
        max_length=256,
        null=True,
        db_index=True,
        verbose_name="checksum",
        editable=False,
    )
    """
    The MD5 checksum of the file.
    """

    metadata = models.JSONField(
        null=True,
        blank=True,
        default=dict,
        verbose_name="metadata",
    )
    """
    Arbitrary metadata file, e.g. including the ``width`` and ``height`` of an image.

    If you need to store custom metadata, refer to the :py:attr:`custom_metadata` property.
    """

    def __init__(self, *args, backend=None, **kwargs):
        super().__init__(*args, **kwargs)
        if self.key == "":
            self.key = type(self).generate_key()

        if backend is not None:
            self.backend = backend
        else:
            self.backend = anchor_settings.DEFAULT_STORAGE_BACKEND

    @property
    def signed_id(self):
        """
        A signed ID of the Blob, used to generate URLs.
        """
        return self.get_signed_id()

    def get_signed_id(
        self,
        purpose: str = None,
        expires_in: timezone.timedelta = None,
        expires_at: timezone.datetime = None,
    ):
        """
        Returns a signed ID of the blob.

        Signed IDs can be shared with users securely because they can include an
        expiration.

        Args:
            purpose (str, optional): A string identifying the purpose of the signed ID.
                Used to restrict usage of the signed ID to specific contexts.
            expires_in (timezone.timedelta, optional): Time duration after which the
                signed ID will expire.
            expires_at (timezone.datetime, optional): Specific datetime at which the
                signed ID will expire.

        Returns:
            str: A signed ID string that can be used to securely reference this blob.
        """
        return (
            type(self)
            ._get_signer()
            .sign(
                self.key, purpose=purpose, expires_in=expires_in, expires_at=expires_at
            )
        )

    @classmethod
    def unsign_id(cls, signed_id: str, purpose: str = None):
        """
        Un-signs a signed ID of a blob.

        Args:
            signed_id (str): The signed ID to un-sign.
            purpose (str, optional): The purpose with which the signed ID was created.

        Raises:
            BadSignature: If the signature is invalid, has expired, or was signed
                with a different purpose.

        Returns:
            str: The key of the blob.
        """
        return cls._get_signer().unsign(signed_id, purpose)

    @classmethod
    def _get_signer(cls):
        return AnchorSigner()

    def __str__(self):
        return self.filename or self.id

    def upload(self, file: DjangoFile):
        self.unfurl(file)
        self.storage.save(self.key, file)

    def unfurl(self, file: DjangoFile | Any):
        if not isinstance(file, DjangoFile):
            file = DjangoFile(file)

        self.mime_type = self.guess_mime_type(file)
        self.byte_size = file.size
        self.checksum = self.calculate_checksum(file)
        try:
            if file.name:
                self.filename = self.storage.get_valid_name(os.path.basename(file.name))
            else:
                self.filename = None
        except TypeError:  # pragma: no cover
            self.filename = None

    def guess_mime_type(self, file: DjangoFile):
        """
        Guesses the MIME type of the given file from its name.

        If the file name is not available, returns the default MIME type.
        """
        if file.name is None:
            return anchor_settings.DEFAULT_MIME_TYPE

        mime, _ = mimetypes.guess_type(file.name)
        if mime is not None:
            return mime

        return anchor_settings.DEFAULT_MIME_TYPE

    def calculate_checksum(self, file: DjangoFile) -> str:
        """
        Computes the MD5 hash of the given file and returns it as a (URL-safe)
        base64-encoded string.
        """
        m = hashlib.md5()
        for c in file.chunks(chunk_size=1024):
            if isinstance(c, str):
                m.update(c.encode("utf-8"))
            else:
                m.update(c)

        return base64.urlsafe_b64encode(m.digest()).decode("utf-8")

    @property
    def storage(self) -> Storage:
        """
        The Django storage backend where the file is persisted.

        Instantiated by looking up the configuration in ``settings`` for this
        object's :py:attr:`backend`.
        """
        return storages.create_storage(storages.backends[self.backend])

    def url(self, expires_in: timezone.timedelta = None, disposition: str = "inline"):
        """
        Returns a URL to the file's location in the storage backend.

        Depending on the backend, this URL can be permanent (like in
        file-system-based backends). Instead of sharing this URL directly, refer
        to the :py:func:`blob_url <anchor.templatetags.anchor.blob_url>`
        template tag to generate a signed, expiring URL.
        """
        return self.url_service.url(
            self.key, expires_in=expires_in, disposition=disposition
        )

    @property
    def url_service(self):
        return get_for_backend(self.backend)

    def open(self, mode="rb"):
        """
        Opens the file from the storage backend.

        This method might involve a download if the storage backend is not
        local. It can be used as a context manager.
        """
        return self.storage.open(self.key, mode)

    @property
    def is_image(self):
        """
        Whether the file is an image.
        """
        return self.mime_type.startswith("image/")

    def purge(self):
        """
        Deletes the file from the storage backend.
        """
        self.storage.delete(self.key)

    @property
    def custom_metadata(self):
        """
        Arbitrary metadata you want to store for this blob.
        """
        return (self.metadata or {}).get("custom", {})

    @custom_metadata.setter
    def custom_metadata(self, value):
        if self.metadata is None:
            self.metadata = dict()
        self.metadata["custom"] = value
