package ch.threema.app.utils;

import android.Manifest;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.accounts.AuthenticatorDescription;
import android.app.Activity;
import android.content.ContentProviderOperation;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.provider.ContactsContract;
import android.widget.Toast;

import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ListMultimap;

import org.koin.java.KoinJavaComponent;
import org.slf4j.Logger;

import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresPermission;
import androidx.annotation.WorkerThread;
import androidx.core.content.ContextCompat;
import ch.threema.app.R;
import ch.threema.app.ThreemaApplication;
import ch.threema.app.services.FileService;
import ch.threema.app.services.UserService;
import ch.threema.data.models.ContactModel;
import ch.threema.data.models.ContactModelData;
import ch.threema.data.datatypes.AndroidContactLookupInfo;

import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
import static ch.threema.common.JavaCompat.isNullOrEmpty;
import static ch.threema.storage.models.ContactModel.DEFAULT_ANDROID_CONTACT_AVATAR_EXPIRY;

public class AndroidContactUtil {
    private static final Logger logger = getThreemaLogger("AndroidContactUtil");

    private static AndroidContactUtil sInstance = null;

    public static synchronized AndroidContactUtil getInstance() {
        if (sInstance == null) {
            sInstance = new AndroidContactUtil();
        }
        return sInstance;
    }

    private AndroidContactUtil() {
    }

    private static final String[] RAW_CONTACT_PROJECTION = new String[]{
        ContactsContract.RawContacts._ID,
        ContactsContract.RawContacts.CONTACT_ID,
        ContactsContract.RawContacts.SYNC1,
    };

    private final ContentResolver contentResolver = ThreemaApplication.getAppContext().getContentResolver();

    private @Nullable Account getAccount() {
        UserService userService = KoinJavaComponent.getOrNull(UserService.class);
        if (userService == null) {
            logger.info("UserService not available");
            return null;
        }
        return userService.getAccount();
    }

    public static class RawContactInfo {
        public final long contactId;
        public final long rawContactId;

        RawContactInfo(long contactId, long rawContactId) {
            this.contactId = contactId;
            this.rawContactId = rawContactId;
        }
    }

    /**
     * Return a valid uri to the given contact that can be used to build an intent for the contact app
     * It is safe to call this method if permission to access contacts is not granted - null will be returned in that case
     *
     * @param contactModel ContactModel for which to get the Android contact URI
     * @return a valid uri pointing to the android contact or null if permission was not granted, no android contact is linked or android contact could not be looked up
     */
    @Nullable
    public Uri getAndroidContactUri(@NonNull ContactModel contactModel, @NonNull Context context) {
        if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
            return null;
        }

        ContactModelData data = contactModel.getData();

        if (data != null) {
            final AndroidContactLookupInfo androidContactLookupInfo = data.androidContactLookupInfo;
            if (androidContactLookupInfo != null) {
                return androidContactLookupInfo.getContactUri();
            }
        }
        return null;
    }

    /**
     * Update the avatar for the specified contact from Android's contact database, if any.
     * If there's no avatar for this Android contact, any current avatar file will be deleted.
     * <p>
     * It is safe to call this method even if permission to read contacts is not given
     */
    @RequiresPermission(Manifest.permission.READ_CONTACTS)
    public void updateAvatarByAndroidContact(@NonNull ContactModel contactModel, @NonNull Context context) {
        FileService fileService = KoinJavaComponent.get(FileService.class);

        ContactModelData data = contactModel.getData();
        if (data == null) {
            logger.warn("Contact model data is null");
            return;
        }

        final AndroidContactLookupInfo androidContactLookupInfo = data.androidContactLookupInfo;
        if (androidContactLookupInfo == null) {
            logger.warn("Cannot update avatar of contact {} because there is no lookup key", contactModel.getIdentity());
            return;
        }

        // contactUri will be null if permission is not granted
        Uri contactUri = getAndroidContactUri(contactModel, context);
        if (contactUri != null) {
            Bitmap bitmap = AvatarConverterUtil.convert(ThreemaApplication.getAppContext(), contactUri);

            if (bitmap != null) {
                try {
                    fileService.writeAndroidDefinedProfilePicture(contactModel.getIdentity(), BitmapUtil.bitmapToByteArray(bitmap, Bitmap.CompressFormat.PNG, 100));
                    contactModel.setLocalAvatarExpires(Instant.ofEpochMilli(System.currentTimeMillis() + DEFAULT_ANDROID_CONTACT_AVATAR_EXPIRY));
                    return;
                } catch (Exception e) {
                    logger.error("Exception while writing android defined profile picture", e);
                }
            } else {
                // delete old avatar
                if (!fileService.hasAndroidDefinedProfilePicture(contactModel.getIdentity())) {
                    return;
                }
                logger.info("Removing android defined profile picture for identity {}", contactModel.getIdentity());
                boolean success = fileService.removeAndroidDefinedProfilePicture(contactModel.getIdentity());
                if (success) {
                    contactModel.setLocalAvatarExpires(Instant.ofEpochMilli(System.currentTimeMillis() + DEFAULT_ANDROID_CONTACT_AVATAR_EXPIRY));
                    return;
                }
            }
        }

        // Note that directly logging the lookup key and the contact uri is ok for debug level.
        logger.debug("Unable to get avatar for {} lookupKey = {} contactUri = {}", contactModel.getIdentity(), androidContactLookupInfo, contactUri);
    }

    /**
     * Add ContentProviderOperations to create a raw contact for the given identity to a provided List of ContentProviderOperations.
     * Put the identity into the SYNC1 column and set data records for messaging and calling
     *
     * @param contentProviderOperations List of ContentProviderOperations to add this operation to
     * @param systemRawContactId        The raw contact that matched the criteria for aggregation (i.e. email or phone number)
     * @param identity                  the identity of the contact to create a raw contact for
     * @param supportsVoiceCalls        Whether the user has voice calls enabled
     */
    @RequiresPermission(allOf = {Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS})
    public void createThreemaRawContact(@NonNull List<ContentProviderOperation> contentProviderOperations, long systemRawContactId, @NonNull String identity, boolean supportsVoiceCalls) {
        Context context = ThreemaApplication.getAppContext();
        Account account = this.getAccount();
        if (account == null) {
            logger.error("Cannot create threema raw contact as the account is null");
            return;
        }

        if (systemRawContactId == 0L) {
            return;
        }

        int backReference = contentProviderOperations.size();
        logger.debug("Adding contact: {}", identity);

        /* Create our RawContact */
        ContentProviderOperation.Builder builder = ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI);
        builder.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, account.name);
        builder.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type);
        builder.withValue(ContactsContract.RawContacts.SYNC1, identity);
        contentProviderOperations.add(builder.build());

        Uri insertUri = ContactsContract.Data.CONTENT_URI.buildUpon().appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build();

        /* Some contact apps may require a name */
        /*
        builder = ContentProviderOperation.newInsert(insertUri);
        builder.withValueBackReference(ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID, backReference);
        builder.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE);
        builder.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, identity);
        contentProviderOperations.add(builder.build());
        */

        /* Create a Data record of custom type */
        builder = ContentProviderOperation.newInsert(insertUri);
        builder.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, backReference);
        builder.withValue(ContactsContract.Data.MIMETYPE, context.getString(R.string.contacts_mime_type));
        builder.withValue(ContactsContract.Data.DATA1, identity);
        builder.withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name));
        builder.withValue(ContactsContract.Data.DATA3, context.getString(R.string.threema_message_to, identity));
        builder.withYieldAllowed(true);
        contentProviderOperations.add(builder.build());

        if (supportsVoiceCalls) {
            /* Create a Data record of custom type for call */
            builder = ContentProviderOperation.newInsert(insertUri);
            builder.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, backReference);
            builder.withValue(ContactsContract.Data.MIMETYPE, context.getString(R.string.call_mime_type));
            builder.withValue(ContactsContract.Data.DATA1, identity);
            builder.withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name));
            builder.withValue(ContactsContract.Data.DATA3, context.getString(R.string.threema_call_with, identity));
            builder.withYieldAllowed(true);
            contentProviderOperations.add(builder.build());
        }

        builder = ContentProviderOperation.newUpdate(ContactsContract.AggregationExceptions.CONTENT_URI);
        builder.withValue(ContactsContract.AggregationExceptions.RAW_CONTACT_ID1, systemRawContactId);
        builder.withValueBackReference(ContactsContract.AggregationExceptions.RAW_CONTACT_ID2, backReference);
        builder.withValue(ContactsContract.AggregationExceptions.TYPE, ContactsContract.AggregationExceptions.TYPE_KEEP_TOGETHER);
        contentProviderOperations.add(builder.build());

        logger.info("Create a raw contact for ID {} and aggregate it with system raw contact {}", identity, systemRawContactId);
    }

    /**
     * Delete all raw contacts where the given identity matches the entry in the contact's SYNC1 column
     * It's safe to call this method without contacts permission
     *
     * @param identity the identity of the contact whose raw contact we want to be deleted
     * @return number of raw contacts deleted
     */
    public int deleteThreemaRawContact(@NonNull String identity) {
        if (!ConfigUtils.isPermissionGranted(ThreemaApplication.getAppContext(), Manifest.permission.WRITE_CONTACTS)) {
            return 0;
        }

        Account account = this.getAccount();
        if (account == null) {
            return 0;
        }

        Uri rawContactUri = ContactsContract.RawContacts.CONTENT_URI
            .buildUpon()
            .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
            .appendQueryParameter(ContactsContract.RawContacts.SYNC1, identity)
            .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
            .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type).build();

        try {
            return contentResolver.delete(rawContactUri, null, null);
        } catch (Exception e) {
            logger.error("Exception", e);
        }
        return 0;
    }

    /**
     * Delete all raw contacts specified in rawContacts Map
     *
     * @param rawContacts Map of the rawContacts to delete. The key of the map entry contains the identity
     * @return Number of raw contacts that were supposed to be deleted. Does not necessarily represent the real number of deleted raw contacts.
     */
    public int deleteThreemaRawContacts(@NonNull ListMultimap<String, RawContactInfo> rawContacts) {
        if (!ConfigUtils.isPermissionGranted(ThreemaApplication.getAppContext(), Manifest.permission.WRITE_CONTACTS)) {
            return 0;
        }

        Account account = this.getAccount();
        if (account == null) {
            return 0;
        }

        if (rawContacts.isEmpty()) {
            return 0;
        }

        ArrayList<ContentProviderOperation> contentProviderOperations = new ArrayList<>();

        for (Map.Entry<String, RawContactInfo> rawContact : rawContacts.entries()) {
            if (!isNullOrEmpty(rawContact.getKey()) && rawContact.getValue().rawContactId != 0L) {
                try {
                    ContentProviderOperation.Builder builder = ContentProviderOperation.newDelete(
                            ContactsContract.RawContacts.CONTENT_URI
                                .buildUpon()
                                .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
                                .appendQueryParameter(ContactsContract.RawContacts.SYNC1, rawContact.getKey())
                                .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
                                .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type).build())
                        .withSelection(ContactsContract.RawContacts._ID + " = ?", new String[]{String.valueOf(rawContact.getValue().rawContactId)});

                    contentProviderOperations.add(builder.build());
                } catch (Exception e) {
                    logger.error("Exception", e);
                }
            }
        }

        int operationCount = contentProviderOperations.size();
        if (operationCount > 0) {
            try {
                ConfigUtils.applyToContentResolverInBatches(ContactsContract.AUTHORITY, contentProviderOperations);
            } catch (Exception e) {
                logger.error("Error during raw contact deletion! ", e);
            }
            contentProviderOperations.clear();
        }

        logger.debug("Deleted {} raw contacts", operationCount);

        return operationCount;
    }

    /**
     * Delete all raw contacts associated with Threema (including stray ones)
     * Safe to be called without permission
     *
     * @return number of raw contacts deleted
     */
    public int deleteAllThreemaRawContacts() {
        if (!ConfigUtils.isPermissionGranted(ThreemaApplication.getAppContext(), Manifest.permission.WRITE_CONTACTS)) {
            return 0;
        }

        Account account = this.getAccount();
        if (account == null) {
            return 0;
        }

        Uri rawContactUri = ContactsContract.RawContacts.CONTENT_URI
            .buildUpon()
            .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
            .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
            .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type).build();

        try {
            return contentResolver.delete(rawContactUri, null, null);
        } catch (Exception e) {
            logger.error("Exception", e);
        }
        return 0;
    }

    /**
     * Get a list of all Threema raw contacts from the contact database. This may include "stray" contacts.
     *
     * @return List containing pairs of identity and android contact id, null if permissions have not been granted
     */
    @Nullable
    public ListMultimap<String, RawContactInfo> getAllThreemaRawContacts() {
        if (!ConfigUtils.isPermissionGranted(ThreemaApplication.getAppContext(), Manifest.permission.WRITE_CONTACTS)) {
            return null;
        }

        Account account = this.getAccount();
        if (account == null) {
            return null;
        }

        Uri rawContactUri = ContactsContract.RawContacts.CONTENT_URI
            .buildUpon()
            .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
            .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type).build();

        ListMultimap<String, RawContactInfo> rawContacts = ArrayListMultimap.create();
        try (Cursor cursor = contentResolver.query(rawContactUri, RAW_CONTACT_PROJECTION, null, null, null)) {
            if (cursor != null) {
                while (cursor.moveToNext()) {
                    long rawContactId = cursor.getLong(0);
                    long contactId = cursor.getLong(1);
                    String identity = cursor.getString(2);
                    rawContacts.put(identity, new RawContactInfo(contactId, rawContactId));
                }
            }
        } catch (Exception e) {
            logger.error("Exception", e);
        }
        return rawContacts;
    }

    /**
     * Get the "main" raw contact representing the Android contact specified by the lookup key
     * We consider the contact referenced as display name source for the Android contact as the "main" contact
     *
     * @param lookupUri The lookup uri of the contact
     * @return ID of the raw contact or 0 if none is found
     */
    @RequiresPermission(Manifest.permission.READ_CONTACTS)
    public long getMainRawContact(@NonNull Uri lookupUri) {
        long rawContactId = 0;

        try (Cursor cursor = ThreemaApplication.getAppContext().getContentResolver().query(
            lookupUri,
            new String[]{
                ContactsContract.Contacts.NAME_RAW_CONTACT_ID
            },
            null,
            null,
            null)) {

            if (cursor != null) {
                if (cursor.moveToFirst()) {
                    rawContactId = cursor.getLong(0);
                }
            }
        }

        return rawContactId;
    }

    @RequiresPermission(allOf = {Manifest.permission.READ_CONTACTS, Manifest.permission.GET_ACCOUNTS})
    @Nullable
    @WorkerThread
    public Drawable getAccountIcon(@Nullable AndroidContactLookupInfo androidContactLookupInfo) {
        final PackageManager pm = ThreemaApplication.getAppContext().getPackageManager();

        Account myAccount = this.getAccount();
        if (myAccount == null) {
            return null;
        }

        if (androidContactLookupInfo == null) {
            return null;
        }

        long nameSourceRawContactId = getMainRawContact(androidContactLookupInfo.getContactUri());
        if (nameSourceRawContactId == 0) {
            return null;
        }

        AccountManager accountManager = AccountManager.get(ThreemaApplication.getAppContext());
        AuthenticatorDescription[] descriptions = accountManager.getAuthenticatorTypes();

        Drawable drawable = null;

        Uri nameSourceRawContactUri = ContentUris.withAppendedId(ContactsContract.RawContacts.CONTENT_URI, nameSourceRawContactId);
        try (Cursor cursor = this.contentResolver.query(
            nameSourceRawContactUri,
            new String[]{
                ContactsContract.RawContacts.ACCOUNT_TYPE
            }, null, null, null)) {
            if (cursor != null) {
                if (cursor.moveToNext()) {
                    String accountType = cursor.getString(0);
                    for (AuthenticatorDescription description : descriptions) {
                        if (description.type.equalsIgnoreCase(accountType)) {
                            drawable = pm.getDrawable(description.packageName, description.iconId, null);
                            break;
                        }
                    }
                }
            }
        }

        //if no icon found, display the icon of the phone or contacts app
        if (drawable == null) {
            for (String substitutePackageName : new String[]{
                "com.android.contacts",
                "com.android.providers.contacts",
                "com.android.phone",
            }) {
                try {
                    drawable = pm.getApplicationIcon(substitutePackageName);
                    break;
                } catch (PackageManager.NameNotFoundException x) {
                    //
                }
            }
        }
        return drawable;
    }

    /**
     * Open the system's contact editor for the provided Threema contact
     *
     * @param context Context
     * @param contactModel Threema contact
     * @return true if the contact is linked with a system contact (even if no app is available for an ACTION_EDIT intent in the system), false otherwise
     */
    public boolean openContactEditor(@NonNull Context context, @NonNull ContactModel contactModel, int requestCode) {
        Uri contactUri = AndroidContactUtil.getInstance().getAndroidContactUri(contactModel, context);

        if (contactUri != null) {
            Intent intent = new Intent(Intent.ACTION_EDIT);
            intent.setDataAndType(contactUri, ContactsContract.Contacts.CONTENT_ITEM_TYPE);
            intent.putExtra("finishActivityOnSaveCompleted", true);

            // make sure users are coming back to threema and not the external activity
            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT);
            try {
                if (context instanceof Activity) {
                    ((Activity) context).startActivityForResult(intent, requestCode);
                } else {
                    context.startActivity(intent);
                }
            } catch (Exception e) {
                Toast.makeText(context, "No contact editor found on device.", Toast.LENGTH_SHORT).show();
            }
            return true;
        }
        return false;
    }
}
