from typing import Dict, Optional

import jsonschema
from django.db import transaction

from ..core.config import config
from ..poll.serializers import default_votes_validator
from ..utils.auth import get_group_model, has_perm
from ..utils.autoupdate import inform_changed_data
from ..utils.rest_api import (
    BooleanField,
    CharField,
    DecimalField,
    DictField,
    Field,
    IdPrimaryKeyRelatedField,
    IntegerField,
    JSONField,
    ModelSerializer,
    SerializerMethodField,
    ValidationError,
)
from ..utils.validate import validate_html
from .models import (
    Category,
    Motion,
    MotionBlock,
    MotionChangeRecommendation,
    MotionComment,
    MotionCommentSection,
    MotionPoll,
    State,
    StatuteParagraph,
    Submitter,
    Workflow,
)


def validate_workflow_field(value):
    """
    Validator to ensure that the workflow with the given id exists.
    """
    if not Workflow.objects.filter(pk=value).exists():
        raise ValidationError(
            {"detail": "Workflow {0} does not exist.", "args": [value]}
        )


class StatuteParagraphSerializer(ModelSerializer):
    """
    Serializer for motion.models.StatuteParagraph objects.
    """

    class Meta:
        model = StatuteParagraph
        fields = ("id", "title", "text", "weight")


class CategorySerializer(ModelSerializer):
    """
    Serializer for motion.models.Category objects.
    """

    class Meta:
        model = Category
        fields = ("id", "name", "prefix", "parent", "weight", "level")
        read_only_fields = ("parent", "weight")


class MotionBlockSerializer(ModelSerializer):
    """
    Serializer for motion.models.Category objects.
    """

    agenda_create = BooleanField(write_only=True, required=False, allow_null=True)
    agenda_type = IntegerField(
        write_only=True, required=False, min_value=1, max_value=3, allow_null=True
    )
    agenda_parent_id = IntegerField(write_only=True, required=False, min_value=1)

    class Meta:
        model = MotionBlock
        fields = (
            "id",
            "title",
            "agenda_item_id",
            "list_of_speakers_id",
            "agenda_create",
            "agenda_type",
            "agenda_parent_id",
            "internal",
        )

    def create(self, validated_data):
        """
        Customized create method. Set information about related agenda item
        into agenda_item_update_information container.
        """
        agenda_create = validated_data.pop("agenda_create", None)
        agenda_type = validated_data.pop("agenda_type", None)
        agenda_parent_id = validated_data.pop("agenda_parent_id", None)
        request_user = validated_data.pop("request_user")  # this should always be there
        motion_block = MotionBlock(**validated_data)
        if has_perm(request_user, "agenda.can_manage"):
            motion_block.agenda_item_update_information["create"] = agenda_create
            motion_block.agenda_item_update_information["type"] = agenda_type
            motion_block.agenda_item_update_information["parent_id"] = agenda_parent_id
        motion_block.save()
        return motion_block


class StateSerializer(ModelSerializer):
    """
    Serializer for motion.models.State objects.
    """

    restriction = JSONField(required=False)

    class Meta:
        model = State
        fields = (
            "id",
            "name",
            "recommendation_label",
            "css_class",
            "restriction",
            "allow_support",
            "allow_create_poll",
            "allow_submitter_edit",
            "dont_set_identifier",
            "show_state_extension_field",
            "merge_amendment_into_final",
            "show_recommendation_extension_field",
            "next_states",
            "workflow",
        )

    def validate_restriction(self, value):
        """
        Ensures that the value is a list and only contains valid values.
        """
        schema = {
            "$schema": "http://json-schema.org/draft-07/schema#",
            "title": "Motion workflow state restriction field schema",
            "description": "An array containing one or more explicit strings to control restriction for motions in this state.",
            "type": "array",
            "items": {
                "type": "string",
                "enum": [
                    "motions.can_see_internal",
                    "motions.can_manage_metadata",
                    "motions.can_manage",
                    "is_submitter",
                ],
            },
        }

        # Validate value.
        try:
            jsonschema.validate(value, schema)
        except jsonschema.ValidationError as err:
            raise ValidationError({"detail": str(err)})
        return value


class WorkflowSerializer(ModelSerializer):
    """
    Serializer for motion.models.Workflow objects.
    """

    # states = StateSerializer(many=True, read_only=True)
    states = IdPrimaryKeyRelatedField(many=True, read_only=True)

    class Meta:
        model = Workflow
        fields = ("id", "name", "states", "first_state")
        read_only_fields = ("first_state",)

    @transaction.atomic
    def create(self, validated_data):
        """
        Customized create method. Creating a new workflow does always create a
        new state which is used as first state.
        """
        workflow = super().create(validated_data)
        first_state = State.objects.create(
            name="new",
            workflow=workflow,
            allow_create_poll=True,
            allow_support=True,
            allow_submitter_edit=True,
        )
        workflow.first_state = first_state
        workflow.save()
        return workflow


class AmendmentParagraphsJSONSerializerField(Field):
    """
    Serializer for motions's amendment_paragraphs JSONField.
    """

    def to_representation(self, obj):
        """
        Returns the value of the field.
        """
        return obj

    def to_internal_value(self, data):
        """
        Checks that data is a list of strings.
        """
        if not isinstance(data, list):
            raise ValidationError({"detail": "Data must be a list."})
        for paragraph in data:
            if not isinstance(paragraph, str) and paragraph is not None:
                raise ValidationError(
                    {"detail": "Paragraph must be either a string or null/None."}
                )
        return data


class MotionPollSerializer(ModelSerializer):
    """
    Serializer for motion.models.MotionPoll objects.
    """

    yes = SerializerMethodField()
    no = SerializerMethodField()
    abstain = SerializerMethodField()
    votes = DictField(
        child=DecimalField(
            max_digits=15, decimal_places=6, min_value=-2, allow_null=True
        ),
        write_only=True,
    )
    has_votes = SerializerMethodField()

    class Meta:
        model = MotionPoll
        fields = (
            "id",
            "motion",
            "yes",
            "no",
            "abstain",
            "votesvalid",
            "votesinvalid",
            "votescast",
            "votes",
            "has_votes",
        )
        validators = (default_votes_validator,)

    def __init__(self, *args, **kwargs):
        # The following dictionary is just a cache for several votes.
        self._votes_dicts: Dict[int, Dict[int, int]] = {}
        super().__init__(*args, **kwargs)

    def get_yes(self, obj):
        try:
            result: Optional[str] = str(self.get_votes_dict(obj)["Yes"])
        except KeyError:
            result = None
        return result

    def get_no(self, obj):
        try:
            result: Optional[str] = str(self.get_votes_dict(obj)["No"])
        except KeyError:
            result = None
        return result

    def get_abstain(self, obj):
        try:
            result: Optional[str] = str(self.get_votes_dict(obj)["Abstain"])
        except KeyError:
            result = None
        return result

    def get_votes_dict(self, obj):
        try:
            votes_dict = self._votes_dicts[obj.pk]
        except KeyError:
            votes_dict = self._votes_dicts[obj.pk] = {}
            for vote in obj.get_votes():
                votes_dict[vote.value] = vote.weight
        return votes_dict

    def get_has_votes(self, obj):
        """
        Returns True if this poll has some votes.
        """
        return obj.has_votes()

    @transaction.atomic
    def update(self, instance, validated_data):
        """
        Customized update method for polls. To update votes use the write
        only field 'votes'.

        Example data:

            "votes": {"Yes": 10, "No": 4, "Abstain": -2}
        """
        # Update votes.
        votes = validated_data.get("votes")
        if votes:
            if len(votes) != len(instance.get_vote_values()):
                raise ValidationError(
                    {
                        "detail": "You have to submit data for {0} vote values.",
                        "args": [len(instance.get_vote_values())],
                    }
                )
            for vote_value in votes.keys():
                if vote_value not in instance.get_vote_values():
                    raise ValidationError(
                        {"detail": "Vote value {0} is invalid.", "args": [vote_value]}
                    )
            instance.set_vote_objects_with_values(
                instance.get_options().get(), votes, skip_autoupdate=True
            )

        # Update remaining writeable fields.
        instance.votesvalid = validated_data.get("votesvalid", instance.votesvalid)
        instance.votesinvalid = validated_data.get(
            "votesinvalid", instance.votesinvalid
        )
        instance.votescast = validated_data.get("votescast", instance.votescast)
        instance.save()
        return instance


class MotionChangeRecommendationSerializer(ModelSerializer):
    """
    Serializer for motion.models.MotionChangeRecommendation objects.
    """

    class Meta:
        model = MotionChangeRecommendation
        fields = (
            "id",
            "motion",
            "rejected",
            "internal",
            "type",
            "other_description",
            "line_from",
            "line_to",
            "text",
            "creation_time",
        )

    def is_title_cr(self, data):
        return int(data["line_from"]) == 0 and int(data["line_to"]) == 0

    def validate(self, data):
        # Change recommendations for titles are stored as plain-text, thus they don't need to be html-escaped
        if "text" in data and not self.is_title_cr(data):
            data["text"] = validate_html(data["text"])
        return data


class MotionCommentSectionSerializer(ModelSerializer):
    """
    Serializer for motion.models.MotionCommentSection objects.
    """

    read_groups = IdPrimaryKeyRelatedField(
        many=True, required=False, queryset=get_group_model().objects.all()
    )

    write_groups = IdPrimaryKeyRelatedField(
        many=True, required=False, queryset=get_group_model().objects.all()
    )

    class Meta:
        model = MotionCommentSection
        fields = ("id", "name", "read_groups", "write_groups", "weight")
        read_only_fields = ("weight",)

    def create(self, validated_data):
        """ Call inform_changed_data on creation, so the cache includes the groups. """
        section = super().create(validated_data)
        inform_changed_data(section)
        return section


class MotionCommentSerializer(ModelSerializer):
    """
    Serializer for motion.models.MotionComment objects.
    """

    read_groups_id = SerializerMethodField()

    class Meta:
        model = MotionComment
        fields = ("id", "comment", "section", "read_groups_id")

    def get_read_groups_id(self, comment):
        return [group.id for group in comment.section.read_groups.all()]


class SubmitterSerializer(ModelSerializer):
    """
    Serializer for motion.models.Submitter objects.
    """

    class Meta:
        model = Submitter
        fields = ("id", "user", "motion", "weight")


class MotionSerializer(ModelSerializer):
    """
    Serializer for motion.models.Motion objects.
    """

    comments = MotionCommentSerializer(many=True, read_only=True)
    polls = MotionPollSerializer(many=True, read_only=True)
    modified_final_version = CharField(allow_blank=True, required=False)
    reason = CharField(allow_blank=True, required=False)
    state_restriction = SerializerMethodField()
    text = CharField(allow_blank=True, required=False)  # This will be checked
    # during validation
    title = CharField(max_length=255)
    amendment_paragraphs = AmendmentParagraphsJSONSerializerField(
        required=False, allow_null=True
    )
    workflow_id = IntegerField(
        min_value=1, required=False, validators=[validate_workflow_field]
    )
    agenda_create = BooleanField(write_only=True, required=False, allow_null=True)
    agenda_type = IntegerField(
        write_only=True, required=False, min_value=1, max_value=3, allow_null=True
    )
    agenda_parent_id = IntegerField(write_only=True, required=False, min_value=1)
    submitters = SubmitterSerializer(many=True, read_only=True)
    change_recommendations = IdPrimaryKeyRelatedField(many=True, read_only=True)

    class Meta:
        model = Motion
        fields = (
            "id",
            "identifier",
            "title",
            "text",
            "amendment_paragraphs",
            "modified_final_version",
            "reason",
            "parent",
            "category",
            "category_weight",
            "comments",
            "motion_block",
            "origin",
            "submitters",
            "supporters",
            "state",
            "state_extension",
            "state_restriction",
            "statute_paragraph",
            "workflow_id",
            "recommendation",
            "recommendation_extension",
            "tags",
            "attachments",
            "polls",
            "agenda_item_id",
            "list_of_speakers_id",
            "agenda_create",
            "agenda_type",
            "agenda_parent_id",
            "sort_parent",
            "weight",
            "created",
            "last_modified",
            "change_recommendations",
        )
        read_only_fields = (
            "state",
            "recommendation",
            "weight",
            "category_weight",
        )  # Some other fields are also read_only. See definitions above.

    def validate(self, data):
        if "text" in data:
            data["text"] = validate_html(data["text"])

        if "modified_final_version" in data:
            data["modified_final_version"] = validate_html(
                data["modified_final_version"]
            )

        if "reason" in data:
            data["reason"] = validate_html(data["reason"])

        # The motion text is only needed, if it is not a paragraph based amendment.
        if data.get("amendment_paragraphs") is not None:
            data["amendment_paragraphs"] = list(
                map(
                    lambda entry: validate_html(entry)
                    if isinstance(entry, str)
                    else None,
                    data["amendment_paragraphs"],
                )
            )
            data["text"] = ""
        else:
            if (self.partial and "text" in data and not data["text"]) or (
                not self.partial and not data.get("text")
            ):
                raise ValidationError({"detail": "The text field may not be blank."})
        if config["motions_reason_required"]:
            if (self.partial and "reason" in data and not data["reason"]) or (
                not self.partial and not data.get("reason")
            ):
                raise ValidationError({"detail": "The reason field may not be blank."})

        return data

    @transaction.atomic
    def create(self, validated_data):
        """
        Customized method to create a new motion from some data.

        Set also information about related agenda item into
        agenda_item_update_information container.
        """
        motion = Motion()
        motion.title = validated_data["title"]
        motion.text = validated_data["text"]
        motion.amendment_paragraphs = validated_data.get("amendment_paragraphs")
        motion.modified_final_version = validated_data.get("modified_final_version", "")
        motion.reason = validated_data.get("reason", "")
        motion.identifier = validated_data.get("identifier")
        motion.category = validated_data.get("category")
        motion.motion_block = validated_data.get("motion_block")
        motion.origin = validated_data.get("origin", "")
        motion.parent = validated_data.get("parent")
        motion.statute_paragraph = validated_data.get("statute_paragraph")
        motion.reset_state(validated_data.get("workflow_id"))
        if has_perm(validated_data["request_user"], "agenda.can_manage"):
            motion.agenda_item_update_information["create"] = validated_data.get(
                "agenda_create"
            )
            motion.agenda_item_update_information["type"] = validated_data.get(
                "agenda_type"
            )
            motion.agenda_item_update_information["parent_id"] = validated_data.get(
                "agenda_parent_id"
            )
        motion.save()
        motion.supporters.add(*validated_data.get("supporters", []))
        motion.attachments.add(*validated_data.get("attachments", []))
        motion.tags.add(*validated_data.get("tags", []))
        return motion

    @transaction.atomic
    def update(self, motion, validated_data):
        """
        Customized method to update a motion.
        - If the workflow changes, the state of the motions is resetted to
          the initial state of the new workflow.
        - If the category changes, the category_weight is reset to the default value.
        """
        workflow_id = None
        if "workflow_id" in validated_data:
            workflow_id = validated_data.pop("workflow_id")

        old_category_id = motion.category.pk if motion.category is not None else None
        new_category_id = (
            validated_data["category"].pk
            if validated_data.get("category") is not None
            else None
        )

        result = super().update(motion, validated_data)

        # Check for changed workflow
        if workflow_id is not None and workflow_id != motion.workflow_id:
            motion.reset_state(workflow_id)
            motion.save(skip_autoupdate=True)

        # Check for changed category
        if old_category_id != new_category_id:
            motion.category_weight = 10000
            motion.save(skip_autoupdate=True)

        inform_changed_data(motion)

        return result

    def get_state_restriction(self, motion):
        """
        Returns the restriction of this state. The default is an empty list so everybody
        with permission to see motions can see this motion.
        """
        return motion.state.restriction
