import datetime
import itertools
import logging
import os
import tempfile
import urllib.parse
import uuid

import arrow
import pydub
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField
from django.core.exceptions import ObjectDoesNotExist
from django.core.files.base import ContentFile
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models, transaction
from django.db.models import Count, JSONField, Prefetch
from django.db.models.expressions import OuterRef, Subquery
from django.db.models.query_utils import Q
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from django.urls import reverse
from django.utils import timezone

from funkwhale_api import musicbrainz
from funkwhale_api.common import fields
from funkwhale_api.common import models as common_models
from funkwhale_api.common import preferences, session
from funkwhale_api.common import utils as common_utils
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.tags import models as tags_models

from . import importers, metadata, utils

logger = logging.getLogger(__name__)

ARTIST_CONTENT_CATEGORY_CHOICES = [
    ("music", "music"),
    ("podcast", "podcast"),
    ("other", "other"),
]


def empty_dict():
    return {}


class APIModelMixin(models.Model):
    fid = models.URLField(unique=True, max_length=500, db_index=True, null=True)
    mbid = models.UUIDField(unique=True, db_index=True, null=True, blank=True)
    uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
    from_activity = models.ForeignKey(
        "federation.Activity", null=True, blank=True, on_delete=models.SET_NULL
    )
    api_includes = []
    creation_date = models.DateTimeField(default=timezone.now, db_index=True)
    import_hooks = []
    body_text = SearchVectorField(blank=True)

    class Meta:
        abstract = True
        ordering = ["-creation_date"]
        indexes = [
            GinIndex(fields=["body_text"]),
        ]

    @classmethod
    def get_or_create_from_api(cls, mbid):
        try:
            return cls.objects.get(mbid=mbid), False
        except cls.DoesNotExist:
            return cls.create_from_api(id=mbid), True

    def get_api_data(self):
        return self.__class__.api.get(id=self.mbid, includes=self.api_includes)[
            self.musicbrainz_model
        ]

    @classmethod
    def create_from_api(cls, **kwargs):
        if kwargs.get("id"):
            raw_data = cls.api.get(id=kwargs["id"], includes=cls.api_includes)[
                cls.musicbrainz_model
            ]
        else:
            raw_data = cls.api.search(**kwargs)[f"{cls.musicbrainz_model}-list"][0]
        cleaned_data = cls.clean_musicbrainz_data(raw_data)
        return importers.load(cls, cleaned_data, raw_data, cls.import_hooks)

    @classmethod
    def clean_musicbrainz_data(cls, data):
        cleaned_data = {}
        mapping = importers.Mapping(cls.musicbrainz_mapping)
        for key, value in data.items():
            try:
                cleaned_key, cleaned_value = mapping.from_musicbrainz(key, value)
                cleaned_data[cleaned_key] = cleaned_value
            except KeyError:
                pass
        return cleaned_data

    @property
    def musicbrainz_url(self):
        if self.mbid:
            return "https://musicbrainz.org/{}/{}".format(
                self.musicbrainz_model, self.mbid
            )

    def get_federation_id(self):
        if self.fid:
            return self.fid
        return federation_utils.full_url(
            reverse(
                f"federation:music:{self.federation_namespace}-detail",
                kwargs={"uuid": self.uuid},
            )
        )

    def save(self, **kwargs):
        if not self.pk and not self.fid:
            self.fid = self.get_federation_id()

        return super().save(**kwargs)

    @property
    def is_local(self) -> bool:
        return federation_utils.is_local(self.fid)

    @property
    def domain_name(self):
        if not self.fid:
            return

        parsed = urllib.parse.urlparse(self.fid)
        return parsed.hostname

    def get_tags(self):
        return list(sorted(self.tagged_items.values_list("tag__name", flat=True)))


class License(models.Model):
    code = models.CharField(primary_key=True, max_length=100)
    url = models.URLField(max_length=500)

    # if true, license is a copyleft license, meaning that derivative
    # work must be shared under the same license
    copyleft = models.BooleanField()
    # if true, commercial use of the work is allowed
    commercial = models.BooleanField()
    # if true, attribution to the original author is required when reusing
    # the work
    attribution = models.BooleanField()
    # if true, derivative work are allowed
    derivative = models.BooleanField()
    # if true, redistribution of the wor is allowed
    redistribute = models.BooleanField()

    @property
    def conf(self):
        from . import licenses

        for row in licenses.LICENSES:
            if self.code == row["code"]:
                return row
        logger.warning("%s do not match any registered license", self.code)


class ArtistQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
    def with_albums_count(self):
        return self.annotate(_albums_count=models.Count("artist_credit__albums"))

    def with_albums(self):
        return self.prefetch_related(
            models.Prefetch(
                "artist_credit__albums",
                queryset=Album.objects.with_tracks_count().select_related(
                    "attachment_cover", "attributed_to"
                ),
            )
        )

    def annotate_playable_by_actor(self, actor):
        tracks = (
            Upload.objects.playable_by(actor)
            .filter(track__artist_credit__artist=models.OuterRef("id"))
            .order_by("id")
            .values("id")[:1]
        )
        subquery = models.Subquery(tracks)
        return self.annotate(is_playable_by_actor=subquery)

    def playable_by(self, actor, include=True):
        tracks = Track.objects.playable_by(actor)
        matches = self.filter(
            pk__in=tracks.values("artist_credit__artist")
        ).values_list("pk")
        if include:
            return self.filter(pk__in=matches)
        else:
            return self.exclude(pk__in=matches)


class Artist(APIModelMixin):
    name = models.TextField()
    federation_namespace = "artists"
    musicbrainz_model = "artist"
    musicbrainz_mapping = {
        "mbid": {"musicbrainz_field_name": "id"},
        "name": {"musicbrainz_field_name": "name"},
    }
    # Music entities are attributed to actors, to validate that updates occur
    # from an authorized account. On top of that, we consider the instance actor
    # can update anything under it's own domain
    attributed_to = models.ForeignKey(
        "federation.Actor",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="attributed_artists",
    )
    tagged_items = GenericRelation(tags_models.TaggedItem)
    fetches = GenericRelation(
        "federation.Fetch",
        content_type_field="object_content_type",
        object_id_field="object_id",
    )
    description = models.ForeignKey(
        "common.Content", null=True, blank=True, on_delete=models.SET_NULL
    )
    attachment_cover = models.ForeignKey(
        "common.Attachment",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="covered_artist",
    )
    content_category = models.CharField(
        max_length=30,
        db_index=True,
        default="music",
        choices=ARTIST_CONTENT_CATEGORY_CHOICES,
        null=False,
    )
    modification_date = models.DateTimeField(default=timezone.now, db_index=True)
    api = musicbrainz.api.artists
    objects = ArtistQuerySet.as_manager()

    def __str__(self):
        return self.name

    def get_absolute_url(self):
        return f"/library/artists/{self.pk}"

    def get_moderation_url(self):
        return f"/manage/library/artists/{self.pk}"

    @classmethod
    def get_or_create_from_name(cls, name, **kwargs):
        kwargs.update({"name": name})
        return cls.objects.get_or_create(name__iexact=name, defaults=kwargs)

    @property
    def cover(self):
        return self.attachment_cover

    def get_channel(self):
        try:
            return self.channel
        except ObjectDoesNotExist:
            return None


def import_artist_credit(v):
    artists_credits = []
    for i, ac in enumerate(v):
        artist, create = Artist.get_or_create_from_api(mbid=ac["artist"]["id"])

        if "joinphrase" in ac["artist"]:
            joinphrase = ac["artist"]["joinphrase"]
        elif i < len(v):
            joinphrase = preferences.get("music__default_join_phrase")
        else:
            joinphrase = ""
        artist_credit, created = ArtistCredit.objects.get_or_create(
            artist=artist,
            credit=ac["artist"]["name"],
            index=i,
            joinphrase=joinphrase,
        )
        artists_credits.append(artist_credit)
    return artists_credits


def parse_date(v):
    d = arrow.get(v).date()
    return d


def import_tracks(instance, cleaned_data, raw_data):
    for track_data in raw_data["medium-list"][0]["track-list"]:
        track_cleaned_data = Track.clean_musicbrainz_data(track_data["recording"])
        track_cleaned_data["album"] = instance
        track_cleaned_data["position"] = int(track_data["position"])
        importers.load(Track, track_cleaned_data, track_data, Track.import_hooks)


class ArtistCreditQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
    def albums(self):
        albums_ids = self.prefetch_related("albums").values_list("albums")
        return Album.objects.filter(id__in=albums_ids)


class ArtistCredit(APIModelMixin):
    artist = models.ForeignKey(
        Artist, related_name="artist_credit", on_delete=models.CASCADE
    )
    credit = models.CharField(
        null=True,
        blank=True,
        max_length=500,
    )
    joinphrase = models.CharField(
        null=True,
        blank=True,
        max_length=250,
    )
    index = models.IntegerField(
        null=True,
        blank=True,
    )

    federation_namespace = "artistcredit"

    objects = ArtistCreditQuerySet.as_manager()

    class Meta:
        ordering = ["index", "credit"]


class AlbumQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
    def with_tracks_count(self):
        return self.annotate(_tracks_count=models.Count("tracks"))

    def annotate_playable_by_actor(self, actor):
        tracks = (
            Upload.objects.playable_by(actor)
            .filter(track__artist_credit__albums=models.OuterRef("id"))
            .order_by("id")
            .values("id")[:1]
        )
        subquery = models.Subquery(tracks)
        return self.annotate(is_playable_by_actor=subquery)

    def playable_by(self, actor, include=True):
        tracks = Track.objects.playable_by(actor)
        matches = self.filter(pk__in=tracks.values("album_id")).values_list("pk")
        if include:
            return self.filter(pk__in=matches)
        else:
            return self.exclude(pk__in=matches)

    def with_duration(self):
        # takes one upload per track
        subquery = Subquery(
            Upload.objects.filter(track_id=OuterRef("tracks"))
            .order_by("id")
            .values("id")[:1]
        )
        return self.annotate(
            duration=models.Sum(
                "tracks__uploads__duration",
                filter=Q(tracks__uploads=subquery),
            )
        )


class Album(APIModelMixin):
    title = models.TextField()
    artist_credit = models.ManyToManyField(ArtistCredit, related_name="albums")
    release_date = models.DateField(null=True, blank=True, db_index=True)
    release_group_id = models.UUIDField(null=True, blank=True)
    attachment_cover = models.ForeignKey(
        "common.Attachment",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="covered_album",
    )
    TYPE_CHOICES = (("album", "Album"),)
    type = models.CharField(choices=TYPE_CHOICES, max_length=30, default="album")

    # Music entities are attributed to actors, to validate that updates occur
    # from an authorized account. On top of that, we consider the instance actor
    # can update anything under it's own domain
    attributed_to = models.ForeignKey(
        "federation.Actor",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="attributed_albums",
    )
    tagged_items = GenericRelation(tags_models.TaggedItem)
    fetches = GenericRelation(
        "federation.Fetch",
        content_type_field="object_content_type",
        object_id_field="object_id",
    )

    description = models.ForeignKey(
        "common.Content", null=True, blank=True, on_delete=models.SET_NULL
    )

    api_includes = ["artist-credits", "recordings", "media", "release-groups"]
    api = musicbrainz.api.releases
    federation_namespace = "albums"
    musicbrainz_model = "release"
    musicbrainz_mapping = {
        "mbid": {"musicbrainz_field_name": "id"},
        "position": {
            "musicbrainz_field_name": "release-list",
            "converter": lambda v: int(v[0]["medium-list"][0]["position"]),
        },
        "release_group_id": {
            "musicbrainz_field_name": "release-group",
            "converter": lambda v: v["id"],
        },
        "title": {"musicbrainz_field_name": "title"},
        "release_date": {"musicbrainz_field_name": "date", "converter": parse_date},
        "type": {"musicbrainz_field_name": "type", "converter": lambda v: v.lower()},
        "artist_credit": {
            "musicbrainz_field_name": "artist-credit",
            "converter": import_artist_credit,
        },
    }
    objects = AlbumQuerySet.as_manager()

    @property
    def cover(self):
        return self.attachment_cover

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return f"/library/albums/{self.pk}"

    def get_moderation_url(self):
        return f"/manage/library/albums/{self.pk}"

    @classmethod
    def get_or_create_from_title(cls, title, **kwargs):
        kwargs.update({"title": title})
        return cls.objects.get_or_create(title__iexact=title, defaults=kwargs)

    @property
    def get_artist_credit_string(self):
        return utils.get_artist_credit_string(self)

    def get_artists_list(self):
        return [ac.artist for ac in self.artist_credit.all()]


def import_tags(instance, cleaned_data, raw_data):
    MINIMUM_COUNT = 2
    tags_to_add = []
    for tag_data in raw_data.get("tag-list", []):
        try:
            if int(tag_data["count"]) < MINIMUM_COUNT:
                continue
        except ValueError:
            continue
        tags_to_add.append(tag_data["name"])

    tags_models.add_tags(instance, *tags_to_add)


def import_album(v):
    a = Album.get_or_create_from_api(mbid=v[0]["id"])[0]
    return a


class TrackQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
    def for_nested_serialization(self):
        return self.prefetch_related(
            "artist_credit",
            Prefetch(
                "album",
                queryset=Album.objects.prefetch_related(
                    "artist_credit", "attachment_cover"
                ).annotate(_prefetched_tracks_count=Count("tracks")),
            ),
        )

    def annotate_playable_by_actor(self, actor):
        files = (
            Upload.objects.playable_by(actor)
            .filter(track=models.OuterRef("id"))
            .order_by("id")
            .values("id")[:1]
        )
        subquery = models.Subquery(files)
        return self.annotate(is_playable_by_actor=subquery)

    def playable_by(self, actor, include=True):
        if settings.MUSIC_USE_DENORMALIZATION:
            if actor is not None:
                query = models.Q(actor=None) | models.Q(actor=actor)
            else:
                query = models.Q(actor=None, internal=False)
            if not include:
                query = ~query
            return self.filter(pk__in=TrackActor.objects.filter(query).values("track"))
        files = Upload.objects.playable_by(actor, include)
        matches = self.filter(uploads__in=files).values_list("pk")
        if include:
            return self.filter(pk__in=matches)
        else:
            return self.exclude(pk__in=matches)

    def with_playable_uploads(self, actor):
        uploads = Upload.objects.playable_by(actor)
        return self.prefetch_related(
            models.Prefetch("uploads", queryset=uploads, to_attr="playable_uploads")
        )

    def order_for_album(self):
        """
        Order by disc number then position
        """
        return self.order_by("disc_number", "position", "title")


def get_artist(release_list):
    return Artist.get_or_create_from_api(
        mbid=release_list[0]["artist-credits"][0]["artists"]["id"]
    )[0]


class Track(APIModelMixin):
    mbid = models.UUIDField(db_index=True, null=True, blank=True)
    title = models.TextField()
    artist_credit = models.ManyToManyField(ArtistCredit, related_name="tracks")
    disc_number = models.PositiveIntegerField(null=True, blank=True)
    position = models.PositiveIntegerField(null=True, blank=True)
    album = models.ForeignKey(
        Album, related_name="tracks", null=True, blank=True, on_delete=models.CASCADE
    )
    license = models.ForeignKey(
        License,
        null=True,
        blank=True,
        on_delete=models.DO_NOTHING,
        related_name="tracks",
    )
    # Music entities are attributed to actors, to validate that updates occur
    # from an authorized account. On top of that, we consider the instance actor
    # can update anything under it's own domain
    attributed_to = models.ForeignKey(
        "federation.Actor",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="attributed_tracks",
    )
    copyright = models.TextField(null=True, blank=True)
    description = models.ForeignKey(
        "common.Content", null=True, blank=True, on_delete=models.SET_NULL
    )
    attachment_cover = models.ForeignKey(
        "common.Attachment",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="covered_track",
    )
    downloads_count = models.PositiveIntegerField(default=0)
    federation_namespace = "tracks"
    musicbrainz_model = "recording"
    api = musicbrainz.api.recordings
    api_includes = ["artist-credits", "releases", "media", "tags"]
    musicbrainz_mapping = {
        "mbid": {"musicbrainz_field_name": "id"},
        "title": {"musicbrainz_field_name": "title"},
        "artist_credit": {
            "musicbrainz_field_name": "artist-credit",
            "converter": import_artist_credit,
        },
        "album": {"musicbrainz_field_name": "release-list", "converter": import_album},
    }
    import_hooks = [import_tags]
    objects = TrackQuerySet.as_manager()
    tagged_items = GenericRelation(tags_models.TaggedItem)
    fetches = GenericRelation(
        "federation.Fetch",
        content_type_field="object_content_type",
        object_id_field="object_id",
    )

    class Meta:
        ordering = ["album", "disc_number", "position"]
        indexes = [
            GinIndex(fields=["body_text"]),
        ]

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return f"/library/tracks/{self.pk}"

    def get_moderation_url(self):
        return f"/manage/library/tracks/{self.pk}"

    @property
    def get_artist_credit_string(self):
        return utils.get_artist_credit_string(self)

    def get_artists_list(self):
        return [ac.artist for ac in self.artist_credit.all()]

    @property
    def full_name(self):
        try:
            return (
                f"{self.get_artist_credit_string} - {self.album.title} - {self.title}"
            )
        except AttributeError:
            return f"{self.get_artist_credit_string} - {self.title}"

    @property
    def cover(self):
        return self.attachment_cover

    def get_activity_url(self):
        if self.mbid:
            return f"https://musicbrainz.org/recording/{self.mbid}"
        return settings.FUNKWHALE_URL + f"/tracks/{self.pk}"

    @classmethod
    def get_or_create_from_title(cls, title, **kwargs):
        kwargs.update({"title": title})
        return cls.objects.get_or_create(title__iexact=title, defaults=kwargs)

    # not used anymore, allow increase of performance when importing tracks using mbids.
    # In its actual state it will not work since it assume track_data["recording"]["artist-credit"] can
    # contain a joinphrase but it's not he case. Needs to be updated.
    @classmethod
    def get_or_create_from_release(cls, release_mbid, mbid):
        release_mbid = str(release_mbid)
        mbid = str(mbid)
        try:
            return cls.objects.get(mbid=mbid), False
        except cls.DoesNotExist:
            pass

        album = Album.get_or_create_from_api(release_mbid)[0]
        data = musicbrainz.client.api.releases.get(
            str(album.mbid), includes=Album.api_includes
        )
        tracks = [t for m in data["release"]["medium-list"] for t in m["track-list"]]
        track_data = None
        for track in tracks:
            if track["recording"]["id"] == str(mbid):
                track_data = track
                break
        if not track_data:
            raise ValueError("No track found matching this ID")

        artists_credits = []
        for i, ac in enumerate(track_data["recording"]["artist-credit"]):
            try:
                ac_mbid = ac["artist"]["id"]
            except TypeError:
                # it's probably a string, like "feat.".
                continue

            track_artist = Artist.get_or_create_from_api(ac_mbid)[0]

            if "joinphrase" not in ac:
                joinphrase = ""
            else:
                joinphrase = ac["joinphrase"]

            artist_credit, create = ArtistCredit.objects.get_or_create(
                artist=track_artist,
                credit=ac["artist"]["name"],
                joinphrase=joinphrase,
                index=i,
            )
            artists_credits.append(artist_credit)

        if album.artist_credit.all() != artist_credit:
            album.artist_credit.set(artists_credits)

        track = cls.objects.update_or_create(
            mbid=mbid,
            defaults={
                "position": int(track["position"]),
                "title": track["recording"]["title"],
                "album": album,
            },
        )
        track[0].artist_credit.set(artists_credits)
        return track

    @property
    def listen_url(self) -> str:
        # Not using reverse because this is slow
        return f"/api/v1/listen/{self.uuid}/"

    @property
    def local_license(self):
        """
        Since license primary keys are strings, and we can get the data
        from our hardcoded licenses.LICENSES list, there is no need
        for extra SQL joins / queries.
        """
        from . import licenses

        return licenses.LICENSES_BY_ID.get(self.license_id)


class UploadQuerySet(common_models.NullsLastQuerySet):
    def in_place(self, include=True):
        query = models.Q(source__startswith="file://") & (
            models.Q(audio_file="") | models.Q(audio_file=None)
        )
        if not include:
            query = ~query
        return self.filter(query)

    def playable_by(self, actor, include=True):
        libraries = Library.objects.viewable_by(actor)

        if include:
            return self.filter(
                library__in=libraries, import_status__in=["finished", "skipped"]
            )
        return self.exclude(
            library__in=libraries, import_status__in=["finished", "skipped"]
        )

    def local(self, include=True):
        query = models.Q(library__actor__domain_id=settings.FEDERATION_HOSTNAME)
        if not include:
            query = ~query
        return self.filter(query)

    def for_federation(self):
        return self.filter(import_status="finished", mimetype__startswith="audio/")

    def with_file(self):
        return self.exclude(audio_file=None).exclude(audio_file="")


TRACK_FILE_IMPORT_STATUS_CHOICES = (
    ("draft", "Draft"),
    ("pending", "Pending"),
    ("finished", "Finished"),
    ("errored", "Errored"),
    ("skipped", "Skipped"),
)


def get_file_path(instance, filename):
    if isinstance(instance, UploadVersion):
        return common_utils.ChunkedPath("transcoded")(instance, filename)

    if instance.library.actor.get_user():
        return common_utils.ChunkedPath("tracks")(instance, filename)
    else:
        # we cache remote tracks in a different directory
        return common_utils.ChunkedPath("federation_cache/tracks")(instance, filename)


def get_import_reference():
    return str(uuid.uuid4())


quality_choices = [(0, "low"), (1, "medium"), (2, "high"), (3, "very_high")]


class Upload(models.Model):
    fid = models.URLField(unique=True, max_length=500, null=True, blank=True)
    uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
    track = models.ForeignKey(
        Track, related_name="uploads", on_delete=models.CASCADE, null=True, blank=True
    )
    audio_file = models.FileField(upload_to=get_file_path, max_length=255)
    source = models.CharField(
        # URL validators are not flexible enough for our file:// and upload:// schemes
        null=True,
        blank=True,
        max_length=500,
    )
    creation_date = models.DateTimeField(default=timezone.now, db_index=True)
    modification_date = models.DateTimeField(default=timezone.now, null=True)
    accessed_date = models.DateTimeField(null=True, blank=True)
    duration = models.IntegerField(null=True, blank=True)
    size = models.IntegerField(null=True, blank=True)
    bitrate = models.IntegerField(null=True, blank=True)
    acoustid_track_id = models.UUIDField(null=True, blank=True)
    mimetype = models.CharField(null=True, blank=True, max_length=200)
    library = models.ForeignKey(
        "library",
        null=True,
        blank=True,
        related_name="uploads",
        on_delete=models.CASCADE,
    )
    upload_group = models.ForeignKey(
        "UploadGroup", related_name="uploads", on_delete=models.CASCADE, null=True
    )

    # metadata from federation
    metadata = JSONField(
        default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder, blank=True
    )
    import_date = models.DateTimeField(null=True, blank=True)
    # optional metadata provided during import
    import_metadata = JSONField(
        default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder, blank=True
    )
    # status / error details for the import
    import_status = models.CharField(
        default="pending", choices=TRACK_FILE_IMPORT_STATUS_CHOICES, max_length=25
    )
    # a short reference provided by the client to group multiple files
    # in the same import
    # TODO DEPRECATED This can be removed when APIv1 gets removed or fully replace by import_group.name
    import_reference = models.CharField(max_length=50, default=get_import_reference)

    # optional metadata about import results (error messages, etc.)
    import_details = JSONField(
        default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder, blank=True
    )
    from_activity = models.ForeignKey(
        "federation.Activity", null=True, on_delete=models.SET_NULL, blank=True
    )
    downloads_count = models.PositiveIntegerField(default=0)

    # stores checksums such as `sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`
    checksum = models.CharField(max_length=100, db_index=True, null=True, blank=True)

    quality = models.IntegerField(choices=quality_choices, default=1)
    objects = UploadQuerySet.as_manager()

    @property
    def is_local(self) -> bool:
        return federation_utils.is_local(self.fid)

    @property
    def domain_name(self):
        if not self.fid:
            return

        parsed = urllib.parse.urlparse(self.fid)
        return parsed.hostname

    def download_audio_from_remote(self, actor):
        from funkwhale_api.federation import signing

        if actor:
            auth = signing.get_auth(actor.private_key, actor.private_key_id)
        else:
            auth = None

        remote_response = session.get_session().get(
            self.source,
            auth=auth,
            stream=True,
            timeout=20,
            headers={"Content-Type": "application/octet-stream"},
        )
        with remote_response as r:
            remote_response.raise_for_status()
            extension = utils.get_ext_from_type(self.mimetype)
            title_parts = []
            title_parts.append(self.track.title)
            if self.track.album:
                title_parts.append(self.track.album.title)
            title_parts.append(self.track.get_artist_credit_string)

            title = " - ".join(title_parts)
            filename = f"{title}.{extension}"
            tmp_file = tempfile.TemporaryFile()
            for chunk in r.iter_content(chunk_size=512):
                tmp_file.write(chunk)
            self.audio_file.save(filename, tmp_file, save=False)
            self.save(update_fields=["audio_file"])

    def get_federation_id(self):
        if self.fid:
            return self.fid

        return federation_utils.full_url(
            reverse("federation:music:uploads-detail", kwargs={"uuid": self.uuid})
        )

    @property
    def filename(self) -> str:
        return f"{self.track.full_name}.{self.extension}"

    @property
    def extension(self):
        try:
            return utils.MIMETYPE_TO_EXTENSION[self.mimetype]
        except KeyError:
            pass
        if self.audio_file:
            return os.path.splitext(self.audio_file.name)[-1].replace(".", "", 1)
        if self.in_place_path:
            return os.path.splitext(self.in_place_path)[-1].replace(".", "", 1)

    def get_file_size(self):
        if self.audio_file:
            return self.audio_file.size

        if self.source.startswith("file://"):
            return os.path.getsize(self.source.replace("file://", "", 1))

    def get_audio_file(self):
        if self.audio_file:
            return self.audio_file.open()
        if self.source and self.source.startswith("file://"):
            return open(self.source.replace("file://", "", 1), "rb")

    def get_audio_data(self):
        audio_file = self.get_audio_file()
        if not audio_file:
            return
        audio_data = utils.get_audio_file_data(audio_file)
        if not audio_data:
            return
        return {
            "duration": int(audio_data["length"]),
            "bitrate": audio_data["bitrate"],
            "size": self.get_file_size(),
        }

    def get_audio_segment(self):
        input = self.get_audio_file()
        if not input:
            return

        audio = pydub.AudioSegment.from_file(input)
        return audio

    def get_quality(self):
        extension_to_mimetypes = utils.get_extension_to_mimetype_dict()

        if not self.bitrate and self.mimetype not in list(
            itertools.chain(
                extension_to_mimetypes["aiff"],
                extension_to_mimetypes["aif"],
                extension_to_mimetypes["flac"],
            )
        ):
            return 1

        bitrate_limits = {
            "mp3": {192: 0, 256: 1, 320: 2},
            "ogg": {96: 0, 192: 1, 256: 2},
            "aac": {96: 0, 128: 1, 288: 2},
            "m4a": {96: 0, 128: 1, 288: 2},
            "opus": {
                96: 0,
                128: 1,
                160: 2,
            },
        }

        for ext in bitrate_limits:
            if self.mimetype in extension_to_mimetypes[ext]:
                for limit, quality in sorted(bitrate_limits[ext].items()):
                    if int(self.bitrate) <= limit:
                        return quality

                # opus higher tham 160
                return 3

        if self.mimetype in list(
            itertools.chain(
                extension_to_mimetypes["aiff"],
                extension_to_mimetypes["aif"],
                extension_to_mimetypes["flac"],
            )
        ):
            return 3

    def save(self, **kwargs):
        if not self.mimetype:
            if self.audio_file:
                self.mimetype = utils.guess_mimetype(self.audio_file)
            elif self.source and self.source.startswith("file://"):
                self.mimetype = utils.guess_mimetype_from_name(self.source)
        if not self.size and self.audio_file:
            self.size = self.audio_file.size
        if not self.checksum:
            try:
                audio_file = self.get_audio_file()
            except FileNotFoundError:
                pass
            else:
                if audio_file:
                    self.checksum = common_utils.get_file_hash(audio_file)

        if not self.pk and not self.fid and self.library.actor.get_user():
            self.fid = self.get_federation_id()
        self.quality = self.get_quality()
        return super().save(**kwargs)

    def get_metadata(self):
        audio_file = self.get_audio_file()
        if not audio_file:
            return
        return metadata.Metadata(audio_file)

    @property
    def listen_url(self) -> str:
        return self.track.listen_url + f"?upload={self.uuid}"

    def get_listen_url(self, to=None, download=True) -> str:
        url = self.listen_url
        if to:
            url += f"&to={to}"
        if not download:
            url += "&download=false"

        return url

    @property
    def listen_url_no_download(self):
        # Not using reverse because this is slow
        return self.listen_url + "&download=false"

    def get_transcoded_version(self, format, max_bitrate=None):
        if format:
            mimetype = utils.EXTENSION_TO_MIMETYPE[format]
        else:
            mimetype = self.mimetype or "audio/mpeg"
            format = utils.MIMETYPE_TO_EXTENSION[mimetype]

        existing_versions = self.versions.filter(mimetype=mimetype)
        if max_bitrate is not None:
            # we don't want to transcode if a 320kbps version is available
            # and we're requestiong 300kbps
            acceptable_max_bitrate = max_bitrate * 1.2
            acceptable_min_bitrate = max_bitrate * 0.8
            existing_versions = existing_versions.filter(
                bitrate__gte=acceptable_min_bitrate, bitrate__lte=acceptable_max_bitrate
            ).order_by("-bitrate")
        if existing_versions:
            # we found an existing version, no need to transcode again
            return existing_versions[0]

        return self.create_transcoded_version(mimetype, format, bitrate=max_bitrate)

    @transaction.atomic
    def create_transcoded_version(self, mimetype, format, bitrate):
        # we create the version with an empty file, then
        # we'll write to it
        f = ContentFile(b"")
        bitrate = min(bitrate or 320000, self.bitrate or 320000)
        version = self.versions.create(mimetype=mimetype, bitrate=bitrate, size=0)
        # we keep the same name, but we update the extension
        new_name = (
            os.path.splitext(os.path.basename(self.audio_file.name))[0] + f".{format}"
        )
        version.audio_file.save(new_name, f)
        utils.transcode_audio(
            audio=self.get_audio_segment(),
            output=version.audio_file,
            output_format=utils.MIMETYPE_TO_EXTENSION[mimetype],
            bitrate=str(bitrate),
        )
        version.size = version.audio_file.size
        version.save(update_fields=["size"])

        return version

    @property
    def in_place_path(self):
        if not self.source or not self.source.startswith("file://"):
            return
        return self.source.lstrip("file://")

    @property
    def audio_file_path(self):
        if not self.audio_file:
            return None
        try:
            return self.audio_file.path
        except NotImplementedError:
            # external storage
            return self.audio_file.name

    def get_all_tagged_items(self):
        track_tags = self.track.tagged_items.all()
        album_tags = (
            self.track.album.tagged_items.all()
            if self.track.album
            else tags_models.TaggedItem.objects.none()
        )
        artist_tags = [
            ac.artist.tagged_items.all() for ac in self.track.artist_credit.all()
        ]
        non_empty_artist_tags = []
        for qs in artist_tags:
            if qs.exists():
                non_empty_artist_tags.append(qs)

        if non_empty_artist_tags:
            final_qs = (track_tags | album_tags).union(*non_empty_artist_tags)
        else:
            final_qs = track_tags | album_tags
        # this is needed to avoid *** RuntimeError: generator raised StopIteration
        final_list = [obj for obj in final_qs]
        items = sorted(final_list, key=lambda x: x.tag.name if x.tag else "")
        return items


MIMETYPE_CHOICES = [(mt, ext) for ext, mt in utils.AUDIO_EXTENSIONS_AND_MIMETYPE]


class UploadVersion(models.Model):
    upload = models.ForeignKey(
        Upload, related_name="versions", on_delete=models.CASCADE
    )
    mimetype = models.CharField(max_length=50, choices=MIMETYPE_CHOICES)
    creation_date = models.DateTimeField(default=timezone.now)
    accessed_date = models.DateTimeField(null=True, blank=True)
    audio_file = models.FileField(upload_to=get_file_path, max_length=255)
    bitrate = models.PositiveIntegerField()
    size = models.IntegerField()

    class Meta:
        unique_together = ("upload", "mimetype", "bitrate")

    @property
    def filename(self) -> str:
        try:
            return (
                self.upload.track.full_name
                + "."
                + utils.MIMETYPE_TO_EXTENSION[self.mimetype]
            )
        except KeyError:
            return self.upload.filename

    @property
    def audio_file_path(self):
        if not self.audio_file:
            return None
        try:
            return self.audio_file.path
        except NotImplementedError:
            # external storage
            return self.audio_file.name


IMPORT_STATUS_CHOICES = (
    ("pending", "Pending"),
    ("finished", "Finished"),
    ("errored", "Errored"),
    ("skipped", "Skipped"),
)


class ImportBatch(models.Model):
    uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
    IMPORT_BATCH_SOURCES = [
        ("api", "api"),
        ("shell", "shell"),
        ("federation", "federation"),
    ]
    source = models.CharField(
        max_length=30, default="api", choices=IMPORT_BATCH_SOURCES
    )
    creation_date = models.DateTimeField(default=timezone.now)
    submitted_by = models.ForeignKey(
        "users.User",
        related_name="imports",
        null=True,
        blank=True,
        on_delete=models.CASCADE,
    )
    status = models.CharField(
        choices=IMPORT_STATUS_CHOICES, default="pending", max_length=30
    )
    import_request = models.ForeignKey(
        "requests.ImportRequest",
        related_name="import_batches",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
    )
    library = models.ForeignKey(
        "Library",
        related_name="import_batches",
        null=True,
        blank=True,
        on_delete=models.CASCADE,
    )

    class Meta:
        ordering = ["-creation_date"]

    def __str__(self):
        return str(self.pk)

    def update_status(self):
        old_status = self.status
        self.status = utils.compute_status(self.jobs.all())
        if self.status == old_status:
            return
        self.save(update_fields=["status"])
        if self.status != old_status and self.status == "finished":
            from . import tasks

            tasks.import_batch_notify_followers.delay(import_batch_id=self.pk)

    def get_federation_id(self):
        return federation_utils.full_url(f"/federation/music/import/batch/{self.uuid}")


class ImportJob(models.Model):
    uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
    replace_if_duplicate = models.BooleanField(default=False)
    batch = models.ForeignKey(
        ImportBatch, related_name="jobs", on_delete=models.CASCADE
    )
    upload = models.ForeignKey(
        Upload, related_name="jobs", null=True, blank=True, on_delete=models.CASCADE
    )
    source = models.CharField(max_length=500)
    mbid = models.UUIDField(editable=False, null=True, blank=True)

    status = models.CharField(
        choices=IMPORT_STATUS_CHOICES, default="pending", max_length=30
    )
    audio_file = models.FileField(
        upload_to="imports/%Y/%m/%d", max_length=255, null=True, blank=True
    )

    library_track = models.ForeignKey(
        "federation.LibraryTrack",
        related_name="import_jobs",
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
    )
    audio_file_size = models.IntegerField(null=True, blank=True)

    class Meta:
        ordering = ("id",)

    def save(self, **kwargs):
        if self.audio_file and not self.audio_file_size:
            self.audio_file_size = self.audio_file.size
        return super().save(**kwargs)


LIBRARY_PRIVACY_LEVEL_CHOICES = [
    (k, l) for k, l in fields.PRIVACY_LEVEL_CHOICES if k != "followers"
]


class LibraryQuerySet(models.QuerySet):
    def local(self, include=True):
        query = models.Q(actor__domain_id=settings.FEDERATION_HOSTNAME)
        if not include:
            query = ~query
        return self.filter(query)

    def with_follows(self, actor):
        return self.prefetch_related(
            models.Prefetch(
                "received_follows",
                queryset=federation_models.LibraryFollow.objects.filter(actor=actor),
                to_attr="_follows",
            )
        )

    def viewable_by(self, actor):
        from funkwhale_api.federation.models import Follow, LibraryFollow

        if actor is None:
            return self.filter(privacy_level="everyone")

        me_query = models.Q(privacy_level="me", actor=actor)
        instance_query = models.Q(privacy_level="instance", actor__domain=actor.domain)
        followed_libraries = LibraryFollow.objects.filter(
            actor=actor, approved=True
        ).values_list("target", flat=True)
        followed_channels_libraries = (
            Follow.objects.exclude(target__channel=None)
            .filter(
                actor=actor,
                approved=True,
            )
            .values_list("target__channel__library", flat=True)
        )
        domains_reachable = federation_models.Domain.objects.filter(
            reachable=True
        ) | federation_models.Domain.objects.filter(name=settings.FUNKWHALE_HOSTNAME)

        return self.filter(
            me_query
            | instance_query
            | models.Q(privacy_level="everyone")
            | models.Q(pk__in=followed_libraries)
            | models.Q(pk__in=followed_channels_libraries)
            & models.Q(actor__domain__in=domains_reachable)
        )


class Library(federation_models.FederationMixin):
    uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
    actor = models.ForeignKey(
        "federation.Actor", related_name="libraries", on_delete=models.CASCADE
    )
    creation_date = models.DateTimeField(default=timezone.now)
    name = models.CharField(max_length=100)
    privacy_level = models.CharField(
        choices=LIBRARY_PRIVACY_LEVEL_CHOICES, default="me", max_length=25
    )
    uploads_count = models.PositiveIntegerField(default=0)
    objects = LibraryQuerySet.as_manager()

    def __str__(self):
        return self.name

    def get_moderation_url(self) -> str:
        return f"/manage/library/libraries/{self.uuid}"

    def get_federation_id(self) -> str:
        return federation_utils.full_url(
            reverse("federation:music:libraries-detail", kwargs={"uuid": self.uuid})
        )

    def get_absolute_url(self) -> str:
        return f"/library/{self.uuid}"

    def save(self, **kwargs):
        if not self.pk and not self.fid and self.actor.is_local:
            self.fid = self.get_federation_id()
            self.followers_url = self.fid + "/followers"

        return super().save(**kwargs)

    def should_autoapprove_follow(self, actor) -> bool:
        if self.privacy_level == "everyone":
            return True
        if self.privacy_level == "instance" and actor.get_user():
            return True
        return False

    def schedule_scan(self, actor, force=False):
        latest_scan = (
            self.scans.exclude(status="errored").order_by("-creation_date").first()
        )
        delay_between_scans = datetime.timedelta(seconds=3600 * 24)
        now = timezone.now()
        if (
            not force
            and latest_scan
            and latest_scan.creation_date + delay_between_scans > now
        ):
            return

        scan = self.scans.create(total_files=self.uploads_count, actor=actor)
        from . import tasks

        common_utils.on_commit(tasks.start_library_scan.delay, library_scan_id=scan.pk)
        return scan

    def get_channel(self):
        try:
            return self.channel
        except ObjectDoesNotExist:
            return None

    def latest_scan(self):
        return self.scans.order_by("-creation_date").first()


SCAN_STATUS = [
    ("pending", "pending"),
    ("scanning", "scanning"),
    ("errored", "errored"),
    ("finished", "finished"),
]


class LibraryScan(models.Model):
    actor = models.ForeignKey(
        "federation.Actor", null=True, blank=True, on_delete=models.CASCADE
    )
    library = models.ForeignKey(Library, related_name="scans", on_delete=models.CASCADE)
    total_files = models.PositiveIntegerField(default=0)
    processed_files = models.PositiveIntegerField(default=0)
    errored_files = models.PositiveIntegerField(default=0)
    status = models.CharField(default="pending", max_length=25)
    creation_date = models.DateTimeField(default=timezone.now)
    modification_date = models.DateTimeField(null=True, blank=True)


class TrackActor(models.Model):
    """
    Denormalization table to store all playable tracks for a given user
    Empty user means the track is public or internal (cf internal flag too)
    """

    id = models.BigAutoField(primary_key=True)
    actor = models.ForeignKey(
        "federation.Actor",
        on_delete=models.CASCADE,
        related_name="track_actor_items",
        blank=True,
        null=True,
    )
    track = models.ForeignKey(
        Track, on_delete=models.CASCADE, related_name="track_actor_items"
    )
    upload = models.ForeignKey(
        Upload, on_delete=models.CASCADE, related_name="track_actor_items"
    )
    internal = models.BooleanField(default=False, db_index=True)

    class Meta:
        unique_together = ("track", "actor", "internal", "upload")

    @classmethod
    def get_objs(cls, library, actor_ids, upload_and_track_ids):
        upload_and_track_ids = upload_and_track_ids or library.uploads.filter(
            import_status="finished", track__isnull=False
        ).values_list("id", "track")
        objs = []
        if library.privacy_level == "me":
            if library.get_channel():
                follow_queryset = library.channel.actor.received_follows
            else:
                follow_queryset = library.received_follows
            follow_queryset = follow_queryset.filter(approved=True).exclude(
                actor__user__isnull=True
            )
            if actor_ids:
                follow_queryset = follow_queryset.filter(actor__pk__in=actor_ids)
            final_actor_ids = list(follow_queryset.values_list("actor", flat=True))

            owner = library.actor if library.actor.is_local else None
            if owner and (not actor_ids or owner in final_actor_ids):
                final_actor_ids.append(owner.pk)
            for actor_id in final_actor_ids:
                for upload_id, track_id in upload_and_track_ids:
                    objs.append(
                        cls(actor_id=actor_id, track_id=track_id, upload_id=upload_id)
                    )

        elif library.privacy_level == "instance":
            for upload_id, track_id in upload_and_track_ids:
                objs.append(
                    cls(
                        actor_id=None,
                        track_id=track_id,
                        upload_id=upload_id,
                        internal=True,
                    )
                )
        elif library.privacy_level == "everyone":
            for upload_id, track_id in upload_and_track_ids:
                objs.append(cls(actor_id=None, track_id=track_id, upload_id=upload_id))
        return objs

    @classmethod
    def create_entries(
        cls, library, delete_existing=True, actor_ids=None, upload_and_track_ids=None
    ):
        if not settings.MUSIC_USE_DENORMALIZATION:
            # skip
            return
        if delete_existing:
            to_delete = cls.objects.filter(upload__library=library)
            if actor_ids:
                to_delete = to_delete.filter(actor__pk__in=actor_ids)
            # we don't use .delete() here because we don't want signals to fire
            to_delete._raw_delete(to_delete.db)

        objs = cls.get_objs(
            library, actor_ids=actor_ids, upload_and_track_ids=upload_and_track_ids
        )
        return cls.objects.bulk_create(objs, ignore_conflicts=True, batch_size=5000)


@receiver(post_save, sender=ImportJob)
def update_batch_status(sender, instance, **kwargs):
    instance.batch.update_status()


@receiver(post_save, sender=Upload)
def update_denormalization_track_actor(sender, instance, created, **kwargs):
    if (
        created
        and settings.MUSIC_USE_DENORMALIZATION
        and instance.track_id
        and instance.import_status == "finished"
    ):
        TrackActor.create_entries(
            instance.library,
            delete_existing=False,
            upload_and_track_ids=[(instance.pk, instance.track_id)],
        )


@receiver(pre_save, sender=Library)
def set_privacy_level_updated(sender, instance, update_fields, **kwargs):
    if not instance.pk:
        return
    if update_fields is not None and "privacy_level" not in update_fields:
        return
    db_value = instance.__class__.objects.filter(pk=instance.pk).values_list(
        "privacy_level", flat=True
    )[0]
    if db_value != instance.privacy_level:
        # Needed to update denormalized permissions
        setattr(instance, "_privacy_level_updated", True)


@receiver(post_save, sender=Library)
def update_denormalization_track_user_library_privacy_level(
    sender, instance, created, **kwargs
):
    if created:
        return
    updated = getattr(instance, "_privacy_level_updated", False)
    if updated:
        TrackActor.create_entries(instance)


@receiver(post_save, sender=ImportBatch)
def update_request_status(sender, instance, created, **kwargs):
    update_fields = kwargs.get("update_fields", []) or []
    if not instance.import_request:
        return

    if not created and "status" not in update_fields:
        return

    r_status = instance.import_request.status
    status = instance.status

    if status == "pending" and r_status == "pending":
        # let's mark the request as accepted since we started an import
        instance.import_request.status = "accepted"
        return instance.import_request.save(update_fields=["status"])

    if status == "finished" and r_status == "accepted":
        # let's mark the request as imported since the import is over
        instance.import_request.status = "imported"
        return instance.import_request.save(update_fields=["status"])


class UploadGroup(models.Model):
    """
    Upload groups are supposed to bundle uploads in order to make it easier to keep an overview

    Attributes
    ----------
    name A name that can be selected by the user
    guid A globally unique identifier to reference the group
    """

    name = models.CharField(max_length=255, default=datetime.datetime.now)
    guid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    owner = models.ForeignKey(
        "federation.Actor", on_delete=models.CASCADE, related_name="upload_groups"
    )
    created_at = models.DateTimeField(default=timezone.now)

    def __str__(self):
        return self.name

    @property
    def upload_url(self):
        return f"{settings.FUNKWHALE_URL}/api/v2/upload-groups/{self.guid}/uploads"
