package ch.threema.app.messagereceiver;

import android.graphics.Bitmap;

import org.slf4j.Logger;

import java.time.Instant;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import ch.threema.app.emojis.EmojiUtil;
import ch.threema.app.managers.ServiceManager;
import ch.threema.app.multidevice.MultiDeviceManager;
import ch.threema.app.services.GroupService;
import ch.threema.app.services.MessageService;
import ch.threema.app.services.UserService;
import ch.threema.app.tasks.OutboundIncomingGroupMessageUpdateReadTask;
import ch.threema.app.tasks.OutgoingFileMessageTask;
import ch.threema.app.tasks.OutgoingGroupDeleteMessageTask;
import ch.threema.app.tasks.OutgoingGroupDeliveryReceiptMessageTask;
import ch.threema.app.tasks.OutgoingGroupEditMessageTask;
import ch.threema.app.tasks.OutgoingGroupReactionMessageTask;
import ch.threema.app.tasks.OutgoingLocationMessageTask;
import ch.threema.app.tasks.OutgoingPollSetupMessageTask;
import ch.threema.app.tasks.OutgoingPollVoteGroupMessageTask;
import ch.threema.app.tasks.OutgoingTextMessageTask;
import ch.threema.app.utils.GroupFeatureSupport;
import ch.threema.app.utils.GroupUtil;
import ch.threema.app.utils.NameUtil;
import ch.threema.base.ThreemaException;
import ch.threema.base.crypto.SymmetricEncryptionResult;

import static ch.threema.base.utils.LoggingKt.getThreemaLogger;

import ch.threema.data.datatypes.ContactNameFormat;
import ch.threema.data.datatypes.ConversationId;
import ch.threema.data.datatypes.GroupConversationId;
import ch.threema.data.datatypes.GroupNotificationTriggerPolicyOverride;
import ch.threema.data.models.GroupModel;
import ch.threema.data.models.GroupModelData;
import ch.threema.data.repositories.ContactModelRepository;
import ch.threema.data.repositories.GroupModelRepository;
import ch.threema.domain.models.MessageId;
import ch.threema.domain.protocol.ThreemaFeature;
import ch.threema.domain.protocol.csp.messages.poll.PollData;
import ch.threema.domain.protocol.csp.messages.poll.PollId;
import ch.threema.domain.protocol.csp.messages.poll.PollVote;
import ch.threema.domain.taskmanager.TaskManager;
import ch.threema.domain.taskmanager.TriggerSource;
import ch.threema.protobuf.csp.e2e.Reaction;
import ch.threema.storage.DatabaseService;
import ch.threema.storage.models.AbstractMessageModel;
import ch.threema.storage.models.group.GroupMessageModel;
import ch.threema.storage.models.group.GroupModelOld;
import ch.threema.storage.models.MessageState;
import ch.threema.storage.models.MessageType;
import ch.threema.storage.models.access.GroupAccessModel;
import ch.threema.storage.models.poll.PollModel;
import ch.threema.storage.models.data.MessageContentsType;
import ch.threema.storage.models.data.media.FileDataModel;

import static ch.threema.app.utils.MessageUtil.canSendUserAcknowledge;
import static ch.threema.common.JavaCompat.hexToByteArray;
import static ch.threema.domain.protocol.csp.ProtocolDefines.DELIVERYRECEIPT_MSGUSERACK;
import static ch.threema.domain.protocol.csp.ProtocolDefines.DELIVERYRECEIPT_MSGUSERDEC;

public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel> {
    private static final Logger logger = getThreemaLogger("GroupMessageReceiver");

    private final GroupModelOld group;
    @Nullable
    private final GroupModel groupModel;
    private final GroupService groupService;
    private final DatabaseService databaseService;
    @NonNull
    private final UserService userService;
    @NonNull
    private final ContactModelRepository contactModelRepository;
    @NonNull
    private final GroupModelRepository groupModelRepository;
    private final @NonNull ServiceManager serviceManager;
    private final TaskManager taskManager;
    private final MultiDeviceManager multiDeviceManager;

    public GroupMessageReceiver(
        GroupModelOld group,
        GroupService groupService,
        DatabaseService databaseService,
        @NonNull UserService userService,
        @NonNull ContactModelRepository contactModelRepository,
        @NonNull GroupModelRepository groupModelRepository,
        @NonNull ServiceManager serviceManager
    ) {
        this.group = group;
        this.groupService = groupService;
        this.databaseService = databaseService;
        this.userService = userService;
        this.contactModelRepository = contactModelRepository;
        this.groupModelRepository = groupModelRepository;
        this.serviceManager = serviceManager;
        this.taskManager = serviceManager.getTaskManager();
        this.multiDeviceManager = serviceManager.getMultiDeviceManager();

        this.groupModel = group != null
            ? groupModelRepository.getByCreatorIdentityAndId(group.getCreatorIdentity(), group.getApiGroupId())
            : null;
    }

    @NonNull
    @Override
    public GroupMessageModel createLocalModel(
        @Nullable final MessageId messageId,
        MessageType type,
        @MessageContentsType int messageContentsType,
        Instant postedAt
    ) {
        GroupMessageModel m = new GroupMessageModel();
        if (type != null && type.getRequiresMessageId()) {
            m.setMessageId(messageId != null ? messageId : MessageId.random());
        }
        m.setType(type);
        m.setMessageContentsType(messageContentsType);
        m.setGroupId(group.getId());
        m.setPostedAt(postedAt);
        m.setCreatedAt(Instant.now());
        m.setSaved(false);
        m.setUid(UUID.randomUUID().toString());
        return m;
    }

    @Override
    @Deprecated
    public GroupMessageModel createAndSaveStatusModel(String statusBody, Instant postedAt) {
        GroupMessageModel m = new GroupMessageModel(true);
        m.setType(MessageType.TEXT);
        m.setGroupId(group.getId());
        m.setPostedAt(postedAt);
        m.setCreatedAt(Instant.now());
        m.setSaved(true);
        m.setUid(UUID.randomUUID().toString());
        m.setBody(statusBody);

        saveLocalModel(m);
        return m;
    }


    @Override
    public void saveLocalModel(GroupMessageModel save) {
        databaseService.getGroupMessageModelFactory().createOrUpdate(save);
    }

    @Override
    public void createAndSendTextMessage(@NonNull GroupMessageModel messageModel) {
        Set<String> otherMembers = groupService.getMembersWithoutUser(group);

        if (otherMembers.isEmpty()) {
            // In case the recipients set is empty, we are sending the message in a notes group. In
            // this case we directly set the message state to sent to prevent confusion when the
            // user is offline and therefore the task has not yet been run.
            messageModel.setState(MessageState.SENT);
        }

        bumpLastUpdate();

        // Schedule outgoing text message task
        taskManager.schedule(new OutgoingTextMessageTask(
            messageModel.getId(),
            Type_GROUP,
            otherMembers
        ));
    }

    public void resendTextMessage(@NonNull GroupMessageModel messageModel, @NonNull Collection<String> recipientIdentities) {
        taskManager.schedule(new OutgoingTextMessageTask(
            messageModel.getId(),
            Type_GROUP,
            getRecipientIdentities(recipientIdentities)
        ));
    }

    @Override
    public void createAndSendLocationMessage(@NonNull GroupMessageModel messageModel) {
        Set<String> otherMembers = groupService.getMembersWithoutUser(group);

        if (otherMembers.isEmpty()) {
            // In case the recipients set is empty, we are sending the message in a notes group. In
            // this case we directly set the message state to sent to prevent confusion when the
            // user is offline and therefore the task has not yet been run.
            messageModel.setState(MessageState.SENT);
        }

        bumpLastUpdate();

        // Schedule outgoing text message task
        taskManager.schedule(new OutgoingLocationMessageTask(
            messageModel.getId(),
            Type_GROUP,
            otherMembers
        ));
    }

    public void resendLocationMessage(
        @NonNull GroupMessageModel messageModel,
        @NonNull Collection<String> recipientIdentities
    ) {
        // Schedule outgoing location message task
        taskManager.schedule(new OutgoingLocationMessageTask(
            messageModel.getId(),
            Type_GROUP,
            getRecipientIdentities(recipientIdentities)
        ));
    }

    @Override
    public void createAndSendFileMessage(
        @Nullable final byte[] thumbnailBlobId,
        @Nullable final byte[] fileBlobId,
        @Nullable SymmetricEncryptionResult encryptionResult,
        @NonNull final GroupMessageModel messageModel,
        @Nullable Collection<String> recipientIdentities
    ) {
        // Enrich file data model with blob id and encryption key
        FileDataModel modelFileData = messageModel.getFileData();
        modelFileData.setBlobId(fileBlobId);
        if (encryptionResult != null) {
            modelFileData.setEncryptionKey(encryptionResult.key);
        }

        // Set file data model again explicitly to enforce that the body of the message is rewritten
        // and therefore updated.
        messageModel.setFileData(modelFileData);
        saveLocalModel(messageModel);

        // Note that lastUpdate lastUpdate was bumped when the file message was created

        // Schedule outgoing text message task
        taskManager.schedule(new OutgoingFileMessageTask(
            messageModel.getId(),
            Type_GROUP,
            getRecipientIdentities(recipientIdentities),
            thumbnailBlobId
        ));
    }

    @Override
    public void createAndSendPollSetupMessage(
        @NonNull final PollData pollData,
        @NonNull final PollModel pollModel,
        @NonNull GroupMessageModel messageModel,
        @Nullable Collection<String> recipientIdentities,
        @NonNull TriggerSource triggerSource
    ) throws ThreemaException {
        final PollId pollId = new PollId(hexToByteArray(pollModel.getApiPollId()));

        bumpLastUpdate();

        // Schedule outgoing text message task if this is triggered from local
        if (triggerSource == TriggerSource.LOCAL) {
            taskManager.schedule(new OutgoingPollSetupMessageTask(
                messageModel.getId(),
                Type_GROUP,
                getRecipientIdentities(recipientIdentities),
                pollId,
                pollData
            ));
        }
    }

    @Override
    public void createAndSendPollVoteMessage(
        final PollVote[] votes,
        final PollModel pollModel,
        @NonNull TriggerSource triggerSource
    ) throws ThreemaException {
        // Create message id
        MessageId messageId = MessageId.random();

        final PollId pollId = new PollId(hexToByteArray(pollModel.getApiPollId()));

        // Schedule outgoing text message task
        taskManager.schedule(new OutgoingPollVoteGroupMessageTask(
            messageId,
            Set.of(groupService.getGroupMemberIdentities(group)),
            pollId,
            pollModel.getCreatorIdentity(),
            votes,
            pollModel.getType(),
            group.getApiGroupId(),
            group.getCreatorIdentity()
        ));
    }

    /**
     * Send an incoming message update to mark the message as read. This method only schedules the
     * outgoing group message update if multi device is activated.
     */
    public void sendIncomingMessageUpdateRead(@NonNull Set<MessageId> messageIds, long timestamp) {
        if (multiDeviceManager.isMultiDeviceActive()) {
            taskManager.schedule(
                new OutboundIncomingGroupMessageUpdateReadTask(
                    messageIds,
                    timestamp,
                    group.getApiGroupId(),
                    group.getCreatorIdentity()
                )
            );
        }
    }

    public void sendEditMessage(int messageModelId, @NonNull String body, @NonNull Instant editedAt) {
        taskManager.schedule(
            new OutgoingGroupEditMessageTask(
                messageModelId,
                MessageId.random(),
                body,
                editedAt,
                GroupUtil.getRecipientIdentitiesByFeatureSupport(
                    getFeatureSupport(ThreemaFeature.EDIT_MESSAGES)
                )
            )
        );
    }

    public void sendDeleteMessage(int messageModelId, @NonNull Instant deletedAt) {
        taskManager.schedule(
            new OutgoingGroupDeleteMessageTask(
                messageModelId,
                MessageId.random(),
                deletedAt,
                GroupUtil.getRecipientIdentitiesByFeatureSupport(
                    getFeatureSupport(ThreemaFeature.DELETE_MESSAGES)
                )
            )
        );
    }

    /**
     * Send a reaction message to the group. Members who do not support reactions will receive an ack/dec instead
     *
     * @param messageModel  MessageModel the reaction reacts to
     * @param actionCase    The action case of the reaction (WITHDRAW is not backwards compatible and wil not cause an ack/dec to be sent)
     * @param emojiSequence The emoji sequence of the reaction
     * @param reactedAt     The timestamp of the reaction
     */
    public void sendReaction(AbstractMessageModel messageModel, Reaction.ActionCase actionCase, @NonNull String emojiSequence, @NonNull Instant reactedAt) {
        // identities that support receiving emoji reactions
        Set<String> emojiReactionsIdentities = GroupUtil.getRecipientIdentitiesByFeatureSupport(getFeatureSupport(ThreemaFeature.EMOJI_REACTIONS));
        // all group identities except sender
        Set<String> identitiesWithoutReactionSupport = groupService.getMembersWithoutUser(group);
        identitiesWithoutReactionSupport.removeAll(emojiReactionsIdentities);

        // Note that there might be no recipients that support emoji reactions but even then we need
        // to schedule this task as it is needed for reflection. It may just not send out any csp
        // messages.
        taskManager.schedule(
            new OutgoingGroupReactionMessageTask(
                messageModel.getId(),
                MessageId.random(),
                actionCase,
                emojiSequence,
                reactedAt,
                emojiReactionsIdentities
            )
        );

        // Fall back to acks for users who do not yet support receiving emoji reactions
        if (actionCase == Reaction.ActionCase.APPLY && !identitiesWithoutReactionSupport.isEmpty() && canSendUserAcknowledge(messageModel)) {
            if (EmojiUtil.isThumbsUpEmoji(emojiSequence)) {
                // send ack to these receivers
                sendGroupDeliveryReceiptWithoutReflection(
                    identitiesWithoutReactionSupport,
                    (GroupMessageModel) messageModel,
                    DELIVERYRECEIPT_MSGUSERACK
                );
            } else if (EmojiUtil.isThumbsDownEmoji(emojiSequence)) {
                // send dec to these receivers
                sendGroupDeliveryReceiptWithoutReflection(
                    identitiesWithoutReactionSupport,
                    (GroupMessageModel) messageModel,
                    DELIVERYRECEIPT_MSGUSERDEC
                );
            }
        }
    }

    /**
     * Send a delivery receipt to the identities specified
     *
     * @param identities   Identities to send the receipt to
     * @param messageModel GroupMessageModel for which a receipt should be sent
     * @param receiptType  Type of receipt (currently only ACK and DEC are supported for groups)
     */
    private void sendGroupDeliveryReceiptWithoutReflection(
        @NonNull Set<String> identities,
        GroupMessageModel messageModel,
        int receiptType
    ) {
        serviceManager.getTaskManager().schedule(
            new OutgoingGroupDeliveryReceiptMessageTask(
                messageModel.getId(),
                receiptType,
                identities
            )
        );
    }

    @Override
    @NonNull
    public List<GroupMessageModel> loadMessages(MessageService.MessageFilter filter) {
        return databaseService.getGroupMessageModelFactory().find(
            group.getId(),
            filter);
    }

    @Override
    public long getMessagesCount() {
        return databaseService.getGroupMessageModelFactory().countMessages(
            group.getId());
    }

    @Override
    public long getUnreadMessagesCount() {
        return databaseService.getGroupMessageModelFactory().countUnreadMessages(
            group.getId());
    }

    @NonNull
    @Override
    public List<GroupMessageModel> getUnreadMessages() {
        return databaseService.getGroupMessageModelFactory().getUnreadMessages(
            group.getId());
    }

    public GroupModelOld getGroup() {
        return group;
    }

    @Nullable
    public GroupModel getGroupModel() {
        return groupModel;
    }

    @Override
    public boolean isEqual(MessageReceiver o) {
        return o instanceof GroupMessageReceiver && ((GroupMessageReceiver) o).getGroup().getId() == getGroup().getId();
    }

    @Override
    public String getDisplayName(@NonNull ContactNameFormat contactNameFormat) {
        // Get new group model to ensure the display name is fresh
        GroupModel groupModel = groupModelRepository.getByCreatorIdentityAndId(
            group.getCreatorIdentity(),
            group.getApiGroupId()
        );
        if (groupModel != null) {
            final @Nullable GroupModelData groupModelData = groupModel.getData();
            if (groupModelData != null) {
                return NameUtil.getGroupDisplayName(
                    groupModelData,
                    contactModelRepository,
                    userService,
                    contactNameFormat
                );
            }
        }

        // In case the new group model cannot be found, we fall back to the old group model
        return NameUtil.getGroupDisplayName(group, groupService, contactNameFormat);
    }

    @Override
    public String getShortName(@NonNull ContactNameFormat contactNameFormat) {
        return getDisplayName(contactNameFormat);
    }

    @Override
    public Bitmap getNotificationAvatar() {
        return groupService.getAvatar(group, false);
    }

    @Override
    public Bitmap getHighResAvatar() {
        return groupService.getAvatar(group, true);
    }

    @Override
    public Bitmap getAvatar() {
        return groupService.getAvatar(group, true, true);
    }

    @Override
    public boolean isMessageBelongsToMe(AbstractMessageModel message) {
        return message instanceof GroupMessageModel
            && ((GroupMessageModel) message).getGroupId() == group.getId();
    }

    @Override
    public boolean shouldSendMediaData() {
        if (multiDeviceManager.isMultiDeviceActive()) {
            // We need to upload the media in any case (also for notes groups) if multi device is
            // active. In this case the upload is needed as the message is reflected.
            return true;
        }

        // don't really send off group media if user is the only group member left - keep it local
        String[] groupIdentities = groupService.getGroupMemberIdentities(group);
        return groupIdentities.length != 1 || !groupService.isGroupMember(group);
    }

    @Override
    public boolean offerRetry() {
        return false;
    }

    @NonNull
    @Override
    public SendingPermissionValidationResult validateSendingPermission() {
        GroupAccessModel access = groupService.getAccess(getGroup(), true);

        if (access == null) {
            //what?
            return new SendingPermissionValidationResult.Denied();
        }

        if (!access.getCanSendMessageAccess().isAllowed()) {
            return new SendingPermissionValidationResult.Denied(
                access.getCanSendMessageAccess().getNotAllowedTestResourceId()
            );
        }
        return SendingPermissionValidationResult.Valid.INSTANCE;
    }

    @Override
    @MessageReceiverType
    public int getType() {
        return Type_GROUP;
    }

    @Override
    public String[] getIdentities() {
        return groupService.getGroupMemberIdentities(group);
    }

    @Override
    public void bumpLastUpdate() {
        if (group != null) {
            groupService.bumpLastUpdate(group);
        }
    }

    @Override
    @EmojiReactionsSupport
    public int getEmojiReactionSupport() {
        if (groupModel == null) {
            logger.error("Group model in group message receiver is null");
            return Reactions_NONE;
        }

        GroupModelData groupModelData = groupModel.getData();

        if (groupModelData == null) {
            logger.warn("Group model data is null");
            return Reactions_NONE;
        }
        if (!groupModelData.isMember()) {
            return Reactions_NONE;
        }
        if (Boolean.TRUE.equals(groupModel.isNotesGroup())) {
            return Reactions_FULL;
        }

        switch (groupService.getFeatureSupport(groupModelData, ThreemaFeature.EMOJI_REACTIONS).getAdoptionRate()) {
            case PARTIAL:
                return Reactions_PARTIAL;
            case ALL:
                return Reactions_FULL;
            case NONE:
                // Fallthrough
            default:
                return Reactions_NONE; // Handle unknown adoption rates
        }
    }

    @Nullable
    @Override
    public GroupNotificationTriggerPolicyOverride getNotificationTriggerPolicyOverrideOrNull() {
        if (groupModel != null) {
            GroupModelData groupModelData = groupModel.getData();
            return groupModelData != null ? groupModelData.notificationTriggerPolicyOverride : null;
        }
        return null;
    }

    @NonNull
    @Override
    public ConversationId getConversationId() {
        return new GroupConversationId(group.getId());
    }

    @Override
    public @NonNull String toString() {
        return "GroupMessageReceiver (GroupId = " + group.getId() + ")";
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof GroupMessageReceiver)) return false;
        GroupMessageReceiver that = (GroupMessageReceiver) o;
        return Objects.equals(group, that.group);
    }

    @Override
    public int hashCode() {
        return Objects.hash(group);
    }

    @NonNull
    private Set<String> getRecipientIdentities(@Nullable Collection<String> recipients) {
        if (recipients != null) {
            return new HashSet<>(recipients);
        } else {
            return Set.of(groupService.getGroupMemberIdentities(group));
        }
    }

    private GroupFeatureSupport getFeatureSupport(long feature) {
        if (groupModel == null) {
            logger.error("Cannot get feature support: Group model is null");
            return new GroupFeatureSupport(feature, List.of());
        }
        GroupModelData groupModelData = groupModel.getData();
        if (groupModelData == null) {
            logger.error("Cannot get feature support: Group model data is null");
            return new GroupFeatureSupport(feature, List.of());
        }
        return groupService.getFeatureSupport(groupModelData, feature);
    }
}
