package ch.threema.app.services;

import android.accounts.Account;
import android.accounts.AccountManager;
import android.accounts.AccountManagerCallback;
import android.content.ContentResolver;
import android.content.Context;
import android.provider.ContactsContract;

import ch.threema.app.eventbus.GlobalEventBuses;
import ch.threema.app.eventbus.events.ProfileEvent;
import ch.threema.app.preference.service.SynchronizedSettingsService;
import ch.threema.app.profilepicture.CheckedProfilePicture;
import ch.threema.app.profilepicture.ProfilePicture;
import ch.threema.app.profilepicture.RawProfilePicture;
import ch.threema.app.stores.ReadonlyInMemoryIdentityStore;
import ch.threema.base.crypto.NaCl;

import org.json.JSONException;
import org.json.JSONObject;
import org.slf4j.Logger;

import java.io.IOException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import ch.threema.app.BuildFlavor;
import ch.threema.app.R;
import ch.threema.app.listeners.SMSVerificationListener;
import ch.threema.app.managers.ListenerManager;
import ch.threema.app.multidevice.MultiDeviceManager;
import ch.threema.app.preference.service.PreferenceService;
import ch.threema.app.routines.UpdateWorkInfoRoutine;
import ch.threema.domain.models.Nickname;
import ch.threema.domain.models.SerialCredentials;
import ch.threema.domain.models.UserCredentials;
import ch.threema.app.stores.PreferenceStore;
import ch.threema.app.tasks.ReflectUserProfileNicknameSyncTask;
import ch.threema.app.tasks.TaskCreator;
import ch.threema.app.utils.ConfigUtils;
import ch.threema.app.utils.ContactUtil;
import ch.threema.app.utils.DeviceIdProvider;
import ch.threema.app.utils.LocaleUtil;
import ch.threema.app.utils.PushUtil;
import ch.threema.base.ThreemaException;
import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
import ch.threema.domain.identitybackup.IdentityBackup;
import ch.threema.domain.models.LicenseCredentials;
import ch.threema.domain.protocol.ThreemaFeature;
import ch.threema.domain.protocol.api.APIConnector;
import ch.threema.domain.protocol.api.CreateIdentityRequestDataInterface;
import ch.threema.domain.protocol.blob.BlobScope;
import ch.threema.domain.protocol.blob.BlobUploader;
import ch.threema.domain.protocol.csp.ProtocolDefines;
import ch.threema.domain.stores.IdentityStore;
import ch.threema.domain.taskmanager.TaskManager;
import ch.threema.domain.taskmanager.TriggerSource;
import ch.threema.libthreema.CryptoException;
import ch.threema.storage.models.ContactModel;

import static ch.threema.app.AppConstants.PHONE_LINKED_PLACEHOLDER;
import static ch.threema.common.JavaCompat.isNullOrEmpty;
import static ch.threema.common.JavaCompat.readBytes;
import static ch.threema.common.OkHttpExtensionsKt.isHttpAuthError;
import static ch.threema.common.SecureRandomExtensionsKt.generateRandomBytes;
import static ch.threema.common.SecureRandomExtensionsKt.secureRandom;

/**
 * This service class handle all user actions (db/identity....)
 */
public class UserServiceImpl implements UserService, CreateIdentityRequestDataInterface {
    private static final Logger logger = getThreemaLogger("UserServiceImpl");

    @NonNull
    private final Context context;
    @NonNull
    private final PreferenceStore preferenceStore;
    @NonNull
    private final IdentityStore identityStore;
    @NonNull
    private final APIConnector apiConnector;
    @NonNull
    private final ApiService apiService;
    @NonNull
    private final FileService fileService;
    @NonNull
    private final LocaleService localeService;
    @NonNull
    private final PreferenceService preferenceService;
    @NonNull
    private final SynchronizedSettingsService synchronizedSettingsService;
    @NonNull
    private final TaskManager taskManager;
    @NonNull
    private final TaskCreator taskCreator;
    @NonNull
    private final MultiDeviceManager multiDeviceManager;
    @NonNull
    private final DeviceIdProvider deviceIdProvider;
    @NonNull
    private final GlobalEventBuses globalEventBuses;
    private String policyResponseData;
    private String policySignature;
    private int policyErrorCode;
    private LicenseCredentials credentials;
    private Account account;

    // TODO(ANDR-2519): Remove when md allows fs
    private boolean isFsEnabled = true;

    public UserServiceImpl(
        @NonNull Context context,
        @NonNull PreferenceStore preferenceStore,
        @NonNull LocaleService localeService,
        @NonNull APIConnector apiConnector,
        @NonNull ApiService apiService,
        @NonNull FileService fileService,
        @NonNull IdentityStore identityStore,
        @NonNull PreferenceService preferenceService,
        @NonNull SynchronizedSettingsService synchronizedSettingsService,
        @NonNull TaskManager taskManager,
        @NonNull TaskCreator taskCreator,
        @NonNull MultiDeviceManager multiDeviceManager,
        @NonNull DeviceIdProvider deviceIdProvider,
        @NonNull GlobalEventBuses globalEventBuses
    ) {
        this.context = context;
        this.preferenceStore = preferenceStore;
        this.localeService = localeService;
        this.identityStore = identityStore;
        this.apiConnector = apiConnector;
        this.apiService = apiService;
        this.fileService = fileService;
        this.preferenceService = preferenceService;
        this.synchronizedSettingsService = synchronizedSettingsService;
        this.taskCreator = taskCreator;
        this.taskManager = taskManager;
        this.multiDeviceManager = multiDeviceManager;
        this.deviceIdProvider = deviceIdProvider;
        this.globalEventBuses = globalEventBuses;
    }

    @Override
    public void createIdentity(byte[] newRandomSeed) throws Exception {
        if (this.hasIdentity()) {
            throw new ThreemaException("please remove your existing identity " + this.getIdentity());
        }

        // no need to send a request if we have no licence
        // note that CheckLicenseRoutine may not have received an upstream response yet.
        if (policySignature == null && policyResponseData == null && credentials == null
            && !(BuildFlavor.getCurrent().getLicenseType().equals(BuildFlavor.LicenseType.NONE))
        ) {
            throw new ThreemaException(context.getString(R.string.missing_app_licence) + "\n" + context.getString(R.string.app_store_error_code, policyErrorCode));    /* Create identity phase 1 unsuccessful:*/
        } else {
            this.apiConnector.createIdentity(
                this.identityStore,
                newRandomSeed,
                this
            );
        }

        // identity has been successfully created. set push token
        PushUtil.enqueuePushTokenUpdate(context, false, false);
    }

    @Override
    public void removeIdentity() throws Exception {
        if (!this.hasIdentity()) {
            throw new ThreemaException("no identity to remove");
        }
        this.removeAccount();
        this.identityStore.clear();
    }

    @Override
    public Account getAccount() {
        return this.getAccount(false);
    }

    @Override
    public Account getAccount(boolean createIfNotExists) {
        if (this.account == null) {
            AccountManager accountManager = AccountManager.get(this.context);

            try {
                Account[] accounts = accountManager.getAccountsByType(context.getPackageName());
                if (accounts.length > 0) {
                    this.account = accounts[0];
                }
            } catch (SecurityException e) {
                logger.error("Could not get account", e);
            }

            //if sync enabled, create one!
            if (this.account == null && (createIfNotExists || synchronizedSettingsService.isSyncContacts())) {
                this.account = new Account(context.getString(R.string.app_name), context.getString(R.string.package_name));
                // This method requires the caller to have the same UID as the added account's authenticator.
                try {
                    accountManager.addAccountExplicitly(this.account, "", null);
                    //auto enable sync
                    ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1);
                    if (!ContentResolver.getSyncAutomatically(account, ContactsContract.AUTHORITY)) {
                        ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true);
                    }
                } catch (SecurityException e) {
                    logger.error("Could not add account", e);
                }
            }

        }
        return this.account;
    }

    @Override
    public boolean checkAccount() {
        AccountManager accountManager = AccountManager.get(this.context);
        return accountManager.getAccountsByType(context.getPackageName()).length != 0;
    }

    @Override
    public boolean enableAccountAutoSync(boolean enable) {
        Account account = this.getAccount();
        if (account != null) {
            if (enable != ContentResolver.getSyncAutomatically(account, ContactsContract.AUTHORITY)) {
                ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, enable);
            }
            return true;
        }
        return false;
    }

    @Override
    public void removeAccount() {
        this.removeAccount(null);
    }

    @Override
    public boolean removeAccount(AccountManagerCallback<Boolean> callback) {
        Account a = this.getAccount(false);
        if (a != null) {
            AccountManager accountManager = AccountManager.get(this.context);
            try {
                accountManager.removeAccount(a, callback, null);
            } catch (Exception e) {
                logger.error("Unable to remove account", e);
                return false;
            }
            this.account = null;
            return true;
        } else {
            return false;
        }
    }

    @Override
    public boolean hasIdentity() {
        return this.getIdentity() != null;
    }

    @Override
    @Nullable
    public String getIdentity() {
        return this.identityStore.getIdentityString();
    }

    @Override
    public boolean isMe(@Nullable String identity) {
        return identity != null && identity.equals(this.getIdentity());
    }

    @Override
    public byte[] getPublicKey() {
        return this.identityStore.getPublicKey();
    }

    @Override
    public byte[] getPrivateKey() {
        return this.identityStore.getPrivateKey();
    }

    @NonNull
    @Override
    public String getLinkedEmail() {
        String email = this.preferenceStore.getString(PreferenceStore.PREFS_LINKED_EMAIL);
        return email != null ? email : "";
    }

    @Override
    public void linkWithEmail(String email, @NonNull TriggerSource triggerSource) throws Exception {
        boolean pending = this.apiConnector.linkEmail(
            email,
            this.getLanguage(),
            this.identityStore);

        this.preferenceStore.save(PreferenceStore.PREFS_LINKED_EMAIL, email);
        this.preferenceStore.save(PreferenceStore.PREFS_LINKED_EMAIL_PENDING, pending);

        // Note that we sync the identity links regardless from the pending-flag to be safe. In both
        // cases there is a scenario where we want to reflect the change:
        //
        // 1. pending == false: The email address is verified immediately and therefore already
        // returns false here. This is very unlikely, but it might happen in theory.
        //
        // 2. pending == true: Prior to the current execution of this method we may have had an
        // email address that has been successfully linked and verified. The new email address won't
        // be reflected yet by the reflection task if it is not yet verified, but the old email
        // address will be removed already (as it technically has been unlinked).
        reflectUserProfileIdentityLinksIfApplicable(triggerSource);
    }

    @Override
    public void unlinkEmail(@NonNull TriggerSource triggerSource) throws Exception {
        String email = this.preferenceStore.getString(PreferenceStore.PREFS_LINKED_EMAIL);
        if (email == null) {
            throw new ThreemaException("no email linked");
        }

        this.apiConnector.linkEmail("", this.getLanguage(), this.identityStore);
        this.preferenceStore.remove(PreferenceStore.PREFS_LINKED_EMAIL);
        this.preferenceStore.remove(PreferenceStore.PREFS_LINKED_EMAIL_PENDING);

        // Reflect an update
        reflectUserProfileIdentityLinksIfApplicable(triggerSource);
    }

    @Override
    public int getEmailLinkingState() {
        if (this.preferenceStore.getBoolean(PreferenceStore.PREFS_LINKED_EMAIL_PENDING)) {
            return LinkingState_PENDING;
        } else if (this.preferenceStore.getString(PreferenceStore.PREFS_LINKED_EMAIL) != null) {
            return LinkingState_LINKED;
        } else {
            return LinkingState_NONE;
        }
    }

    @Override
    public void checkEmailLinkState(@NonNull TriggerSource triggerSource) {
        if (this.getEmailLinkingState() == LinkingState_PENDING) {
            try {
                if (this.apiConnector.linkEmailCheckStatus(this.getLinkedEmail(), this.identityStore)) {
                    this.preferenceStore.remove(PreferenceStore.PREFS_LINKED_EMAIL_PENDING);
                    reflectUserProfileIdentityLinksIfApplicable(triggerSource);
                }
            } catch (Exception e) {
                logger.error("Exception", e);
            }
        }
    }

    @Override
    public void linkWithMobileNumber(String number, @NonNull TriggerSource triggerSource) throws Exception {
        boolean aPhoneNumberHasBeenLinked = getMobileLinkingState() == LinkingState_LINKED;

        String normalizedMobileNo = this.localeService.getNormalizedPhoneNumber(number);

        if (normalizedMobileNo != null && normalizedMobileNo.startsWith("+")) {
            normalizedMobileNo = normalizedMobileNo.substring(1);
        }

        String verificationId = this.apiConnector.linkMobileNo(
            normalizedMobileNo,
            this.getLanguage(),
            this.identityStore,
            (BuildFlavor.getCurrent().getLicenseType() == BuildFlavor.LicenseType.GOOGLE_WORK ||
                BuildFlavor.getCurrent().getLicenseType() == BuildFlavor.LicenseType.HMS_WORK)
                ? "threemawork" : null
        );

        this.preferenceStore.save(PreferenceStore.PREFS_LINKED_MOBILE, number);

        if (verificationId == null) {
            throw new ThreemaException(this.context.getResources().getString(R.string.mobile_already_linked));
        }

        this.preferenceStore.save(PreferenceStore.PREFS_LINKED_MOBILE_PENDING, Instant.now());
        this.preferenceStore.save(PreferenceStore.PREFS_MOBILE_VERIFICATION_ID, verificationId);

        // If a phone number is currently successfully linked, then we trigger an update already now
        // as it is removed immediately and the new phone number may never be verified
        if (aPhoneNumberHasBeenLinked) {
            reflectUserProfileIdentityLinksIfApplicable(triggerSource);
        }

        ListenerManager.smsVerificationListeners.handle(SMSVerificationListener::onVerificationStarted);
    }

    @Override
    public void makeMobileLinkCall() throws Exception {
        if (this.getMobileLinkingState() != LinkingState_PENDING) {
            throw new ThreemaException("no verification in progress");
        }

        this.apiConnector.linkMobileNoCall(getCurrentMobileNumberVerificationId());
    }

    private String getCurrentMobileNumberVerificationId() {
        return this.preferenceStore.getString(PreferenceStore.PREFS_MOBILE_VERIFICATION_ID);
    }

    private String getCurrentMobileNumber() {
        return this.preferenceStore.getString(PreferenceStore.PREFS_LINKED_MOBILE);
    }

    @Override
    public void unlinkMobileNumber(@NonNull TriggerSource triggerSource) throws Exception {
        String mobileNumber = this.preferenceStore.getString(PreferenceStore.PREFS_LINKED_MOBILE);
        if (mobileNumber == null) {

            String currentMobileNumber = getCurrentMobileNumber();
            if (currentMobileNumber == null || currentMobileNumber.isEmpty()) {
                throw new ThreemaException("no mobile number linked");
            }
        }

        this.apiConnector.linkMobileNo("", this.getLanguage(), this.identityStore);
        this.preferenceStore.remove(PreferenceStore.PREFS_LINKED_MOBILE);
        this.preferenceStore.remove(PreferenceStore.PREFS_LINKED_MOBILE_PENDING);
        this.preferenceStore.remove(PreferenceStore.PREFS_MOBILE_VERIFICATION_ID);

        reflectUserProfileIdentityLinksIfApplicable(triggerSource);

        ListenerManager.smsVerificationListeners.handle(SMSVerificationListener::onVerified);
    }

    @Override
    public boolean verifyMobileNumber(String code, @NonNull TriggerSource triggerSource) throws Exception {
        if (this.getMobileLinkingState() == LinkingState_PENDING) {
            this.apiConnector.linkMobileNoVerify(getCurrentMobileNumberVerificationId(), code);

            //verification ok, save phone number
            this.preferenceStore.remove(PreferenceStore.PREFS_LINKED_MOBILE_PENDING);
            this.preferenceStore.remove(PreferenceStore.PREFS_MOBILE_VERIFICATION_ID);

            reflectUserProfileIdentityLinksIfApplicable(triggerSource);

            ListenerManager.smsVerificationListeners.handle(SMSVerificationListener::onVerified);
            return true;
        }

        return false;
    }

    @Override
    public String getLinkedMobileE164() {
        return this.preferenceStore.getString(PreferenceStore.PREFS_LINKED_MOBILE);
    }

    @Override
    public String getLinkedMobile() {
        String linkedMobile = getLinkedMobileE164();

        if (PHONE_LINKED_PLACEHOLDER.equals(linkedMobile)) {
            return linkedMobile;
        }

        if (isNullOrEmpty(linkedMobile)) {
            return null;
        }
        return "+" + linkedMobile;
    }

    @Override
    public String getLinkedMobile(boolean returnPendingNumber) {
        String currentMobileNumber = getCurrentMobileNumber();
        if (currentMobileNumber != null && !currentMobileNumber.isEmpty()) {
            return currentMobileNumber;
        }

        return this.getLinkedMobile();
    }

    @Override
    public int getMobileLinkingState() {
        if (preferenceStore.getInstant(PreferenceStore.PREFS_LINKED_MOBILE_PENDING) != null) {
            return LinkingState_PENDING;
        } else if (this.getLinkedMobile() != null) {
            return LinkingState_LINKED;
        } else {
            return LinkingState_NONE;
        }
    }

    @Override
    @Nullable
    public Instant getMobileLinkingTime() {
        return this.preferenceStore.getInstant(PreferenceStore.PREFS_LINKED_MOBILE_PENDING);
    }

    @Override
    public void persistPhoneIdentityLinkFromSync(
        @Nullable String phoneNumber,
        @NonNull TriggerSource triggerSource
    ) {
        if (triggerSource != TriggerSource.SYNC) {
            throw new IllegalArgumentException("Cannot persist phone number from " + triggerSource);
        }

        if (phoneNumber != null) {
            this.preferenceStore.save(PreferenceStore.PREFS_LINKED_MOBILE, phoneNumber);
        } else {
            this.preferenceStore.remove(PreferenceStore.PREFS_LINKED_MOBILE);
        }
        this.preferenceStore.remove(PreferenceStore.PREFS_LINKED_MOBILE_PENDING);
        this.preferenceStore.remove(PreferenceStore.PREFS_MOBILE_VERIFICATION_ID);

        ListenerManager.smsVerificationListeners.handle(SMSVerificationListener::onVerified);
    }

    @Override
    public void persistEmailIdentityLinkFromSync(
        @Nullable String email,
        @NonNull TriggerSource triggerSource
    ) {
        if (triggerSource != TriggerSource.SYNC) {
            throw new IllegalArgumentException("Cannot persist email from " + triggerSource);
        }

        if (email != null) {
            this.preferenceStore.save(PreferenceStore.PREFS_LINKED_EMAIL, email);
        } else {
            this.preferenceStore.remove(PreferenceStore.PREFS_LINKED_EMAIL);
        }

        this.preferenceStore.remove(PreferenceStore.PREFS_LINKED_EMAIL_PENDING);
    }

    @Override
    public String getPublicNickname() {
        return this.identityStore.getPublicNickname();
    }

    @Override
    public String setPublicNickname(@Nullable String publicNickname, @NonNull TriggerSource triggerSource) {
        logger.info("Setting nickname");
        final @NonNull String oldNickname = this.identityStore.getPublicNickname();
        final @NonNull String publicNicknameTruncated = publicNickname != null
            ? new Nickname(publicNickname).getNickname()
            : "";
        this.identityStore.setPublicNickname(publicNicknameTruncated);
        // run update work info (only if the app is the work version)
        if (ConfigUtils.isWorkBuild()) {
            UpdateWorkInfoRoutine.start();
        }
        if (!publicNicknameTruncated.equals(oldNickname)
            && multiDeviceManager.isMultiDeviceActive()
            && triggerSource != TriggerSource.SYNC) {
            taskManager.schedule(
                new ReflectUserProfileNicknameSyncTask(
                    publicNicknameTruncated
                )
            );
        }
        return publicNicknameTruncated;
    }

    @NonNull
    @Override
    public String getDisplayName() {
        return context.getString(R.string.me_myself_and_i);
    }

    @Override
    @WorkerThread
    @Nullable
    public ProfilePicture getUserProfilePicture() {
        String identity = getIdentity();
        if (identity == null) {
            logger.error("Cannot get user profile picture if there is no identity");
            return null;
        }

        try (var stream = fileService.getUserDefinedProfilePictureStream(getIdentity())) {
            if (stream == null) {
                return null;
            }
            return new RawProfilePicture(readBytes(stream));
        } catch (Exception e) {
            logger.error("Could not get user profile picture", e);
            return null;
        }
    }

    @Override
    @WorkerThread
    public boolean setUserProfilePicture(@NonNull CheckedProfilePicture userProfilePicture, @NonNull TriggerSource triggerSource) {
        String identity = getIdentity();
        if (identity == null) {
            logger.error("Cannot set user profile picture if there is no identity");
            return false;
        }

        try {
            fileService.writeUserDefinedProfilePicture(identity, userProfilePicture.getBytes());
            onUserProfilePictureChanged(triggerSource);
            if (multiDeviceManager.isMultiDeviceActive() && triggerSource != TriggerSource.SYNC) {
                taskCreator.scheduleReflectUserProfilePictureTask();
            }
            return true;
        } catch (Exception e) {
            logger.error("Could not set user profile picture", e);
            return false;
        }
    }

    @Override
    @WorkerThread
    public void setUserProfilePictureFromSync(
        @NonNull ContactService.ProfilePictureUploadData uploadData,
        @NonNull TriggerSource triggerSource
    ) throws IOException {
        if (triggerSource != TriggerSource.SYNC) {
            throw new IllegalArgumentException("This method must only be used from sync");
        }
        String identity = getIdentity();
        if (identity == null) {
            logger.error("Cannot set user profile picture from sync when there is no identity");
            return;
        }

        if (!uploadData.profilePicture.isValid()) {
            logger.warn("Received invalid user profile picture from sync");
        }

        // Persist the profile picture itself
        fileService.writeUserDefinedProfilePicture(identity, uploadData.profilePicture.getBytes());

        // Persist the changes regarding blob id and upload date
        this.preferenceService.setProfilePicUploadData(uploadData);

        // Notify event bus
        globalEventBuses.getProfiles().emit(new ProfileEvent.ProfilePictureUpdated(triggerSource));
    }

    @Override
    public void removeUserProfilePicture(@NonNull TriggerSource triggerSource) {
        String identity = getIdentity();
        if (identity == null) {
            logger.error("Cannot remove profile picture when there is no identity");
            return;
        }

        fileService.removeUserDefinedProfilePicture(getIdentity());
        onUserProfilePictureChanged(triggerSource);
        if (multiDeviceManager.isMultiDeviceActive() && triggerSource != TriggerSource.SYNC) {
            taskCreator.scheduleReflectUserProfilePictureTask();
        }
    }

    @Override
    @WorkerThread
    @NonNull
    public ContactService.ProfilePictureUploadData uploadUserProfilePictureOrGetPreviousUploadData() {
        ProfilePicture profilePicture = getUserProfilePicture();
        if (profilePicture == null) {
            // If there is no profile picture set, then return empty upload data with an empty byte
            // array as blob ID.
            ContactService.ProfilePictureUploadData data = new ContactService.ProfilePictureUploadData();
            data.blobId = ContactModel.NO_PROFILE_PICTURE_BLOB_ID;
            return data;
        }

        // Only upload blob every 7 days
        var uploadedAtInstant = preferenceService.getProfilePicUploadTimestamp();
        long uploadedAt = uploadedAtInstant != null ? uploadedAtInstant.toEpochMilli() : 0;
        Instant uploadDeadline = Instant.ofEpochMilli(uploadedAt + ContactUtil.PROFILE_PICTURE_BLOB_CACHE_DURATION);
        Instant now = Instant.now();

        if (now.isAfter(uploadDeadline)) {
            logger.info("Uploading profile picture blob");

            @Nullable ContactService.ProfilePictureUploadData data = uploadContactPhoto(profilePicture);

            if (data == null) {
                return new ContactService.ProfilePictureUploadData();
            }

            data.uploadedAt = now.toEpochMilli();

            preferenceService.setProfilePicUploadData(data);
            return data;
        } else {
            ContactService.ProfilePictureUploadData data = preferenceService.getProfilePicUploadData();
            if (data != null) {
                data.uploadedAt = uploadedAt;
                data.profilePicture = profilePicture;
                return data;
            } else {
                return new ContactService.ProfilePictureUploadData();
            }
        }
    }

    @Nullable
    private ContactService.ProfilePictureUploadData uploadContactPhoto(@NonNull ProfilePicture contactPhoto) {
        ContactService.ProfilePictureUploadData data = new ContactService.ProfilePictureUploadData();
        data.encryptionKey = generateRandomBytes(secureRandom(), NaCl.SYMM_KEY_BYTES);
        data.profilePicture = contactPhoto;
        final byte[] imageData;
        try {
            imageData = NaCl.symmetricEncryptData(
                data.profilePicture.getBytes(),
                data.encryptionKey,
                ProtocolDefines.CONTACT_PHOTO_NONCE
            );
        } catch (CryptoException cryptoException) {
            logger.error("Failed to encrypt data", cryptoException);
            return null;
        }
        try {
            BlobUploader blobUploader = this.apiService.createUploader(
                imageData,
                true,
                BlobScope.Public.INSTANCE
            );
            data.blobId = blobUploader.upload();
        } catch (ThreemaException | IOException e) {
            logger.error("Could not upload contact photo", e);
            if (isHttpAuthError(e) && ConfigUtils.isOnPremBuild()) {
                apiService.invalidateAuthToken();
            }

            return null;
        }
        data.size = imageData.length;
        return data;
    }

    private void onUserProfilePictureChanged(@NonNull TriggerSource triggerSource) {
        // Reset the last profile picture upload date
        this.preferenceService.setProfilePicUploadData(null);

        // Notify event bus
        globalEventBuses.getProfiles().emit(new ProfileEvent.ProfilePictureUpdated(triggerSource));
    }

    private String getLanguage() {
        return LocaleUtil.getLanguage();
    }

    @Override
    public boolean restoreIdentity(final String backupString, final String password) throws Exception {
        IdentityBackup.PlainBackupData decryptedBackup = IdentityBackup.decryptIdentityBackup(
            password,
            new IdentityBackup.EncryptedIdentityBackup(backupString)
        );
        return restoreIdentity(decryptedBackup.getThreemaId(), decryptedBackup.getClientKey());
    }

    @Override
    public boolean restoreIdentity(@NonNull String identity, @NonNull byte[] privateKey) throws Exception {
        // TODO(ANDR-4067): There should be no need to create a dummy identity store here
        //fetching identity group
        var temporaryIdentityStore =  new ReadonlyInMemoryIdentityStore(identity, "", privateKey);
        APIConnector.FetchIdentityPrivateResult result = this.apiConnector.fetchIdentityPrivate(temporaryIdentityStore);
        if (result == null) {
            throw new ThreemaException("fetching private identity data failed");
        }

        this.removeAccount();

        //store to the REAL identity store!
        this.identityStore.storeIdentity(
            identity,
            result.serverGroup,
            privateKey
        );

        if (result.email != null && !result.email.isEmpty()) {
            this.preferenceStore.save(PreferenceStore.PREFS_LINKED_EMAIL, result.email);
        }
        if (result.mobileNo != null && !result.mobileNo.isEmpty()) {
            this.preferenceStore.save(PreferenceStore.PREFS_LINKED_MOBILE, result.mobileNo);
        }

        // identity has been successfully restored. set push token
        PushUtil.enqueuePushTokenUpdate(context, false, false);

        return true;
    }

    @Override
    public void setPolicyResponse(String responseData, String signature, int policyErrorCode) {
        this.policyResponseData = responseData;
        this.policySignature = signature;
        this.policyErrorCode = policyErrorCode;
    }


    @Override
    public void setCredentials(LicenseCredentials credentials) {
        this.credentials = credentials;
    }

    @Override
    @WorkerThread
    public boolean sendFeatureMask() {
        boolean success = false;
        try {
            long featureMask = getMyFeatureMask();
            if (!shouldUpdateFeatureMask(featureMask)) {
                logger.info("No feature mask update necessary ({})", featureMask);
                return true;
            }

            logger.info("Sending feature mask {}", featureMask);
            this.apiConnector.setFeatureMask(featureMask, this.identityStore);
            this.preferenceService.setTransmittedFeatureMask(featureMask);
            this.preferenceService.setLastFeatureMaskTransmission(Instant.now());
            logger.info("Successfully sent feature mask");
            success = true;
        } catch (Exception e) {
            logger.error("Could not send feature mask", e);
        }

        return success;
    }

    private long getMyFeatureMask() {
        ThreemaFeature.Builder builder = (new ThreemaFeature.Builder())
            .audio(true)
            .group(true)
            .poll(true)
            .file(true)
            .voip(true)
            .videocalls(true)
            .forwardSecurity(isFsEnabled)
            .groupCalls(true)
            .editMessages(true)
            .deleteMessages(true)
            .emojiReactions(true);

        return builder.build();
    }

    private boolean shouldUpdateFeatureMask(long actualFeatureMask) {
        long transmittedFeatureMask = preferenceService.getTransmittedFeatureMask();
        if (transmittedFeatureMask != actualFeatureMask) {
            logger.info("Feature mask update necessary: {} -> {}", transmittedFeatureMask, actualFeatureMask);
            return true;
        }

        Instant lastFeatureMaskTransmission = preferenceService.getLastFeatureMaskTransmission();
        if (lastFeatureMaskTransmission == null) {
            return true;
        }
        return lastFeatureMaskTransmission.isBefore(Instant.now().minus(1, ChronoUnit.DAYS));
    }

    @Override
    public void setForwardSecurityEnabled(boolean isFsEnabled) {
        this.isFsEnabled = isFsEnabled;
    }

    @Override
    public boolean setRevocationKey(@NonNull String revocationKey) {
        APIConnector.SetRevocationKeyResult result;
        try {
            result = this.apiConnector.setRevocationKey(this.identityStore, revocationKey);
            if (!result.success) {
                logger.error("set revocation key failed: {}", result.error);
                return false;
            } else {
                //update
                this.checkRevocationKey(true);
            }

            return true;
        } catch (Exception e) {
            logger.error("Could not set revocation key", e);
        }
        return false;
    }

    @Override
    @Nullable
    public Instant getLastRevocationKeySet() {
        return this.preferenceStore.getInstant(PreferenceStore.PREFS_LAST_REVOCATION_KEY_SET);
    }

    @Override
    public void checkRevocationKey(boolean force) {
        logger.debug("checkRevocationKey (force={})", force);
        Instant lastSet = null;
        try {
            //check if force = true or PREFS_REVOCATION_KEY_CHECKED is false or not set
            boolean check = force
                || !this.preferenceStore.getBoolean(PreferenceStore.PREFS_REVOCATION_KEY_CHECKED);


            logger.debug("checkRevocationKey (check={})", check);
            if (check) {
                APIConnector.CheckRevocationKeyResult result = this.apiConnector.checkRevocationKey(this.identityStore);
                if (result != null) {
                    if (result.isSet) {
                        lastSet = result.lastChanged;
                    }

                    logger.debug("checkRevocationKey (result={})", result.isSet);
                    //update new state
                    this.preferenceStore.save(PreferenceStore.PREFS_LAST_REVOCATION_KEY_SET, lastSet);

                    //update checked state
                    this.preferenceStore.save(PreferenceStore.PREFS_REVOCATION_KEY_CHECKED, true);
                } else {
                    logger.debug("checkRevocationKey (result is null)");
                }
            }
        } catch (Exception e) {
            logger.error("Exception", e);
        }
    }

    @Override
    @NonNull
    public JSONObject createIdentityRequestDataJSON() throws JSONException {
        JSONObject baseObject = new JSONObject();

        BuildFlavor.LicenseType licenseType = BuildFlavor.getCurrent().getLicenseType();
        String deviceId = deviceIdProvider.getDeviceId();

        baseObject.put("deviceId", deviceId);

        if (licenseType == BuildFlavor.LicenseType.GOOGLE) {
            baseObject.put("lvlResponseData", policyResponseData);
            baseObject.put("lvlSignature", policySignature);
        } else if (licenseType == BuildFlavor.LicenseType.HMS) {
            baseObject.put("hmsResponseData", policyResponseData);
            baseObject.put("hmsSignature", policySignature);
        } else {
            String licenseKey = null;
            String licenseUsername = null;
            String licensePassword = null;

            if (this.credentials != null) {
                if (this.credentials instanceof SerialCredentials) {
                    licenseKey = ((SerialCredentials) this.credentials).licenseKey;
                } else if (this.credentials instanceof UserCredentials) {
                    licenseUsername = ((UserCredentials) this.credentials).username;
                    licensePassword = ((UserCredentials) this.credentials).password;
                }
            }
            if (licenseKey != null) {
                baseObject.put("licenseKey", licenseKey);
            }

            if (licenseUsername != null) {
                baseObject.put("licenseUsername", licenseUsername);
            }

            if (licensePassword != null) {
                baseObject.put("licensePassword", licensePassword);
            }
        }

        return baseObject;
    }

    private void reflectUserProfileIdentityLinksIfApplicable(@NonNull TriggerSource triggerSource) {
        if (triggerSource != TriggerSource.SYNC && multiDeviceManager.isMultiDeviceActive()) {
            taskCreator.scheduleReflectUserProfileIdentityLinksTask();
        }
    }
}
