package ch.threema.app.routines;

import android.Manifest;
import android.content.ContentProviderOperation;
import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;
import android.provider.ContactsContract;

import androidx.annotation.NonNull;
import androidx.annotation.OptIn;
import androidx.annotation.RequiresPermission;

import com.google.common.collect.ListMultimap;

import org.slf4j.Logger;

import java.math.BigInteger;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;

import ch.threema.app.apptaskexecutor.AppTaskExecutor;
import ch.threema.app.preference.service.SynchronizedSettingsService;
import ch.threema.app.androidcontactsync.usecases.UpdateContactNameUseCase;
import ch.threema.data.datatypes.AndroidContactLookupInfo;
import ch.threema.app.asynctasks.AddContactBackgroundTask;
import ch.threema.app.services.BlockedIdentitiesService;
import ch.threema.app.services.ContactService;
import ch.threema.app.services.DeviceService;
import ch.threema.app.services.ExcludedSyncIdentitiesService;
import ch.threema.app.services.LocaleService;
import ch.threema.app.preference.service.PreferenceService;
import ch.threema.app.services.UserService;
import ch.threema.app.stores.MatchTokenStore;
import ch.threema.app.utils.AndroidContactUtil;
import ch.threema.app.utils.ConfigUtils;
import ch.threema.app.utils.ContactUtil;
import ch.threema.base.ThreemaException;

import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
import static ch.threema.common.JavaCompat.isNullOrEmpty;

import ch.threema.data.datatypes.AvailabilityStatus;
import ch.threema.data.datatypes.ConversationVisibility;
import ch.threema.data.models.ContactModel;
import ch.threema.data.models.ContactModelData;
import ch.threema.data.repositories.ContactModelRepository;
import ch.threema.domain.models.AcquaintanceLevel;
import ch.threema.domain.models.ContactSyncState;
import ch.threema.domain.models.IdentityState;
import ch.threema.domain.models.IdentityType;
import ch.threema.domain.models.ReadReceiptPolicy;
import ch.threema.domain.models.TypingIndicatorPolicy;
import ch.threema.domain.models.VerificationLevel;
import ch.threema.domain.models.WorkVerificationLevel;
import ch.threema.domain.protocol.api.APIConnector;
import ch.threema.domain.stores.IdentityStore;
import ch.threema.data.datatypes.IdColor;
import kotlin.Unit;
import kotlinx.coroutines.DelicateCoroutinesApi;

public class SynchronizeContactsRoutine implements Runnable {
    private static final Logger logger = getThreemaLogger("SynchronizeContactsRoutine");

    private final UserService userService;
    private final Context context;
    private final APIConnector apiConnector;
    private final ContactService contactService;
    private final ContactModelRepository contactModelRepository;
    private final LocaleService localeService;
    private final ContentResolver contentResolver;
    @NonNull
    private final ExcludedSyncIdentitiesService excludedSyncIdentitiesService;
    private final DeviceService deviceService;
    private final PreferenceService preferenceService;
    private final SynchronizedSettingsService synchronizedSettingsService;
    private final IdentityStore identityStore;
    private final BlockedIdentitiesService blockedIdentitiesService;
    @NonNull
    private final AppTaskExecutor appTaskExecutor;
    @NonNull
    private final UpdateContactNameUseCase updateContactNameUseCase;

    private OnStatusUpdate onStatusUpdate;
    private final List<OnFinished> onFinished = new ArrayList<>();

    @NonNull
    private final Set<String> processingIdentities;
    private boolean abort = false;
    private boolean running = false;

    public interface OnStatusUpdate {
        void error(final Exception x);
    }

    public interface OnFinished {
        void finished(boolean success, long modifiedAccounts, List<ContactModel> createdContacts, long deletedAccounts);
    }

    public SynchronizeContactsRoutine(
        Context context,
        APIConnector apiConnector,
        ContactService contactService,
        @NonNull ContactModelRepository contactModelRepository,
        UserService userService,
        LocaleService localeService,
        ContentResolver contentResolver,
        @NonNull ExcludedSyncIdentitiesService excludedSyncIdentitiesService,
        DeviceService deviceService,
        PreferenceService preferenceService,
        @NonNull SynchronizedSettingsService synchronizedSettingsService,
        IdentityStore identityStore,
        BlockedIdentitiesService blockedIdentitiesService,
        @NonNull AppTaskExecutor appTaskExecutor,
        @NonNull UpdateContactNameUseCase updateContactNameUseCase,
        @NonNull Set<String> processingIdentities
    ) {
        this.context = context;
        this.apiConnector = apiConnector;
        this.userService = userService;
        this.contactService = contactService;
        this.contactModelRepository = contactModelRepository;
        this.localeService = localeService;
        this.contentResolver = contentResolver;
        this.excludedSyncIdentitiesService = excludedSyncIdentitiesService;
        this.deviceService = deviceService;
        this.preferenceService = preferenceService;
        this.synchronizedSettingsService = synchronizedSettingsService;
        this.identityStore = identityStore;
        this.blockedIdentitiesService = blockedIdentitiesService;
        this.appTaskExecutor = appTaskExecutor;
        this.updateContactNameUseCase = updateContactNameUseCase;
        this.processingIdentities = processingIdentities;
    }

    public void abort() {
        this.abort = true;
    }

    public boolean running() {
        return this.running;
    }

    /**
     * Check whether all contacts are being synchronized. If it is not a full sync, then only some
     * specific identities are updated.
     */
    public boolean isFullSync() {
        return this.processingIdentities.isEmpty();
    }

    @OptIn(markerClass = DelicateCoroutinesApi.class)
    @Override
    @RequiresPermission(allOf = {Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS})
    public void run() {
        logger.info("SynchronizeContacts run started.");

        if (!ConfigUtils.isPermissionGranted(context, Manifest.permission.WRITE_CONTACTS)) {
            logger.info("No contacts permission. Aborting.");
            return;
        }

        this.running = true;

        boolean success = false;

        long deletedCount = 0;
        long modifiedRawContactCount = 0;
        long newRawContactCount = 0;
        // The list of contacts that have been added in this run because they are found by phone or email.
        List<ContactModel> insertedContacts = new ArrayList<>();

        try {
            if (!synchronizedSettingsService.isSyncContacts()) {
                throw new ThreemaException("Sync is disabled in preferences, not allowed to call synchronizecontacts routine");
            }

            if (this.deviceService != null && !this.deviceService.isOnline()) {
                throw new ThreemaException("No connection");
            }

            //read emails
            final Map<String, ContactMatchKeyEmail> emails = this.readEmails();
            final int emailsHash = emails.keySet().hashCode();

            //read phone numbers
            final Map<String, ContactMatchKeyPhone> phoneNumbers = this.readPhoneNumbers();
            final int phoneNumbersHash = phoneNumbers.keySet().hashCode();

            Instant timeOfLastContactSync = preferenceService.getTimeOfLastContactSync();
            if (preferenceService.getEmailSyncHashCode() == emailsHash
                && preferenceService.getPhoneNumberSyncHashCode() == phoneNumbersHash
                && (timeOfLastContactSync != null && timeOfLastContactSync.isAfter(Instant.now().minus(23, ChronoUnit.DAYS)))) {
                logger.info("System contacts are unchanged or grace time not yet reached. Not syncing.");
                success = true;
                return;
            }

            preferenceService.setEmailSyncHashCode(emailsHash);
            preferenceService.setPhoneNumberSyncHashCode(phoneNumbersHash);
            preferenceService.setTimeOfLastContactSync(Instant.now());

            logger.info("Attempting to sync contacts: {} emails, {} phone numbers", emails.size(), phoneNumbers.size());

            //send hashes to server and get result
            MatchTokenStore matchTokenStore = new MatchTokenStore(this.preferenceService);
            Map<String, APIConnector.MatchIdentityResult> matchIdentityResults = this.apiConnector.matchIdentities(
                emails, phoneNumbers, this.localeService.getCountryIsoCode(), false, identityStore, matchTokenStore);

            // remove own ID
            if (userService != null) {
                matchIdentityResults.remove(userService.getIdentity());
            }

            final List<String> preSynchronizedIdentities = new ArrayList<>();
            if (this.isFullSync()) {
                List<String> synchronizedIdentities = this.contactService.getSynchronizedIdentities();
                if (synchronizedIdentities != null) {
                    preSynchronizedIdentities.addAll(synchronizedIdentities);
                }
            }

            ListMultimap<String, AndroidContactUtil.RawContactInfo> existingRawContacts = AndroidContactUtil.getInstance().getAllThreemaRawContacts();
            if (existingRawContacts == null) {
                throw new ThreemaException("No permission or no account");
            }
            logger.info("Number of existing raw contacts: {}", existingRawContacts.size());

            logger.info("Number of IDs matching phone or email: {}", matchIdentityResults.size());

            ArrayList<ContentProviderOperation> contentProviderOperations = new ArrayList<>();
            for (Map.Entry<String, APIConnector.MatchIdentityResult> matchIdentityResultEntry : matchIdentityResults.entrySet()) {
                String identity = matchIdentityResultEntry.getKey();
                APIConnector.MatchIdentityResult result = matchIdentityResultEntry.getValue();

                if (this.abort) {
                    //abort!
                    for (OnFinished f : this.onFinished) {
                        f.finished(false, modifiedRawContactCount, insertedContacts, deletedCount);
                    }
                    return;
                }

                // Do not sync contacts on exclude list
                if (this.excludedSyncIdentitiesService.isExcluded(identity)) {
                    logger.info("Identity {} is on exclude list", identity);
                    continue;
                }

                // If it is not a full sync and the current identity is not one of the identities to
                // be updated in this run, we skip processing this identity.
                if (!isFullSync() && !this.processingIdentities.contains(identity)) {
                    continue;
                }

                if (this.isFullSync()) {
                    // remove if list contains this key
                    preSynchronizedIdentities.remove(identity);
                }

                final ContactMatchKeyEmail matchKeyEmail = (ContactMatchKeyEmail) result.refObjectEmail;
                final ContactMatchKeyPhone matchKeyPhone = (ContactMatchKeyPhone) result.refObjectMobileNo;

                long contactId;
                String lookupKey;
                if (matchKeyEmail != null) {
                    contactId = matchKeyEmail.contactId;
                    lookupKey = matchKeyEmail.lookupKey;
                } else {
                    contactId = matchKeyPhone.contactId;
                    lookupKey = matchKeyPhone.lookupKey;
                }

                //try to get the contact
                ContactModel contact = contactModelRepository.getByIdentity(identity);
                ContactModelData data = contact != null ? contact.getData() : null;
                //contact does not exist, create a new one
                if (contact == null || data == null) {
                    ContactModelData contactModelData = ContactModelData.javaCreate(
                        identity,
                        result.publicKey,
                        Instant.now(),
                        null,
                        "",
                        "",
                        null,
                        IdColor.ofIdentity(identity),
                        VerificationLevel.SERVER_VERIFIED,
                        WorkVerificationLevel.NONE,
                        IdentityType.REGULAR,   // TODO(ANDR-3044): Fetch identity type
                        AcquaintanceLevel.DIRECT,
                        IdentityState.ACTIVE,  // TODO(ANDR-3044): Fetch identity state
                        BigInteger.valueOf(0), // TODO(ANDR-3044): Fetch feature mask
                        ContactSyncState.IMPORTED,
                        ReadReceiptPolicy.DEFAULT,
                        TypingIndicatorPolicy.DEFAULT,
                        ConversationVisibility.NORMAL,
                        null,
                        null,
                        false,
                        null,
                        null,
                        null,
                        null,
                        AvailabilityStatus.None.INSTANCE,
                        null
                    );
                    contact = new AddContactBackgroundTask(contactModelData, contactModelRepository)
                        .runSynchronously();

                    if (contact == null) {
                        logger.error("Could not create contact with identity {}", identity);
                        continue;
                    }

                    data = contact.getData();
                    if (data == null) {
                        logger.error("Contact data is null");
                        continue;
                    }

                    insertedContacts.add(contact);

                    logger.info("Inserting new Threema contact {}", identity);
                }
                contact.setAndroidContactLookupKey(
                    new AndroidContactLookupInfo(lookupKey, contactId)
                );

                boolean createNewRawContact;

                CountDownLatch latch = new CountDownLatch(1);
                final ContactModel finalContactModel = contact;
                appTaskExecutor.runInAppTask((continuation -> {
                    // Note that continuation can only be used once to call a suspend function!
                    updateContactNameUseCase.call(Set.of(finalContactModel), continuation);
                    return Unit.INSTANCE;
                })).invokeOnCompletion(throwable -> {
                    latch.countDown();
                    return Unit.INSTANCE;
                });
                latch.await();

                AndroidContactUtil.getInstance().updateAvatarByAndroidContact(contact, context);

                contact.setAcquaintanceLevelFromLocal(AcquaintanceLevel.DIRECT);
                if (data.verificationLevel == VerificationLevel.UNVERIFIED) {
                    contact.setVerificationLevelFromLocal(VerificationLevel.SERVER_VERIFIED);
                }

                List<AndroidContactUtil.RawContactInfo> rawContactInfos = existingRawContacts.get(contact.getIdentity());

                if (rawContactInfos.isEmpty()) {
                    // raw contact does not exist yet, create it
                    createNewRawContact = true;
                    newRawContactCount++;
                } else {
                    // a raw contact exists - check if it points to the correct parent
                    createNewRawContact = true;
                    for (AndroidContactUtil.RawContactInfo rawContactInfo : rawContactInfos) {
                        if (rawContactInfo.contactId > 0L && rawContactInfo.contactId == contactId) {
                            // all good - no change necessary
                            createNewRawContact = false;
                            existingRawContacts.remove(contact.getIdentity(), rawContactInfo);
                            break;
                        }
                    }
                    if (createNewRawContact) {
                        modifiedRawContactCount++;
                    }
                }

                if (createNewRawContact) {
                    boolean supportsVoiceCalls = ContactUtil.canReceiveVoipMessages(contact.getIdentity(), this.blockedIdentitiesService)
                        && ConfigUtils.isCallsEnabled();

                    // create a raw contact for our stuff and aggregate it
                    AndroidContactUtil.getInstance().createThreemaRawContact(
                        contentProviderOperations,
                        matchKeyEmail != null ?
                            matchKeyEmail.rawContactId :
                            matchKeyPhone.rawContactId,
                        contact.getIdentity(),
                        supportsVoiceCalls);
                }
            }

            if (!contentProviderOperations.isEmpty()) {
                try {
                    ConfigUtils.applyToContentResolverInBatches(
                        ContactsContract.AUTHORITY,
                        contentProviderOperations);
                } catch (Exception e) {
                    logger.error("Error during raw contact creation! ", e);
                }
                contentProviderOperations.clear();
            }

            if (this.isFullSync()) {
                // delete remaining / stray raw contacts
                if (!existingRawContacts.isEmpty()) {
                    AndroidContactUtil.getInstance().deleteThreemaRawContacts(existingRawContacts);
                    logger.info("Deleted {} stray raw contacts", existingRawContacts.size());
                }

                if (!preSynchronizedIdentities.isEmpty()) {
                    logger.info("Found {} synchronized contacts that are no longer synchronized", preSynchronizedIdentities.size());

                    for (String identity : preSynchronizedIdentities) {
                        ContactModel contactModel = contactModelRepository.getByIdentity(identity);
                        if (contactModel != null) {
                            contactModel.removeAndroidContactLink();
                        }
                    }
                }
            }
            success = true;
        } catch (final Exception x) {
            logger.error("Exception", x);
            if (this.onStatusUpdate != null) {
                this.onStatusUpdate.error(x);
            }
        } finally {
            logger.info("Finished [success = {}, inserted = {}, deleted = {}, modifiedRawContacts = {}, newRawContacts = {}] fullSync = {}", success, insertedContacts.size(), deletedCount, modifiedRawContactCount, newRawContactCount, this.isFullSync());
            for (OnFinished f : this.onFinished) {
                f.finished(success, modifiedRawContactCount, insertedContacts, deletedCount);
            }
            this.running = false;
        }
    }

    public SynchronizeContactsRoutine setOnStatusUpdate(OnStatusUpdate onStatusUpdate) {
        this.onStatusUpdate = onStatusUpdate;
        return this;
    }

    public SynchronizeContactsRoutine addOnFinished(OnFinished onFinished) {
        this.onFinished.add(onFinished);
        return this;
    }

    private Map<String, ContactMatchKeyPhone> readPhoneNumbers() {
        Map<String, ContactMatchKeyPhone> phoneNumbers = new HashMap<>();
        String selection;

        selection = ContactsContract.Contacts.HAS_PHONE_NUMBER + " > 0 AND " + ContactsContract.CommonDataKinds.Phone.IN_DEFAULT_DIRECTORY + " = 1";

        try (Cursor phonesCursor = this.contentResolver.query(
            ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
            new String[]{
                ContactsContract.CommonDataKinds.Phone.RAW_CONTACT_ID,
                ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
                ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY,
                ContactsContract.CommonDataKinds.Phone.NUMBER,
            },
            selection,
            null,
            null)) {

            if (phonesCursor != null && phonesCursor.getCount() > 0) {
                final int rawContactIdIndex = phonesCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.RAW_CONTACT_ID);
                final int idColumnIndex = phonesCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID);
                final int lookupKeyColumnIndex = phonesCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY);
                final int phoneNumberIndex = phonesCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER);

                while (phonesCursor.moveToNext()) {
                    long rawContactId = phonesCursor.getLong(rawContactIdIndex);
                    long contactId = phonesCursor.getLong(idColumnIndex);
                    String lookupKey = phonesCursor.getString(lookupKeyColumnIndex);
                    String phoneNumber = phonesCursor.getString(phoneNumberIndex);

                    if (rawContactId > 0L && contactId > 0L && lookupKey != null && !isNullOrEmpty(phoneNumber)) {
                        ContactMatchKeyPhone matchKey = new ContactMatchKeyPhone();
                        matchKey.contactId = contactId;
                        matchKey.lookupKey = lookupKey;
                        matchKey.rawContactId = rawContactId;
                        matchKey.phoneNumber = phoneNumber;
                        phoneNumbers.put(phoneNumber, matchKey);
                    }
                }
            }
        }

        return phoneNumbers;
    }

    private Map<String, ContactMatchKeyEmail> readEmails() {
        Map<String, ContactMatchKeyEmail> emails = new HashMap<>();

        String selection = ContactsContract.CommonDataKinds.Email.IN_DEFAULT_DIRECTORY + " = 1";

        try (Cursor emailsCursor = this.contentResolver.query(
            ContactsContract.CommonDataKinds.Email.CONTENT_URI,
            new String[]{
                ContactsContract.CommonDataKinds.Email.RAW_CONTACT_ID,
                ContactsContract.CommonDataKinds.Email.CONTACT_ID,
                ContactsContract.CommonDataKinds.Email.LOOKUP_KEY,
                ContactsContract.CommonDataKinds.Email.DATA
            },
            selection,
            null,
            null)) {

            if (emailsCursor != null && emailsCursor.getCount() > 0) {
                final int rawContactIdIndex = emailsCursor.getColumnIndex(ContactsContract.CommonDataKinds.Email.RAW_CONTACT_ID);
                final int idColumnIndex = emailsCursor.getColumnIndex(ContactsContract.CommonDataKinds.Email.CONTACT_ID);
                final int lookupKeyColumnIndex = emailsCursor.getColumnIndex(ContactsContract.CommonDataKinds.Email.LOOKUP_KEY);
                final int emailIndex = emailsCursor.getColumnIndex(ContactsContract.CommonDataKinds.Email.DATA);

                while (emailsCursor.moveToNext()) {
                    long rawContactId = emailsCursor.getLong(rawContactIdIndex);
                    long contactId = emailsCursor.getLong(idColumnIndex);
                    String lookupKey = emailsCursor.getString(lookupKeyColumnIndex);
                    String email = emailsCursor.getString(emailIndex);

                    if (rawContactId > 0L && contactId > 0L && lookupKey != null && !isNullOrEmpty(email)) {
                        ContactMatchKeyEmail matchKey = new ContactMatchKeyEmail();
                        matchKey.contactId = contactId;
                        matchKey.lookupKey = lookupKey;
                        matchKey.rawContactId = rawContactId;
                        matchKey.email = email;
                        emails.put(email, matchKey);
                    }
                }
            }
        }

        return emails;
    }

    private static class ContactMatchKey {
        long contactId;
        String lookupKey;
        long rawContactId;
    }

    private static class ContactMatchKeyEmail extends ContactMatchKey {
        String email;
    }

    private static class ContactMatchKeyPhone extends ContactMatchKey {
        String phoneNumber;
    }
}
