#
# 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 datetime import datetime
from functools import cached_property
from zoneinfo import ZoneInfo

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, transaction
from django.db.models import Q
from django.utils import text, timezone
from program.models import (
    CBA,
    Category,
    FundingCategory,
    Image,
    Language,
    License,
    LinkType,
    MusicFocus,
    Note,
    NoteLink,
    Playlist,
    PlaylistEntry,
    Profile,
    ProfileLink,
    ProgramEntry,
    RadioSettings,
    RRule,
    Schedule,
    Show,
    ShowLink,
    TimeSlot,
    Topic,
    Type,
    application_state_manager,
)
from program.typing import (
    Logo,
    MicroProgram,
    ProgramFallback,
    RadioCBASettings,
    RadioImageRequirementsSettings,
    RadioPlayoutSettings,
    RadioProgramSettings,
    RadioStationSettings,
)
from program.utils import update_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 CBASerializer(serializers.ModelSerializer):
    class Meta:
        model = CBA
        read_only_fields = (
            "created_at",
            "created_by",
            "updated_at",
            "updated_by",
        )
        fields = (
            "username",
            "user_token",
        ) + read_only_fields

    def to_representation(self, instance):
        if not self.parent.context.get("request").user.is_authenticated:
            return None

        return super().to_representation(instance)


class UserSerializer(serializers.ModelSerializer):
    is_privileged = serializers.SerializerMethodField()
    permissions = serializers.SerializerMethodField()
    cba = CBASerializer(required=False)
    profile_ids = serializers.PrimaryKeyRelatedField(
        many=True, queryset=Profile.objects.all(), required=False, source="profiles"
    )

    class Meta:
        extra_kwargs = {
            "password": {"write_only": True},
        }
        model = User
        read_only_fields = (
            "id",
            "is_privileged",
            "permissions",
            "profile_ids",
        )
        fields = (
            "cba",
            "email",
            "first_name",
            "is_active",
            "is_staff",
            "is_superuser",
            "last_name",
            "password",
            "username",
        ) + read_only_fields

    @staticmethod
    def get_permissions(obj: User) -> list[str]:
        return sorted(
            [p for p in obj.get_all_permissions() if p.split(".", 1)[0] in ["auth", "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.
        """

        cba_data = validated_data.pop("cba") if "cba" 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 cba_data:
            CBA.objects.create(
                username=cba_data.get("username").strip(),
                user_token=cba_data.get("user_token").strip(),
                created_by=self.context.get("request").user.username,
                user=user,
            )

        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")

        cba_data = validated_data.pop("cba") if "cba" in validated_data else None

        try:
            cba = instance.cba
        except ObjectDoesNotExist:
            cba = None

        if cba_data:
            user = self.context.get("request").user

            if cba:
                # having the update_cba permission overrides being the user
                if not (user.has_perm("program.update_cba") or user.id == instance.id):
                    raise exceptions.PermissionDenied(
                        detail="You do not have permission to update this user CBA profile."
                    )

                if "username" in cba_data:
                    cba.username = cba_data.get("username")

                if "user_token" in cba_data:
                    cba.user_token = cba_data.get("user_token")

                cba.updated_by = self.context.get("request").user.username
                cba.save()
            else:
                # having the create_cba permission overrides being the user
                if not (user.has_perm("program.create_cba") or user.id == instance.id):
                    raise exceptions.PermissionDenied(
                        detail="You do not have permission to create this user CBA profile."
                    )

                CBA.objects.create(
                    created_by=self.context.get("request").user.username,
                    user=instance,
                    **cba_data,
                )

        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 ProfileLinkSerializer(serializers.ModelSerializer):
    type_id = serializers.PrimaryKeyRelatedField(queryset=LinkType.objects.all(), source="type")

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


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 ImageSerializer(serializers.ModelSerializer):
    license_id = serializers.PrimaryKeyRelatedField(
        allow_null=True,
        queryset=License.objects.all(),
        required=False,
        source="license",
        help_text="`License` ID of this image.",
    )
    ppoi = PPOIField(required=False, help_text="PPOI specifies the crop centerpoint of the image.")
    url = serializers.SerializerMethodField()

    @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:
        extra_kwargs = {"image": {"write_only": True}}
        model = Image
        read_only_fields = (
            "height",
            "id",
            "url",
            "width",
        )
        fields = (
            "alt_text",
            "credits",
            "image",
            "is_use_explicitly_granted_by_author",
            "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 ImageRenderSerializer(serializers.Serializer):
    width = serializers.IntegerField(required=False, min_value=1)
    height = serializers.IntegerField(required=False, min_value=1)


class ProfileSerializer(serializers.ModelSerializer):
    image_id = serializers.PrimaryKeyRelatedField(
        allow_null=True,
        queryset=Image.objects.all(),
        required=False,
        source="image",
        help_text="`Image` id of the profile.",
    )
    links = ProfileLinkSerializer(
        many=True, required=False, help_text="Array of `Link` objects. Can be empty."
    )
    owner_ids = serializers.PrimaryKeyRelatedField(
        many=True,
        queryset=User.objects.all(),
        source="owners",
        help_text="User ID(s) that own this profile.",
    )

    class Meta:
        model = Profile
        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 to_representation(self, instance):
        representation = super().to_representation(instance)

        if not self.context.get("request").user.is_authenticated:
            del representation["email"]

        return representation

    def create(self, validated_data):
        """
        Create and return a new Profile 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)

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

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

        profile.owners.set(owners)

        profile.save()

        return profile

    def update(self, instance, validated_data):
        """Update and return an existing Profile 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__profile")
        )
        update_fields = set(validated_data.keys())

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

        # without the update_profile permission, fields without edit permission are not allowed
        if not user.has_perm("program.update_profile") 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 = update_links(instance, validated_data.get("links"))

        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",
        help_text="Array of `Category` IDs.",
    )
    cba_series_id = serializers.IntegerField(
        allow_null=True, required=False, help_text="CBA series ID."
    )
    default_playlist_id = serializers.PrimaryKeyRelatedField(
        allow_null=True,
        help_text="Default `Playlist` ID for this show.",
        queryset=Playlist.objects.all(),
        required=False,
        source="default_playlist",
    )
    funding_category_id = serializers.PrimaryKeyRelatedField(
        queryset=FundingCategory.objects.all(),
        source="funding_category",
        help_text="`FundingCategory` ID.",
    )
    host_ids = serializers.PrimaryKeyRelatedField(
        many=True,
        queryset=Profile.objects.all(),
        source="hosts",
        help_text="`Profile` IDs that host this show.",
    )
    image_id = serializers.PrimaryKeyRelatedField(
        allow_null=True,
        queryset=Image.objects.all(),
        required=False,
        source="image",
        help_text="`Image` ID of this show.",
    )
    language_ids = serializers.PrimaryKeyRelatedField(
        many=True,
        queryset=Language.objects.all(),
        source="language",
        help_text="`Language` IDs of this show.",
    )
    links = ProfileLinkSerializer(many=True, required=False, help_text="Array of `Link` objects.")
    logo_id = serializers.PrimaryKeyRelatedField(
        allow_null=True,
        queryset=Image.objects.all(),
        required=False,
        source="logo",
        help_text="`Image` ID of the logo of this show.",
    )
    music_focus_ids = serializers.PrimaryKeyRelatedField(
        many=True,
        queryset=MusicFocus.objects.all(),
        source="music_focus",
        help_text="Array of `MusicFocus` IDs.",
    )
    owner_ids = serializers.PrimaryKeyRelatedField(
        many=True,
        queryset=User.objects.all(),
        source="owners",
        help_text="Array of `User` IDs owning this Show.",
    )
    predecessor_id = serializers.PrimaryKeyRelatedField(
        allow_null=True,
        queryset=Show.objects.all(),
        required=False,
        source="predecessor",
        help_text="`Show` ID that predeceeded this one.",
    )
    topic_ids = serializers.PrimaryKeyRelatedField(
        many=True, queryset=Topic.objects.all(), source="topic", help_text="Array of `Topic` IDs."
    )
    type_id = serializers.PrimaryKeyRelatedField(
        queryset=Type.objects.all(), source="type", help_text="Array of `Type` IDs."
    )

    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

    def to_internal_value(self, data):
        if data.get("cba_series_id") == "":
            data["cba_series_id"] = None

        if data.get("default_playlist_id") == "":
            data["default_playlist_id"] = None

        return super().to_internal_value(data)

    def to_representation(self, instance):
        representation = super().to_representation(instance)

        if not self.context.get("request").user.is_authenticated:
            del representation["email"]
        elif not self.context.get("request").user.has_perm("display__show__internal_note"):
            del representation["internal_note"]

        return representation

    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["default_playlist"] = validated_data.pop("default_playlist", None)
        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" in validated_data:
            instance.default_playlist = validated_data.get("default_playlist")

        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 = update_links(instance, validated_data.get("links"))

        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["default_playlist"] = validated_data.pop("default_playlist_id")
        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 = validated_data.get(
            "default_playlist_id", instance.default_playlist
        )
        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):
    start = serializers.DateTimeField()
    end = serializers.DateTimeField()
    playlist_id = serializers.IntegerField(allow_null=True)
    show_id = serializers.IntegerField()
    show_name = serializers.CharField()
    schedule_id = serializers.IntegerField()
    memo = serializers.CharField()
    timeslot_id = serializers.IntegerField()
    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(help_text="`Schedule` object.")
    solutions = serializers.DictField(
        child=serializers.ChoiceField(SOLUTION_CHOICES),
        required=False,
        help_text="Array of solution choices.",
    )
    notes = serializers.DictField(
        child=serializers.IntegerField(), required=False, help_text="Array of `Note` objects."
    )
    playlists = serializers.DictField(
        child=serializers.IntegerField(), required=False, help_text="Array of `Playlist` IDs."
    )


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",
        help_text="`Schedule` ID of this timeslot.",
    )
    repetition_of_id = serializers.PrimaryKeyRelatedField(
        allow_null=True,
        queryset=TimeSlot.objects.all(),
        required=False,
        source="repetition_of",
        help_text="This timeslot is a repetition of `Timeslot` ID.",
    )
    playlist_id = serializers.PrimaryKeyRelatedField(
        allow_null=True,
        help_text="",
        queryset=Playlist.objects.all(),
        required=False,
        source="playlist",
    )

    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

    @staticmethod
    def get_start(obj) -> datetime:
        return obj.start.astimezone(tz=ZoneInfo(settings.TIME_ZONE))

    @staticmethod
    def get_end(obj) -> datetime:
        return obj.end.astimezone(tz=ZoneInfo(settings.TIME_ZONE))

    def to_representation(self, instance):
        representation = super().to_representation(instance)

        if not (self.context.get("request") and self.context.get("request").user.is_authenticated):
            del representation["memo"]

        return representation

    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.schedule.show.owners.all()

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

        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 = 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=Profile.objects.all(),
        required=False,
        source="contributors",
        help_text="`Profile` IDs that contributed to this episode.",
    )
    image_id = serializers.PrimaryKeyRelatedField(
        queryset=Image.objects.all(),
        required=False,
        allow_null=True,
        source="image",
        help_text="`Image` ID.",
    )
    language_ids = serializers.PrimaryKeyRelatedField(
        allow_null=True,
        many=True,
        queryset=Language.objects.all(),
        required=False,
        source="language",
        help_text="Array of `Language` IDs.",
    )
    links = NoteLinkSerializer(many=True, required=False, help_text="Array of `Link` objects.")
    playlist_id = serializers.IntegerField(required=False, help_text="Array of `Playlist` IDs.")
    tags = JSONSchemaField(tags_json_schema, required=False, help_text="Tags of the Note.")
    timeslot_id = serializers.PrimaryKeyRelatedField(
        queryset=TimeSlot.objects.all(),
        required=False,
        source="timeslot",
        help_text="`Timeslot` ID.",
    )
    topic_ids = serializers.PrimaryKeyRelatedField(
        allow_null=True,
        many=True,
        queryset=Topic.objects.all(),
        required=False,
        source="topic",
        help_text="Array of `Topic`IDs.",
    )

    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 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 = update_links(instance, validated_data.get("links"))

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

        instance.save()

        return instance


class RadioSettingsSerializer(serializers.ModelSerializer):
    cba = serializers.SerializerMethodField()
    image_requirements = serializers.SerializerMethodField()
    playout = serializers.SerializerMethodField()
    program = serializers.SerializerMethodField()
    station = serializers.SerializerMethodField()

    class Meta:
        fields = read_only_fields = (
            "id",
            "cba",
            "image_requirements",
            "playout",
            "program",
            "station",
        )
        model = RadioSettings

    def get_cba(self, obj) -> RadioCBASettings:
        if self.context.get("request").user.is_authenticated:
            return RadioCBASettings(
                api_key=obj.cba_api_key,
                domains=obj.cba_domains,
            )
        else:
            return RadioCBASettings(domains=obj.cba_domains)

    @staticmethod
    def get_image_requirements(obj) -> RadioImageRequirementsSettings:
        def get_aspect_ratio(field) -> tuple[int, int] | tuple[float, float]:
            """return the tuple of ints or floats representing the aspect ratio of the image."""

            values = field.split(":")

            try:
                return int(values[0]), int(values[1])
            except ValueError:
                return float(values[0]), float(values[1])

        aspect_ratios = {
            "note.image": get_aspect_ratio(obj.note_image_aspect_ratio),
            "profile.image": get_aspect_ratio(obj.profile_image_aspect_ratio),
            "show.image": get_aspect_ratio(obj.show_image_aspect_ratio),
            "show.logo": get_aspect_ratio(obj.show_logo_aspect_ratio),
        }

        return {
            "note.image": {
                "frame": {
                    "aspect_ratio": aspect_ratios["note.image"],
                    "shape": obj.profile_image_shape,
                }
            },
            "profile.image": {
                "frame": {
                    "aspect_ratio": aspect_ratios["profile.image"],
                    "shape": obj.profile_image_shape,
                }
            },
            "show.image": {
                "frame": {
                    "aspect_ratio": aspect_ratios["show.image"],
                    "shape": obj.profile_image_shape,
                }
            },
            "show.logo": {
                "frame": {
                    "aspect_ratio": aspect_ratios["show.logo"],
                    "shape": obj.profile_image_shape,
                }
            },
        }

    @staticmethod
    def get_program(obj) -> RadioProgramSettings:
        return RadioProgramSettings(
            micro=MicroProgram(show_id=obj.micro_show.id if obj.micro_show else None),
            fallback=ProgramFallback(
                show_id=obj.fallback_show.id if obj.fallback_show else None,
                default_pool="fallback" if obj.fallback_default_pool else "",
            ),
        )

    @staticmethod
    def get_playout(obj) -> RadioPlayoutSettings:
        return RadioPlayoutSettings(
            line_in_channels=obj.line_in_channels,
            pools=obj.pools,
        )

    @staticmethod
    def get_station(obj) -> RadioStationSettings:
        logo = (
            Logo(
                url=f"{settings.SITE_URL}{obj.station_logo.url}",
                height=obj.station_logo.height,
                width=obj.station_logo.width,
            )
            if obj.station_logo
            else None
        )

        return RadioStationSettings(
            name=obj.station_name,
            logo=logo,
            website=obj.station_website,
        )


class BasicProgramEntrySerializer(serializers.Serializer):
    id = serializers.UUIDField()
    start = serializers.DateTimeField()
    end = serializers.DateTimeField()
    timeslot_id = serializers.IntegerField(allow_null=True, source="timeslot.id")
    playlist_id = serializers.IntegerField(allow_null=True)
    show_id = serializers.IntegerField(source="show.id")


class PlayoutProgramEntrySerializer(BasicProgramEntrySerializer):
    class PlayoutShowSerializer(serializers.ModelSerializer):
        class Meta:
            model = Show
            fields = ["id", "name", "default_playlist_id"]

    class PlayoutScheduleSerializer(serializers.ModelSerializer):
        class Meta:
            model = Schedule
            fields = ["id", "default_playlist_id"]

    class PlayoutEpisodeSerializer(serializers.ModelSerializer):
        class Meta:
            model = Note
            fields = ["id", "title"]

    timeslot = TimeSlotSerializer(allow_null=True)
    show = PlayoutShowSerializer()
    episode = PlayoutEpisodeSerializer(allow_null=True, source="timeslot.note")
    schedule = PlayoutScheduleSerializer(allow_null=True, source="timeslot.schedule")


class CalendarSchemaSerializer(serializers.Serializer):
    class Wrapper:
        def __init__(self, program: list[ProgramEntry]):
            self.program = program

        @cached_property
        def shows(self):
            show_ids = set(entry.show.id for entry in self.program)
            return Show.objects.distinct().filter(id__in=show_ids)

        @cached_property
        def timeslots(self):
            timeslot_ids = set(entry.timeslot.id for entry in self.program if entry.timeslot)
            return TimeSlot.objects.distinct().filter(id__in=timeslot_ids)

        @cached_property
        def episodes(self):
            return Note.objects.distinct().filter(timeslot__in=self.timeslots)

        @cached_property
        def profiles(self):
            return Profile.objects.distinct().filter(
                Q(shows__in=self.shows) | Q(notes__in=self.episodes)
            )

        @property
        def categories(self):
            return Category.objects.distinct().filter(shows__in=self.shows)

        @property
        def funding_categories(self):
            return FundingCategory.objects.distinct().filter(shows__in=self.shows)

        @property
        def types(self):
            return Type.objects.distinct().filter(shows__in=self.shows)

        @property
        def topics(self):
            return Topic.objects.distinct().filter(
                Q(shows__in=self.shows) | Q(episodes__in=self.episodes)
            )

        @property
        def languages(self):
            return Language.objects.distinct().filter(
                Q(shows__in=self.shows) | Q(episodes__in=self.episodes)
            )

        @property
        def music_focuses(self):
            return MusicFocus.objects.distinct().filter(shows__in=self.shows)

        @cached_property
        def images(self):
            return Image.objects.distinct().filter(
                Q(logo_shows__in=self.shows)
                | Q(shows__in=self.shows)
                | Q(profiles__in=self.profiles)
                | Q(notes__in=self.episodes)
            )

        @property
        def licenses(self):
            return License.objects.distinct().filter(images__in=self.images)

        @property
        def link_types(self):
            return LinkType.objects.all()

    class CalendarTimeslotSerializer(TimeSlotSerializer):
        class Meta(TimeSlotSerializer.Meta):
            fields = [f for f in TimeSlotSerializer.Meta.fields if f != "memo"]

    class CalendarEpisodeSerializer(NoteSerializer):
        class Meta(NoteSerializer.Meta):
            fields = [
                field
                for field in NoteSerializer.Meta.fields
                if field not in ["created_at", "created_by", "updated_at", "updated_by"]
            ]

    class CalendarProfileSerializer(ProfileSerializer):
        class Meta(ProfileSerializer.Meta):
            fields = [
                field
                for field in ProfileSerializer.Meta.fields
                if field
                not in [
                    "created_at",
                    "created_by",
                    "owner_ids",
                    "updated_at",
                    "updated_by",
                ]
            ]

    class CalendarShowSerializer(ShowSerializer):
        class Meta(ShowSerializer.Meta):
            fields = [
                field
                for field in ShowSerializer.Meta.fields
                if field
                not in [
                    "created_at",
                    "created_by",
                    "internal_note",
                    "owner_ids",
                    "updated_at",
                    "updated_by",
                ]
            ]

    shows = CalendarShowSerializer(many=True)
    timeslots = CalendarTimeslotSerializer(many=True)
    profiles = CalendarProfileSerializer(many=True)
    categories = CategorySerializer(many=True)
    funding_categories = FundingCategorySerializer(many=True)
    types = TypeSerializer(many=True)
    images = ImageSerializer(many=True)
    topics = TopicSerializer(many=True)
    languages = LanguageSerializer(many=True)
    music_focuses = MusicFocusSerializer(many=True)
    program = BasicProgramEntrySerializer(many=True)
    episodes = CalendarEpisodeSerializer(many=True)
    licenses = LicenseSerializer(many=True)
    link_types = LinkTypeSerializer(many=True)


class ApplicationStatePurgeSerializer(serializers.Serializer):
    @staticmethod
    def _render_model_category_definitions():
        yield "<dl>"
        for category_name, models in application_state_manager.categorized_models.items():
            model_names = ", ".join(sorted(model._meta.label for model in models))
            yield f"<dt>{category_name}</dt>"
            yield f"<dd>{model_names}</dd>"
        yield "</dl>"

    models = serializers.MultipleChoiceField(
        choices=application_state_manager.model_choices, default=set()
    )
    model_categories = serializers.MultipleChoiceField(
        choices=application_state_manager.model_category_choices,
        default=set(),
        help_text=(
            "Selects multiple models by their categorization. "
            "Models included in the categories are: "
            f"{''.join(_render_model_category_definitions())}"
        ),
    )
    invert_selection = serializers.BooleanField(
        default=False,
        help_text=(
            "Inverts the model selection that is selected through other filters. "
            "Selects all models if set to true and no other filters have been set."
        ),
    )


class PlaylistSerializer(serializers.ModelSerializer):
    class PlaylistEntrySerializer(serializers.ModelSerializer):
        class Meta:
            model = PlaylistEntry
            fields = (
                "duration",
                "file_id",
                "uri",
            )

    entries = PlaylistEntrySerializer(many=True, required=False)
    show_id = serializers.PrimaryKeyRelatedField(
        queryset=Show.objects.all(),
        required=True,
        source="show",
    )

    class Meta:
        model = Playlist
        read_only_fields = (
            "id",
            "created_at",
            "created_by",
            "updated_at",
            "updated_by",
        )
        fields = (
            "description",
            "entries",
            "playout_mode",
            "show_id",
        ) + read_only_fields

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

        A ValidationError is raised if a playlist entry is invalid or if multiple null duration
        entries are present."""

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

        # having the create_playlist permission overrules the ownership
        if not (user.has_perm("program.create_playlist") or user_is_owner):
            raise exceptions.PermissionDenied(detail="You are not allowed to create a playlist.")

        entries = validated_data.pop("entries", [])

        with transaction.atomic():
            playlist = Playlist.objects.create(created_by=user.username, **validated_data)

            for order, entry_data in enumerate(entries, start=1):
                entry_data.update({"order": order})

                try:
                    PlaylistEntry.objects.create(playlist=playlist, **entry_data)
                except IntegrityError:
                    raise exceptions.ValidationError(
                        code="playlist-entry-file-id-or-uri",
                        detail="playlist entries must either have file id or uri.",
                    )

            if playlist.entries.filter(duration__isnull=True).count() > 1:
                raise exceptions.ValidationError(
                    code="multiple-null-duration-playlist-entries",
                    detail="playlist may only have one entry without duration",
                )

        return playlist

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

        A ValidationError is raised if a playlist entry is invalid or if multiple null duration
        entries are present."""

        user = self.context["request"].user
        user_is_owner = user in instance.show.owners.all()

        # having the update_playlist permission overrules the ownership
        if not (user.has_perm("program.update_playlist") or user_is_owner):
            raise exceptions.PermissionDenied(
                detail="You are not allowed to update this playlist."
            )

        with transaction.atomic():
            if "description" in validated_data:
                instance.description = validated_data.pop("description")

            if "playout_mode" in validated_data:
                instance.playout_mode = validated_data.pop("playout_mode")

            if "entries" in validated_data:
                if instance.entries.count() > 0:
                    for entry in instance.entries.all():
                        entry.delete(keep_parents=True)

                for order, entry_data in enumerate(validated_data.get("entries"), start=1):
                    entry_data.update({"order": order})

                    try:
                        PlaylistEntry.objects.create(playlist=instance, **entry_data)
                    except IntegrityError:
                        raise exceptions.ValidationError(
                            code="playlist-entry-file-id-or-uri",
                            detail="playlist entries must either have file id or uri.",
                        )

                if instance.entries.filter(duration__isnull=True).count() > 1:
                    raise exceptions.ValidationError(
                        code="multiple-null-duration-playlist-entries",
                        detail="playlist may only have one entry without duration",
                    )

            instance.save()

        return instance