import logging
from dataclasses import dataclass
from typing import Optional
from uuid import uuid4

import musicbrainzngs
import requests
from django.conf import settings
from django.core.files.base import ContentFile
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from music.allmusic import get_allmusic_slug, scrape_data_from_allmusic
from music.bandcamp import get_bandcamp_slug
from music.musicbrainz import (
    extract_featured_artists,
    get_album_metadata_with_artist,
    get_track_metadata_with_artist,
    resolve_track,
)
from music.theaudiodb import lookup_album_from_tadb, lookup_artist_from_tadb
from music.utils import clean_artist_name
from scrobbles.dataclasses import BaseLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin

logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}


@dataclass
class TrackLogData(BaseLogData):
    mopidy_source: Optional[str] = None
    rockbox_info: Optional[str] = None
    rating: Optional[int] = None


class Artist(TimeStampedModel):
    """Represents a music artist.

    # Lookup or create by title alone
    >>> Artist.find_or_create(name="Bon Iver")

    # Lookup or create by MB id alone
    >>> Artist.find_or_create(musicbrainz_id="0307edfc-437c-4b48-8700-80680e66a228")

    """

    uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
    name = models.CharField(max_length=255)
    biography = models.TextField(**BNULL)
    theaudiodb_id = models.CharField(max_length=255, unique=True, **BNULL)
    theaudiodb_genre = models.CharField(max_length=255, **BNULL)
    theaudiodb_mood = models.CharField(max_length=255, **BNULL)
    musicbrainz_id = models.CharField(max_length=255, **BNULL)
    similar_artists = models.JSONField(**BNULL)
    allmusic_id = models.CharField(max_length=100, **BNULL)
    bandcamp_id = models.CharField(max_length=100, **BNULL)
    thumbnail = models.ImageField(upload_to="artist/", **BNULL)
    thumbnail_small = ImageSpecField(
        source="thumbnail",
        processors=[ResizeToFit(100, 100)],
        format="JPEG",
        options={"quality": 60},
    )
    thumbnail_medium = ImageSpecField(
        source="thumbnail",
        processors=[ResizeToFit(300, 300)],
        format="JPEG",
        options={"quality": 75},
    )
    alt_names = models.TextField(**BNULL)

    class Meta:
        unique_together = [["name", "musicbrainz_id"]]

    def __str__(self):
        return self.name

    @property
    def primary_image_url(self) -> str:
        if self.thumbnail:
            return self.thumbnail.url
        if self.album_set.first().cover_image:
            return self.album_set.first().cover_image.url
        return ""

    @property
    def mb_link(self) -> str:
        if self.musicbrainz_id:
            return f"https://musicbrainz.org/artist/{self.musicbrainz_id}"
        return ""

    @property
    def allmusic_link(self):
        if self.allmusic_id:
            return f"https://www.allmusic.com/artist/{self.allmusic_id}"
        return ""

    @property
    def bandcamp_link(self):
        if self.bandcamp_id:
            return f"https://{self.bandcamp_id}.bandcamp.com/"
        return ""

    def get_absolute_url(self):
        return reverse("music:artist_detail", kwargs={"slug": self.uuid})

    def scrobbles(self):
        from scrobbles.models import Scrobble

        return Scrobble.objects.filter(track__in=self.track_set.all()).order_by(
            "-timestamp"
        )

    @property
    def tracks(self):
        return (
            self.track_set.all()
            .annotate(scrobble_count=models.Count("scrobble"))
            .order_by("-scrobble_count")
        )

    def charts(self):
        from scrobbles.models import ChartRecord

        return ChartRecord.objects.filter(track__artists=self).order_by("-year")

    def scrape_allmusic(self, force=False) -> None:
        if not self.allmusic_id or force:
            slug = get_allmusic_slug(self.name)
            if not slug:
                logger.info("No allmusic link found", extra={"track_id": self.id})
                return
            self.allmusic_id = slug
            self.save(update_fields=["allmusic_id"])

    def scrape_bandcamp(self, force=False) -> None:
        if not self.bandcamp_id or force:
            slug = get_bandcamp_slug(self.name)
            if not slug:
                logger.info("No bandcamp link found", extra={"track_id": self.id})
                return
            self.bandcamp_id = slug
            self.save(update_fields=["bandcamp_id"])

    def fix_metadata(self, force_update=False):
        tadb_info = {}
        if self.theaudiodb_id and force_update:
            tadb_info = lookup_artist_from_tadb(self.theaudiodb_id)

        if not self.theaudiodb_id or (force_update and not tadb_info):
            tadb_info = lookup_artist_from_tadb(self.name)

        if not tadb_info:
            logger.warn(
                f"No response from TADB for artist {self.name}, try force_update=True"
            )
            return

        self.biography = tadb_info["biography"]
        self.theaudiodb_genre = tadb_info["genre"]
        self.theaudiodb_mood = tadb_info["mood"]

        thumb_url = tadb_info.get("thumb_url", "")
        if thumb_url:
            r = requests.get(thumb_url)
            if r.status_code == 200:
                fname = f"{self.name}_{self.uuid}.jpg"
                self.thumbnail.save(fname, ContentFile(r.content), save=True)

    @property
    def rym_link(self):
        artist_slug = self.name.lower().replace(" ", "-").replace(",", "")
        return f"https://rateyourmusic.com/artist/{artist_slug}/"

    @property
    def bandcamp_search_link(self):
        artist = self.name.lower()
        return f"https://bandcamp.com/search?q={artist}&item_type=b"

    @classmethod
    def find_or_create(
        cls, name: str, album_name: str = "", track_name: str = ""
    ) -> "Artist":
        """The biggest challenge to finding artists is that the search often
        fails miserably unless you can look it up along with an album or a track name.

        Thus, when we find or create an artist, we should always provide an optional
        album name or track name, but probably not both."""
        if album_name:
            logger.info(f"Looking for artist with name {name} and album {album_name}")
        if track_name:
            logger.info(f"Looking for artist with name {name} and track {track_name}")
        keys = {}

        name = clean_artist_name(name)
        keys["name"] = name
        artist = cls.objects.filter(name=name).first()

        if artist:
            return artist

        # alt_name = None
        artist_dict = {}
        if album_name:
            album_dict = get_album_metadata_with_artist(album_name, name)
            if album_dict:
                artist_dict = album_dict.get("primary_artist")
        if track_name:
            track_dict = get_track_metadata_with_artist(track_name, name)
            if track_dict:
                artist_dict = track_dict.get("primary_artist")

        if not artist_dict:
            artist, created = cls.objects.get_or_create(name=name)
            if created:
                artist.fix_metadata()
            return artist

        musicbrainz_id = artist_dict.get("mbid")
        found_name = artist_dict.get("name", name)
        if found_name and name != found_name:
            alt_name = found_name

        artist = cls.objects.filter(
            name=found_name, musicbrainz_id=musicbrainz_id
        ).first()
        if not artist:
            artist = cls.objects.create(
                name=found_name,
                musicbrainz_id=musicbrainz_id,
            )
            artist.fix_metadata()

        return artist


class Album(TimeStampedModel):
    uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
    name = models.CharField(max_length=255)
    album_artist = models.ForeignKey(
        Artist, related_name="albums", on_delete=models.DO_NOTHING, **BNULL
    )
    artists = models.ManyToManyField(Artist)
    year = models.IntegerField(**BNULL)
    musicbrainz_id = models.CharField(max_length=255, unique=True, **BNULL)
    musicbrainz_releasegroup_id = models.CharField(max_length=255, **BNULL)
    musicbrainz_albumartist_id = models.CharField(max_length=255, **BNULL)
    cover_image = models.ImageField(upload_to="albums/", **BNULL)
    cover_image_small = ImageSpecField(
        source="cover_image",
        processors=[ResizeToFit(100, 100)],
        format="JPEG",
        options={"quality": 60},
    )
    cover_image_medium = ImageSpecField(
        source="cover_image",
        processors=[ResizeToFit(300, 300)],
        format="JPEG",
        options={"quality": 75},
    )
    theaudiodb_id = models.CharField(max_length=255, unique=True, **BNULL)
    theaudiodb_description = models.TextField(**BNULL)
    theaudiodb_year_released = models.IntegerField(**BNULL)
    theaudiodb_score = models.FloatField(**BNULL)
    theaudiodb_score_votes = models.IntegerField(**BNULL)
    theaudiodb_genre = models.CharField(max_length=255, **BNULL)
    theaudiodb_style = models.CharField(max_length=255, **BNULL)
    theaudiodb_mood = models.CharField(max_length=255, **BNULL)
    theaudiodb_speed = models.CharField(max_length=255, **BNULL)
    theaudiodb_theme = models.CharField(max_length=255, **BNULL)
    allmusic_id = models.CharField(max_length=255, **BNULL)
    allmusic_rating = models.IntegerField(**BNULL)
    allmusic_review = models.TextField(**BNULL)
    bandcamp_id = models.CharField(max_length=100, **BNULL)
    rateyourmusic_id = models.CharField(max_length=255, **BNULL)
    wikipedia_slug = models.CharField(max_length=255, **BNULL)
    discogs_id = models.CharField(max_length=255, **BNULL)
    wikidata_id = models.CharField(max_length=255, **BNULL)
    alt_names = models.TextField(**BNULL)

    def __str__(self) -> str:
        return "{} by {}".format(self.name, self.artist)

    def get_absolute_url(self):
        return reverse("music:album_detail", kwargs={"slug": self.uuid})

    def scrobbles(self):
        from scrobbles.models import Scrobble

        return Scrobble.objects.filter(track__in=self.track_set.all()).order_by(
            "-timestamp"
        )

    @property
    def primary_image_url(self) -> str:
        if self.cover_image:
            return self.cover_image_medium.url
        return ""

    @property
    def tracks(self):
        return (
            self.track_set.all()
            .annotate(scrobble_count=models.Count("scrobble"))
            .order_by("-scrobble_count")
        )

    @property
    def artist(self):
        return self.artists.first() or self.album_artist

    def fix_album_artist(self):
        from music.utils import get_or_create_various_artists

        multiple_artists = self.artists.count() > 1
        if multiple_artists:
            self.album_artist = get_or_create_various_artists()
        else:
            self.album_artist = self.artists.first()
        self.save(update_fields=["album_artist"])

    def scrape_allmusic(self, force=False) -> None:
        if not self.name:
            logger.warning(
                "Album without a name cannot be scraped",
                extra={"album_id": self.id},
            )
            return

        if self.artist and (not self.allmusic_id or force):
            slug = get_allmusic_slug(self.artist.name, self.name)
            if not slug:
                logger.info(f"No allmsuic link for {self} by {self.artist}")
                return
            self.allmusic_id = slug
            self.save(update_fields=["allmusic_id"])

        allmusic_data = None
        if self.allmusic_link:
            allmusic_data = scrape_data_from_allmusic(self.allmusic_link)

        if not allmusic_data:
            logger.info(f"No allmsuic data for {self} by {self.artist}")
            return

        self.allmusic_review = allmusic_data["review"]
        self.allmusic_rating = allmusic_data["rating"]
        self.save(update_fields=["allmusic_review", "allmusic_rating"])

    def scrape_theaudiodb(self) -> None:
        artist = "Various Artists"
        if self.artist:
            artist = self.artist.name
        album_data = lookup_album_from_tadb(self.name, artist)
        if not album_data.get("theaudiodb_id"):
            logger.info(f"No data for {self} found in TheAudioDB")
            return

        try:
            Album.objects.filter(pk=self.pk).update(**album_data)
        except:
            logger.info(f"Could not save info for album {self} with data {album_data}")

    def scrape_bandcamp(self, force=False) -> None:
        if not self.bandcamp_id or force:
            slug = get_bandcamp_slug(self.artist.name, self.name)
            if not slug:
                logger.info(f"No bandcamp link for {self}")
                return
            self.bandcamp_id = slug
            self.save(update_fields=["bandcamp_id"])

    def fix_metadata(self):
        if (
            not self.musicbrainz_albumartist_id
            or not self.year
            or not self.musicbrainz_releasegroup_id
        ):
            musicbrainzngs.set_useragent("vrobbler", "0.3.0")
            mb_data = musicbrainzngs.get_release_by_id(
                self.musicbrainz_id, includes=["artists", "release-groups"]
            )
            if not self.musicbrainz_releasegroup_id:
                self.musicbrainz_releasegroup_id = mb_data["release"]["release-group"][
                    "id"
                ]
            if not self.musicbrainz_albumartist_id:
                self.musicbrainz_albumartist_id = mb_data["release"]["artist-credit"][
                    0
                ]["artist"]["id"]
            if not self.year:
                try:
                    self.year = mb_data["release"]["date"][0:4]
                except KeyError:
                    pass
                except IndexError:
                    pass

            self.save(
                update_fields=[
                    "musicbrainz_albumartist_id",
                    "musicbrainz_releasegroup_id",
                    "year",
                ]
            )

            new_artist = Artist.objects.filter(
                musicbrainz_id=self.musicbrainz_albumartist_id
            ).first()
            if self.musicbrainz_albumartist_id and new_artist:
                self.artists.add(new_artist)
            if not new_artist:
                for t in self.track_set.all():
                    for a in t.artists.all():
                        self.artists.add(a)
            if not self.cover_image or self.cover_image == "default-image-replace-me":
                self.fetch_artwork()
        self.fix_album_artist()
        self.scrape_theaudiodb()
        self.scrape_allmusic()

    def fetch_artwork(self, force=False):
        if not self.cover_image and not force:
            if self.musicbrainz_id:
                try:
                    img_data = musicbrainzngs.get_image_front(self.musicbrainz_id)
                    name = f"{self.name}_{self.uuid}.jpg"
                    self.cover_image = ContentFile(img_data, name=name)
                    logger.info(f"Setting image to {name}")
                except musicbrainzngs.ResponseError:
                    logger.warning(f"No cover art found for {self.name} by release")

            if (
                not self.cover_image or self.cover_image == "default-image-replace-me"
            ) and self.musicbrainz_releasegroup_id:
                try:
                    img_data = musicbrainzngs.get_release_group_image_front(
                        self.musicbrainz_releasegroup_id
                    )
                    name = f"{self.name}_{self.uuid}.jpg"
                    self.cover_image = ContentFile(img_data, name=name)
                    logger.info(f"Setting image to {name}")
                except musicbrainzngs.ResponseError:
                    logger.warning(
                        f"No cover art found for {self.name} by release group"
                    )
            if not self.cover_image:
                logger.debug(
                    f"No cover art found for release or release group for {self.name}, setting to default"
                )
            self.save()

    @property
    def mb_link(self) -> str:
        return f"https://musicbrainz.org/release/{self.musicbrainz_id}"

    @property
    def allmusic_link(self) -> str:
        if self.allmusic_id:
            return f"https://www.allmusic.com/album/{self.allmusic_id}"
        return ""

    @property
    def wikipedia_link(self):
        if self.wikipedia_slug:
            return f"https://www.wikipedia.org/en/{self.wikipedia_slug}"
        return ""

    @property
    def tadb_link(self):
        if self.theaudiodb_id:
            return f"https://www.theaudiodb.com/album/{self.theaudiodb_id}"
        return ""

    @property
    def rym_link(self):
        artist_slug = self.artist.name.lower().replace(" ", "-")
        album_slug = self.name.lower().replace(" ", "-")
        return f"https://rateyourmusic.com/release/album/{artist_slug}/{album_slug}/"

    @property
    def bandcamp_link(self):
        if self.bandcamp_id and self.artist.bandcamp_id:
            return f"https://{self.artist.bandcamp_id}.bandcamp.com/album/{self.bandcamp_id}"
        return ""

    @property
    def bandcamp_search_link(self):
        artist = self.artist.name.lower()
        album = self.name.lower()
        return f"https://bandcamp.com/search?q={album} {artist}&item_type=a"

    @classmethod
    def find_or_create(cls, name: str, artist_name: str) -> "Album":
        logger.info(f"Looking for album with name {name} and artist_name {artist_name}")
        artist = Artist.find_or_create(artist_name, album_name=name)
        album_dict = get_album_metadata_with_artist(name, artist.name)

        if not album_dict:
            logger.info(
                f"Could not find album {name} with artist {artist.name} on musicbrainz"
            )
            album = Album.objects.filter(name=name).first()
            if not album:
                album, created = Album.objects.get_or_create(
                    name=name,
                )
                if created:
                    # album.fix_metadata()
                    # album.fetch_artwork()
                    ...
            return album

        if not artist:
            artist_dict = album_dict.get("primary_artist", {})
            if artist_dict:
                artist = Artist.objects.filter(
                    musicbrainz_id=artist_dict.get("mbid"),
                ).first()
                if not artist:
                    artist = Artist.objects.create(
                        musicbrainz_id=artist_dict.get("mbid"),
                    )

        extra_artists = []
        if not artist and len(album_dict.get("all_artists")) > 1:
            artist = Artist.objects.filter(name="Various Artists").first()
            extra_artists.append(artist)

        if not artist:
            raise Exception("No album artist found, and not a compliation")

        album = cls.objects.filter(
            models.Q(name=name) | models.Q(alt_names__icontains=name),
            album_artist=artist,
        ).first()

        alt_name = None
        found_name = album_dict.get("album_title", name)
        if found_name and name != found_name:
            alt_name = name

        album = Album.objects.filter(musicbrainz_id=album_dict.get("mbid")).first()

        if not album:
            year = None
            if album_dict.get("release_date"):
                year = album_dict.get("release_date", "").split("-")[0]
            album = Album.objects.create(
                name=found_name,
                musicbrainz_id=album_dict.get("mbid"),
                musicbrainz_releasegroup_id=album_dict.get("release_group_mbid"),
                year=year,
                album_artist=artist,
                alt_names=alt_name,
            )
            album.artists.add(artist, *extra_artists)
            album.fetch_artwork()

        return album


class Track(ScrobblableMixin):
    COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 100)

    artist_fk = models.ForeignKey(
        Artist, on_delete=models.DO_NOTHING, **BNULL, related_name="+"
    )
    artists = models.ManyToManyField(Artist)
    albums = models.ManyToManyField(Album, related_name="tracks")
    album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
    musicbrainz_id = models.CharField(max_length=255, **BNULL)
    similar_recordings = models.JSONField(**BNULL)

    class Meta:
        unique_together = [["album", "musicbrainz_id"]]

    @property
    def artist(self):
        return self.artists.first() or self.artist_fk

    def __str__(self):
        return f"{self.title} by {self.artist}"

    @property
    def logdata_cls(self):
        return TrackLogData

    @property
    def primary_album(self):
        if self.album:
            return self.album
        return self.albums.order_by("year").first()

    def get_absolute_url(self):
        return reverse("music:track_detail", kwargs={"slug": self.uuid})

    @property
    def subtitle(self) -> str:
        return str(self.artist)

    @property
    def strings(self) -> ScrobblableConstants:
        return ScrobblableConstants(verb="Listening", tags="notes")

    @property
    def mb_link(self):
        return f"https://musicbrainz.org/recording/{self.musicbrainz_id}"

    @property
    def info_link(self):
        return self.mb_link

    @property
    def primary_image_url(self) -> str:
        url = ""
        primary_artist = self.artist
        if primary_artist and primary_artist.thumbnail:
            url = primary_artist.thumbnail_medium.url
        if self.primary_album and self.primary_album.cover_image:
            url = self.primary_album.cover_image_medium.url
        return url

    @classmethod
    def find_or_create(
        cls,
        title: str = "",
        artist_names: list[str] | None = None,
        artist_name: str = "",
        album_name: str = "",
        run_time_seconds: int | None = None,
        mbid: str | None = None,
        enrich: bool = False,
        commit: bool = True,
        artist_mbid: str | None = None,
        album_mbid: str | None = None,
        trust_webhook_data: bool = False,
    ) -> "Track":
        """Given a name, try to find the track by the artist from Musicbrainz.

        As a basic conceit we trust the source for giving us the track and artist
        name

        Optionally, we can update any found artists with overwrite.

        When ``trust_webhook_data`` is set (e.g. for Jellyfin/Mopidy webhooks)
        the track is built purely from the provided POST data with no MusicBrainz
        lookups, so scrobbling is never blocked on network calls. An existing
        track already known by ``musicbrainz_id`` is returned as-is without
        further enrichment; otherwise the track is created and stamped with the
        provided mbids, and async enrichment can pick it up later via
        :meth:`fix_metadata`."""
        from music.utils import resolve_artist_names

        if artist_names is None and artist_name:
            artist_names = resolve_artist_names(artist_name, artist_mbid or "")

        if not artist_names:
            artist_names = []

        # Trust-the-webhook fast path: if we already know this track by its
        # musicbrainz_id, reuse it and skip enrichment entirely.
        if trust_webhook_data and mbid:
            existing = cls.objects.filter(musicbrainz_id=mbid).first()
            if existing:
                logger.info(
                    "Found existing track by mbid, not enriching",
                    extra={"mbid": mbid, "track_id": existing.id},
                )
                return existing

        album = None
        artist_objs = []
        if trust_webhook_data:
            if album_mbid:
                album = Album.objects.filter(musicbrainz_id=album_mbid).first()
            if not album and album_name:
                album = Album.objects.filter(name=album_name).first()
            if not album and album_name:
                album = Album.objects.create(name=album_name)
                if album_mbid:
                    album.musicbrainz_id = album_mbid
                    album.save()

            for name in artist_names:
                artist = None
                if artist_mbid:
                    artist = Artist.objects.filter(musicbrainz_id=artist_mbid).first()
                if not artist:
                    artist = Artist.objects.filter(name=clean_artist_name(name)).first()
                if not artist:
                    artist = Artist.objects.create(name=clean_artist_name(name))
                    if artist_mbid:
                        artist.musicbrainz_id = artist_mbid
                        artist.save()
                artist_objs.append(artist)
        else:
            if album_name:
                logger.info(f"Looking up album for: {album_name}")
                first_name = artist_names[0] if artist_names else ""
                album = Album.find_or_create(name=album_name, artist_name=first_name)

            for name in artist_names:
                artist = Artist.find_or_create(name, track_name=title)
                if artist:
                    artist_objs.append(artist)

        track = None
        if artist_objs:
            track = cls.objects.filter(title=title, artists__in=artist_objs).first()

        if not track:
            track = cls.objects.filter(title=title).first()

        if not track:
            track = cls(title=title)
            track.save()
            track.refresh_from_db()

        if artist_objs:
            track.artists.add(*artist_objs)

        if album:
            track.albums.add(album)

        if trust_webhook_data:
            if mbid:
                track.musicbrainz_id = mbid
            if run_time_seconds:
                track.base_run_time_seconds = run_time_seconds
            if mbid or run_time_seconds:
                track.tags.add("musicbrainz-provider")
            if commit:
                track.save()
            return track

        if enrich or not track.base_run_time_seconds:
            track.fix_metadata()
            if commit:
                track.save()

        return track

    def fix_metadata(self, force_update: bool = False) -> bool:
        """Resolve and enrich this track's metadata from MusicBrainz in place.

        Sets ``musicbrainz_id`` and ``base_run_time_seconds``, adds any
        featured artists and tags the track with the resolution method. Skips
        already-enriched tracks (tagged ``musicbrainz-enriched``) unless
        ``force_update`` is set. Returns True when enrichment succeeded, False
        otherwise.

        This is safe to call off the request path (e.g. from the async webhook
        enrichment task), where it can afford slower lookups and subtler
        matching rules."""
        if "musicbrainz-enriched" in self.tags.names() and not force_update:
            logger.info(
                f"Track {self} already enriched, skipping",
                extra={"track_id": self.id},
            )
            return True

        artist_name_str = str(self.artist) if self.artist else ""
        album_name = ""
        if self.primary_album:
            album_name = self.primary_album.name

        logger.info(
            f"Enriching track {self}",
            extra={
                "title": self.title,
                "artist_name": artist_name_str,
                "track_id": self.id,
            },
        )
        result, method = resolve_track(self.title, artist_name_str, album_name)
        if not result or not result.get("recording_mbid"):
            logger.warning(f"No musicbrainz result found, cannot enrich {self}")
            self.tags.add("musicbrainz-notfound")
            return False

        self.musicbrainz_id = result["recording_mbid"]
        length_ms = result.get("length_ms")
        if length_ms and not self.base_run_time_seconds:
            self.base_run_time_seconds = int(int(length_ms) / 1000)
        method_tag = f"musicbrainz-{method}" if method else ""
        self.tags.add(method_tag, "musicbrainz-enriched")
        cleaned_title, featured_names = extract_featured_artists(self.title)
        for feat_name in featured_names:
            artist = Artist.find_or_create(feat_name, track_name=self.title)
            if artist:
                self.artists.add(artist)
        self.save()
        return True

    @classmethod
    def merge(cls, duplicate: "Track", canonical: "Track") -> int:
        """Merge a duplicate track into its canonical match.

        Repoints any scrobbles and favorites from ``duplicate`` onto
        ``canonical``, copies tags, artists and albums over, then deletes the
        duplicate. Scrobbles for a user who already has an in-progress
        scrobble of the canonical track are left alone to avoid two concurrent
        in-progress scrobbles. Returns the number of scrobbles repointed."""
        if duplicate.id == canonical.id:
            return 0

        from scrobbles.models import FavoriteMedia, Scrobble

        logger.info(
            f"Merging track {duplicate} into {canonical}",
            extra={"duplicate_id": duplicate.id, "canonical_id": canonical.id},
        )

        repointed = 0
        conflict_ids = []
        for scrobble in Scrobble.objects.filter(track=duplicate):
            conflict = Scrobble.objects.filter(
                track=canonical, user_id=scrobble.user_id, in_progress=True
            ).exists()
            if conflict and scrobble.in_progress:
                conflict_ids.append(scrobble.id)
                logger.info(
                    "Skipping merge of in-progress scrobble due to conflict",
                    extra={"scrobble_id": scrobble.id, "canonical_id": canonical.id},
                )
                continue
            scrobble.track = canonical
            scrobble.save(update_fields=["track"])
            repointed += 1

        FavoriteMedia.objects.filter(track=duplicate).update(track=canonical)

        for tag in duplicate.tags.all():
            canonical.tags.add(tag.name)
        for genre in duplicate.genre.all():
            canonical.genre.add(genre.name)
        for artist in duplicate.artists.all():
            canonical.artists.add(artist)
        for album in duplicate.albums.all():
            canonical.albums.add(album)

        if not canonical.album and duplicate.album:
            canonical.album = duplicate.album
        if not canonical.musicbrainz_id and duplicate.musicbrainz_id:
            canonical.musicbrainz_id = duplicate.musicbrainz_id
        if not canonical.base_run_time_seconds and duplicate.base_run_time_seconds:
            canonical.base_run_time_seconds = duplicate.base_run_time_seconds
        canonical.save()

        if conflict_ids:
            logger.warning(
                "Not deleting duplicate; %s in-progress scrobbles reference it",
                len(conflict_ids),
                extra={"duplicate_id": duplicate.id, "canonical_id": canonical.id},
            )
            return repointed

        duplicate.delete()
        return repointed
