import calendar
import datetime
import json
import logging
from collections import defaultdict
from typing import Optional
from uuid import uuid4
from zoneinfo import ZoneInfo

import pendulum
import pytz
from agents.models import AgentSession
from birds.models import BirdingLocation
from boardgames.models import BoardGame
from books.koreader import process_koreader_sqlite_file
from books.models import Book, BookLogData, BookPageLogData, Paper
from bricksets.models import BrickSet
from charts.utils import build_charts
from dataclass_wizard.errors import ParseError
from discgolf.models import DiscGolfCourse
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.files import File
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django_extensions.db.models import TimeStampedModel
from drinks.models import Beer, Coffee, Drink, Wine
from foods.models import Food, FoodLogData
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from lifeevents.models import LifeEvent
from locations.models import GeoLocation
from moods.models import Mood
from music.models import Artist, Track
from nature.models import SpeciesObservation
from podcasts.models import PodcastEpisode
from profiles.utils import (
    end_of_day,
    end_of_month,
    end_of_week,
    fix_profile_historic_timezones,
    start_of_day,
    start_of_month,
    start_of_week,
)
from puzzles.models import Puzzle
from scrobbles import dataclasses as logdata
from scrobbles.constants import (
    AUTO_FINISH_MEDIA,
    LONG_PLAY_MEDIA,
    MEDIA_END_PADDING_SECONDS,
    Visibility,
)
from scrobbles.importers.lastfm import LastFM
from scrobbles.notifications import (
    LastFmImportNtfyNotification,
    ScrobbleNtfyNotification,
)
from scrobbles.sqids import encode_scrobble_share
from scrobbles.utils import (
    get_file_md5_hash,
    media_class_to_foreign_key,
    media_type_for_media_obj,
)
from sports.models import SportEvent
from taggit.managers import TaggableManager
from tasks.models import Task
from trails.models import Trail
from videogames import retroarch
from videogames.models import VideoGame
from videos.models import Series, Video
from webpages.models import WebPage

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

POINTS_FOR_MOVEMENT_HISTORY = int(getattr(settings, "POINTS_FOR_MOVEMENT_HISTORY", 3))


class BaseFileImportMixin(TimeStampedModel):
    user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
    uuid = models.UUIDField(editable=False, default=uuid4)
    processing_started = models.DateTimeField(**BNULL)
    processed_finished = models.DateTimeField(**BNULL)
    process_log = models.TextField(**BNULL)
    process_count = models.IntegerField(**BNULL)
    error_log = models.TextField(**BNULL)

    class Meta:
        abstract = True

    def __str__(self):
        return f"{self.import_type} import on {self.human_start}"

    @property
    def human_start(self):
        start = "Unknown"
        if self.processing_started:
            start = self.processing_started.strftime("%B %d, %Y at %H:%M")
        return start

    @property
    def import_type(self) -> str:
        return "Unknown Import Source"

    def process(self, force=False):
        logger.warning("Process not implemented")

    def undo(self, dryrun=False):
        """Accepts the log from a scrobble import and removes the scrobbles"""
        from scrobbles.models import Scrobble

        if not self.process_log:
            logger.warning("No lines in process log found to undo")
            return

        for line in self.process_log.split("\n"):
            scrobble_id = line.split("\t")[0]
            scrobble = Scrobble.objects.filter(id=scrobble_id).first()
            if not scrobble:
                logger.warning(f"Could not find scrobble {scrobble_id} to undo")
                continue
            logger.info(f"Removing scrobble {scrobble_id}")
            if not dryrun:
                scrobble.delete()
        self.processed_finished = None
        self.processing_started = None
        self.process_count = None
        self.process_log = ""
        self.save(
            update_fields=[
                "processed_finished",
                "processing_started",
                "process_log",
                "process_count",
            ]
        )

    def scrobbles(self) -> models.QuerySet:
        scrobble_ids = []
        if self.process_log:
            for line in self.process_log.split("\n"):
                sid = line.split("\t")[0]
                if sid:
                    scrobble_ids.append(sid)
        return Scrobble.objects.filter(id__in=scrobble_ids)

    def mark_started(self):
        self.processing_started = timezone.now()
        self.save(update_fields=["processing_started"])

    def mark_finished(self):
        self.processed_finished = timezone.now()
        self.save(update_fields=["processed_finished"])

    def record_log(self, scrobbles):
        self.process_log = ""
        if not scrobbles:
            self.process_count = 0
            self.save(update_fields=["process_log", "process_count"])
            return

        for count, scrobble in enumerate(scrobbles):
            scrobble_str = f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj}"
            log_line = f"{scrobble_str}"
            if count > 0:
                log_line = "\n" + log_line
            self.process_log += log_line
        self.process_count = len(scrobbles)
        self.save(update_fields=["process_log", "process_count"])

    def record_error(self, error_message):
        log_line = f"{timezone.now().isoformat()}: {error_message}"
        if self.error_log:
            self.error_log += "\n" + log_line
        else:
            self.error_log = log_line
        self.save(update_fields=["error_log"])

    @property
    def upload_file_path(self):
        raise NotImplementedError


class KoReaderImport(BaseFileImportMixin):
    class Meta:
        verbose_name = "KOReader Import"

    @property
    def import_type(self) -> str:
        return "KOReader"

    def get_absolute_url(self):
        return reverse("scrobbles:koreader-import-detail", kwargs={"slug": self.uuid})

    def get_path(instance, filename):
        extension = filename.split(".")[-1]
        uuid = instance.uuid
        return f"koreader-uploads/{uuid}.{extension}"

    @property
    def upload_file_path(self) -> str:
        if getattr(settings, "USE_S3_STORAGE"):
            path = self.sqlite_file.url
        else:
            path = self.sqlite_file.path
        return path

    sqlite_file = models.FileField(upload_to=get_path, **BNULL)
    webdav_etag = models.CharField(max_length=255, **BNULL)

    def save_sqlite_file_to_self(self, file_path):
        with open(file_path, "rb") as f:
            self.sqlite_file.save(
                f"{self.user_id}-koreader-statistics.sqlite",
                File(f),
                save=True,
            )

    def file_md5_hash(self) -> str:
        if self.sqlite_file:
            return get_file_md5_hash(self.sqlite_file.path)
        return ""

    def process(self, force=False):

        if self.user.id == 1:
            fix_profile_historic_timezones(self.user.profile)

        if self.processed_finished and not force:
            logger.info(f"{self} already processed on {self.processed_finished}")
            return

        self.mark_started()
        try:
            scrobbles = process_koreader_sqlite_file(
                self.upload_file_path, self.user.id
            )
            self.record_log(scrobbles)
        except Exception as e:
            self.record_error(f"Import failed: {e}")
            logger.exception(f"Import failed for {self}")
        finally:
            self.mark_finished()


class AudioScrobblerTSVImport(BaseFileImportMixin):
    class Meta:
        verbose_name = "AudioScrobbler TSV Import"

    @property
    def import_type(self) -> str:
        return "AudiosScrobbler"

    def get_absolute_url(self):
        return reverse("scrobbles:tsv-import-detail", kwargs={"slug": self.uuid})

    def get_path(instance, filename):
        extension = filename.split(".")[-1]
        uuid = instance.uuid
        return f"audioscrobbler-uploads/{uuid}.{extension}"

    @property
    def upload_file_path(self):
        if getattr(settings, "USE_S3_STORAGE"):
            path = self.tsv_file.url
        else:
            path = self.tsv_file.path
        return path

    tsv_file = models.FileField(upload_to=get_path, **BNULL)

    def process(self, force=False):
        from scrobbles.importers.tsv import import_audioscrobbler_tsv_file

        if self.user.id == 1:
            fix_profile_historic_timezones(self.user.profile)

        if self.processed_finished and not force:
            logger.info(f"{self} already processed on {self.processed_finished}")
            return

        self.mark_started()
        try:
            scrobbles = import_audioscrobbler_tsv_file(
                self.upload_file_path, self.user.id
            )
            self.record_log(scrobbles)
        except Exception as e:
            self.record_error(f"Import failed: {e}")
            logger.exception(f"Import failed for {self}")
        finally:
            self.mark_finished()


class ScaleCSVImport(BaseFileImportMixin):
    class Meta:
        verbose_name = "Scale CSV Import"

    @property
    def import_type(self) -> str:
        return "Scale"

    def get_absolute_url(self):
        return reverse("scrobbles:scale-csv-import-detail", kwargs={"slug": self.uuid})

    def get_path(instance, filename):
        extension = filename.split(".")[-1]
        uuid = instance.uuid
        return f"scale-csv-uploads/{uuid}.{extension}"

    @property
    def upload_file_path(self):
        if getattr(settings, "USE_S3_STORAGE"):
            path = self.csv_file.url
        else:
            path = self.csv_file.path
        return path

    csv_file = models.FileField(upload_to=get_path, **BNULL)
    original_filename = models.CharField(max_length=255, **BNULL)
    file_hash = models.CharField(max_length=32, **BNULL)

    def process(self, force=False):
        from scrobbles.importers.scale import import_scale_csv

        if self.processed_finished and not force:
            logger.info(f"{self} already processed on {self.processed_finished}")
            return

        self.mark_started()
        try:
            scrobbles = import_scale_csv(self.upload_file_path, self.user.id)
            self.record_log(scrobbles)
        except Exception as e:
            self.record_error(f"Import failed: {e}")
            logger.exception(f"Import failed for {self}")
        finally:
            self.mark_finished()


class TrailGPXImport(BaseFileImportMixin):
    class Meta:
        verbose_name = "Trail GPX Import"

    @property
    def import_type(self) -> str:
        return "Trail GPX"

    def get_absolute_url(self):
        return reverse("scrobbles:trail-gpx-import-detail", kwargs={"slug": self.uuid})

    def get_path(instance, filename):
        extension = filename.split(".")[-1]
        uuid = instance.uuid
        return f"trail-gpx-uploads/{uuid}.{extension}"

    @property
    def upload_file_path(self):
        if getattr(settings, "USE_S3_STORAGE"):
            path = self.gpx_file.url
        else:
            path = self.gpx_file.path
        return path

    gpx_file = models.FileField(upload_to=get_path, **BNULL)
    original_filename = models.CharField(max_length=255, **BNULL)

    def process(self, force=False):
        from scrobbles.importers.trail_gpx import import_trail_gpx

        if self.processed_finished and not force:
            logger.info(f"{self} already processed on {self.processed_finished}")
            return

        self.mark_started()
        try:
            scrobbles = import_trail_gpx(
                self.upload_file_path, self.user.id, self.original_filename
            )
            self.record_log(scrobbles)
        except Exception as e:
            self.record_error(f"Import failed: {e}")
            logger.exception(f"Import failed for {self}")
        finally:
            self.mark_finished()


class LastFmImport(BaseFileImportMixin):
    import_in_progress = models.BooleanField(default=False)

    class Meta:
        verbose_name = "Last.FM Import"

    @property
    def import_type(self) -> str:
        return "LastFM"

    def get_absolute_url(self):
        return reverse("scrobbles:lastfm-import-detail", kwargs={"slug": self.uuid})

    def process(self, import_all=False, time_from=None, time_to=None):
        """Import scrobbles found on LastFM"""

        if self.user.id == 1:
            fix_profile_historic_timezones(self.user.profile)

        if self.processed_finished:
            logger.info(f"{self} already processed on {self.processed_finished}")
            return

        lastfm = LastFM(self.user)
        last_processed = None

        if time_from is not None or time_to is not None:
            last_processed = time_from
        else:
            last_import = None
            if not import_all:
                try:
                    last_import = LastFmImport.objects.exclude(id=self.id).last()
                except:
                    pass

            if not import_all and not last_import:
                logger.warn(
                    "No previous import, to import all Last.fm scrobbles, "
                    "pass import_all=True"
                )
                return

            if last_import:
                last_processed = last_import.processed_finished

        self.mark_started()
        try:
            scrobbles = lastfm.import_from_lastfm(last_processed, time_to=time_to)
            self.record_log(scrobbles)
            if scrobbles:
                LastFmImportNtfyNotification(self, len(scrobbles)).send()
        except Exception as e:
            self.record_error(f"Import failed: {e}")
            logger.exception(f"Import failed for {self}")
        finally:
            self.mark_finished()


class RetroarchImport(BaseFileImportMixin):
    class Meta:
        verbose_name = "Retroarch Import"

    @property
    def import_type(self) -> str:
        return "Retroarch"

    def get_absolute_url(self):
        return reverse("scrobbles:retroarch-import-detail", kwargs={"slug": self.uuid})

    original_filename = models.CharField(max_length=255, **BNULL)
    lrtl_file = models.FileField(upload_to="scrobbles/lrtl_file/", **BNULL)
    files_hash = models.CharField(max_length=64, **BNULL)

    def process(self, import_all=False, force=False):
        """Import scrobbles found on Retroarch"""
        if self.user.id == 1:
            fix_profile_historic_timezones(self.user.profile)

        if self.processed_finished and not force:
            logger.info(f"{self} already processed on {self.processed_finished}")
            return

        if force:
            logger.info(f"You told me to force import from Retroarch")

        self.mark_started()
        scrobbles = None
        try:
            if self.lrtl_file:
                import os
                import tempfile
                import zipfile

                tmpdir = tempfile.mkdtemp()
                try:
                    zip_path = os.path.join(tmpdir, "archive.zip")
                    with open(zip_path, "wb") as f:
                        f.write(self.lrtl_file.read())
                    with zipfile.ZipFile(zip_path, "r") as zf:
                        zf.extractall(tmpdir)
                    os.unlink(zip_path)
                    scrobbles = retroarch.import_retroarch_lrtl_files(
                        tmpdir + "/",
                        self.user.id,
                    )
                finally:
                    import shutil

                    shutil.rmtree(tmpdir, ignore_errors=True)
            else:
                if not self.user.profile.retroarch_path:
                    logger.info(
                        "Trying to import Retroarch logs, but user has no retroarch_path configured"
                    )
                    self.mark_finished()
                    return

                scrobbles = retroarch.import_retroarch_lrtl_files(
                    self.user.profile.retroarch_path,
                    self.user.id,
                )

            self.record_log(scrobbles)
        except Exception as e:
            self.record_error(f"Import failed: {e}")
            logger.exception(f"Import failed for {self}")
        finally:
            self.mark_finished()


class BGStatsImport(BaseFileImportMixin):
    class Meta:
        verbose_name = "BG Stats Import"

    @property
    def import_type(self) -> str:
        return "BG Stats"

    def get_absolute_url(self):
        return reverse("scrobbles:bgstats-import-detail", kwargs={"slug": self.uuid})

    def get_path(instance, filename):
        extension = filename.split(".")[-1]
        uuid = instance.uuid
        return f"bgstats-uploads/{uuid}.{extension}"

    @property
    def upload_file_path(self):
        if getattr(settings, "USE_S3_STORAGE"):
            path = self.bgsplay_file.url
        else:
            path = self.bgsplay_file.path
        return path

    bgsplay_file = models.FileField(upload_to=get_path, **BNULL)
    original_filename = models.CharField(max_length=255, **BNULL)

    def process(self, force=False):
        """Import scrobbles from a single BG Stats bgsplay file"""
        if self.processed_finished and not force:
            logger.info(f"{self} already processed on {self.processed_finished}")
            return

        self.mark_started()
        try:
            import json

            from scrobbles.scrobblers import scrobble_board_game_from_bgstats

            with open(self.upload_file_path, "r", encoding="utf-8") as f:
                parsed_json = json.load(f)
            scrobbles = scrobble_board_game_from_bgstats(parsed_json, self.user_id)

            self.record_log(scrobbles)
        except Exception as e:
            self.record_error(f"Import failed: {e}")
            logger.exception(f"Import failed for {self}")
        finally:
            self.mark_finished()


class EBirdCSVImport(BaseFileImportMixin):
    class Meta:
        verbose_name = "eBird CSV Import"

    user = models.ForeignKey(
        User,
        on_delete=models.DO_NOTHING,
        **BNULL,
        related_name="scrobbles_ebirdcsvimport_set",
    )

    @property
    def import_type(self) -> str:
        return "eBird"

    def get_absolute_url(self):
        return reverse("scrobbles:ebird-csv-import-detail", kwargs={"slug": self.uuid})

    def get_path(instance, filename):
        extension = filename.split(".")[-1]
        uuid = instance.uuid
        return f"birding-csv-uploads/{uuid}.{extension}"

    @property
    def upload_file_path(self):
        if getattr(settings, "USE_S3_STORAGE"):
            path = self.csv_file.url
        else:
            path = self.csv_file.path
        return path

    csv_file = models.FileField(upload_to=get_path, **BNULL)
    original_filename = models.CharField(max_length=255, **BNULL)

    def process(self, force=False):
        from birds.importer import import_birding_csv

        if self.processed_finished and not force:
            logger.info(f"{self} already processed on {self.processed_finished}")
            return

        self.mark_started()
        try:
            scrobbles = import_birding_csv(
                self.upload_file_path, self.user_id, record_error=self.record_error
            )
            self.record_log(scrobbles)
        except Exception as e:
            self.record_error(f"Import failed: {e}")
            logger.exception(f"Import failed for {self}")
        finally:
            self.mark_finished()


class UDiscCSVImport(BaseFileImportMixin):
    class Meta:
        verbose_name = "uDisc CSV Import"

    user = models.ForeignKey(
        User,
        on_delete=models.DO_NOTHING,
        **BNULL,
        related_name="scrobbles_udisccsvimport_set",
    )

    @property
    def import_type(self) -> str:
        return "uDisc"

    def get_absolute_url(self):
        return reverse("scrobbles:udisc-csv-import-detail", kwargs={"slug": self.uuid})

    def get_path(instance, filename):
        extension = filename.split(".")[-1]
        uuid = instance.uuid
        return f"udisc-csv-uploads/{uuid}.{extension}"

    @property
    def upload_file_path(self):
        if getattr(settings, "USE_S3_STORAGE"):
            path = self.csv_file.url
        else:
            path = self.csv_file.path
        return path

    csv_file = models.FileField(upload_to=get_path, **BNULL)
    original_filename = models.CharField(max_length=255, **BNULL)

    def process(self, force=False):
        from discgolf.utils import import_udisc_csv

        if self.processed_finished and not force:
            logger.info(f"{self} already processed on {self.processed_finished}")
            return

        self.mark_started()
        try:
            scrobbles = import_udisc_csv(
                self.upload_file_path, self.user_id, record_error=self.record_error
            )
            self.record_log(scrobbles)
        except Exception as e:
            self.record_error(f"Import failed: {e}")
            logger.exception(f"Import failed for {self}")
        finally:
            self.mark_finished()


TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
    "AgentSession": ("agent_session",),
    "Video": ("video",),
    "Track": ("track", "track__artist_fk"),
    "PodcastEpisode": ("podcast_episode", "podcast_episode__podcast"),
    "SportEvent": ("sport_event",),
    "Book": ("book",),
    "Paper": ("paper",),
    "VideoGame": ("video_game",),
    "BoardGame": ("board_game",),
    "GeoLocation": ("geo_location",),
    "Trail": ("trail",),
    "Beer": ("beer",),
    "Wine": ("wine",),
    "Coffee": ("coffee",),
    "Drink": ("drink",),
    "Puzzle": ("puzzle",),
    "Food": ("food",),
    "Task": ("task",),
    "WebPage": ("web_page",),
    "LifeEvent": ("life_event",),
    "Mood": ("mood",),
    "BrickSet": ("brick_set",),
    "Channel": ("channel",),
    "BirdingLocation": ("birding_location",),
    "DiscGolfCourse": ("disc_golf_course",),
    "SpeciesObservation": ("species_observation",),
    "Workout": ("workout_routine",),
}


class ScrobbleQuerySet(models.QuerySet):
    def with_related(self):
        return self.select_related("user").prefetch_related(
            "agent_session",
            "video",
            "track",
            "track__artist_fk",
            "podcast_episode",
            "podcast_episode__podcast",
            "sport_event",
            "book",
            "paper",
            "video_game",
            "board_game",
            "geo_location",
            "beer",
            "wine",
            "coffee",
            "drink",
            "puzzle",
            "food",
            "trail",
            "task",
            "web_page",
            "life_event",
            "mood",
            "brick_set",
            "birding_location",
            "disc_golf_course",
            "species_observation",
            "workout_routine",
        )

    def with_related_for_types(self, media_types: list[str]):
        prefetches = []
        for t in media_types:
            if t in TYPE_FK_PREFETCHES:
                prefetches.extend(TYPE_FK_PREFETCHES[t])
        return self.select_related("user").prefetch_related(*prefetches)


class ShareViewLog(TimeStampedModel):
    scrobble = models.ForeignKey(
        "Scrobble", on_delete=models.CASCADE, related_name="share_views"
    )
    ip_address = models.GenericIPAddressField(**BNULL)
    user_agent = models.TextField(**BNULL)
    referrer = models.URLField(max_length=2048, **BNULL)

    def __str__(self):
        return f"View of {self.scrobble} at {self.created}"


class Scrobble(TimeStampedModel):
    """A scrobble tracks played media items by a user."""

    objects = ScrobbleQuerySet.as_manager()

    class MediaType(models.TextChoices):
        """Enum mapping a media model type to a string"""

        AGENT_SESSION = "AgentSession", "Agent session"
        VIDEO = "Video", "Video"
        TRACK = "Track", "Track"
        PODCAST_EPISODE = "PodcastEpisode", "Podcast episode"
        SPORT_EVENT = "SportEvent", "Sport event"
        BOOK = "Book", "Book"
        PAPER = "Paper", "Paper"
        VIDEO_GAME = "VideoGame", "Video game"
        BOARD_GAME = "BoardGame", "Board game"
        GEO_LOCATION = "GeoLocation", "GeoLocation"
        TRAIL = "Trail", "Trail"
        BEER = "Beer", "Beer"
        WINE = "Wine", "Wine"
        COFFEE = "Coffee", "Coffee"
        DRINK = "Drink", "Drink"
        PUZZLE = "Puzzle", "Puzzle"
        FOOD = "Food", "Food"
        TASK = "Task", "Task"
        WEBPAGE = "WebPage", "Web Page"
        LIFE_EVENT = "LifeEvent", "Life event"
        MOOD = "Mood", "Mood"
        BRICKSET = "BrickSet", "Brick set"
        CHANNEL = "Channel", "Channel"
        BIRDING_LOCATION = "BirdingLocation", "Birding location"
        DISC_GOLF = "DiscGolfCourse", "Disc golf"
        SPECIES_OBSERVATION = "SpeciesObservation", "Species observation"
        WORKOUT = "Workout", "Workout"

        @classmethod
        def list(cls):
            return list(map(lambda c: c.value, cls))

    uuid = models.UUIDField(editable=False, **BNULL)
    agent_session = models.ForeignKey(
        AgentSession, on_delete=models.DO_NOTHING, **BNULL
    )
    video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
    channel = models.ForeignKey("videos.Channel", on_delete=models.DO_NOTHING, **BNULL)
    track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
    podcast_episode = models.ForeignKey(
        PodcastEpisode, on_delete=models.DO_NOTHING, **BNULL
    )
    sport_event = models.ForeignKey(SportEvent, on_delete=models.DO_NOTHING, **BNULL)
    book = models.ForeignKey(Book, on_delete=models.DO_NOTHING, **BNULL)
    paper = models.ForeignKey(Paper, on_delete=models.DO_NOTHING, **BNULL)
    video_game = models.ForeignKey(VideoGame, on_delete=models.DO_NOTHING, **BNULL)
    board_game = models.ForeignKey(BoardGame, on_delete=models.DO_NOTHING, **BNULL)
    geo_location = models.ForeignKey(GeoLocation, on_delete=models.DO_NOTHING, **BNULL)
    beer = models.ForeignKey(Beer, on_delete=models.DO_NOTHING, **BNULL)
    wine = models.ForeignKey(Wine, on_delete=models.DO_NOTHING, **BNULL)
    coffee = models.ForeignKey(Coffee, on_delete=models.DO_NOTHING, **BNULL)
    drink = models.ForeignKey(Drink, on_delete=models.DO_NOTHING, **BNULL)
    puzzle = models.ForeignKey(Puzzle, on_delete=models.DO_NOTHING, **BNULL)
    food = models.ForeignKey(Food, on_delete=models.DO_NOTHING, **BNULL)
    trail = models.ForeignKey(Trail, on_delete=models.DO_NOTHING, **BNULL)
    task = models.ForeignKey(Task, on_delete=models.DO_NOTHING, **BNULL)
    web_page = models.ForeignKey(WebPage, on_delete=models.DO_NOTHING, **BNULL)
    life_event = models.ForeignKey(LifeEvent, on_delete=models.DO_NOTHING, **BNULL)
    mood = models.ForeignKey(Mood, on_delete=models.DO_NOTHING, **BNULL)
    brick_set = models.ForeignKey(BrickSet, on_delete=models.DO_NOTHING, **BNULL)
    birding_location = models.ForeignKey(
        BirdingLocation, on_delete=models.DO_NOTHING, **BNULL
    )
    disc_golf_course = models.ForeignKey(
        DiscGolfCourse, on_delete=models.DO_NOTHING, **BNULL
    )
    species_observation = models.ForeignKey(
        SpeciesObservation, on_delete=models.DO_NOTHING, **BNULL
    )
    workout_routine = models.ForeignKey(
        "workouts.WorkoutRoutine", on_delete=models.DO_NOTHING, **BNULL
    )
    media_type = models.CharField(
        max_length=20, choices=MediaType.choices, default=MediaType.VIDEO
    )
    visibility = models.CharField(
        max_length=10,
        choices=Visibility.choices,
        default=Visibility.PRIVATE,
        db_index=True,
    )
    share_token_version = models.PositiveIntegerField(default=0)
    share_view_count = models.PositiveIntegerField(default=0)
    user = models.ForeignKey(User, blank=True, null=True, on_delete=models.DO_NOTHING)

    # Time keeping
    timestamp = models.DateTimeField(**BNULL)
    stop_timestamp = models.DateTimeField(**BNULL)
    playback_position_seconds = models.IntegerField(**BNULL)

    # Status indicators
    is_paused = models.BooleanField(default=False)
    played_to_completion = models.BooleanField(default=False)
    in_progress = models.BooleanField(default=True)

    # Metadata
    source = models.CharField(max_length=255, **BNULL)
    source_id = models.CharField(max_length=255, **BNULL)
    log = models.JSONField(
        **BNULL,
        default=dict,
        encoder=logdata.ScrobbleLogDataEncoder,
        decoder=logdata.ScrobbleLogDataDecoder,
    )
    timezone = models.CharField(max_length=50, **BNULL)
    tags = TaggableManager(blank=True, verbose_name="Tags")

    # Fields for keeping track of video game data
    videogame_save_data = models.FileField(
        upload_to="scrobbles/videogame_save_data/", **BNULL
    )
    gpx_file = models.FileField(upload_to="scrobbles/gpx_file/", **BNULL)
    screenshot = models.ImageField(upload_to="scrobbles/videogame_screenshot/", **BNULL)
    screenshot_small = ImageSpecField(
        source="screenshot",
        processors=[ResizeToFit(100, 100)],
        format="JPEG",
        options={"quality": 60},
    )
    screenshot_medium = ImageSpecField(
        source="screenshot",
        processors=[ResizeToFit(300, 300)],
        format="JPEG",
        options={"quality": 75},
    )
    screenshot_large = ImageSpecField(
        source="screenshot",
        processors=[ResizeToFit(800, 800)],
        format="JPEG",
        options={"quality": 85},
    )
    long_play_seconds = models.BigIntegerField(**BNULL)
    long_play_complete = models.BooleanField(**BNULL)
    long_play_last_scrobble = models.ForeignKey(
        "self",
        **BNULL,
        on_delete=models.SET_NULL,
        related_name="next_long_play_scrobbles",
    )

    class Meta:
        indexes = [
            models.Index(fields=["timestamp"]),
            models.Index(fields=["media_type"]),
            models.Index(fields=["source_id"]),
            models.Index(
                fields=[
                    "user",
                    "in_progress",
                    "played_to_completion",
                    "is_paused",
                ]
            ),
            models.Index(fields=["user", "-timestamp"]),
        ]

    @classmethod
    def for_year(cls, user, year):
        return cls.objects.filter(timestamp__year=year, user=user).order_by(
            "-timestamp"
        )

    @classmethod
    def for_month(cls, user, year, month):
        return cls.objects.filter(
            timestamp__year=year, timestamp__month=month, user=user
        ).order_by("-timestamp")

    @classmethod
    def for_day(cls, user, year, month, day):
        return cls.objects.filter(
            timestamp__year=year,
            timestamp__month=month,
            timestamp__day=day,
            user=user,
        ).order_by("-timestamp")

    @classmethod
    def for_week(cls, user, year, week):
        return cls.objects.filter(
            timestamp__year=year, timestamp__week=week, user=user
        ).order_by("-timestamp")

    @classmethod
    def as_dict_by_type(cls, scrobble_qs: models.QuerySet) -> dict:
        scrobbles_by_type = defaultdict(list)

        if hasattr(scrobble_qs, "with_related"):
            media_types_present = list(
                scrobble_qs.values_list("media_type", flat=True).distinct()
            )
            scrobbles = scrobble_qs.with_related_for_types(media_types_present)
        else:
            scrobbles = scrobble_qs

        for scrobble in scrobbles:
            scrobbles_by_type[scrobble.media_type].append(scrobble)
            if not scrobbles_by_type.get(scrobble.media_type + "_count"):
                scrobbles_by_type[scrobble.media_type + "_count"] = 0
            scrobbles_by_type[scrobble.media_type + "_count"] += 1
            if not scrobbles_by_type.get(scrobble.media_type + "_time"):
                scrobbles_by_type[scrobble.media_type + "_time"] = 0
            scrobbles_by_type[scrobble.media_type + "_time"] += int(
                (scrobble.elapsed_time)
            )

        # Remove any locations without titles
        if "GeoLocation" in scrobbles_by_type.keys():
            for loc_scrobble in list(scrobbles_by_type["GeoLocation"]):
                if not loc_scrobble.media_obj.title:
                    scrobbles_by_type["GeoLocation"].remove(loc_scrobble)
                    scrobbles_by_type["GeoLocation_count"] -= 1

        return scrobbles_by_type

    @classmethod
    def in_progress_for_user(cls, user_id: int) -> models.QuerySet:
        return cls.objects.filter(
            user=user_id,
            in_progress=True,
            played_to_completion=False,
            is_paused=False,
        )

    @property
    def last_serial_scrobble(self) -> Optional["Scrobble"]:
        from scrobbles.models import Scrobble

        if self.logdata and self.logdata.serial_scrobble_id:
            return Scrobble.objects.filter(id=self.logdata.serial_scrobble_id).first()

    @property
    def finish_url(self) -> str:
        return reverse("scrobbles:finish", kwargs={"pk": self.pk})

    def save(self, *args, **kwargs):
        class_name = self.media_obj.__class__.__name__

        if not self.uuid:
            self.uuid = uuid4()

        if not self.timezone:
            timezone = settings.TIME_ZONE
            if self.user and self.user.profile:
                timezone = self.user.profile.timezone
            self.timezone = timezone

        # Microseconds mess up Django's filtering, and we don't need be that specific
        if self.timestamp:
            self.timestamp = self.timestamp.replace(microsecond=0)
        if self.media_obj:
            self.media_type = self.MediaType(
                media_type_for_media_obj(self.media_obj)
            )

        if (self.timestamp and self.stop_timestamp) and (
            not self.playback_position_seconds or self.playback_position_seconds <= 0
        ):
            self.playback_position_seconds = (
                self.stop_timestamp - self.timestamp
            ).seconds

        if class_name == "Book":
            self.calculate_reading_stats(commit=False)

        if (
            self.log
            and isinstance(self.log, dict)
            and self.log.get("page_start") is not None
            and self.log.get("page_end") is not None
            and "pages_read" not in self.log
        ):
            self.log["pages_read"] = self.log["page_end"] - self.log["page_start"] + 1

        return super(Scrobble, self).save(*args, **kwargs)

    def get_absolute_url(self):
        return reverse("scrobbles:detail", kwargs={"pk": self.pk})

    def get_share_url(self):
        if self.visibility == Visibility.PRIVATE:
            return None
        sqid = encode_scrobble_share(self.id, self.share_token_version)
        return reverse("scrobbles:shared-detail", kwargs={"sqid": sqid})

    def regenerate_share_token(self):
        self.share_token_version += 1
        self.save(update_fields=["share_token_version"])

    @property
    def logdata(self) -> Optional[logdata.BaseLogData]:
        if self.media_obj:
            logdata_cls = self.media_obj.logdata_cls
        else:
            logdata_cls = logdata.BaseLogData

        log_dict = self.log
        if isinstance(log_dict, logdata.BaseLogData):
            log_dict = log_dict.asdict
        if isinstance(self.log, str):
            # There's nothing stopping django from saving a string in a JSONField :(
            logger.warning(
                "[scrobbles] Received string in JSON data in log",
                extra={"log": self.log},
            )
            log_dict = json.loads(self.log)

        if not log_dict:
            log_dict = {}

        # Use LogData's from_log_dict to handle any custom nesting/structure
        logdata_kwargs = logdata_cls.from_log_dict(log_dict)

        try:
            return logdata_cls(**logdata_kwargs)
        except ParseError as e:
            logger.warning(
                "Could not parse log data",
                extra={
                    "log_dict": log_dict,
                    "scrobble_id": self.id,
                    "error": e,
                },
            )
            return logdata_cls()
        except TypeError as e:
            return logdata_cls()

    def _strip_sensitive_log_keys(self, log_dict: dict) -> dict:
        sensitive = settings.SENSITIVE_LOGDATA_KEYS.get(self.media_type, [])
        if not sensitive:
            return log_dict
        return {k: v for k, v in log_dict.items() if k not in sensitive}

    @property
    def public_log(self):
        """Raw log dict with sensitive keys removed, safe for shared rendering."""
        log_dict = self.log
        if not isinstance(log_dict, dict):
            return log_dict
        return self._strip_sensitive_log_keys(log_dict)

    @property
    def public_logdata(self):
        """LogData with sensitive keys removed, safe for shared rendering."""
        logdata_obj = self.logdata
        if not self.media_obj:
            return logdata_obj
        filtered = self._strip_sensitive_log_keys(logdata_obj.asdict)
        return logdata_obj.__class__(**filtered)

    @property
    def notes(self):
        if self.logdata:
            return self.logdata.notes or []
        return []

    def redirect_url(self, user_id) -> str:
        user = User.objects.filter(id=user_id).first()
        redirect_url = self.media_obj.get_absolute_url()

        if (
            self.media_type == self.MediaType.WEBPAGE
            and user
            and user.profile.redirect_to_webpage
        ):
            logger.info(f"Redirecting to {self.media_obj.url}")
            redirect_url = self.media_obj.url

        if self.media_type == self.MediaType.VIDEO and self.media_obj.youtube_id:
            redirect_url = self.media_obj.youtube_link

        return redirect_url

    @property
    def tzinfo(self):
        return ZoneInfo(self.timezone)

    @property
    def local_timestamp(self):
        return timezone.localtime(self.timestamp, timezone=self.tzinfo)

    @property
    def local_stop_timestamp(self):
        if self.stop_timestamp:
            return timezone.localtime(self.stop_timestamp, timezone=self.tzinfo)

    @property
    def scrobble_media_key(self) -> str:
        return media_class_to_foreign_key(self.media_type) + "_id"

    @property
    def status(self) -> str:
        if self.is_paused:
            return "paused"
        if self.played_to_completion:
            return "finished"
        if self.in_progress:
            return "in-progress"
        return "zombie"

    @property
    def is_stale(self) -> bool:
        """Mark scrobble as stale if it's been more than an hour since it was updated

        Effectively, this allows 'resuming' a video scrobble within an hour of starting it.
        """
        is_stale = False
        now = timezone.now()
        seconds_since_last_update = (now - self.modified).total_seconds()
        if seconds_since_last_update >= self.media_obj.SECONDS_TO_STALE:
            is_stale = True
        return is_stale

    @property
    def previous(self) -> "Scrobble":
        return (
            self.media_obj.scrobble_set.order_by("-timestamp")
            .filter(timestamp__lt=self.timestamp)
            .first()
        )

    @property
    def next(self) -> "Scrobble":
        return (
            self.media_obj.scrobble_set.order_by("timestamp")
            .filter(timestamp__gt=self.timestamp)
            .first()
        )

    @property
    def previous_by_media(self) -> "Scrobble":
        return (
            Scrobble.objects.filter(
                media_type=self.media_type,
                user=self.user,
                timestamp__lt=self.timestamp,
            )
            .order_by("-timestamp")
            .first()
        )

    @property
    def next_by_media(self) -> "Scrobble":
        return (
            Scrobble.objects.filter(
                media_type=self.media_type,
                user=self.user,
                timestamp__gt=self.timestamp,
            )
            .order_by("-timestamp")
            .first()
        )

    @property
    def previous_by_user(self) -> "Scrobble":
        return (
            Scrobble.objects.order_by("-timestamp")
            .filter(timestamp__lt=self.timestamp)
            .first()
        )

    @property
    def next_by_user(self) -> "Scrobble":
        return (
            Scrobble.objects.order_by("-timestamp")
            .filter(timestamp__gt=self.timestamp)
            .first()
        )

    @property
    def session_pages_read(self) -> Optional[int]:
        pages_read = 0
        if self.log and isinstance(self.log, dict):
            pages_read = self.log.get("pages_read", 0)
        return pages_read

    @property
    def is_long_play(self) -> bool:
        return self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values()

    @property
    def elapsed_time(self) -> int | None:
        if self.played_to_completion:
            if self.playback_position_seconds:
                return self.playback_position_seconds
            if self.media_obj and self.media_obj.run_time_seconds:
                return self.media_obj.run_time_seconds

        return (timezone.now() - self.timestamp).seconds

    @property
    def percent_played(self) -> int:
        if not self.media_obj:
            return 0

        if self.media_obj and not self.media_obj.run_time_seconds:
            return 100

        if not self.playback_position_seconds and self.played_to_completion:
            return 100

        playback_seconds = self.playback_position_seconds
        if not playback_seconds:
            playback_seconds = self.elapsed_time

        run_time_secs = self.media_obj.run_time_seconds
        percent = int((playback_seconds / run_time_secs) * 100)

        if self.is_long_play:
            long_play_secs = 0
            if (
                self.long_play_last_scrobble
                and not self.long_play_last_scrobble.long_play_complete
            ):
                long_play_secs = self.long_play_last_scrobble.long_play_seconds or 0
            percent = int(((playback_seconds + long_play_secs) / run_time_secs) * 100)

        return percent

    @property
    def probably_still_in_progress(self) -> bool:
        """Add our start time to our media run time to get when we expect to

        Audio tracks should be given a second or two of grace, videos should
        be given closer to 30 minutes, because the odds of watching it back to
        back are very slim.

        """
        is_in_progress = False
        padding_seconds = MEDIA_END_PADDING_SECONDS.get(self.media_type)
        if not padding_seconds:
            return is_in_progress

        if not self.media_obj:
            logger.info(
                "[scrobbling] scrobble has no media obj",
                extra={
                    "media_id": self.media_obj,
                    "scrobble_id": self.id,
                    "media_type": self.media_type,
                    "probably_still_in_progress": is_in_progress,
                },
            )
            return is_in_progress

        if not self.media_obj.run_time_seconds:
            logger.info(
                "[scrobbling] media has no run time seconds, cannot calculate end",
                extra={
                    "media_id": self.media_obj.id,
                    "scrobble_id": self.id,
                    "media_type": self.media_type,
                    "probably_still_in_progress": is_in_progress,
                },
            )
            return is_in_progress

        expected_end = self.timestamp + datetime.timedelta(
            seconds=self.media_obj.run_time_seconds
        )
        expected_end_padded = expected_end + datetime.timedelta(seconds=padding_seconds)
        # Take our start time, add our media length and an extra 30 min (1800s) is it still in the future? keep going
        is_in_progress = expected_end_padded > pendulum.now()
        logger.info(
            "[scrobbling] checking if we're probably still playing",
            extra={
                "media_id": self.media_obj.id,
                "scrobble_id": self.id,
                "media_type": self.media_type,
                "probably_still_in_progress": is_in_progress,
            },
        )
        return is_in_progress

    @property
    def can_be_updated(self) -> bool:
        if (
            self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values()
            and self.source != "readcomicsonline.ru"
        ):
            logger.info(
                "[scrobbling] cannot be updated, long play media",
                extra={
                    "media_id": self.media_obj.id,
                    "scrobble_id": self.id,
                    "media_type": self.media_type,
                },
            )
            return False

        if self.percent_played >= 100 and not self.probably_still_in_progress:
            logger.info(
                "[scrobbling] cannot be updated, existing scrobble is 100% played",
                extra={
                    "media_id": self.media_obj.id,
                    "scrobble_id": self.id,
                    "media_type": self.media_type,
                },
            )
            return False

        if self.is_stale:
            logger.info(
                "[scrobbling] cannot be udpated, stale",
                extra={
                    "media_id": self.media_obj.id,
                    "scrobble_id": self.id,
                    "media_type": self.media_type,
                },
            )
            return False
        logger.info(
            "[scrobbling] can be updated",
            extra={
                "media_id": self.media_obj.id,
                "scrobble_id": self.id,
                "media_type": self.media_type,
            },
        )
        return True

    @classmethod
    def by_date(cls, media_type: str = "Track"):
        cls.objects.filter(media_type=media_type).values("timestamp__date").annotate(
            count=models.Count("id")
        ).values("timestamp__date", "count").order_by(
            "-count",
        )

    @property
    def media_obj(self):
        media_obj = None
        if self.agent_session:
            media_obj = self.agent_session
        if self.video:
            media_obj = self.video
        if self.track:
            media_obj = self.track
        if self.podcast_episode:
            media_obj = self.podcast_episode
        if self.sport_event:
            media_obj = self.sport_event
        if self.book:
            media_obj = self.book
        if self.video_game:
            media_obj = self.video_game
        if self.board_game:
            media_obj = self.board_game
        if self.geo_location:
            media_obj = self.geo_location
        if self.web_page:
            media_obj = self.web_page
        if self.life_event:
            media_obj = self.life_event
        if self.mood:
            media_obj = self.mood
        if self.brick_set:
            media_obj = self.brick_set
        if self.trail:
            media_obj = self.trail
        if self.beer:
            media_obj = self.beer
        if self.wine:
            media_obj = self.wine
        if self.coffee:
            media_obj = self.coffee
        if self.drink:
            media_obj = self.drink
        if self.puzzle:
            media_obj = self.puzzle
        if self.task:
            media_obj = self.task
        if self.food:
            media_obj = self.food
        if self.channel:
            media_obj = self.channel
        if self.birding_location:
            media_obj = self.birding_location
        if self.paper:
            media_obj = self.paper
        if self.disc_golf_course:
            media_obj = self.disc_golf_course
        if self.species_observation:
            media_obj = self.species_observation
        if self.workout_routine:
            media_obj = self.workout_routine
        return media_obj

    def __str__(self):
        return f"Scrobble of {self.media_obj} ({self.timestamp})"

    def calc_reading_duration(self) -> int:
        duration = 0
        if self.logdata.page_data:
            for k, v in self.logdata.page_data.items():
                duration += v.get("duration")
        return duration

    def calc_pages_read(self) -> int:
        pages_read = 0
        if self.logdata.page_data:
            pages = [int(k) for k in self.logdata.page_data.keys()]
            pages.sort()
            if len(pages) == 1:
                pages_read = 1
            elif len(pages) >= 2:
                pages_read += pages[-1] - pages[0]
            else:
                pages_read = pages[-1] - pages[0]
        return pages_read

    @property
    def last_page_read(self) -> int:
        last_page = 0
        if self.logdata.page_data:
            pages = [int(k) for k in self.logdata.page_data.keys()]
            pages.sort()
            last_page = pages[-1]
        return last_page

    @property
    def get_media_source_url(self) -> str:
        url = ""
        if self.media_type == "Website":
            url = self.media_obj.url
        if self.media_type == "Task":
            url = self.media_obj.source_url_for_user(self.user)
        return url

    @classmethod
    def create_or_update(
        cls, media, user_id: int, scrobble_data: dict, **kwargs
    ) -> "Scrobble":
        key = media_class_to_foreign_key(media.__class__.__name__)
        media_query = models.Q(**{key: media})
        scrobble_data[key + "_id"] = media.id
        skip_in_progress_check = kwargs.get("skip_in_progress_check", False)
        read_log_page = kwargs.get("read_log_page", None)

        source_id = scrobble_data.get("source_id")

        # If source_id is provided (e.g., MediaSourceId from Jellyfin), check for existing scrobble first
        # This prevents duplicates when webhooks arrive faster than scrobble creation
        # if source_id:
        #    existing_by_source_id = cls.objects.filter(
        #        media_query,
        #        user_id=user_id,
        #        source_id=source_id,
        #    ).first()
        #    if existing_by_source_id:
        #        logger.info(
        #            "[create_or_update] found existing scrobble by source_id, updating",
        #            extra={
        #                "scrobble_id": existing_by_source_id.id,
        #                "source_id": source_id,
        #            },
        #        )
        #        scrobble_data["playback_status"] = scrobble_data.pop(
        #            "status", None
        #        )
        #        return existing_by_source_id.update(scrobble_data)

        # Find our last scrobble of this media item (track, video, etc)
        scrobble = (
            cls.objects.filter(
                media_query,
                user_id=user_id,
            )
            .order_by("-timestamp")
            .first()
        )
        source = scrobble_data.get("source", "Vrobbler")
        mtype = media.__class__.__name__

        # GeoLocations are a special case scrobble
        if mtype == cls.MediaType.GEO_LOCATION:
            logger.warning(
                f"[create_or_update] geoloc requires create_or_update_location"
            )
            scrobble = cls.create_or_update_location(media, scrobble_data, user_id)
            return scrobble

        if not skip_in_progress_check or read_log_page:
            logger.info(
                f"[create_or_update] check for existing scrobble to update ",
                extra={
                    "scrobble_id": scrobble.id if scrobble else None,
                    "media_type": mtype,
                    "media_id": media.id,
                    "scrobble_data": scrobble_data,
                },
            )
            scrobble_data["playback_status"] = scrobble_data.pop("status", None)
            # If it's marked as stopped, send it through our update mechanism, which will complete it
            if scrobble and (
                scrobble.can_be_updated
                or (read_log_page and scrobble.can_be_updated)
                or scrobble_data["playback_status"] == "stopped"
            ):
                if read_log_page:
                    page_data = scrobble.log.get("page_data", {})
                    if page_data:
                        for page_num, page in page_data.items():
                            if not page.get("end_ts", None):
                                page["end_ts"] = int(timezone.now().timestamp())
                                page["duration"] = page["end_ts"] - page.get("start_ts")

                    page_data[read_log_page] = BookPageLogData(
                        page_number=read_log_page,
                        start_ts=int(timezone.now().timestamp()),
                    )
                    scrobble.log["page_data"] = page_data
                    scrobble.save(update_fields=["log"])
                elif "log" in scrobble_data.keys() and scrobble.log:
                    scrobble_data["log"] = scrobble.log | scrobble_data["log"]
                return scrobble.update(scrobble_data)

            # Discard status before creating
            scrobble_data.pop("playback_status")

        if read_log_page:
            scrobble_data["log"] = BookLogData(
                page_data={
                    read_log_page: BookPageLogData(
                        page_number=read_log_page,
                        start_ts=int(timezone.now().timestamp()),
                    )
                }
            ).asdict

        logger.info(
            f"[scrobbling] creating new scrobble",
            extra={
                "scrobble_id": scrobble.id if scrobble else None,
                "media_type": mtype,
                "media_id": media.id,
                "source": source,
            },
        )
        if mtype == cls.MediaType.FOOD and not scrobble_data.get("log", {}).get(
            "calories", None
        ):
            if media.calories:
                scrobble_data["log"] = FoodLogData(calories=media.calories).asdict

        if mtype not in LONG_PLAY_MEDIA.values():
            scrobble_data.pop("long_play_complete", None)

        scrobble = cls.create(scrobble_data)

        if mtype in LONG_PLAY_MEDIA.values():
            last_finished = (
                cls.objects.filter(
                    models.Q(**{key: media}),
                    user_id=user_id,
                    timestamp__lt=scrobble.timestamp,
                )
                .order_by("-timestamp")
                .first()
            )
            if last_finished:
                scrobble.long_play_last_scrobble = last_finished
                scrobble.save(update_fields=["long_play_last_scrobble"])

        return scrobble

    @classmethod
    def create_or_update_location(
        cls, location: GeoLocation, scrobble_data: dict, user_id: int
    ) -> "Scrobble":
        """Location is special type, where the current scrobble for a user is always the
        current active scrobble, and we only finish it a move on if we get a new location
        that is far enough (and far enough over the last three past scrobbles) to have
        actually moved.
        """
        key = media_class_to_foreign_key(location.__class__.__name__)
        scrobble_data[key + "_id"] = location.id

        scrobble = (
            cls.objects.filter(
                media_type=cls.MediaType.GEO_LOCATION,
                user_id=user_id,
                timestamp__lte=scrobble_data.get("timestamp"),
            )
            .order_by("-timestamp")
            .first()
        )

        logger.info(
            f"[scrobbling] fetching last location scrobble",
            extra={
                "scrobble_id": scrobble.id if scrobble else None,
                "media_type": cls.MediaType.GEO_LOCATION,
                "media_id": location.id,
                "scrobble_data": scrobble_data,
            },
        )

        if not scrobble:
            logger.info(
                f"[scrobbling] finished - no existing location scrobbles found",
                extra={
                    "media_id": location.id,
                    "media_type": cls.MediaType.GEO_LOCATION,
                },
            )
            return cls.create(scrobble_data)

        if scrobble.media_obj == location:
            logger.info(
                f"[scrobbling] finished - same location - not moved",
                extra={
                    "media_type": cls.MediaType.GEO_LOCATION,
                    "media_id": location.id,
                    "scrobble_id": scrobble.id,
                    "scrobble_media_id": scrobble.media_obj.id,
                },
            )
            return scrobble

        has_moved = location.has_moved(scrobble.media_obj)
        logger.info(
            f"[scrobbling] checking - has last location has moved?",
            extra={
                "scrobble_id": scrobble.id,
                "scrobble_media_id": scrobble.media_obj.id,
                "media_type": cls.MediaType.GEO_LOCATION,
                "media_id": location.id,
                "has_moved": has_moved,
            },
        )
        if not has_moved:
            logger.info(
                f"[scrobbling] finished - not from old location - not moved",
                extra={
                    "scrobble_id": scrobble.id,
                    "media_id": location.id,
                    "media_type": cls.MediaType.GEO_LOCATION,
                    "old_media__id": scrobble.media_obj.id,
                },
            )
            return scrobble

        if existing_locations := location.in_proximity(named=True):
            existing_location = existing_locations.first()
            ts = int(pendulum.now().timestamp())
            scrobble.log[ts] = f"Location {location.id} too close to this scrobble"
            scrobble.save(update_fields=["log"])
            logger.info(
                f"[scrobbling] finished - found existing named location",
                extra={
                    "media_id": location.id,
                    "media_type": cls.MediaType.GEO_LOCATION,
                    "old_media_id": existing_location.id,
                },
            )
            return scrobble

        scrobble.stop(force_finish=True)
        scrobble = cls.create(scrobble_data)
        logger.info(
            f"[scrobbling] finished - created for location",
            extra={
                "scrobble_id": scrobble.id,
                "media_id": location.id,
                "scrobble_data": scrobble_data,
                "media_type": cls.MediaType.GEO_LOCATION,
                "source": scrobble_data.get("source"),
            },
        )
        return scrobble

    def update(self, scrobble_data: dict) -> "Scrobble":
        # Status is a field we get from Mopidy, which refuses to poll us
        playback_status = scrobble_data.pop("playback_status", None)

        logger.info(
            "[update] called",
            extra={
                "scrobble_id": self.id,
                "scrobble_data": scrobble_data,
                "media_type": self.media_type,
                "playback_status": playback_status,
            },
        )
        if self.beyond_completion_percent:
            playback_status = "stopped"

        if playback_status == "stopped":
            self.stop()
        if playback_status == "paused":
            self.pause()
        if playback_status == "resumed":
            self.resume()

        if playback_status != "resumed":
            scrobble_data["stop_timestamp"] = (
                scrobble_data.pop("timestamp", None) or timezone.now()
            )

        # timestamp should be more-or-less immutable
        scrobble_data.pop("timestamp", None)

        update_fields = []
        for key, value in scrobble_data.items():
            setattr(self, key, value)
            update_fields.append(key)
        self.save(update_fields=update_fields)

        return self

    @classmethod
    def create(
        cls,
        scrobble_data: dict,
    ) -> "Scrobble":
        if "visibility" not in scrobble_data:
            user = scrobble_data.get("user")
            media_type = scrobble_data.get("media_type")
            override = None
            if user and media_type:
                try:
                    profile = user.profile
                    overrides = profile.media_type_visibility or {}
                    override = overrides.get(media_type)
                except user.__class__.profile.RelatedObjectDoesNotExist:
                    pass
            scrobble_data["visibility"] = override or Visibility.PRIVATE
        scrobble = cls.objects.create(**scrobble_data)
        if not (
            scrobble.media_type == cls.MediaType.GEO_LOCATION
            and scrobble.media_obj
            and not scrobble.media_obj.title
        ):
            ScrobbleNtfyNotification(scrobble).send()
        return scrobble

    def stop(self, timestamp=None, force_finish=False) -> None:
        self.stop_timestamp = timestamp or timezone.now()
        self.played_to_completion = True
        self.in_progress = False

        if not self.playback_position_seconds:
            self.playback_position_seconds = int(
                (self.stop_timestamp - self.timestamp).total_seconds()
            )

        self.save(
            update_fields=[
                "in_progress",
                "played_to_completion",
                "stop_timestamp",
                "playback_position_seconds",
            ]
        )

        self._add_labels_as_tags()

        class_name = self.media_obj.__class__.__name__
        if class_name in LONG_PLAY_MEDIA.values():
            self.finish_long_play()

        if class_name == "Book":
            self.calculate_reading_stats()

        logger.info(
            f"[scrobbling] stopped",
            extra={
                "scrobble_id": self.id,
                "media_id": self.media_obj.id,
                "media_type": self.media_type,
                "source": self.source,
            },
        )

    def _add_labels_as_tags(self) -> None:
        if self.logdata and hasattr(self.logdata, "labels") and self.logdata.labels:
            for label in self.logdata.labels:
                self.tags.add(label)

    def pause(self) -> None:
        if self.is_paused:
            logger.warning(f"{self.id} - already paused - {self.source}")
            return
        self.is_paused = True
        self.save(update_fields=["is_paused"])
        logger.info(
            f"[scrobbling] paused",
            extra={
                "scrobble_id": self.id,
                "media_type": self.media_type,
                "source": self.source,
            },
        )

    def resume(self) -> None:
        if self.is_paused or not self.in_progress:
            self.is_paused = False
            self.in_progress = True
            self.save(update_fields=["is_paused", "in_progress"])
            logger.info(
                f"[scrobbling] resumed",
                extra={
                    "scrobble_id": self.id,
                    "media_type": self.media_type,
                    "source": self.source,
                },
            )

    def cancel(self) -> None:
        self.delete()

    def update_ticks(self, data) -> None:
        self.playback_position_seconds = data.get("playback_position_seconds")
        self.save(update_fields=["playback_position_seconds"])

    def finish_long_play(self):
        seconds_elapsed = (timezone.now() - self.timestamp).seconds
        past_seconds = 0

        # Set our playback seconds, and calc long play seconds
        self.playback_position_seconds = seconds_elapsed
        if self.long_play_last_scrobble:
            past_seconds = self.long_play_last_scrobble.long_play_seconds or 0

        self.long_play_seconds = past_seconds + seconds_elapsed

        # Long play scrobbles are always finished when we say they are
        self.played_to_completion = True

        self.save(
            update_fields=[
                "playback_position_seconds",
                "played_to_completion",
                "long_play_seconds",
            ]
        )
        logger.info(
            f"[scrobbling] finishing long play",
            extra={
                "scrobble_id": self.id,
            },
        )

    @property
    def beyond_completion_percent(self) -> bool:
        """Returns true if our media is beyond our completion percent, unless
        our type is geolocation in which case we always return false
        """
        beyond_completion = self.percent_played >= self.media_obj.COMPLETION_PERCENT

        if self.media_type == "GeoLocation":
            logger.info(
                f"[scrobbling] locations are ONLY completed when new one is created",
                extra={
                    "scrobble_id": self.id,
                    "media_type": self.media_type,
                    "beyond_completion": beyond_completion,
                },
            )
            beyond_completion = False

        return beyond_completion

    @property
    def is_over_time(self) -> bool:
        """Has the scrobble overrun it's time?"""
        elapsed_scrobble_seconds = (timezone.now() - self.timestamp).seconds

        if elapsed_scrobble_seconds > self.media_obj.run_time_seconds:
            return True
        return False

    def auto_finish(self) -> bool:
        """Check if media type should auto finish.

        Return True if scrobble is finished, False if not.
        """
        if self.is_over_time and self.media_type in AUTO_FINISH_MEDIA.values():
            self.stop(force_finish=True)
            return True
        return False

    def calculate_reading_stats(self, commit=True):
        page_data = self.logdata.page_data

        if page_data:
            if isinstance(page_data, dict):
                valid_pages = sorted(int(k) for k in page_data.keys())
                if valid_pages:
                    self.log["page_start"] = min(valid_pages)
                    self.log["page_end"] = max(valid_pages)
                    self.log["pages_read"] = len(set(valid_pages))
            elif isinstance(page_data, list):
                valid_pages = []
                for page in page_data:
                    try:
                        valid_pages.append(int(page["page_number"]))
                    except (ValueError, TypeError):
                        continue

                if valid_pages:
                    self.log["page_start"] = min(valid_pages)
                    self.log["page_end"] = max(valid_pages)
                    self.log["pages_read"] = len(set(valid_pages))
        else:
            page_start = self.log.get("page_start")
            page_end = self.log.get("page_end")
            if page_start is not None and page_end is not None:
                self.log["pages_read"] = page_end - page_start + 1

        if commit and "pages_read" in self.log:
            self.save(update_fields=["log"])


class FavoriteMedia(TimeStampedModel):

    uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
    user = models.ForeignKey(User, on_delete=models.CASCADE)

    video = models.ForeignKey(Video, on_delete=models.CASCADE, **BNULL)
    channel = models.ForeignKey("videos.Channel", on_delete=models.CASCADE, **BNULL)
    track = models.ForeignKey(Track, on_delete=models.CASCADE, **BNULL)
    podcast_episode = models.ForeignKey(
        PodcastEpisode, on_delete=models.CASCADE, **BNULL
    )
    sport_event = models.ForeignKey(SportEvent, on_delete=models.CASCADE, **BNULL)
    book = models.ForeignKey(Book, on_delete=models.CASCADE, **BNULL)
    paper = models.ForeignKey(Paper, on_delete=models.CASCADE, **BNULL)
    video_game = models.ForeignKey(VideoGame, on_delete=models.CASCADE, **BNULL)
    board_game = models.ForeignKey(BoardGame, on_delete=models.CASCADE, **BNULL)
    geo_location = models.ForeignKey(GeoLocation, on_delete=models.CASCADE, **BNULL)
    beer = models.ForeignKey(Beer, on_delete=models.CASCADE, **BNULL)
    wine = models.ForeignKey(Wine, on_delete=models.CASCADE, **BNULL)
    coffee = models.ForeignKey(Coffee, on_delete=models.CASCADE, **BNULL)
    drink = models.ForeignKey(Drink, on_delete=models.CASCADE, **BNULL)
    puzzle = models.ForeignKey(Puzzle, on_delete=models.CASCADE, **BNULL)
    food = models.ForeignKey(Food, on_delete=models.CASCADE, **BNULL)
    trail = models.ForeignKey(Trail, on_delete=models.CASCADE, **BNULL)
    task = models.ForeignKey(Task, on_delete=models.CASCADE, **BNULL)
    web_page = models.ForeignKey(WebPage, on_delete=models.CASCADE, **BNULL)
    life_event = models.ForeignKey(LifeEvent, on_delete=models.CASCADE, **BNULL)
    mood = models.ForeignKey(Mood, on_delete=models.CASCADE, **BNULL)
    brick_set = models.ForeignKey(BrickSet, on_delete=models.CASCADE, **BNULL)
    birding_location = models.ForeignKey(
        BirdingLocation, on_delete=models.CASCADE, **BNULL
    )
    disc_golf_course = models.ForeignKey(
        DiscGolfCourse, on_delete=models.CASCADE, **BNULL
    )
    workout_routine = models.ForeignKey(
        "workouts.WorkoutRoutine", on_delete=models.CASCADE, **BNULL
    )
    media_type = models.CharField(max_length=20, choices=Scrobble.MediaType.choices)
    sent_to_mopidy = models.BooleanField(default=False)

    class Meta:
        ordering = ["-created"]

    def __str__(self):
        return f"{self.user} favorites {self.media_obj}"

    @property
    def media_obj(self):
        media_obj = None
        if self.video:
            media_obj = self.video
        if self.track:
            media_obj = self.track
        if self.podcast_episode:
            media_obj = self.podcast_episode
        if self.sport_event:
            media_obj = self.sport_event
        if self.book:
            media_obj = self.book
        if self.video_game:
            media_obj = self.video_game
        if self.board_game:
            media_obj = self.board_game
        if self.geo_location:
            media_obj = self.geo_location
        if self.web_page:
            media_obj = self.web_page
        if self.life_event:
            media_obj = self.life_event
        if self.mood:
            media_obj = self.mood
        if self.brick_set:
            media_obj = self.brick_set
        if self.trail:
            media_obj = self.trail
        if self.beer:
            media_obj = self.beer
        if self.wine:
            media_obj = self.wine
        if self.coffee:
            media_obj = self.coffee
        if self.drink:
            media_obj = self.drink
        if self.puzzle:
            media_obj = self.puzzle
        if self.task:
            media_obj = self.task
        if self.food:
            media_obj = self.food
        if self.channel:
            media_obj = self.channel
        if self.birding_location:
            media_obj = self.birding_location
        if self.disc_golf_course:
            media_obj = self.disc_golf_course
        if self.workout_routine:
            media_obj = self.workout_routine
        return media_obj

    @classmethod
    def toggle(cls, media_obj, user):
        media_type = media_type_for_media_obj(media_obj)
        if media_type not in Scrobble.MediaType.list():
            raise ValueError(f"Unknown media type: {media_type}")

        fk_map = {
            "Video": "video",
            "Channel": "channel",
            "Track": "track",
            "PodcastEpisode": "podcast_episode",
            "SportEvent": "sport_event",
            "Book": "book",
            "Paper": "paper",
            "VideoGame": "video_game",
            "BoardGame": "board_game",
            "GeoLocation": "geo_location",
            "Beer": "beer",
            "Wine": "wine",
            "Coffee": "coffee",
            "Drink": "drink",
            "Puzzle": "puzzle",
            "Food": "food",
            "Trail": "trail",
            "Task": "task",
            "WebPage": "web_page",
            "LifeEvent": "life_event",
            "Mood": "mood",
            "BrickSet": "brick_set",
            "BirdingLocation": "birding_location",
            "DiscGolfCourse": "disc_golf_course",
            "Workout": "workout_routine",
        }

        fk = fk_map.get(media_type)
        if not fk:
            raise ValueError(f"No FK mapping for media type: {media_type}")

        existing = cls.objects.filter(user=user, **{fk: media_obj}).first()
        if existing:
            existing.delete()
            return None

        return cls.objects.create(
            user=user,
            media_type=media_type,
            **{fk: media_obj},
        )
