#
# steering, Programme/schedule management for AURA
#
# Copyright (C) 2011-2017, 2020, Ernesto Rico Schmidt
# Copyright (C) 2017-2019, Ingo Leindecker
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#

import re
from typing import List, TypedDict

from drf_jsonschema_serializer import JSONSchemaField
from rest_framework import serializers
from rest_framework.permissions import exceptions

from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ObjectDoesNotExist
from django.db import IntegrityError
from django.utils import text, timezone
from program.models import (
    Category,
    FundingCategory,
    Host,
    HostLink,
    Image,
    Language,
    License,
    LinkType,
    MusicFocus,
    Note,
    NoteLink,
    RRule,
    Schedule,
    Show,
    ShowLink,
    TimeSlot,
    Topic,
    Type,
    UserProfile,
)
from program.utils import delete_links

SOLUTION_CHOICES = {
    "theirs": "Discard projected timeslot. Keep existing timeslot(s).",
    "ours": "Create projected timeslot. Delete existing timeslot(s).",
    "theirs-start": (
        "Keep existing timeslot. Create projected timeslot with start time of existing end."
    ),
    "ours-start": (
        "Create projected timeslot. Change end of existing timeslot to projected start time."
    ),
    "theirs-end": (
        "Keep existing timeslot. Create projected timeslot with end of existing start time."
    ),
    "ours-end": (
        "Create projected timeslot. Change start of existing timeslot to projected end time."
    ),
    "theirs-both": (
        "Keep existing timeslot. "
        "Create two projected timeslots with end of existing start and start of existing end."
    ),
    "ours-both": (
        "Create projected timeslot. Split existing timeslot into two: \n\n"
        "* set existing end time to projected start,\n"
        "* create another timeslot with start = projected end and end = existing end."
    ),
}


class ErrorSerializer(serializers.Serializer):
    message = serializers.CharField()
    code = serializers.CharField(allow_null=True)


class ProfileSerializer(serializers.ModelSerializer):
    class Meta:
        model = UserProfile
        fields = (
            "cba_username",
            "cba_user_token",
            "created_at",
            "created_by",
            "updated_at",
            "updated_by",
        )
        read_only_fields = (
            "created_at",
            "created_by",
            "updated_at",
            "updated_by",
        )


class UserSerializer(serializers.ModelSerializer):
    is_privileged = serializers.SerializerMethodField()
    permissions = serializers.SerializerMethodField()
    # Add profile fields to JSON
    profile = ProfileSerializer(required=False)

    class Meta:
        model = User
        read_only_fields = (
            "id",
            "is_privileged",
            "permissions",
        )
        fields = (
            "email",
            "first_name",
            "is_active",
            "is_staff",
            "is_superuser",
            "last_name",
            "password",
            "profile",
            "username",
        ) + read_only_fields

    @staticmethod
    def get_permissions(obj: User) -> list[str]:
        return sorted([p for p in obj.get_all_permissions() if p.startswith("program")])

    @staticmethod
    def get_is_privileged(obj: User) -> bool:
        return obj.is_superuser

    def create(self, validated_data):
        """
        Create and return a new User instance, given the validated data.
        """

        profile_data = validated_data.pop("profile") if "profile" in validated_data else None

        user = super(UserSerializer, self).create(validated_data)
        user.date_joined = timezone.now()
        user.set_password(validated_data["password"])
        user.save()

        if profile_data:
            profile = UserProfile(
                cba_username=profile_data.get("cba_username").strip(),
                cba_user_token=profile_data.get("cba_user_token").strip(),
                created_by=self.context.get("request").user.username,
                user=user,
            )
            profile.save()

        return user

    def update(self, instance, validated_data):
        """
        Update and return an existing User instance, given the validated data.
        """

        if "first_name" in validated_data:
            instance.first_name = validated_data.get("first_name")

        if "last_name" in validated_data:
            instance.last_name = validated_data.get("last_name")

        if "email" in validated_data:
            instance.email = validated_data.get("email")

        if "is_active" in validated_data:
            instance.is_active = validated_data.get("is_active")

        if "is_staff" in validated_data:
            instance.is_staff = validated_data.get("is_staff")

        if "is_superuser" in validated_data:
            instance.is_superuser = validated_data.get("is_superuser")

        profile_data = validated_data.pop("profile") if "profile" in validated_data else None

        if profile_data:
            # TODO: How to hook into this from ProfileSerializer without having to call it here?
            try:
                profile = UserProfile.objects.get(user=instance.id)
            except ObjectDoesNotExist:
                profile = UserProfile.objects.create(user=instance, **profile_data)

            if "cba_username" in profile_data:
                profile.cba_username = profile_data.get("cba_username")

            if "cba_user_token" in profile_data:
                profile.cba_user_token = profile_data.get("cba_user_token")

            profile.updated_by = self.context.get("request").user.username
            profile.save()

        instance.save()
        return instance


class CategorySerializer(serializers.ModelSerializer):
    class Meta:
        model = Category
        fields = ("description", "id", "is_active", "name", "slug", "subtitle")


class LinkTypeSerializer(serializers.ModelSerializer):
    class Meta:
        model = LinkType
        fields = ("id", "is_active", "name")


class LicenseSerializer(serializers.ModelSerializer):
    class Meta:
        model = License
        fields = (
            "id",
            "identifier",
            "name",
            "needs_author",
            "requires_express_permission_for_publication",
            "url",
        )


class HostLinkSerializer(serializers.ModelSerializer):
    type_id = serializers.PrimaryKeyRelatedField(queryset=LinkType.objects.all(), source="type")

    class Meta:
        fields = ("type_id", "url")
        model = HostLink


class PPOIField(serializers.CharField):
    def validate_format(self, value: str):
        if not re.match(r"\d(?:\.\d+)?x\d(?:\.\d+)?", value):
            raise serializers.ValidationError("PPOI must match format: ${float}x${float}")

    def __init__(self, **kwargs):
        kwargs["max_length"] = 20
        kwargs.setdefault("validators", [])
        kwargs["validators"].append(self.validate_format)
        super().__init__(**kwargs)

    def to_representation(self, value: tuple[float, float]):
        [left, top] = value
        return f"{left}x{top}"


class Thumbnail(TypedDict):
    width: float
    height: float
    url: str


class ImageSerializer(serializers.ModelSerializer):
    license_id = serializers.PrimaryKeyRelatedField(
        allow_null=True, queryset=License.objects.all(), required=False, source="license"
    )
    ppoi = PPOIField(required=False)
    thumbnails = serializers.SerializerMethodField()
    url = serializers.SerializerMethodField()

    @staticmethod
    def get_thumbnails(instance) -> List[Thumbnail]:
        """Returns thumbnails"""
        thumbnails = []

        if instance.image.name and settings.THUMBNAIL_SIZES:
            for size in settings.THUMBNAIL_SIZES:
                [width, height] = size.split("x")
                thumbnails.append(
                    {
                        "width": int(width),
                        "height": int(height),
                        "url": instance.image.crop[size].url,
                    }
                )

        return thumbnails

    @staticmethod
    def get_url(instance: Image) -> str:
        """Returns the image URL, using settings.SITE_URL to include the protocol and avoid mixed
        media warnings."""

        return f"{settings.SITE_URL}{instance.image.url}"

    class Meta:
        # TODO: make `image` a write-only field once dashboard is updated to read from `url`
        # extra_kwargs = {"image": {"write_only": True}}
        model = Image
        read_only_fields = (
            "height",
            "id",
            "thumbnails",
            "url",
            "width",
        )
        fields = (
            "alt_text",
            "credits",
            "image",
            "license_id",
            "ppoi",
        ) + read_only_fields

    def create(self, validated_data):
        """Create and return a new Image instance, given the validated data."""

        image = Image.objects.create(
            owner=self.context.get("request").user.username, **validated_data
        )
        image.save()

        return image

    def update(self, instance, validated_data):
        """Update and return an existing Image instance, given the validated data."""

        if "alt_text" in validated_data:
            instance.alt_text = validated_data.get("alt_text")

        if "credits" in validated_data:
            instance.credits = validated_data.get("credits")

        if "license" in validated_data:
            instance.license = validated_data.get("license")

        if "ppoi" in validated_data:
            instance.image.ppoi = validated_data.get("ppoi")

        instance.save()

        return instance


class HostSerializer(serializers.ModelSerializer):
    image_id = serializers.PrimaryKeyRelatedField(
        allow_null=True, queryset=Image.objects.all(), required=False, source="image"
    )
    links = HostLinkSerializer(many=True, required=False)
    owner_ids = serializers.PrimaryKeyRelatedField(
        allow_null=True, many=True, queryset=User.objects.all(), source="owners"
    )

    class Meta:
        model = Host
        read_only_fields = (
            "created_at",
            "created_by",
            "updated_at",
            "updated_by",
        )
        fields = (
            "biography",
            "email",
            "id",
            "image_id",
            "is_active",
            "links",
            "name",
            "owner_ids",
        ) + read_only_fields

    def create(self, validated_data):
        """
        Create and return a new Host instance, given the validated data.
        """

        # optional nested objects
        links_data = validated_data.pop("links", [])

        # optional many-to-many
        owners = validated_data.pop("owners", [])

        # optional foreign key
        validated_data["image"] = validated_data.pop("image", None)

        host = Host.objects.create(
            created_by=self.context.get("request").user.username, **validated_data
        )

        for link_data in links_data:
            HostLink.objects.create(host=host, **link_data)

        host.owners.set(owners)

        host.save()

        return host

    def update(self, instance, validated_data):
        """Update and return an existing Host instance, given the validated data."""

        user = self.context.get("request").user
        user_is_owner = user in instance.owners.all()
        user_permissions = set(
            permission.split("__")[-1]
            for permission in user.get_all_permissions()
            if permission.startswith("program.edit__host")
        )
        update_fields = set(validated_data.keys())

        # having the update_host permission overrides the ownership
        if not (user.has_perm("program.update_host") or (user_is_owner and user_permissions)):
            raise exceptions.PermissionDenied(detail="You are not allowed to update this host.")

        # without the update_host permission, fields without edit permission are not allowed
        if not user.has_perm("program.update_host") and (
            not_allowed := update_fields.difference(user_permissions)
        ):
            detail = {field: "You are not allowed to edit this field" for field in not_allowed}
            raise exceptions.PermissionDenied(detail=detail)

        if "biography" in validated_data:
            instance.biography = validated_data.get("biography")

        if "name" in validated_data:
            instance.name = validated_data.get("name")

        if "email" in validated_data:
            instance.email = validated_data.get("email")

        if "image" in validated_data:
            instance.image = validated_data.get("image")

        if "is_active" in validated_data:
            instance.is_active = validated_data.get("is_active")

        # optional many-to-many
        if "owners" in validated_data:
            instance.owners.set(validated_data.get("owners"))

        # optional nested objects
        if "links" in validated_data:
            instance = delete_links(instance)

            for link_data in validated_data.get("links"):
                HostLink.objects.create(host=instance, **link_data)

        instance.updated_by = self.context.get("request").user.username

        instance.save()

        return instance


class LanguageSerializer(serializers.ModelSerializer):
    class Meta:
        model = Language
        fields = ("id", "is_active", "name")


class TopicSerializer(serializers.ModelSerializer):
    class Meta:
        model = Topic
        fields = ("id", "is_active", "name", "slug")


class MusicFocusSerializer(serializers.ModelSerializer):
    class Meta:
        model = MusicFocus
        fields = ("id", "is_active", "name", "slug")


class TypeSerializer(serializers.ModelSerializer):
    class Meta:
        model = Type
        fields = ("id", "is_active", "name", "slug")


class FundingCategorySerializer(serializers.ModelSerializer):
    class Meta:
        model = FundingCategory
        fields = ("id", "is_active", "name", "slug")


class ShowLinkSerializer(serializers.ModelSerializer):
    type_id = serializers.PrimaryKeyRelatedField(queryset=LinkType.objects.all(), source="type")

    class Meta:
        model = ShowLink
        fields = ("type_id", "url")


class ShowSerializer(serializers.HyperlinkedModelSerializer):
    category_ids = serializers.PrimaryKeyRelatedField(
        many=True, queryset=Category.objects.all(), source="category"
    )
    funding_category_id = serializers.PrimaryKeyRelatedField(
        queryset=FundingCategory.objects.all(), source="funding_category"
    )
    host_ids = serializers.PrimaryKeyRelatedField(
        many=True, queryset=Host.objects.all(), source="hosts"
    )
    image_id = serializers.PrimaryKeyRelatedField(
        allow_null=True, queryset=Image.objects.all(), required=False, source="image"
    )
    language_ids = serializers.PrimaryKeyRelatedField(
        many=True, queryset=Language.objects.all(), source="language"
    )
    links = HostLinkSerializer(many=True, required=False)
    logo_id = serializers.PrimaryKeyRelatedField(
        allow_null=True, queryset=Image.objects.all(), required=False, source="logo"
    )
    music_focus_ids = serializers.PrimaryKeyRelatedField(
        many=True, queryset=MusicFocus.objects.all(), source="music_focus"
    )
    owner_ids = serializers.PrimaryKeyRelatedField(
        many=True, queryset=User.objects.all(), source="owners"
    )
    predecessor_id = serializers.PrimaryKeyRelatedField(
        allow_null=True, queryset=Show.objects.all(), required=False, source="predecessor"
    )
    topic_ids = serializers.PrimaryKeyRelatedField(
        many=True, queryset=Topic.objects.all(), source="topic"
    )
    type_id = serializers.PrimaryKeyRelatedField(queryset=Type.objects.all(), source="type")

    class Meta:
        model = Show
        read_only_fields = (
            "created_at",
            "created_by",
            "updated_at",
            "updated_by",
        )
        fields = (
            "category_ids",
            "cba_series_id",
            "default_playlist_id",
            "description",
            "email",
            "funding_category_id",
            "host_ids",
            "id",
            "image_id",
            "internal_note",
            "is_active",
            "is_public",
            "language_ids",
            "links",
            "logo_id",
            "music_focus_ids",
            "name",
            "owner_ids",
            "predecessor_id",
            "short_description",
            "slug",
            "topic_ids",
            "type_id",
        ) + read_only_fields

    @staticmethod
    def validate_cba_series_id(value):
        # FIXME: ugly hack to capture 0 as None
        return None if value == 0 else value

    @staticmethod
    def validate_default_playlist_id(value):
        # FIXME: ugly hack to capture 0 as None
        return None if value == 0 else value

    def create(self, validated_data):
        """
        Create and return a new Show instance, given the validated data.
        """

        # required many-to-many
        category = validated_data.pop("category")
        hosts = validated_data.pop("hosts")
        language = validated_data.pop("language")
        music_focus = validated_data.pop("music_focus")
        owners = validated_data.pop("owners")
        topic = validated_data.pop("topic")

        # optional nested objects
        links_data = validated_data.pop("links", [])

        # required foreign key
        validated_data["funding_category"] = validated_data.pop("funding_category")
        validated_data["type"] = validated_data.pop("type")

        # optional foreign key
        validated_data["image"] = validated_data.pop("image", None)
        validated_data["logo"] = validated_data.pop("logo", None)
        validated_data["predecessor"] = validated_data.pop("predecessor", None)

        validated_data["slug"] = validated_data.get(
            "slug", text.slugify(validated_data.get("name"))
        )

        show = Show.objects.create(
            created_by=self.context.get("request").user.username,
            **validated_data,
        )

        # Save many-to-many relationships
        show.category.set(category)
        show.hosts.set(hosts)
        show.language.set(language)
        show.music_focus.set(music_focus)
        show.owners.set(owners)
        show.topic.set(topic)

        for link_data in links_data:
            ShowLink.objects.create(show=show, **link_data)

        show.save()

        return show

    def update(self, instance, validated_data):
        """Update and return an existing Show instance, given the validated data."""

        user = self.context.get("request").user
        user_is_owner = user in instance.owners.all()
        user_permissions = set(
            permission.split("__")[-1]
            for permission in user.get_all_permissions()
            if permission.startswith("program.edit__show")
        )
        update_fields = set(validated_data.keys())

        # having update_show permission overrides the ownership
        if not (user.has_perm("program.update_show") or (user_is_owner and user_permissions)):
            raise exceptions.PermissionDenied(detail="You are not allowed to update this show.")

        # without the update_show permission, fields without edit permission are not allowed
        if not user.has_perm("program.update_show") and (
            not_allowed := update_fields.difference(user_permissions)
        ):
            detail = {field: "You are not allowed to edit this field" for field in not_allowed}
            raise exceptions.PermissionDenied(detail=detail)

        if "description" in validated_data:
            instance.description = validated_data.get("description")

        if "name" in validated_data:
            instance.name = validated_data.get("name")

        if "short_description" in validated_data:
            instance.short_description = validated_data.get("short_description")

        if "cba_series_id" in validated_data:
            instance.cba_series_id = validated_data.get("cba_series_id")

        if "default_playlist_id" in validated_data:
            instance.default_playlist_id = validated_data.get("default_playlist_id")

        if "email" in validated_data:
            instance.email = validated_data.get("email")

        if "funding_category" in validated_data:
            instance.funding_category = validated_data.get("funding_category")

        if "image" in validated_data:
            instance.image = validated_data.get("image")

        if "internal_note" in validated_data:
            instance.internal_note = validated_data.get("internal_note")

        if "is_active" in validated_data:
            instance.is_active = validated_data.get("is_active")

        if "is_public" in validated_data:
            instance.is_public = validated_data.get("is_public")

        if "logo" in validated_data:
            instance.logo = validated_data.get("logo")

        if "predecessor" in validated_data:
            instance.predecessor = validated_data.get("predecessor")

        if "slug" in validated_data:
            instance.slug = validated_data.get("slug")

        if "type" in validated_data:
            instance.type = validated_data.get("type")

        # optional many-to-many
        if "category" in validated_data:
            instance.category.set(validated_data.get("category", []))

        if "hosts" in validated_data:
            instance.hosts.set(validated_data.get("hosts", []))

        if "language" in validated_data:
            instance.language.set(validated_data.get("language", []))

        if "music_focus" in validated_data:
            instance.music_focus.set(validated_data.get("music_focus", []))

        if "owners" in validated_data:
            instance.owners.set(validated_data.get("owners", []))

        if "topic" in validated_data:
            instance.topic.set(validated_data.get("topic", []))

        # optional nested objects
        if "links" in validated_data:
            instance = delete_links(instance)

            for link_data in validated_data.get("links"):
                ShowLink.objects.create(host=instance, **link_data)

        instance.updated_by = self.context.get("request").user.username

        instance.save()

        return instance


class RRuleSerializer(serializers.ModelSerializer):
    class Meta:
        model = RRule
        fields = (
            "by_set_pos",
            "by_weekdays",
            "count",
            "freq",
            "id",
            "interval",
            "name",
        )
        read_only_fields = fields


class ScheduleSerializer(serializers.ModelSerializer):
    class Meta:
        model = Schedule
        fields = (
            "add_business_days_only",
            "add_days_no",
            "by_weekday",
            "default_playlist_id",
            "end_time",
            "first_date",
            "id",
            "is_repetition",
            "last_date",
            "rrule_id",
            "show_id",
            "start_time",
        )


class UnsavedScheduleSerializer(ScheduleSerializer):
    id = serializers.IntegerField(allow_null=True)


class ScheduleInRequestSerializer(ScheduleSerializer):
    dryrun = serializers.BooleanField(
        write_only=True,
        required=False,
        help_text=(
            "Whether to simulate the database changes. If true, no database changes will occur. "
            "Instead a list of objects that would be created, updated and deleted if dryrun was "
            "false will be returned."
        ),
    )

    class Meta:
        model = Schedule
        fields = (
            "add_business_days_only",
            "add_days_no",
            "by_weekday",
            "default_playlist_id",
            "dryrun",
            "end_time",
            "first_date",
            "is_repetition",
            "last_date",
            "rrule_id",
            "show_id",
            "start_time",
        )

    def create(self, validated_data):
        """Create and return a new Schedule instance, given the validated data."""

        validated_data["rrule"] = validated_data.pop("rrule_id")
        validated_data["show"] = validated_data.pop("show_id")

        schedule = Schedule.objects.create(**validated_data)

        schedule.save()
        return schedule

    def update(self, instance, validated_data):
        """Update and return an existing Schedule instance, given the validated data."""

        instance.by_weekday = validated_data.get("by_weekday", instance.by_weekday)
        instance.first_date = validated_data.get("first_date", instance.first_date)
        instance.start_time = validated_data.get("start_time", instance.start_time)
        instance.end_time = validated_data.get("end_time", instance.end_time)
        instance.last_date = validated_data.get("last_date", instance.last_date)
        instance.is_repetition = validated_data.get("is_repetition", instance.is_repetition)
        instance.default_playlist_id = validated_data.get(
            "default_playlist_id", instance.default_playlist_id
        )
        instance.rrule = validated_data.get("rrule_id", instance.rrule)
        instance.show = validated_data.get("show_id", instance.show)
        instance.add_days_no = validated_data.get("add_days_no", instance.add_days_no)
        instance.add_business_days_only = validated_data.get(
            "add_business_days_only", instance.add_business_days_only
        )

        instance.save()
        return instance


class CollisionSerializer(serializers.Serializer):
    id = serializers.IntegerField()
    start = serializers.DateTimeField()
    end = serializers.DateTimeField()
    playlist_id = serializers.IntegerField(allow_null=True)
    show_id = serializers.IntegerField()
    show_name = serializers.CharField()
    repetition_of_id = serializers.IntegerField(allow_null=True)
    schedule_id = serializers.IntegerField()
    memo = serializers.CharField()
    note_id = serializers.IntegerField(allow_null=True)


class ProjectedTimeSlotSerializer(serializers.Serializer):
    hash = serializers.CharField()
    start = serializers.DateTimeField()
    end = serializers.DateTimeField()
    collisions = CollisionSerializer(many=True)
    error = serializers.CharField(allow_null=True)
    solution_choices = serializers.ListField(child=serializers.ChoiceField(SOLUTION_CHOICES))


class DryRunTimeSlotSerializer(serializers.Serializer):
    id = serializers.PrimaryKeyRelatedField(queryset=TimeSlot.objects.all(), allow_null=True)
    schedule_id = serializers.PrimaryKeyRelatedField(
        queryset=Schedule.objects.all(), allow_null=True
    )
    playlist_id = serializers.IntegerField(allow_null=True)
    start = serializers.DateField()
    end = serializers.DateField()
    repetition_of_id = serializers.IntegerField(allow_null=True)
    memo = serializers.CharField()


class ScheduleCreateUpdateRequestSerializer(serializers.Serializer):
    schedule = ScheduleInRequestSerializer()
    solutions = serializers.DictField(
        child=serializers.ChoiceField(SOLUTION_CHOICES), required=False
    )
    notes = serializers.DictField(child=serializers.IntegerField(), required=False)
    playlists = serializers.DictField(child=serializers.IntegerField(), required=False)


class ScheduleResponseSerializer(serializers.Serializer):
    projected = ProjectedTimeSlotSerializer(many=True)
    solutions = serializers.DictField(child=serializers.ChoiceField(SOLUTION_CHOICES))
    notes = serializers.DictField(child=serializers.IntegerField())
    playlists = serializers.DictField(child=serializers.IntegerField())
    schedule = ScheduleSerializer()


class ScheduleConflictResponseSerializer(ScheduleResponseSerializer):
    schedule = UnsavedScheduleSerializer()


class ScheduleDryRunResponseSerializer(serializers.Serializer):
    created = DryRunTimeSlotSerializer(many=True)
    updated = DryRunTimeSlotSerializer(many=True)
    deleted = DryRunTimeSlotSerializer(many=True)


class TimeSlotSerializer(serializers.ModelSerializer):
    note_id = serializers.SerializerMethodField()
    show_id = serializers.SerializerMethodField()
    schedule_id = serializers.PrimaryKeyRelatedField(
        queryset=Schedule.objects.all(), required=False, source="schedule"
    )
    repetition_of_id = serializers.PrimaryKeyRelatedField(
        allow_null=True, queryset=TimeSlot.objects.all(), required=False, source="repetition_of"
    )

    class Meta:
        model = TimeSlot
        read_only_fields = (
            "end",
            "id",
            "note_id",
            "schedule_id",
            "show_id",
            "start",
        )
        fields = (
            "memo",
            "playlist_id",
            "repetition_of_id",
        ) + read_only_fields

    @staticmethod
    def get_show_id(obj) -> int:
        return obj.schedule.show.id

    @staticmethod
    def get_note_id(obj) -> int:
        return obj.note.id if hasattr(obj, "note") else None

    def update(self, instance, validated_data):
        """Update and return an existing Show instance, given the validated data."""

        if "memo" in validated_data:
            instance.memo = validated_data.get("memo")

        if "repetition_of" in validated_data:
            instance.repetition_of = validated_data.get("repetition_of")

        if "playlist_id" in validated_data:
            instance.playlist_id = validated_data.get("playlist_id")

        instance.save()

        return instance


class NoteLinkSerializer(serializers.ModelSerializer):
    type_id = serializers.PrimaryKeyRelatedField(queryset=LinkType.objects.all(), source="type")

    class Meta:
        model = NoteLink
        fields = ("type_id", "url")


tags_json_schema = {"type": "array", "items": {"type": "string"}}


class NoteSerializer(serializers.ModelSerializer):
    contributor_ids = serializers.PrimaryKeyRelatedField(
        many=True,
        queryset=Host.objects.all(),
        required=False,
        source="contributors",
    )
    image_id = serializers.PrimaryKeyRelatedField(
        queryset=Image.objects.all(), required=False, allow_null=True, source="image"
    )
    language_ids = serializers.PrimaryKeyRelatedField(
        allow_null=True,
        many=True,
        queryset=Language.objects.all(),
        required=False,
        source="language",
    )
    links = NoteLinkSerializer(many=True, required=False)
    playlist_id = serializers.IntegerField(required=False)
    tags = JSONSchemaField(tags_json_schema, required=False)
    timeslot_id = serializers.PrimaryKeyRelatedField(
        queryset=TimeSlot.objects.all(), required=False, source="timeslot"
    )
    topic_ids = serializers.PrimaryKeyRelatedField(
        allow_null=True, many=True, queryset=Topic.objects.all(), required=False, source="topic"
    )

    class Meta:
        model = Note
        read_only_fields = (
            "id",
            "created_at",
            "created_by",
            "updated_at",
            "updated_by",
        )
        fields = (
            "cba_id",
            "content",
            "contributor_ids",
            "id",
            "image_id",
            "language_ids",
            "links",
            "playlist_id",
            "summary",
            "tags",
            "timeslot_id",
            "title",
            "topic_ids",
        ) + read_only_fields

    def create(self, validated_data):
        """Create and return a new Note instance, given the validated data."""

        links_data = validated_data.pop("links", [])

        show = validated_data["timeslot"].schedule.show
        user = self.context.get("request").user
        user_is_owner = user in show.owners.all()

        # Having the create_note permission overrides the ownership
        if not (
            user.has_perm("program.create_note")
            or (user.has_perm("program.add_note") and user_is_owner)
        ):
            raise exceptions.PermissionDenied(detail="You are not allowed to create this note.")

        # we derive `contributors`, `language` and `topic` from the Show's values if not set
        contributors = validated_data.pop("contributors", show.hosts.values_list("id", flat=True))
        language = validated_data.pop("language", show.language.values_list("id", flat=True))
        topic = validated_data.pop("topic", show.topic.values_list("id", flat=True))

        # optional foreign key
        validated_data["image"] = validated_data.pop("image", None)

        try:
            note = Note.objects.create(
                created_by=self.context.get("request").user.username,
                **validated_data,
            )
        except IntegrityError:
            raise exceptions.ValidationError(
                code="duplicate", detail="note for this timeslot already exists."
            )
        else:
            note.contributors.set(contributors)
            note.language.set(language)
            note.topic.set(topic)

            # optional nested objects
            for link_data in links_data:
                NoteLink.objects.create(note=note, **link_data)

            note.save()

            return note

    def update(self, instance, validated_data):
        """Update and return an existing Note instance, given the validated data."""

        user = self.context.get("request").user
        user_is_owner = user in instance.timeslot.schedule.show.owners.all()

        # Having the update_note permission overrides the ownership
        if not (
            user.has_perm("program.update_note")
            or (user.has_perm("program.change_note") and user_is_owner)
        ):
            raise exceptions.PermissionDenied(detail="You are not allowed to update this note.")

        if "cba_id" in validated_data:
            instance.cba_id = validated_data.get("cba_id")

        if "content" in validated_data:
            instance.content = validated_data.get("content")

        if "image" in validated_data:
            instance.image = validated_data.get("image")

        if "summary" in validated_data:
            instance.summary = validated_data.get("summary")

        if "timeslot" in validated_data:
            instance.timeslot = validated_data.get("timeslot")

        if "tags" in validated_data:
            instance.tags = validated_data.get("tags")

        if "title" in validated_data:
            instance.title = validated_data.get("title")

        # optional many-to-many
        if "contributors" in validated_data:
            instance.contributors.set(validated_data.get("contributors", []))

        if "language" in validated_data:
            instance.language.set(validated_data.get("language", []))

        # Only update this field if the user has the update_note permission, ignore otherwise
        if "topic" in validated_data and user.has_perm("program.update_note"):
            instance.topic.set(validated_data.get("topic", []))

        # optional nested objects
        if "links" in validated_data:
            instance = delete_links(instance)

            for link_data in validated_data.get("links"):
                NoteLink.objects.create(note=instance, **link_data)

        instance.updated_by = self.context.get("request").user.username

        instance.save()

        return instance