from collections.abc import Iterable

from django.db import models, transaction
from django.utils import timezone

from activities.models.fan_out import FanOut
from activities.models.post import Post
from activities.models.post_types import QuestionData
from core.ld import format_ld_date, get_str_or_id, parse_ld_date
from core.snowflake import Snowflake
from stator.models import State, StateField, StateGraph, StatorModel
from users.models.identity import Identity


class PostInteractionStates(StateGraph):
    new = State(try_interval=300)
    fanned_out = State(externally_progressed=True)
    undone = State(try_interval=300)
    undone_fanned_out = State(delete_after=24 * 60 * 60)

    new.transitions_to(fanned_out)
    fanned_out.transitions_to(undone)
    undone.transitions_to(undone_fanned_out)

    @classmethod
    def group_active(cls):
        return [cls.new, cls.fanned_out]

    @classmethod
    def handle_new(cls, instance: "PostInteraction"):
        """
        Creates all needed fan-out objects for a new PostInteraction.
        """
        # Boost: send a copy to all people who follow this user (limiting
        # to just local follows if it's a remote boost)
        # Pin: send Add activity to all people who follow this user
        if instance.type == instance.Types.boost or instance.type == instance.Types.pin:
            for target in instance.get_targets():
                FanOut.objects.create(
                    type=FanOut.Types.interaction,
                    identity=target,
                    subject_post=instance.post,
                    subject_post_interaction=instance,
                )
        # Like: send a copy to the original post author only,
        # if the liker is local or they are
        elif instance.type == instance.Types.like:
            if instance.identity.local or instance.post.local:
                FanOut.objects.create(
                    type=FanOut.Types.interaction,
                    identity_id=instance.post.author_id,
                    subject_post=instance.post,
                    subject_post_interaction=instance,
                )
        # Vote: send a copy of the vote to the original
        # post author only if it's a local interaction
        # to a non local post
        elif instance.type == instance.Types.vote:
            if instance.identity.local and not instance.post.local:
                FanOut.objects.create(
                    type=FanOut.Types.interaction,
                    identity_id=instance.post.author_id,
                    subject_post=instance.post,
                    subject_post_interaction=instance,
                )
        else:
            raise ValueError("Cannot fan out unknown type")
        # And one for themselves if they're local and it's a boost
        if instance.type == PostInteraction.Types.boost and instance.identity.local:
            FanOut.objects.create(
                identity_id=instance.identity_id,
                type=FanOut.Types.interaction,
                subject_post=instance.post,
                subject_post_interaction=instance,
            )
        return cls.fanned_out

    @classmethod
    def handle_undone(cls, instance: "PostInteraction"):
        """
        Creates all needed fan-out objects to undo a PostInteraction.
        """
        # Undo Boost: send a copy to all people who follow this user
        # Undo Pin: send a Remove activity to all people who follow this user
        if instance.type == instance.Types.boost or instance.type == instance.Types.pin:
            for follow in instance.identity.inbound_follows.select_related(
                "source", "target"
            ):
                if follow.source.local or follow.target.local:
                    FanOut.objects.create(
                        type=FanOut.Types.undo_interaction,
                        identity_id=follow.source_id,
                        subject_post=instance.post,
                        subject_post_interaction=instance,
                    )
        # Undo Like: send a copy to the original post author only
        elif instance.type == instance.Types.like:
            FanOut.objects.create(
                type=FanOut.Types.undo_interaction,
                identity_id=instance.post.author_id,
                subject_post=instance.post,
                subject_post_interaction=instance,
            )
        else:
            raise ValueError("Cannot fan out unknown type")
        # And one for themselves if they're local and it's a boost
        if instance.type == PostInteraction.Types.boost and instance.identity.local:
            FanOut.objects.create(
                identity_id=instance.identity_id,
                type=FanOut.Types.undo_interaction,
                subject_post=instance.post,
                subject_post_interaction=instance,
            )
        return cls.undone_fanned_out


class PostInteraction(StatorModel):
    """
    Handles both boosts and likes
    """

    class Types(models.TextChoices):
        like = "like"
        boost = "boost"
        vote = "vote"
        pin = "pin"

    id = models.BigIntegerField(
        primary_key=True,
        default=Snowflake.generate_post_interaction,
    )

    # The state the boost is in
    state = StateField(PostInteractionStates)

    # The canonical object ID
    object_uri = models.CharField(max_length=500, blank=True, null=True, unique=True)

    # What type of interaction it is
    type = models.CharField(max_length=100, choices=Types.choices)

    # The user who boosted/liked/etc.
    identity = models.ForeignKey(
        "users.Identity",
        on_delete=models.CASCADE,
        related_name="interactions",
    )

    # The post that was boosted/liked/etc
    post = models.ForeignKey(
        "activities.Post",
        on_delete=models.CASCADE,
        related_name="interactions",
    )

    # Used to store any interaction extra text value like the vote
    # in the question/poll case
    value = models.CharField(max_length=50, blank=True, null=True)

    # When the activity was originally created (as opposed to when we received it)
    # Mastodon only seems to send this for boosts, not likes
    published = models.DateTimeField(default=timezone.now)

    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

    class Meta:
        indexes = [models.Index(fields=["type", "identity", "post"])]

    ### Display helpers ###

    @classmethod
    def get_post_interactions(cls, posts, identity):
        """
        Returns a dict of {interaction_type: set(post_ids)} for all the posts
        and the given identity, for use in templates.
        """
        # Bulk-fetch any of our own interactions
        ids_with_interaction_type = cls.objects.filter(
            identity=identity,
            post_id__in=[post.pk for post in posts],
            type__in=[cls.Types.like, cls.Types.boost, cls.Types.pin],
            state__in=[PostInteractionStates.new, PostInteractionStates.fanned_out],
        ).values_list("post_id", "type")
        # Make it into the return dict
        result = {}
        for post_id, interaction_type in ids_with_interaction_type:
            result.setdefault(interaction_type, set()).add(post_id)
        return result

    @classmethod
    def get_event_interactions(cls, events, identity) -> dict[str, set[str]]:
        """
        Returns a dict of {interaction_type: set(post_ids)} for all the posts
        within the events and the given identity, for use in templates.
        """
        return cls.get_post_interactions(
            [e.subject_post for e in events if e.subject_post], identity
        )

    def get_targets(self) -> Iterable[Identity]:
        """
        Returns an iterable with Identities of followers that have unique
        shared_inbox among each other to be used as target.

        When interaction is boost, only boost follows are considered,
        for pins all followers are considered.
        """
        # Start including the post author
        targets = {self.post.author}

        query = self.identity.inbound_follows.active()
        # Include all followers that are following the boosts
        if self.type == self.Types.boost:
            query = query.filter(boosts=True)
        for follow in query.select_related("source"):
            targets.add(follow.source)

        # Fetch the full blocks and remove them as targets
        for block in (
            self.identity.outbound_blocks.active()
            .filter(mute=False)
            .select_related("target")
        ):
            try:
                targets.remove(block.target)
            except KeyError:
                pass

        deduped_targets = set()
        shared_inboxes = set()
        for target in targets:
            if target.local:
                # Local targets always gets the boosts
                # despite its creator locality
                deduped_targets.add(target)
            elif self.identity.local:
                # Dedupe the targets based on shared inboxes
                # (we only keep one per shared inbox)
                if not target.shared_inbox_uri:
                    deduped_targets.add(target)
                elif target.shared_inbox_uri not in shared_inboxes:
                    shared_inboxes.add(target.shared_inbox_uri)
                    deduped_targets.add(target)

        return deduped_targets

    ### Create helpers ###

    @classmethod
    def create_votes(cls, post, identity, choices) -> list["PostInteraction"]:
        question = post.type_data

        if question.end_time and timezone.now() > question.end_time:
            raise ValueError("Validation failed: The poll has already ended")

        if post.interactions.filter(identity=identity, type=cls.Types.vote).exists():
            raise ValueError("Validation failed: You have already voted on this poll")

        votes = []
        with transaction.atomic():
            for choice in set(choices):
                vote = cls.objects.create(
                    identity=identity,
                    post=post,
                    type=PostInteraction.Types.vote,
                    value=question.options[choice].name,
                )
                vote.object_uri = f"{identity.actor_uri}#votes/{vote.id}"
                vote.save()
                votes.append(vote)

                if not post.local:
                    question.options[choice].votes += 1

            if not post.local:
                question.voter_count += 1

            post.calculate_type_data()

        return votes

    ### ActivityPub (outbound) ###

    def to_ap(self) -> dict:
        """
        Returns the AP JSON for this object
        """
        # Create an object URI if we don't have one
        if self.object_uri is None:
            self.object_uri = self.identity.actor_uri + f"#{self.type}/{self.id}"
        if self.type == self.Types.boost:
            value = {
                "type": "Announce",
                "id": self.object_uri,
                "published": format_ld_date(self.published),
                "actor": self.identity.actor_uri,
                "object": self.post.object_uri,
                "to": "as:Public",
            }
        elif self.type == self.Types.like:
            value = {
                "type": "Like",
                "id": self.object_uri,
                "published": format_ld_date(self.published),
                "actor": self.identity.actor_uri,
                "object": self.post.object_uri,
            }
        elif self.type == self.Types.vote:
            value = {
                "type": "Note",
                "id": self.object_uri,
                "to": self.post.author.actor_uri,
                "name": self.value,
                "inReplyTo": self.post.object_uri,
                "attributedTo": self.identity.actor_uri,
            }
        elif self.type == self.Types.pin:
            raise ValueError("Cannot turn into AP")
        return value

    def to_create_ap(self):
        """
        Returns the AP JSON to create this object
        """
        object = self.to_ap()
        return {
            "to": object.get("to", []),
            "cc": object.get("cc", []),
            "type": "Create",
            "id": self.object_uri,
            "actor": self.identity.actor_uri,
            "object": object,
        }

    def to_undo_ap(self) -> dict:
        """
        Returns the AP JSON to undo this object
        """
        object = self.to_ap()
        return {
            "id": object["id"] + "/undo",
            "type": "Undo",
            "actor": self.identity.actor_uri,
            "object": object,
        }

    def to_add_ap(self):
        """
        Returns the AP JSON to add a pin interaction to the featured collection
        """
        return {
            "type": "Add",
            "actor": self.identity.actor_uri,
            "object": self.post.object_uri,
            "target": self.identity.actor_uri + "collections/featured/",
        }

    def to_remove_ap(self):
        """
        Returns the AP JSON to remove a pin interaction from the featured collection
        """
        return {
            "type": "Remove",
            "actor": self.identity.actor_uri,
            "object": self.post.object_uri,
            "target": self.identity.actor_uri + "collections/featured/",
        }

    ### ActivityPub (inbound) ###

    @classmethod
    def by_ap(cls, data, create=False) -> "PostInteraction":
        """
        Retrieves a PostInteraction instance by its ActivityPub JSON object.

        Optionally creates one if it's not present.
        Raises KeyError if it's not found and create is False.
        """
        # Do we have one with the right ID?
        try:
            boost = cls.objects.get(object_uri=data["id"])
        except cls.DoesNotExist:
            if create:
                # Resolve the author
                identity = Identity.by_actor_uri(data["actor"], create=True)
                # Resolve the post
                object = data["object"]
                target = get_str_or_id(object, "inReplyTo") or get_str_or_id(object)
                post = Post.by_object_uri(target, fetch=True)
                value = None
                # Get the right type
                if data["type"].lower() == "like":
                    type = cls.Types.like
                elif data["type"].lower() == "announce":
                    type = cls.Types.boost
                elif (
                    data["type"].lower() == "create"
                    and object["type"].lower() == "note"
                    and isinstance(post.type_data, QuestionData)
                ):
                    type = cls.Types.vote
                    question = post.type_data
                    value = object["name"]
                    if question.end_time and timezone.now() > question.end_time:
                        # TODO: Maybe create an expecific expired exception?
                        raise cls.DoesNotExist(
                            f"Cannot create a vote to the expired question {post.id}"
                        )

                    already_voted = (
                        post.type_data.mode == "oneOf"
                        and post.interactions.filter(
                            type=cls.Types.vote, identity=identity
                        ).exists()
                    )
                    if already_voted:
                        raise cls.DoesNotExist(
                            f"The identity {identity.handle} already voted in question {post.id}"
                        )

                else:
                    raise ValueError(f"Cannot handle AP type {data['type']}")
                # Make the actual interaction
                boost = cls.objects.create(
                    object_uri=data["id"],
                    identity=identity,
                    post=post,
                    published=parse_ld_date(data.get("published", None))
                    or timezone.now(),
                    type=type,
                    value=value,
                )
            else:
                raise cls.DoesNotExist(f"No interaction with ID {data['id']}", data)
        return boost

    @classmethod
    def handle_ap(cls, data):
        """
        Handles an incoming announce/like
        """
        with transaction.atomic():
            # Create it
            try:
                interaction = cls.by_ap(data, create=True)
            except (cls.DoesNotExist, Post.DoesNotExist):
                # That post is gone, boss
                # TODO: Limited retry state?
                return

            if interaction and interaction.post:
                interaction.post.calculate_stats()
                interaction.post.calculate_type_data()

    @classmethod
    def handle_undo_ap(cls, data):
        """
        Handles an incoming undo for a announce/like
        """
        with transaction.atomic():
            # Find it
            try:
                interaction = cls.by_ap(data["object"])
            except (cls.DoesNotExist, Post.DoesNotExist):
                # Well I guess we don't need to undo it do we
                return
            # Verify the actor matches
            if data["actor"] != interaction.identity.actor_uri:
                raise ValueError("Actor mismatch on interaction undo")
            # Delete all events that reference it
            interaction.timeline_events.all().delete()
            # Force it into undone_fanned_out as it's not ours
            interaction.transition_perform(PostInteractionStates.undone_fanned_out)
            # Recalculate post stats
            interaction.post.calculate_stats()
            interaction.post.calculate_type_data()

    @classmethod
    def handle_add_ap(cls, data):
        """
        Handles an incoming Add activity which is a pin
        """
        target = data.get("target", None)
        if not target:
            return

        # we only care about pinned posts, not hashtags
        object = data.get("object", {})
        if isinstance(object, dict) and object.get("type") == "Hashtag":
            return

        with transaction.atomic():
            identity = Identity.by_actor_uri(data["actor"], create=True)
            # it's only a pin if the target is the identity's featured collection URI
            if identity.featured_collection_uri != target:
                return

            object_uri = get_str_or_id(object)
            if not object_uri:
                return
            post = Post.by_object_uri(object_uri, fetch=True)

            return PostInteraction.objects.get_or_create(
                type=cls.Types.pin,
                identity=identity,
                post=post,
                state__in=PostInteractionStates.group_active(),
            )[0]

    @classmethod
    def handle_remove_ap(cls, data):
        """
        Handles an incoming Remove activity which is an unpin
        """
        target = data.get("target", None)
        if not target:
            return

        # we only care about pinned posts, not hashtags
        object = data.get("object", {})
        if isinstance(object, dict) and object.get("type") == "Hashtag":
            return

        with transaction.atomic():
            identity = Identity.by_actor_uri(data["actor"], create=True)
            # it's only an unpin if the target is the identity's featured collection URI
            if identity.featured_collection_uri != target:
                return

            try:
                object_uri = get_str_or_id(object)
                if not object_uri:
                    return
                post = Post.by_object_uri(object_uri, fetch=False)
                for interaction in cls.objects.filter(
                    type=cls.Types.pin,
                    identity=identity,
                    post=post,
                    state__in=PostInteractionStates.group_active(),
                ):
                    # Force it into undone_fanned_out as it's not ours
                    interaction.transition_perform(
                        PostInteractionStates.undone_fanned_out
                    )
            except (cls.DoesNotExist, Post.DoesNotExist):
                return

    ### Mastodon API ###

    def to_mastodon_status_json(self, interactions=None, identity=None):
        """
        This wraps Posts in a fake Status for boost interactions.
        """
        if self.type != self.Types.boost:
            raise ValueError(
                f"Cannot make status JSON for interaction of type {self.type}"
            )
        # Make a fake post for this boost (because mastodon treats boosts as posts)
        post_json = self.post.to_mastodon_json(
            interactions=interactions, identity=identity
        )
        return {
            "id": f"{self.pk}",
            "uri": post_json["uri"],
            "created_at": format_ld_date(self.published),
            "account": self.identity.to_mastodon_json(include_counts=False),
            "content": "",
            "visibility": post_json["visibility"],
            "sensitive": post_json["sensitive"],
            "spoiler_text": post_json["spoiler_text"],
            "media_attachments": [],
            "mentions": [],
            "tags": [],
            "emojis": [],
            "reblogs_count": 0,
            "favourites_count": 0,
            "replies_count": 0,
            "url": post_json["url"],
            "in_reply_to_id": None,
            "in_reply_to_account_id": None,
            "poll": post_json["poll"],
            "card": None,
            "language": None,
            "text": "",
            "edited_at": None,
            "reblog": post_json,
        }
