import collections
import datetime
import logging
import os
import re

from django.conf import settings
from django.core.cache import cache
from django.db import transaction
from django.db.models import F, Q
from django.dispatch import receiver
from django.utils import timezone
from musicbrainzngs import NetworkError, ResponseError
from requests.exceptions import RequestException

from funkwhale_api import musicbrainz
from funkwhale_api.common import channels, preferences
from funkwhale_api.common import utils as common_utils
from funkwhale_api.federation import library as lb
from funkwhale_api.federation import routes
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music.management.commands import import_files
from funkwhale_api.tags import models as tags_models
from funkwhale_api.tags import tasks as tags_tasks
from funkwhale_api.taskapp import celery

from . import licenses, metadata, models, signals

logger = logging.getLogger(__name__)


def populate_album_cover(album, source=None, replace=False):
    if album.attachment_cover and not replace:
        return
    if source and source.startswith("file://"):
        # let's look for a cover in the same directory
        path = os.path.dirname(source.replace("file://", "", 1))
        logger.info("[Album %s] scanning covers from %s", album.pk, path)
        cover = get_cover_from_fs(path)
        return common_utils.attach_file(album, "attachment_cover", cover)
    if album.mbid:
        logger.info(
            "[Album %s] Fetching cover from musicbrainz release %s",
            album.pk,
            str(album.mbid),
        )
        try:
            image_data = musicbrainz.api.images.get_front(str(album.mbid))
        except ResponseError as exc:
            logger.warning(
                "[Album %s] cannot fetch cover from musicbrainz: %s", album.pk, str(exc)
            )
        else:
            return common_utils.attach_file(
                album,
                "attachment_cover",
                {"content": image_data, "mimetype": "image/jpeg"},
                fetch=True,
            )


IMAGE_TYPES = [("jpg", "image/jpeg"), ("jpeg", "image/jpeg"), ("png", "image/png")]
FOLDER_IMAGE_NAMES = ["cover", "folder"]


def get_cover_from_fs(dir_path):
    if os.path.exists(dir_path):
        for name in FOLDER_IMAGE_NAMES:
            for e, m in IMAGE_TYPES:
                cover_path = os.path.join(dir_path, f"{name}.{e}")
                if not os.path.exists(cover_path):
                    logger.debug("Cover %s does not exists", cover_path)
                    continue
                with open(cover_path, "rb") as c:
                    logger.info("Found cover at %s", cover_path)
                    return {"mimetype": m, "content": c.read()}


@celery.app.task(name="music.library.schedule_remote_scan")
def schedule_scan_for_all_remote_libraries():
    from funkwhale_api.federation import actors

    libraries = models.Library.objects.all().prefetch_related()
    actor = actors.get_service_actor()

    for library in libraries:
        if library.actor.is_local:
            continue
        library.schedule_scan(actor)


@celery.app.task(name="music.start_library_scan")
@celery.require_instance(
    models.LibraryScan.objects.select_related().filter(status="pending"), "library_scan"
)
def start_library_scan(library_scan):
    try:
        data = lb.get_library_data(library_scan.library.fid, actor=library_scan.actor)
    except Exception:
        library_scan.status = "errored"
        library_scan.save(update_fields=["status", "modification_date"])
        raise
    if "errors" in data.keys():
        library_scan.status = "errored"
        library_scan.save(update_fields=["status", "modification_date"])
        raise Exception("Error from remote server : " + str(data))
    library_scan.modification_date = timezone.now()
    library_scan.status = "scanning"
    library_scan.total_files = data["totalItems"]
    library_scan.save(update_fields=["status", "modification_date", "total_files"])
    scan_library_page.delay(library_scan_id=library_scan.pk, page_url=data["first"])


@celery.app.task(
    name="music.scan_library_page",
    retry_backoff=60,
    max_retries=5,
    autoretry_for=[RequestException],
)
@celery.require_instance(
    models.LibraryScan.objects.select_related().filter(status="scanning"),
    "library_scan",
)
def scan_library_page(library_scan, page_url):
    data = lb.get_library_page(library_scan.library, page_url, library_scan.actor)
    uploads = []

    for item_serializer in data["items"]:
        upload = item_serializer.save(library=library_scan.library)
        uploads.append(upload)

    library_scan.processed_files = F("processed_files") + len(uploads)
    library_scan.modification_date = timezone.now()
    update_fields = ["modification_date", "processed_files"]

    next_page = data.get("next")
    fetch_next = next_page and next_page != page_url

    if not fetch_next:
        update_fields.append("status")
        library_scan.status = "finished"
    library_scan.save(update_fields=update_fields)

    if fetch_next:
        scan_library_page.delay(library_scan_id=library_scan.pk, page_url=next_page)


def getter(data, *keys, default=None):
    if not data:
        return default
    v = data
    for k in keys:
        try:
            v = v[k]
        except KeyError:
            return default

    return v


class UploadImportError(ValueError):
    def __init__(self, code):
        self.code = code
        super().__init__(code)


def fail_import(upload, error_code, detail=None, **fields):
    old_status = upload.import_status
    upload.import_status = "errored"
    upload.import_details = {"error_code": error_code, "detail": detail}
    upload.import_details.update(fields)
    upload.import_date = timezone.now()
    upload.save(update_fields=["import_details", "import_status", "import_date"])

    broadcast = getter(
        upload.import_metadata, "funkwhale", "config", "broadcast", default=True
    )
    if broadcast:
        signals.upload_import_status_updated.send(
            old_status=old_status,
            new_status=upload.import_status,
            upload=upload,
            sender=None,
        )


@celery.app.task(name="music.process_upload")
@celery.require_instance(
    models.Upload.objects.filter(import_status="pending").select_related(
        "library__actor__user",
        "library__channel__artist",
    ),
    "upload",
)
def process_upload(upload, update_denormalization=True):
    """
    Main handler to process uploads submitted by user and create the corresponding
    metadata (tracks/artists/albums) in our DB.
    """
    from . import serializers

    channel = upload.library.get_channel()
    # When upload is linked to a channel instead of a library
    # we willingly ignore the metadata embedded in the file itself
    # and rely on user metadata only
    use_file_metadata = channel is None

    import_metadata = upload.import_metadata or {}
    internal_config = {"funkwhale": import_metadata.get("funkwhale", {})}
    forced_values_serializer = serializers.ImportMetadataSerializer(
        data=import_metadata,
        context={"actor": upload.library.actor, "channel": channel},
    )
    if forced_values_serializer.is_valid():
        forced_values = forced_values_serializer.validated_data
    else:
        forced_values = {}
        if not use_file_metadata:
            detail = forced_values_serializer.errors
            metadata_dump = import_metadata
            return fail_import(
                upload, "invalid_metadata", detail=detail, file_metadata=metadata_dump
            )

    if channel:
        # ensure the upload is associated with the channel artist
        forced_values["artist"] = upload.library.channel.artist

    old_status = upload.import_status
    upload_source = {"upload_source": upload.source}

    if use_file_metadata:
        audio_file = upload.get_audio_file()

        m = metadata.Metadata(audio_file)
        try:
            serializer = metadata.TrackMetadataSerializer(data=m)
            serializer.is_valid()
        except Exception:
            fail_import(upload, "unknown_error")
            raise
        if not serializer.is_valid():
            detail = serializer.errors
            try:
                metadata_dump = m.all()
            except Exception as e:
                logger.warn("Cannot dump metadata for file %s: %s", audio_file, str(e))
            return fail_import(
                upload, "invalid_metadata", detail=detail, file_metadata=metadata_dump
            )
        check_mbid = preferences.get("music__only_allow_musicbrainz_tagged_files")
        if check_mbid and not serializer.validated_data.get("mbid"):
            return fail_import(
                upload,
                "Only content tagged with a MusicBrainz ID is permitted on this pod.",
                detail="You can tag your files with MusicBrainz Picard",
            )

        final_metadata = collections.ChainMap(
            upload_source, serializer.validated_data, internal_config
        )
    else:
        final_metadata = collections.ChainMap(
            upload_source,
            forced_values,
            internal_config,
        )
    try:
        track = get_track_from_import_metadata(
            final_metadata, attributed_to=upload.library.actor, **forced_values
        )
    except UploadImportError as e:
        return fail_import(upload, e.code)
    except Exception as e:
        fail_import(upload, "unknown_error", e)
        raise

    broadcast = getter(
        internal_config, "funkwhale", "config", "broadcast", default=True
    )

    # under some situations, we want to skip the import (
    # for instance if the user already owns the files)
    owned_duplicates = get_owned_duplicates(upload, track)
    upload.track = track

    if owned_duplicates:
        upload.import_status = "skipped"
        upload.import_details = {
            "code": "already_imported_in_owned_libraries",
            # In order to avoid exponential growth of the database, we only
            # reference the first known upload which gets duplicated
            "duplicates": owned_duplicates[0],
        }
        upload.import_date = timezone.now()
        upload.save(
            update_fields=["import_details", "import_status", "import_date", "track"]
        )
        if broadcast:
            signals.upload_import_status_updated.send(
                old_status=old_status,
                new_status=upload.import_status,
                upload=upload,
                sender=None,
            )
        return

    # all is good, let's finalize the import
    audio_data = upload.get_audio_data()
    if audio_data:
        upload.duration = audio_data["duration"]
        upload.size = audio_data["size"]
        upload.bitrate = audio_data["bitrate"]
    upload.import_status = "finished"
    upload.import_date = timezone.now()
    upload.save(
        update_fields=[
            "track",
            "import_status",
            "import_date",
            "size",
            "duration",
            "bitrate",
        ]
    )
    if channel:
        common_utils.update_modification_date(channel.artist)

    if update_denormalization:
        models.TrackActor.create_entries(
            library=upload.library,
            upload_and_track_ids=[(upload.pk, upload.track_id)],
            delete_existing=False,
        )

    # update album cover, if needed
    if track.album and not track.album.attachment_cover:
        populate_album_cover(
            track.album,
            source=final_metadata.get("upload_source"),
        )

    if broadcast:
        signals.upload_import_status_updated.send(
            old_status=old_status,
            new_status=upload.import_status,
            upload=upload,
            sender=None,
        )
    dispatch_outbox = getter(
        internal_config, "funkwhale", "config", "dispatch_outbox", default=True
    )
    if dispatch_outbox:
        routes.outbox.dispatch(
            {"type": "Create", "object": {"type": "Audio"}}, context={"upload": upload}
        )


def get_cover(obj, field):
    cover = obj.get(field)
    if cover:
        try:
            url = cover["url"]
        except KeyError:
            url = cover["href"]

        return {"mimetype": cover["mediaType"], "url": url}


def federation_audio_track_to_metadata(payload, references):
    """
    Given a valid payload as returned by federation.serializers.TrackSerializer.validated_data,
    returns a correct metadata payload for use with get_track_from_import_metadata.
    """
    new_data = {
        "title": payload["name"],
        "position": payload.get("position") or 1,
        "disc_number": payload.get("disc"),
        "license": payload.get("license"),
        "copyright": payload.get("copyright"),
        "description": payload.get("description"),
        "attributed_to": references.get(payload.get("attributedTo")),
        "mbid": (
            str(payload.get("musicbrainzId")) if payload.get("musicbrainzId") else None
        ),
        "cover_data": get_cover(payload, "image"),
        "album": {
            "title": payload["album"]["name"],
            "fdate": payload["album"]["published"],
            "fid": payload["album"]["id"],
            "description": payload["album"].get("description"),
            "attributed_to": references.get(payload["album"].get("attributedTo")),
            "mbid": (
                str(payload["album"]["musicbrainzId"])
                if payload["album"].get("musicbrainzId")
                else None
            ),
            "cover_data": get_cover(payload["album"], "image"),
            "release_date": payload["album"].get("released"),
            "tags": [t["name"] for t in payload["album"].get("tags", []) or []],
            "artist_credit": [
                {
                    "artist": {
                        "fid": a["artist"]["id"],
                        "name": a["artist"]["name"],
                        "fdate": a["artist"]["published"],
                        "cover_data": get_cover(a["artist"], "image"),
                        "description": a["artist"].get("description"),
                        "attributed_to": references.get(
                            a["artist"].get("attributedTo")
                        ),
                        "mbid": (
                            str(a["artist"]["musicbrainzId"])
                            if a["artist"].get("musicbrainzId")
                            else None
                        ),
                        "tags": [t["name"] for t in a["artist"].get("tags", []) or []],
                    },
                    "joinphrase": (a["joinphrase"] if "joinphrase" in a else ""),
                    "credit": a["credit"],
                }
                for a in payload["album"]["artist_credit"]
            ],
        },
        "artist_credit": [
            {
                "artist": {
                    "fid": a["artist"]["id"],
                    "name": a["artist"]["name"],
                    "fdate": a["artist"]["published"],
                    "description": a["artist"].get("description"),
                    "attributed_to": references.get(a["artist"].get("attributedTo")),
                    "mbid": (
                        str(a["artist"]["musicbrainzId"])
                        if a["artist"].get("musicbrainzId")
                        else None
                    ),
                    "tags": [t["name"] for t in a["artist"].get("tags", []) or []],
                    "cover_data": get_cover(a["artist"], "image"),
                },
                "joinphrase": (a["joinphrase"] if "joinphrase" in a else ""),
                "credit": a["credit"],
            }
            for a in payload["artist_credit"]
        ],
        # federation
        "fid": payload["id"],
        "fdate": payload["published"],
        "tags": [t["name"] for t in payload.get("tags", []) or []],
    }

    return new_data


def get_owned_duplicates(upload, track):
    """
    Ensure we skip duplicate tracks to avoid wasting user/instance storage
    """

    owned_libraries = upload.library.actor.libraries.all()
    return (
        models.Upload.objects.filter(
            track__isnull=False, library__in=owned_libraries, track=track
        )
        .exclude(pk=upload.pk)
        .values_list("uuid", flat=True)
        .order_by("creation_date")
    )


def get_best_candidate_or_create(model, query, defaults, sort_fields):
    """
    Like queryset.get_or_create() but does not crash if multiple objects
    are returned on the get() call
    """
    candidates = model.objects.filter(query)
    if candidates:
        return sort_candidates(candidates, sort_fields)[0], False

    return model.objects.create(**defaults), True


def sort_candidates(candidates, important_fields):
    """
    Given a list of objects and a list of fields,
    will return a sorted list of those objects by score.

    Score is higher for objects that have a non-empty attribute
    that is also present in important fields::

        artist1 = Artist(mbid=None, fid=None)
        artist2 = Artist(mbid="something", fid=None)

        # artist2 has a mbid, so is sorted first
        assert sort_candidates([artist1, artist2], ['mbid'])[0] == artist2

    Only supports string fields.
    """

    # map each fields to its score, giving a higher score to first fields
    fields_scores = {f: i + 1 for i, f in enumerate(sorted(important_fields))}
    candidates_with_scores = []
    for candidate in candidates:
        current_score = 0
        for field, score in fields_scores.items():
            v = getattr(candidate, field, "")
            if v:
                current_score += score

        candidates_with_scores.append((candidate, current_score))

    return [c for c, s in reversed(sorted(candidates_with_scores, key=lambda v: v[1]))]


@transaction.atomic
def get_track_from_import_metadata(
    data, update_cover=False, attributed_to=None, query_mb=True, **forced_values
):
    track = _get_track(
        data, attributed_to=attributed_to, query_mb=query_mb, **forced_values
    )
    if update_cover and track and not track.album.attachment_cover:
        populate_album_cover(track.album, source=data.get("upload_source"))
    return track


def truncate(v, length):
    if v is None:
        return v
    return v[:length]


def _get_track(data, attributed_to=None, query_mb=True, **forced_values):
    sync_mb_tag = preferences.get("music__sync_musicbrainz_tags")
    track_uuid = getter(data, "funkwhale", "track", "uuid")

    logger.debug(f"Getting track from import metadata: {data}")
    if track_uuid:
        # easy case, we have a reference to a uuid of a track that
        # already exists in our database
        try:
            track = models.Track.objects.get(uuid=track_uuid)
        except models.Track.DoesNotExist:
            raise UploadImportError(code="track_uuid_not_found")

        return track

    from_activity_id = data.get("from_activity_id", None)
    track_mbid = (
        forced_values["mbid"] if "mbid" in forced_values else data.get("mbid", None)
    )
    try:
        album_mbid = getter(data, "album", "mbid")
    except TypeError:
        # album is forced
        album_mbid = None
    track_fid = getter(data, "fid")

    query = None

    if album_mbid and track_mbid:
        query = Q(mbid=track_mbid, album__mbid=album_mbid)

    if track_fid:
        query = query | Q(fid=track_fid) if query else Q(fid=track_fid)

    if query:
        # second easy case: we have a (track_mbid, album_mbid) pair or
        # a federation uuid we can check on
        try:
            return sort_candidates(models.Track.objects.filter(query), ["mbid", "fid"])[
                0
            ]
        except IndexError:
            pass

    # get / create artist, artist_credit
    album_artists_credits = None
    artist_credit_data = getter(data, "artist_credit", default=[])
    if "artist" in forced_values:
        artist = forced_values["artist"]
        query = Q(artist=artist)
        defaults = {
            "artist": artist,
            "joinphrase": "",
            "credit": artist.name,
        }
        track_artist_credit, created = get_best_candidate_or_create(
            models.ArtistCredit, query, defaults=defaults, sort_fields=["mbid", "fid"]
        )
        track_artists_credits = [track_artist_credit]
    else:
        mbid = query_mb and (data.get("musicbrainz_id", None) or data.get("mbid", None))
        try:
            track_artists_credits = get_or_create_artists_credits_from_musicbrainz(
                "recording",
                mbid,
                attributed_to=attributed_to,
                from_activity_id=from_activity_id,
            )
        except (NoMbid, ResponseError, NetworkError):
            track_artists_credits = (
                get_or_create_artists_credits_from_artist_credit_metadata(
                    artist_credit_data,
                    attributed_to=attributed_to,
                    from_activity_id=from_activity_id,
                )
            )

    # get / create album artist, album artist_credit
    if "album" in forced_values:
        album = forced_values["album"]
        album_artists_credits = track_artists_credits
    else:
        if album_artists_credits:
            pass
        mbid = query_mb and (data.get("musicbrainz_albumid", None) or album_mbid)
        try:
            album_artists_credits = get_or_create_artists_credits_from_musicbrainz(
                "release",
                mbid,
                attributed_to=attributed_to,
                from_activity_id=from_activity_id,
            )
        except (NoMbid, ResponseError, NetworkError):
            if album_artists := getter(data, "album", "artist_credit", default=None):
                album_artists_credits = (
                    get_or_create_artists_credits_from_artist_credit_metadata(
                        album_artists,
                        attributed_to=attributed_to,
                        from_activity_id=from_activity_id,
                    )
                )
            else:
                album_artists_credits = track_artists_credits

        # get / create album
        if "album" in data:
            album_data = data["album"]
            album_title = album_data["title"]
            album_fid = album_data.get("fid", None)

            if album_mbid:
                query = Q(mbid=album_mbid)
            else:
                query = Q(
                    title__iexact=album_title, artist_credit__in=album_artists_credits
                )

            if album_fid:
                query |= Q(fid=album_fid)

            defaults = {
                "title": album_title,
                "mbid": album_mbid,
                "release_date": album_data.get("release_date"),
                "fid": album_fid,
                "from_activity_id": from_activity_id,
                "attributed_to": album_data.get("attributed_to", attributed_to),
            }
            if album_data.get("fdate"):
                defaults["creation_date"] = album_data.get("fdate")

            album, created = get_best_candidate_or_create(
                models.Album, query, defaults=defaults, sort_fields=["mbid", "fid"]
            )
            album.artist_credit.set(album_artists_credits)

            if created:
                tags_models.add_tags(album, *album_data.get("tags", []))
                common_utils.attach_content(
                    album, "description", album_data.get("description")
                )
                common_utils.attach_file(
                    album, "attachment_cover", album_data.get("cover_data")
                )

            if sync_mb_tag and album_mbid:
                tags_tasks.sync_fw_item_tag_with_musicbrainz_tags(album)

        else:
            album = None
    # get / create track
    track_title = forced_values["title"] if "title" in forced_values else data["title"]
    position = (
        forced_values["position"]
        if "position" in forced_values
        else data.get("position", 1)
    )
    disc_number = (
        forced_values["disc_number"]
        if "disc_number" in forced_values
        else data.get("disc_number")
    )
    license = (
        forced_values["license"]
        if "license" in forced_values
        else licenses.match(data.get("license"), data.get("copyright"))
    )
    copyright = (
        forced_values["copyright"]
        if "copyright" in forced_values
        else data.get("copyright")
    )
    description = (
        {"text": forced_values["description"], "content_type": "text/markdown"}
        if "description" in forced_values
        else data.get("description")
    )
    cover_data = (
        forced_values["cover"] if "cover" in forced_values else data.get("cover_data")
    )

    query = Q(
        title__iexact=track_title,
        artist_credit__in=track_artists_credits,
        album=album,
        position=position,
        disc_number=disc_number,
    )
    if track_mbid:
        if album_mbid:
            query |= Q(mbid=track_mbid, album__mbid=album_mbid)
        else:
            query |= Q(mbid=track_mbid)
    if track_fid:
        query |= Q(fid=track_fid)

    defaults = {
        "title": track_title,
        "album": album,
        "mbid": track_mbid,
        "position": position,
        "disc_number": disc_number,
        "fid": track_fid,
        "from_activity_id": from_activity_id,
        "attributed_to": data.get("attributed_to", attributed_to),
        "license": license,
        "copyright": copyright,
    }
    if data.get("fdate"):
        defaults["creation_date"] = data.get("fdate")

    track, created = get_best_candidate_or_create(
        models.Track, query, defaults=defaults, sort_fields=["mbid", "fid"]
    )

    if created:
        tags = (
            forced_values["tags"] if "tags" in forced_values else data.get("tags", [])
        )
        tags_models.add_tags(track, *tags)
        common_utils.attach_content(track, "description", description)
        common_utils.attach_file(track, "attachment_cover", cover_data)

    if sync_mb_tag and track_mbid:
        tags_tasks.sync_fw_item_tag_with_musicbrainz_tags(track)

    track.artist_credit.set(track_artists_credits)
    return track


def get_or_create_artist_from_ac(ac_data, attributed_to, from_activity_id):
    sync_mb_tag = preferences.get("music__sync_musicbrainz_tags")
    mbid = ac_data.get("artist", {}).get("mbid", None)
    fid = ac_data.get("artist", {}).get("fid", None)
    name = ac_data.get("artist", {}).get("name", ac_data.get("credit", None))
    creation_date = ac_data.get("artist", {}).get("fdate", timezone.now())
    description = ac_data.get("artist", {}).get("description", None)
    attributed_to = ac_data.get("artist", {}).get("attributed_to", attributed_to)
    tags = ac_data.get("artist", {}).get("tags", [])
    cover = ac_data.get("artist", {}).get("cover_data", None)

    if mbid:
        query = Q(mbid=mbid)
    else:
        query = Q(name__iexact=name)

    if fid:
        query |= Q(fid=fid)

    defaults = {
        "name": name,
        "mbid": mbid,
        "fid": fid,
        "from_activity_id": from_activity_id,
        "attributed_to": attributed_to,
        "creation_date": creation_date,
    }
    if ac_data.get("fdate"):
        defaults["creation_date"] = ac_data.get("fdate")

    artist, created = get_best_candidate_or_create(
        models.Artist, query, defaults=defaults, sort_fields=["mbid", "fid"]
    )
    if created:
        tags_models.add_tags(artist, *tags)
        common_utils.attach_content(artist, "description", description)
        common_utils.attach_file(artist, "attachment_cover", cover)
    if sync_mb_tag and mbid:
        tags_tasks.sync_fw_item_tag_with_musicbrainz_tags(artist)

    return artist


class NoMbid(Exception):
    pass


def get_or_create_artists_credits_from_musicbrainz(
    mb_obj_type, mbid, attributed_to, from_activity_id
):
    if not mbid:
        raise NoMbid

    try:
        if mb_obj_type == "release":
            mb_obj = musicbrainz.api.releases.get(mbid, includes=["artists"])
        elif mb_obj_type == "recording":
            mb_obj = musicbrainz.api.recordings.get(mbid, includes=["artists"])
    except (ResponseError, NetworkError) as e:
        logger.warning(
            f"Couldn't get Musicbrainz information for {mb_obj_type} with {mbid} mbid  \
            because of the following exception : {e}"
        )
        raise e

    artists_credits = []
    acs = mb_obj.get("recording", mb_obj)["artist-credit"]
    logger.debug(f"MusicBrainz responded with : {mb_obj}")
    for i, ac in enumerate(acs):
        if isinstance(ac, str):
            continue
        artist_name = ac["artist"]["name"]
        joinphrase = ac["joinphrase"]

        # mb use "name" instead of "credit" and id instead of mbdi
        credit = ac.get("name", ac.get("credit", artist_name))
        ac["credit"] = credit
        ac["artist"]["mbid"] = ac["artist"]["id"]

        artist = get_or_create_artist_from_ac(ac, attributed_to, from_activity_id)

        # artist_credit creation
        defaults = {
            "artist": artist,
            "joinphrase": joinphrase,
            "credit": credit,
            "index": i,
        }
        query = (
            Q(artist=artist.pk)
            & Q(joinphrase=joinphrase)
            & Q(credit=credit)
            & Q(index=i)
        )

        artist_credit, created = get_best_candidate_or_create(
            models.ArtistCredit, query, defaults=defaults, sort_fields=["mbid", "fid"]
        )
        artists_credits.append(artist_credit)
    return artists_credits


def parse_credits(artist_string, forced_joinphrase, forced_index, forced_artist=None):
    """
    Return a list of parsed artist_credit information from a string like :
    LoveDiversity featuring Hatingprisons
    """
    if not artist_string:
        return []
    join_phrase = preferences.get("music__join_phrases")
    join_phrase_regex = re.compile(rf"({join_phrase})", re.IGNORECASE)
    split = re.split(join_phrase_regex, artist_string)
    raw_artists_credits = tuple(zip(split[0::2], split[1::2]))

    artists_credits_tuple = []
    for index, raw_artist_credit in enumerate(raw_artists_credits):
        credit = raw_artist_credit[0].strip()
        if forced_joinphrase:
            join_phrase = forced_joinphrase
        else:
            join_phrase = raw_artist_credit[1]
            if join_phrase == "( " or join_phrase == ") ":
                join_phrase = join_phrase.strip()

        artists_credits_tuple.append(
            (
                credit,
                join_phrase,
                (index if not forced_index else forced_index),
                forced_artist,
            )
        )

    # impar split :
    if len(split) % 2 != 0 and split[len(split) - 1] != "" and len(split) > 1:
        artists_credits_tuple.append(
            (
                str(split[len(split) - 1]).rstrip(),
                ("" if not forced_joinphrase else forced_joinphrase),
                (len(artists_credits_tuple) if not forced_index else forced_index),
                forced_artist,
            )
        )

    # if "name" is empty or didn't split
    if not raw_artists_credits:
        credit = forced_artist.name if forced_artist else artist_string
        artists_credits_tuple.append(
            (
                credit,
                ("" if not forced_joinphrase else forced_joinphrase),
                (0 if not forced_index else forced_index),
                forced_artist,
            )
        )
    return artists_credits_tuple


def get_or_create_artists_credits_from_artist_credit_metadata(
    artists_credits_data, attributed_to, from_activity_id
):
    artists_credits = []
    for i, ac in enumerate(artists_credits_data):
        ac["artist"] = get_or_create_artist_from_ac(ac, attributed_to, from_activity_id)
        ac["index"] = ac.get("index", i)

        credit = ac.get("credit", ac["artist"].name)
        query = (
            Q(artist=ac["artist"])
            & Q(credit=credit)
            & Q(joinphrase=ac["joinphrase"])
            & Q(index=ac.get("index", i))
        )

        artist_credit, created = get_best_candidate_or_create(
            models.ArtistCredit, query, ac, ["artist", "credit", "joinphrase"]
        )
        artists_credits.append(artist_credit)

    return artists_credits


@receiver(signals.upload_import_status_updated)
def broadcast_import_status_update_to_owner(old_status, new_status, upload, **kwargs):
    user = upload.library.actor.get_user()
    if not user:
        return

    from . import serializers

    group = f"user.{user.pk}.imports"
    channels.group_send(
        group,
        {
            "type": "event.send",
            "text": "",
            "data": {
                "type": "import.status_updated",
                "upload": serializers.UploadForOwnerSerializer(upload).data,
                "old_status": old_status,
                "new_status": new_status,
            },
        },
    )


@celery.app.task(name="music.clean_transcoding_cache")
def clean_transcoding_cache():
    delay = preferences.get("music__transcoding_cache_duration")
    if delay < 1:
        return  # cache clearing disabled
    limit = timezone.now() - datetime.timedelta(minutes=delay)
    candidates = (
        models.UploadVersion.objects.filter(
            Q(accessed_date__lt=limit) | Q(accessed_date=None)
        )
        .only("audio_file", "id")
        .order_by("id")
    )
    return candidates.delete()


@celery.app.task(name="music.albums_set_tags_from_tracks")
@transaction.atomic
def albums_set_tags_from_tracks(ids=None, dry_run=False):
    qs = models.Album.objects.filter(tagged_items__isnull=True).order_by("id")
    qs = federation_utils.local_qs(qs)
    qs = qs.values_list("id", flat=True)
    if ids is not None:
        qs = qs.filter(pk__in=ids)
    data = tags_tasks.get_tags_from_foreign_key(
        ids=qs,
        foreign_key_model=models.Track,
        foreign_key_attr="album",
    )
    logger.info("Found automatic tags for %s albums…", len(data))
    if dry_run:
        logger.info("Running in dry-run mode, not committing")
        return

    tags_tasks.add_tags_batch(
        data,
        model=models.Album,
    )
    return data


@celery.app.task(name="music.artists_set_tags_from_tracks")
@transaction.atomic
def artists_set_tags_from_tracks(ids=None, dry_run=False):
    qs = models.Artist.objects.filter(tagged_items__isnull=True).order_by("id")
    qs = federation_utils.local_qs(qs)
    qs = qs.values_list("id", flat=True)
    if ids is not None:
        qs = qs.filter(pk__in=ids)
    data = tags_tasks.get_tags_from_foreign_key(
        ids=qs,
        foreign_key_model=models.Track,
        foreign_key_attr="artist",
    )
    logger.info("Found automatic tags for %s artists…", len(data))
    if dry_run:
        logger.info("Running in dry-run mode, not committing")
        return

    tags_tasks.add_tags_batch(
        data,
        model=models.Artist,
    )
    return data


def get_prunable_tracks(
    exclude_favorites=True, exclude_playlists=True, exclude_listenings=True
):
    """
    Returns a list of tracks with no associated uploads,
    excluding the one that were listened/favorited/included in playlists.
    """
    purgeable_tracks_with_upload = (
        models.Upload.objects.exclude(track=None)
        .filter(import_status="skipped")
        .values("track")
    )
    queryset = models.Track.objects.all()
    queryset = queryset.filter(
        Q(uploads__isnull=True) | Q(pk__in=purgeable_tracks_with_upload)
    )
    if exclude_favorites:
        queryset = queryset.filter(track_favorites__isnull=True)
    if exclude_playlists:
        queryset = queryset.filter(playlist_tracks__isnull=True)
    if exclude_listenings:
        queryset = queryset.filter(listenings__isnull=True)

    return queryset


def get_prunable_albums():
    return models.Album.objects.filter(tracks__isnull=True)


def get_prunable_artists():
    return models.Artist.objects.filter(artist_credit__isnull=True)


def update_library_entity(obj, data):
    """
    Given an obj and some updated fields, will persist the changes on the obj
    and also check if the entity need to be aliased with existing objs (i.e
    if a mbid was added on the obj, and match another entity with the same mbid)
    """
    for key, value in data.items():
        setattr(obj, key, value)

    # Todo: handle integrity error on unique fields (such as MBID)
    obj.save(update_fields=list(data.keys()))

    return obj


UPDATE_CONFIG = {
    "track": {
        "position": {},
        "title": {},
        "mbid": {},
        "disc_number": {},
        "copyright": {},
        "license": {
            "getter": lambda data, field: licenses.match(
                data.get("license"), data.get("copyright")
            )
        },
    },
    "artists": {},
    "album": {"title": {}, "mbid": {}, "release_date": {}},
    "album_artist": {"name": {}, "mbid": {}},
}


@transaction.atomic
def update_track_metadata(audio_metadata, track):
    serializer = metadata.TrackMetadataSerializer(data=audio_metadata)
    serializer.is_valid(raise_exception=True)
    new_data = serializer.validated_data

    to_update = [
        ("track", track, lambda data: data),
        ("album", track.album, lambda data: data["album"]),
        (
            "artist_credit",
            track.artist_credit.all(),
            lambda data: data["artist_credit"],
        ),
        (
            "album_artist",
            track.album.artist_credit.all() if track.album else None,
            lambda data: data["album"]["artist_credit"],
        ),
    ]
    for id, obj, data_getter in to_update:
        if not obj:
            continue
        obj_updated_fields = []
        try:
            obj_data = data_getter(new_data)
        except IndexError:
            continue

        if id == "artist_credit":
            if new_data.get("mbid", False):
                logger.warning(
                    "If a track mbid is provided, it will be use to generate artist_credit \
                    information. If you want to set a custom artist_credit you nee to remove the track mbid"
                )
                track_artists_credits = get_or_create_artists_credits_from_musicbrainz(
                    "recording", new_data.get("mbid"), None, None
                )
            else:
                track_artists_credits = (
                    get_or_create_artists_credits_from_artist_credit_metadata(
                        obj_data,
                        None,
                        None,
                    )
                )
            if track_artists_credits == obj:
                continue

            track.artist_credit.set(track_artists_credits)
            continue

        if id == "album_artist":
            if new_data["album"].get("mbid", False):
                logger.warning(
                    "If a album mbid is provided, it will be use to generate album artist_credit \
                    information. If you want to set a custom artist_credit you nee to remove the track mbid"
                )
                album_artists_credits = get_or_create_artists_credits_from_musicbrainz(
                    "release", new_data["album"].get("mbid"), None, None
                )
            else:
                album_artists_credits = (
                    get_or_create_artists_credits_from_artist_credit_metadata(
                        obj_data,
                        None,
                        None,
                    )
                )

            if album_artists_credits == obj:
                continue

            track.album.artist_credit.set(album_artists_credits)
            continue

        for field, config in UPDATE_CONFIG[id].items():
            getter = config.get(
                "getter", lambda data, field: data[config.get("field", field)]
            )
            try:
                new_value = getter(obj_data, field)
            except KeyError:
                continue
            old_value = getattr(obj, field)
            if new_value == old_value:
                continue
            obj_updated_fields.append(field)
            setattr(obj, field, new_value)

        if obj_updated_fields:
            obj.save(update_fields=obj_updated_fields)
    tags_models.set_tags(track, *new_data.get("tags", []))

    if track.album and "album" in new_data and new_data["album"].get("cover_data"):
        common_utils.attach_file(
            track.album, "attachment_cover", new_data["album"].get("cover_data")
        )


@celery.app.task(name="music.fs_import")
@celery.require_instance(models.Library.objects.all(), "library")
def fs_import(
    library,
    path,
    import_reference,
    prune=True,
    outbox=False,
    broadcast=False,
    batch_size=1000,
    verbosity=1,
):
    if cache.get("fs-import:status") != "pending":
        raise ValueError("Invalid import status")

    command = import_files.Command()

    options = {
        "recursive": True,
        "library_id": str(library.uuid),
        "path": [os.path.join(settings.MUSIC_DIRECTORY_PATH, path)],
        "update_cache": True,
        "in_place": True,
        "reference": import_reference,
        "watch": False,
        "interactive": False,
        "batch_size": batch_size,
        "async_": False,
        "prune": prune,
        "replace": False,
        "verbosity": verbosity,
        "exit_on_failure": False,
        "outbox": outbox,
        "broadcast": broadcast,
    }
    command.handle(**options)
