package ch.threema.app.messagereceiver;

import android.graphics.Bitmap;

import org.slf4j.Logger;

import java.time.Instant;
import java.util.Collection;
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.R;
import ch.threema.app.emojis.EmojiUtil;
import ch.threema.app.managers.ServiceManager;
import ch.threema.app.multidevice.MultiDeviceManager;
import ch.threema.app.services.BlockedIdentitiesService;
import ch.threema.app.services.ContactService;
import ch.threema.app.services.MessageService;
import ch.threema.app.tasks.OutboundIncomingContactMessageUpdateReadTask;
import ch.threema.app.tasks.OutgoingContactDeliveryReceiptMessageTask;
import ch.threema.app.tasks.OutgoingContactDeleteMessageTask;
import ch.threema.app.tasks.OutgoingContactEditMessageTask;
import ch.threema.app.tasks.OutgoingFileMessageTask;
import ch.threema.app.tasks.OutgoingLocationMessageTask;
import ch.threema.app.tasks.OutgoingContactReactionMessageTask;
import ch.threema.app.tasks.OutgoingPollSetupMessageTask;
import ch.threema.app.tasks.OutgoingPollVoteContactMessageTask;
import ch.threema.app.tasks.OutgoingTextMessageTask;
import ch.threema.app.tasks.OutgoingTypingIndicatorMessageTask;
import ch.threema.app.tasks.OutgoingVoipCallAnswerMessageTask;
import ch.threema.app.tasks.OutgoingVoipCallHangupMessageTask;
import ch.threema.app.tasks.OutgoingVoipCallOfferMessageTask;
import ch.threema.app.tasks.OutgoingVoipCallRingingMessageTask;
import ch.threema.app.tasks.OutgoingVoipICECandidateMessageTask;
import ch.threema.app.utils.MessageUtil;
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 static ch.threema.common.JavaCompat.areEqual;
import static ch.threema.common.JavaCompat.hexToByteArray;

import ch.threema.data.datatypes.ContactConversationId;
import ch.threema.data.datatypes.ContactNameFormat;
import ch.threema.data.datatypes.ContactNotificationTriggerPolicyOverride;
import ch.threema.data.datatypes.ConversationId;
import ch.threema.data.models.ContactModelData;
import ch.threema.data.repositories.ContactModelRepository;
import ch.threema.domain.models.AcquaintanceLevel;
import ch.threema.domain.models.MessageId;
import ch.threema.domain.protocol.ThreemaFeature;
import ch.threema.domain.protocol.csp.ProtocolDefines;
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.protocol.csp.messages.voip.VoipCallAnswerData;
import ch.threema.domain.protocol.csp.messages.voip.VoipCallHangupData;
import ch.threema.domain.protocol.csp.messages.voip.VoipCallOfferData;
import ch.threema.domain.protocol.csp.messages.voip.VoipCallRingingData;
import ch.threema.domain.protocol.csp.messages.voip.VoipICECandidatesData;
import ch.threema.domain.stores.IdentityStore;
import ch.threema.domain.taskmanager.ActiveTaskCodec;
import ch.threema.domain.taskmanager.Task;
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.ContactModel;
import ch.threema.storage.models.MessageModel;
import ch.threema.storage.models.MessageState;
import ch.threema.storage.models.MessageType;
import ch.threema.storage.models.poll.PollModel;
import ch.threema.storage.models.data.MessageContentsType;
import ch.threema.storage.models.data.media.FileDataModel;

public class ContactMessageReceiver implements MessageReceiver<MessageModel> {

    private static final Logger logger = getThreemaLogger("ContactMessageReceiver");

    private final ContactModel contact;
    private final @Nullable ch.threema.data.models.ContactModel contactModel;
    private final @NonNull ContactModelRepository contactModelRepository;
    private final ContactService contactService;
    private final @NonNull ServiceManager serviceManager;
    private final DatabaseService databaseService;
    private final IdentityStore identityStore;
    private final BlockedIdentitiesService blockedIdentitiesService;
    private final @NonNull TaskManager taskManager;
    private final @NonNull MultiDeviceManager multiDeviceManager;

    public ContactMessageReceiver(
        ContactModel contact,
        ContactService contactService,
        @NonNull ServiceManager serviceManager,
        DatabaseService databaseService,
        IdentityStore identityStore,
        @NonNull BlockedIdentitiesService blockedIdentitiesService,
        @NonNull ContactModelRepository contactModelRepository
    ) {
        this.contact = contact;
        this.contactService = contactService;
        this.serviceManager = serviceManager;
        this.databaseService = databaseService;
        this.identityStore = identityStore;
        this.blockedIdentitiesService = blockedIdentitiesService;
        this.taskManager = serviceManager.getTaskManager();
        this.multiDeviceManager = serviceManager.getMultiDeviceManager();
        this.contactModelRepository = contactModelRepository;

        contactModel = (contact != null) ? contactModelRepository.getByIdentity(contact.getIdentity()) : null;
    }

    protected ContactMessageReceiver(@NonNull ContactMessageReceiver contactMessageReceiver) {
        this(
            contactMessageReceiver.contact,
            contactMessageReceiver.contactService,
            contactMessageReceiver.serviceManager,
            contactMessageReceiver.databaseService,
            contactMessageReceiver.identityStore,
            contactMessageReceiver.blockedIdentitiesService,
            contactMessageReceiver.contactModelRepository
        );
    }

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

    /**
     * @deprecated use createAndSaveStatusDataModel instead.
     */
    @Override
    @Deprecated
    public MessageModel createAndSaveStatusModel(String statusBody, Instant postedAt) {
        MessageModel m = new MessageModel(true);
        m.setType(MessageType.TEXT);
        m.setPostedAt(postedAt);
        m.setCreatedAt(Instant.now());
        m.setSaved(true);
        m.setUid(UUID.randomUUID().toString());
        m.setIdentity(contact.getIdentity());
        m.setBody(statusBody);

        saveLocalModel(m);

        return m;
    }

    @Override
    public void saveLocalModel(MessageModel save) {
        databaseService.getMessageModelFactory().createOrUpdate(save);
    }

    @Override
    public void createAndSendTextMessage(@NonNull MessageModel messageModel) {
        // Mark the contact as non-hidden and unarchived
        contactService.setAcquaintanceLevel(contact.getIdentity(), AcquaintanceLevel.DIRECT);
        contactService.unarchive(contact.getIdentity(), TriggerSource.LOCAL);

        bumpLastUpdate();

        // Schedule outgoing text message task
        scheduleTask(new OutgoingTextMessageTask(
            messageModel.getId(),
            Type_CONTACT,
            Set.of(messageModel.getIdentity())
        ));
    }

    public void resendTextMessage(@NonNull MessageModel messageModel) {
        contactService.setAcquaintanceLevel(contact.getIdentity(), AcquaintanceLevel.DIRECT);
        contactService.unarchive(contact.getIdentity(), TriggerSource.LOCAL);

        scheduleTask(new OutgoingTextMessageTask(
            messageModel.getId(),
            Type_CONTACT,
            Set.of(messageModel.getIdentity())
        ));
    }

    @Override
    public void createAndSendLocationMessage(@NonNull MessageModel messageModel) {
        // Mark the contact as non-hidden and unarchived
        contactService.setAcquaintanceLevel(contact.getIdentity(), AcquaintanceLevel.DIRECT);
        contactService.unarchive(contact.getIdentity(), TriggerSource.LOCAL);

        bumpLastUpdate();

        // Schedule outgoing text message task
        scheduleTask(new OutgoingLocationMessageTask(
            messageModel.getId(),
            Type_CONTACT,
            Set.of(messageModel.getIdentity())
        ));
    }

    public void resendLocationMessage(@NonNull MessageModel messageModel) {
        // Mark the contact as non-hidden and unarchived
        contactService.setAcquaintanceLevel(contact.getIdentity(), AcquaintanceLevel.DIRECT);
        contactService.unarchive(contact.getIdentity(), TriggerSource.LOCAL);

        // Schedule outgoing text message task
        scheduleTask(new OutgoingLocationMessageTask(
            messageModel.getId(),
            Type_CONTACT,
            Set.of(messageModel.getIdentity())
        ));
    }

    @Override
    public void createAndSendFileMessage(
        @Nullable byte[] thumbnailBlobId,
        @Nullable byte[] fileBlobId,
        @Nullable SymmetricEncryptionResult encryptionResult,
        @NonNull MessageModel messageModel,
        @Nullable Collection<String> recipientIdentities
    ) throws ThreemaException {
        // 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);

        // Mark the contact as non-hidden and unarchived
        contactService.setAcquaintanceLevel(contact.getIdentity(), AcquaintanceLevel.DIRECT);
        contactService.unarchive(contact.getIdentity(), TriggerSource.LOCAL);

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

        // Schedule outgoing text message task
        scheduleTask(new OutgoingFileMessageTask(
            messageModel.getId(),
            Type_CONTACT,
            Set.of(messageModel.getIdentity()),
            thumbnailBlobId
        ));
    }

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

        // Mark the contact as non-hidden and unarchived
        contactService.setAcquaintanceLevel(contact.getIdentity(), AcquaintanceLevel.DIRECT);
        contactService.unarchive(contact.getIdentity(), TriggerSource.LOCAL);

        bumpLastUpdate();

        // Schedule outgoing text message task if this has been triggered by local
        if (triggerSource == TriggerSource.LOCAL) {
            scheduleTask(new OutgoingPollSetupMessageTask(
                messageModel.getId(),
                Type_CONTACT,
                Set.of(messageModel.getIdentity()),
                pollId,
                pollData
            ));
        }
    }

    @Override
    public void createAndSendPollVoteMessage(
        PollVote[] votes,
        @NonNull PollModel pollModel,
        @NonNull TriggerSource triggerSource
    ) throws ThreemaException {
        final PollId pollId = new PollId(hexToByteArray(pollModel.getApiPollId()));

        if (pollModel.getType() == PollModel.Type.RESULT_ON_CLOSE) {
            // If I am the creator do not send anything
            if (areEqual(pollModel.getCreatorIdentity(), identityStore.getIdentityString())) {
                return;
            }
        }

        // Mark the contact as non-hidden and unarchived
        contactService.setAcquaintanceLevel(contact.getIdentity(), AcquaintanceLevel.DIRECT);
        contactService.unarchive(contact.getIdentity(), TriggerSource.LOCAL);


        if (triggerSource == TriggerSource.LOCAL) {
            // Create message id
            MessageId messageId = MessageId.random();

            // Schedule outgoing text message task
            scheduleTask(new OutgoingPollVoteContactMessageTask(
                messageId,
                pollId,
                pollModel.getCreatorIdentity(),
                votes,
                contact.getIdentity()
            ));
        }
    }

    /**
     * Send a typing indicator to the receiver.
     *
     * @param isTyping true if the user is typing, false otherwise
     * @throws ThreemaException if enqueuing the message fails
     */
    public void sendTypingIndicatorMessage(boolean isTyping) throws ThreemaException {
        scheduleTask(new OutgoingTypingIndicatorMessageTask(isTyping, contact.getIdentity()));
    }

    /**
     * Send a delivery receipt to the receiver.
     *
     * @param receiptType the type of the delivery receipt
     * @param messageIds  the message ids
     */
    public void sendDeliveryReceipt(int receiptType, @NonNull MessageId[] messageIds, long time) {
        scheduleTask(
            new OutgoingContactDeliveryReceiptMessageTask(
                receiptType, messageIds, time, contact.getIdentity()
            )
        );
    }

    /**
     * Send an incoming message update to mark the message as read. Note that this is the
     * alternative of {@link ContactMessageReceiver#sendDeliveryReceipt(int, MessageId[], long)}
     * when no delivery receipt should be sent. This method only schedules the outgoing message
     * update if multi device is activated.
     */
    public void sendIncomingMessageUpdateRead(@NonNull Set<MessageId> messageIds, long timestamp) {
        if (multiDeviceManager.isMultiDeviceActive()) {
            scheduleTask(
                new OutboundIncomingContactMessageUpdateReadTask(
                    messageIds,
                    timestamp,
                    contact.getIdentity()
                )
            );
        }
    }

    /**
     * Send a voip call offer message to the receiver.
     *
     * @param callOfferData the call offer data
     */
    public void sendVoipCallOfferMessage(@NonNull VoipCallOfferData callOfferData) {
        scheduleTask(
            new OutgoingVoipCallOfferMessageTask(
                callOfferData, contact.getIdentity()
            )
        );
    }

    /**
     * Send a voip call answer message to the receiver.
     *
     * @param callAnswerData the call answer data
     */
    public void sendVoipCallAnswerMessage(@NonNull VoipCallAnswerData callAnswerData) {
        scheduleTask(
            new OutgoingVoipCallAnswerMessageTask(
                callAnswerData, contact.getIdentity()
            )
        );
    }

    /**
     * Send a voip ICE candidates message to the receiver.
     *
     * @param voipICECandidatesData the voip ICE candidate data
     */
    public void sendVoipICECandidateMessage(@NonNull VoipICECandidatesData voipICECandidatesData) {
        scheduleTask(
            new OutgoingVoipICECandidateMessageTask(
                voipICECandidatesData, contact.getIdentity()
            )
        );
    }

    /**
     * Send a voip call hangup message to the receiver.
     *
     * @param callHangupData the call hangup data
     */
    public void sendVoipCallHangupMessage(@NonNull VoipCallHangupData callHangupData) {
        scheduleTask(
            new OutgoingVoipCallHangupMessageTask(
                callHangupData, contact.getIdentity()
            )
        );
    }

    /**
     * Send a voip call ringing message to the receiver.
     *
     * @param callRingingData the call ringing data
     */
    public void sendVoipCallRingingMessage(@NonNull VoipCallRingingData callRingingData) {
        scheduleTask(
            new OutgoingVoipCallRingingMessageTask(
                callRingingData, contact.getIdentity()
            )
        );
    }

    public void sendEditMessage(int messageModelId, @NonNull String newText, @NonNull Instant editedAt) {
        scheduleTask(
            new OutgoingContactEditMessageTask(
                contact.getIdentity(),
                messageModelId,
                MessageId.random(),
                newText,
                editedAt
            )
        );
    }

    public void sendDeleteMessage(int messageModelId, @NonNull Instant deletedAt) {
        scheduleTask(
            new OutgoingContactDeleteMessageTask(
                contact.getIdentity(),
                messageModelId,
                MessageId.random(),
                deletedAt
            )
        );
    }

    /**
     * Send a reaction to the given message model. Depending on reaction support, this may be mapped
     * to a legacy reaction. In this case, the new message state that should be applied to the
     * message model is returned.
     */
    @Nullable
    public MessageState sendReaction(AbstractMessageModel messageModel, Reaction.ActionCase actionCase, @NonNull String emojiSequence, @NonNull Instant reactedAt) {
        if (getEmojiReactionSupport() == MessageReceiver.Reactions_NONE) {
            return sendLegacyReaction(messageModel, actionCase, emojiSequence, reactedAt);
        } else {
            scheduleTask(
                new OutgoingContactReactionMessageTask(
                    contact.getIdentity(),
                    messageModel.getId(),
                    MessageId.random(),
                    actionCase,
                    emojiSequence,
                    reactedAt
                )
            );
            return null;
        }
    }

    @Nullable
    private MessageState sendLegacyReaction(
        AbstractMessageModel messageModel,
        Reaction.ActionCase actionCase,
        @NonNull String emojiSequence,
        @NonNull Instant reactedAt
    ) {
        if (actionCase == Reaction.ActionCase.WITHDRAW) {
            // In case we withdraw the reaction we do not send a delivery receipt because
            // withdrawing is not supported with delivery receipts.
            logger.info("Cannot withdraw legacy reaction");
            return null;
        }

        // fallback to ack/dec
        if (EmojiUtil.isThumbsUpEmoji(emojiSequence)) {
            if (MessageUtil.canSendUserAcknowledge(messageModel)) {
                sendDeliveryReceipt(
                    ProtocolDefines.DELIVERYRECEIPT_MSGUSERACK,
                    new MessageId[]{messageModel.getMessageId()},
                    reactedAt.toEpochMilli()
                );
                return MessageState.USERACK;
            } else {
                logger.error("Unable to send ack message.");
            }
        } else if (EmojiUtil.isThumbsDownEmoji(emojiSequence)) {
            if (MessageUtil.canSendUserDecline(messageModel)) {
                sendDeliveryReceipt(
                    ProtocolDefines.DELIVERYRECEIPT_MSGUSERDEC,
                    new MessageId[]{messageModel.getMessageId()},
                    reactedAt.toEpochMilli()
                );
                return MessageState.USERDEC;
            } else {
                logger.error("Unable to send dec message");
            }
        }
        return null;
    }

    @Override
    @NonNull
    public List<MessageModel> loadMessages(MessageService.MessageFilter filter) {
        return databaseService.getMessageModelFactory().find(
            contact.getIdentity(),
            filter
        );
    }

    /**
     * Check if there is a call among the latest calls with the given call id.
     *
     * @param callId the call id
     * @param limit  the maximum number of latest calls
     * @return {@code true} if there is a call with the given id within the latest calls, {@code false} otherwise
     */
    public boolean hasVoipCallStatus(long callId, int limit) {
        return databaseService.getMessageModelFactory().hasVoipStatusForCallId(contact.getIdentity(), callId, limit);
    }

    @Override
    public long getMessagesCount() {
        return databaseService.getMessageModelFactory().countMessages(
            contact.getIdentity());
    }

    @Override
    public long getUnreadMessagesCount() {
        return databaseService.getMessageModelFactory().countUnreadMessages(
            contact.getIdentity());
    }

    @NonNull
    @Override
    public List<MessageModel> getUnreadMessages() {
        return databaseService.getMessageModelFactory().getUnreadMessages(contact.getIdentity());
    }

    public MessageModel getLastMessage() {
        return databaseService.getMessageModelFactory().getLastMessage(contact.getIdentity());
    }

    public ContactModel getContact() {
        return contact;
    }

    @Nullable
    public ch.threema.data.models.ContactModel getContactModel() {
        return contactModel;
    }

    @Override
    public boolean isEqual(MessageReceiver o) {
        return o instanceof ContactMessageReceiver && ((ContactMessageReceiver) o).getContact().getIdentity().equals(getContact().getIdentity());
    }

    @Override
    @NonNull
    public String getDisplayName(@NonNull ContactNameFormat contactNameFormat) {
        return NameUtil.getContactDisplayNameOrNickname(contact, true, contactNameFormat);
    }

    @Override
    public String getShortName(@NonNull ContactNameFormat contactNameFormat) {
        return NameUtil.getShortName(contact, contactNameFormat);
    }

    @Override
    @Nullable
    public Bitmap getNotificationAvatar() {
        return getAvatar(false);
    }

    @Override
    public Bitmap getHighResAvatar() {
        return getAvatar(true);
    }

    @Override
    @Nullable
    public Bitmap getAvatar() {
        return getAvatar(true);
    }

    @Nullable
    private Bitmap getAvatar(boolean highResolution) {
        String identity = contact != null
            ? contact.getIdentity()
            : null;
        return contactService.getAvatar(identity, highResolution);
    }

    @Override
    public boolean isMessageBelongsToMe(AbstractMessageModel message) {
        return message instanceof MessageModel
            && message.getIdentity().equals(contact.getIdentity());
    }

    @Override
    public boolean shouldSendMediaData() {
        return true;
    }

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

    @NonNull
    @Override
    public SendingPermissionValidationResult validateSendingPermission() {
        int cannotSendResId = 0;
        if (blockedIdentitiesService.isBlocked(contact.getIdentity())) {
            cannotSendResId = R.string.blocked_cannot_send;
        } else {
            if (contact.getState() != null) {
                switch (contact.getState()) {
                    case INVALID:
                        cannotSendResId = R.string.invalid_cannot_send;
                        break;
                    case INACTIVE:
                        //inactive allowed
                        break;
                }
            } else {
                cannotSendResId = R.string.invalid_cannot_send;
            }
        }

        return cannotSendResId > 0
            ? new SendingPermissionValidationResult.Denied(cannotSendResId)
            : SendingPermissionValidationResult.Valid.INSTANCE;
    }

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

    @Override
    public String[] getIdentities() {
        return new String[]{contact.getIdentity()};
    }

    @Override
    public void bumpLastUpdate() {
        contactService.bumpLastUpdate(contact.getIdentity());
    }

    /**
     * Check whether we should send emoji reactions to this particular MessageReceiver
     *
     * @return [Reactions_FULL] if we should send emoji reactions to this MessageReceiver, [Reactions_NONE] otherwise
     */
    @Override
    @EmojiReactionsSupport
    public int getEmojiReactionSupport() {
        return ThreemaFeature.canEmojiReactions((this).getContact().getFeatureMask())
            ? Reactions_FULL
            : Reactions_NONE;
    }

    @Nullable
    @Override
    public ContactNotificationTriggerPolicyOverride getNotificationTriggerPolicyOverrideOrNull() {
        if (contactModel != null) {
            ContactModelData contactModelData = contactModel.getData();
            return contactModelData != null ? contactModelData.notificationTriggerPolicyOverride : null;
        }
        return null;
    }

    @NonNull
    @Override
    public ConversationId getConversationId() {
        return new ContactConversationId(contact.getIdentity());
    }

    @Override
    @NonNull
    public String toString() {
        return "ContactMessageReceiver (identity = " + contact.getIdentity() + ")";
    }

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

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

    private void scheduleTask(@NonNull Task<?, ActiveTaskCodec> task) {
        taskManager.schedule(task);
    }
}
