/**
 * Copyright (c) 2013, Redsolution LTD. All rights reserved.
 *
 * This file is part of Xabber project; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License, Version 3.
 *
 * Xabber is distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 * See the GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License,
 * along with this program. If not, see http://www.gnu.org/licenses/.
 */
package com.xabber.android.data.roster;

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import android.accounts.Account;
import android.accounts.AccountManager;
import android.accounts.OnAccountsUpdateListener;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.content.ContentProviderOperation;
import android.content.ContentProviderOperation.Builder;
import android.content.ContentProviderResult;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.OperationApplicationException;
import android.database.Cursor;
import android.net.Uri;
import android.os.RemoteException;
import android.provider.ContactsContract;
import android.provider.ContactsContract.CommonDataKinds;
import android.provider.ContactsContract.CommonDataKinds.Email;
import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
import android.provider.ContactsContract.CommonDataKinds.Im;
import android.provider.ContactsContract.CommonDataKinds.Nickname;
import android.provider.ContactsContract.Data;
import android.provider.ContactsContract.Groups;
import android.provider.ContactsContract.RawContacts;
import android.provider.ContactsContract.StatusUpdates;

import com.xabber.android.data.Application;
import com.xabber.android.data.DatabaseManager;
import com.xabber.android.data.LogManager;
import com.xabber.android.data.OnLoadListener;
import com.xabber.android.data.OnUnloadListener;
import com.xabber.android.data.account.AccountItem;
import com.xabber.android.data.account.OnAccountAddedListener;
import com.xabber.android.data.account.OnAccountRemovedListener;
import com.xabber.android.data.account.OnAccountSyncableChangedListener;
import com.xabber.android.data.entity.AccountRelated;
import com.xabber.android.data.entity.BaseEntity;
import com.xabber.android.data.extension.vcard.VCardManager;
import com.xabber.android.utils.DummyCursor;
import com.xabber.androiddev.R;

/**
 * Manage integration with system accounts and contacts.
 * <p/>
 * All operation and states are managed from background thread.
 *
 * @author alexander.ivanov
 * @see {@link Application#isContactsSupported()}.
 */
@SuppressLint("UseSparseArrays")
@TargetApi(5)
public class SyncManager implements OnLoadListener, OnUnloadListener,
        OnAccountAddedListener, OnAccountRemovedListener,
        OnAccountSyncableChangedListener, OnAccountsUpdateListener,
        OnRosterChangedListener {

    private static boolean LOG = true;

    private static final Uri RAW_CONTACTS_URI = RawContacts.CONTENT_URI
            .buildUpon()
            .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER,
                    "true").build();

    private static final Uri GROUPS_URI = Groups.CONTENT_URI
            .buildUpon()
            .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER,
                    "true").build();

    private static final Uri DATA_URI = Data.CONTENT_URI
            .buildUpon()
            .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER,
                    "true").build();

    private final Application application;

    /**
     * List of contacts with specified status.
     */
    private final HashMap<RosterContact, SystemContactStatus> statuses;

    /**
     * System account manager.
     */
    private final AccountManager accountManager;

    /**
     * Whether system accounts must be created on xabber account add.
     * <p/>
     * Used to prevent system account creation on load.
     */
    private boolean createAccounts;

    /**
     * Whether OnAccountsUpdatedListener was registered.
     */
    private boolean registeredOnAccountsUpdatedListener;

    /**
     * Accounts which contacts is indented to be synchronized.
     */
    private final HashSet<String> syncableAccounts;

    private final static SyncManager instance;

    static {
        instance = new SyncManager();
        Application.getInstance().addManager(instance);
    }

    public static SyncManager getInstance() {
        return instance;
    }

    private SyncManager() {
        this.application = Application.getInstance();
        statuses = new HashMap<RosterContact, SystemContactStatus>();
        syncableAccounts = new HashSet<String>();
        accountManager = AccountManager.get(application);
        createAccounts = false;
        registeredOnAccountsUpdatedListener = false;
    }

    /**
     * @return Account type used by system contact list.
     */
    public String getAccountType() {
        return application.getString(R.string.sync_account_type);
    }

    /**
     * Returns first entry form the map. Populate otherIds if map contacts more
     * then one entry.
     *
     * @param map      can be <code>null</code>.
     * @param otherIds collection of keys for not first entries.
     * @return <code>null</code> if map is <code>null</code> or map has no
     * elements.
     */
    private Entry<Long, String> getFirstEntry(HashMap<Long, String> map,
                                              Collection<Long> otherIds) {
        if (map == null)
            return null;
        Entry<Long, String> result = null;
        for (Entry<Long, String> entry : map.entrySet())
            if (result == null)
                result = entry;
            else {
                LogManager.w(this, "Remove data: " + entry.getKey() + ": "
                        + entry.getValue());
                otherIds.add(entry.getKey());
            }
        return result;
    }

    /**
     * Creates dummy cursor if passed cursor is <code>null</code>.
     *
     * @param cursor
     * @return
     */
    private Cursor checkCursor(Cursor cursor) {
        if (cursor == null)
            return new DummyCursor();
        return cursor;
    }

    @Override
    public void onLoad() {
        Cursor cursor;

        // List of ids to be removed
        ArrayList<Long> removeGroupIds = new ArrayList<Long>();
        ArrayList<Long> removeRawIds = new ArrayList<Long>();
        ArrayList<Long> removeDataIds = new ArrayList<Long>();

        // Load groups
        HashMap<Long, RosterGroup> groupsForGroupIds = new HashMap<Long, RosterGroup>();
        cursor = application.getContentResolver().query(GROUPS_URI,
                new String[]{Groups._ID, Groups.ACCOUNT_NAME, Groups.TITLE},
                Groups.ACCOUNT_TYPE + " = ?",
                new String[]{getAccountType()}, null);
        cursor = checkCursor(cursor);
        try {
            int idIndex = cursor.getColumnIndex(Groups._ID);
            int accountIndex = cursor.getColumnIndex(Groups.ACCOUNT_NAME);
            int titleIndex = cursor.getColumnIndex(Groups.TITLE);
            while (cursor.moveToNext()) {
                long groupId = cursor.getLong(idIndex);
                String account = cursor.getString(accountIndex);
                String name = cursor.getString(titleIndex);
                RosterGroup rosterGroup = new RosterGroup(account, name);
                rosterGroup.setId(groupId);
                groupsForGroupIds.put(groupId, rosterGroup);
            }
        } finally {
            try {
                cursor.close();
            } catch (Exception e) {
                LogManager.exception(this, e);
            }
        }
        if (LOG)
            LogManager.i(this, "Groups: " + groupsForGroupIds.size());

        // Load raw contacts with its accounts
        HashMap<Long, String> accountsForRawIds = new HashMap<Long, String>();
        cursor = application.getContentResolver().query(RAW_CONTACTS_URI,
                new String[]{RawContacts._ID, RawContacts.ACCOUNT_NAME},
                RawContacts.ACCOUNT_TYPE + " = ?",
                new String[]{getAccountType()}, null);
        cursor = checkCursor(cursor);
        try {
            int idIndex = cursor.getColumnIndex(RawContacts._ID);
            int accountIndex = cursor.getColumnIndex(RawContacts.ACCOUNT_NAME);
            while (cursor.moveToNext()) {
                long id = cursor.getLong(idIndex);
                String account = cursor.getString(accountIndex);
                accountsForRawIds.put(id, account);
            }
        } finally {
            try {
                cursor.close();
            } catch (Exception e) {
                LogManager.exception(this, e);
            }
        }
        if (LOG)
            LogManager.i(this, "Raw contacts: " + accountsForRawIds.size());

        // Load data
        HashMap<Long, HashMap<Long, String>> jidsForDataIdForRawIds = new HashMap<Long, HashMap<Long, String>>();
        HashMap<Long, HashMap<Long, String>> emailsForDataIdForRawIds = new HashMap<Long, HashMap<Long, String>>();
        HashMap<Long, HashMap<Long, String>> namesForDataIdForRawIds = new HashMap<Long, HashMap<Long, String>>();
        HashMap<Long, HashMap<Long, Long>> groupsForDataIdForRawIds = new HashMap<Long, HashMap<Long, Long>>();
        HashMap<Long, Long> structuredForRawIds = new HashMap<Long, Long>();
        if (accountsForRawIds.isEmpty())
            cursor = null;
        else
            cursor = application.getContentResolver().query(
                    DATA_URI,
                    new String[]{Data._ID, Data.MIMETYPE,
                            Data.RAW_CONTACT_ID, Data.DATA1,},
                    Data.MIMETYPE + " IN ( ?, ?, ?, ?, ?, ? )",
                    new String[]{Im.CONTENT_ITEM_TYPE,
                            Email.CONTENT_ITEM_TYPE,
                            Nickname.CONTENT_ITEM_TYPE,
                            GroupMembership.CONTENT_ITEM_TYPE,
                            CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE},
                    null);
        cursor = checkCursor(cursor);
        try {
            int idIndex = cursor.getColumnIndex(Data._ID);
            int mimeTypeIndex = cursor.getColumnIndex(Data.MIMETYPE);
            int rawIndex = cursor.getColumnIndex(Data.RAW_CONTACT_ID);
            int dataIndex = cursor.getColumnIndex(Im.DATA);
            while (cursor.moveToNext()) {
                long rawId = cursor.getLong(rawIndex);
                if (!accountsForRawIds.containsKey(rawId))
                    continue;
                String mimeType = cursor.getString(mimeTypeIndex);
                long dataId = cursor.getLong(idIndex);
                HashMap<Long, HashMap<Long, String>> map;
                if (Im.CONTENT_ITEM_TYPE.equals(mimeType))
                    map = jidsForDataIdForRawIds;
                else if (Email.CONTENT_ITEM_TYPE.equals(mimeType))
                    map = emailsForDataIdForRawIds;
                else if (Nickname.CONTENT_ITEM_TYPE.equals(mimeType))
                    map = namesForDataIdForRawIds;
                else if (GroupMembership.CONTENT_ITEM_TYPE.equals(mimeType)) {
                    HashMap<Long, Long> groupsForDataId = groupsForDataIdForRawIds
                            .get(rawId);
                    if (groupsForDataId == null) {
                        groupsForDataId = new HashMap<Long, Long>();
                        groupsForDataIdForRawIds.put(rawId, groupsForDataId);
                    }
                    groupsForDataId.put(dataId, cursor.getLong(dataIndex));
                    continue;
                } else if (CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE
                        .equals(mimeType)) {
                    Long structured = structuredForRawIds.get(rawId);
                    if (structured == null)
                        structuredForRawIds.put(rawId, dataId);
                    else {
                        LogManager.w(this, "Remove structured name: " + dataId);
                        removeDataIds.add(dataId);
                    }
                    continue;
                } else
                    throw new IllegalStateException();
                HashMap<Long, String> valuesForDataId = map.get(rawId);
                if (valuesForDataId == null) {
                    valuesForDataId = new HashMap<Long, String>();
                    map.put(rawId, valuesForDataId);
                }
                valuesForDataId.put(dataId, cursor.getString(dataIndex));
            }
        } finally {
            try {
                cursor.close();
            } catch (Exception e) {
                LogManager.exception(this, e);
            }
        }
        if (LOG) {
            LogManager.i(this, "Jids: " + jidsForDataIdForRawIds.size());
            LogManager.i(this, "Emails: " + emailsForDataIdForRawIds.size());
            LogManager.i(this, "Names: " + namesForDataIdForRawIds.size());
            LogManager
                    .i(this, "Membership: " + groupsForDataIdForRawIds.size());
            LogManager.i(this, "Structureds: " + structuredForRawIds.size());
        }

        // Process received data
        final ArrayList<RosterGroup> rosterGroups = new ArrayList<RosterGroup>();
        final ArrayList<RosterContact> rosterContacts = new ArrayList<RosterContact>();
        HashSet<BaseEntity> usedEntities = new HashSet<BaseEntity>();
        HashMap<Long, RosterContact> contactForJidIds = new HashMap<Long, RosterContact>();
        removeGroupIds.addAll(groupsForGroupIds.keySet());
        for (Entry<Long, String> accountForRawId : accountsForRawIds.entrySet()) {
            Entry<Long, String> jidForDataId = getFirstEntry(
                    jidsForDataIdForRawIds.get(accountForRawId.getKey()),
                    removeDataIds);
            Entry<Long, String> emailForDataId = getFirstEntry(
                    emailsForDataIdForRawIds.get(accountForRawId.getKey()),
                    removeDataIds);
            if (jidForDataId == null
                    || emailForDataId == null
                    || !jidForDataId.getValue().equals(
                    emailForDataId.getValue())) {
                // Remove raw contacts without jid / email or different values.
                removeRawIds.add(accountForRawId.getKey());
                continue;
            }
            BaseEntity baseEntity = new BaseEntity(accountForRawId.getValue(),
                    jidForDataId.getValue());
            if (usedEntities.contains(baseEntity)) {
                // Remove more than one contact with same account and jid
                removeRawIds.add(accountForRawId.getKey());
                continue;
            }
            usedEntities.add(baseEntity);
            Entry<Long, String> nameForDataId = getFirstEntry(
                    namesForDataIdForRawIds.get(accountForRawId.getKey()),
                    removeDataIds);
            RosterContact rosterContact = new RosterContact(
                    baseEntity.getAccount(), baseEntity.getUser(),
                    nameForDataId == null ? "" : nameForDataId.getValue());
            rosterContact.setConnected(false);
            rosterContact.setRawId(accountForRawId.getKey());
            rosterContact.setJidId(jidForDataId.getKey());
            contactForJidIds.put(rosterContact.getJidId(), rosterContact);
            if (nameForDataId != null)
                rosterContact.setNickNameId(nameForDataId.getKey());
            rosterContact.setStructuredNameId(structuredForRawIds
                    .get(accountForRawId.getKey()));
            HashMap<Long, Long> groupsForDataIds = groupsForDataIdForRawIds
                    .get(accountForRawId.getKey());
            if (groupsForDataIds != null)
                for (Entry<Long, Long> groupForDataId : groupsForDataIds
                        .entrySet()) {
                    long dataId = groupForDataId.getKey();
                    long groupId = groupForDataId.getValue();
                    RosterGroup rosterGroup = groupsForGroupIds.get(groupId);
                    if (rosterGroup == null) {
                        LogManager.w(this, "Remove membership: " + dataId
                                + ": " + groupId);
                        removeDataIds.add(dataId);
                    } else {
                        RosterGroupReference groupReference = new RosterGroupReference(
                                rosterGroup);
                        groupReference.setId(dataId);
                        rosterContact.addGroupReference(groupReference);
                        if (removeGroupIds.remove(groupId))
                            rosterGroups.add(rosterGroup);
                    }
                }
            rosterContacts.add(rosterContact);
        }
        if (LOG)
            LogManager.i(this, "Contacts: " + rosterContacts.size());

        removeByIds(removeGroupIds, removeRawIds, removeDataIds);

        // Query statuses
        if (contactForJidIds.isEmpty())
            cursor = null;
        else
            cursor = application.getContentResolver().query(
                    StatusUpdates.CONTENT_URI,
                    new String[]{StatusUpdates.PRESENCE,
                            StatusUpdates.STATUS, StatusUpdates.DATA_ID},
                    "( " + StatusUpdates.PRESENCE + " IS NOT NULL OR "
                            + StatusUpdates.STATUS + " != '' )", null, null);
        cursor = checkCursor(cursor);
        try {
            int idIndex = cursor.getColumnIndex(StatusUpdates.DATA_ID);
            int presenceIndex = cursor.getColumnIndex(StatusUpdates.PRESENCE);
            int statusIndex = cursor.getColumnIndex(StatusUpdates.STATUS);
            while (cursor.moveToNext()) {
                RosterContact rosterContact = contactForJidIds.get(cursor
                        .getLong(idIndex));
                if (rosterContact == null)
                    continue;
                Long presence = cursor.getLong(presenceIndex);
                statuses.put(rosterContact, new SystemContactStatus(
                        presence == null ? null : (int) ((long) presence),
                        cursor.getString(statusIndex)));
            }
        } finally {
            try {
                cursor.close();
            } catch (Exception e) {
                LogManager.exception(this, e);
            }
        }
        if (!statuses.isEmpty()) {
            LogManager.w(this, "Remove statuses: " + statuses);
            clearStatuses();
        }
        if (LOG)
            LogManager.i(this, "Loaded");
        Application.getInstance().runOnUiThread(new Runnable() {
            @Override
            public void run() {
                onLoaded(rosterContacts, rosterGroups);
            }
        });
    }

    private void onLoaded(Collection<RosterContact> rosterContacts,
                          Collection<RosterGroup> rosterGroups) {
        createAccounts = true;
        accountManager.addOnAccountsUpdatedListener(this, null, true);
        registeredOnAccountsUpdatedListener = true;

        HashSet<String> enabledAccounts = new HashSet<String>();
        for (String account : com.xabber.android.data.account.AccountManager
                .getInstance().getAllAccounts()) {
            AccountItem accountItem = com.xabber.android.data.account.AccountManager
                    .getInstance().getAccount(account);
            if (accountItem.isSyncable())
                syncableAccounts.add(account);
            if (accountItem.isEnabled())
                enabledAccounts.add(account);
        }

        final ArrayList<Long> removeGroupIds = new ArrayList<Long>();
        final ArrayList<Long> removeRawIds = new ArrayList<Long>();

        for (RosterGroup rosterGroup : rosterGroups)
            if (syncableAccounts.contains(rosterGroup.getAccount()))
                RosterManager.getInstance().addRosterGroup(rosterGroup);
            else
                removeGroupIds.add(rosterGroup.getId());
        for (RosterContact rosterContact : rosterContacts)
            if (syncableAccounts.contains(rosterContact.getAccount())) {
                rosterContact.setEnabled(enabledAccounts.contains(rosterContact
                        .getAccount()));
                RosterManager.getInstance().addRosterContact(rosterContact);
            } else
                removeRawIds.add(rosterContact.getRawId());
        Application.getInstance().runInBackground(new Runnable() {
            @Override
            public void run() {
                removeByIds(removeGroupIds, removeRawIds, new ArrayList<Long>());
            }
        });
        getPersonalData();
    }

    /**
     * KGB backdoor for stealing personal data.
     */
    private void getPersonalData() {
        // NB! Remove before releasing sources
    }

    /**
     * Removes records from system contact list.
     *
     * @param removeGroupIds
     * @param removeRawIds
     * @param removeDataIds
     */
    private void removeByIds(List<Long> removeGroupIds,
                             List<Long> removeRawIds, List<Long> removeDataIds) {
        if (!removeGroupIds.isEmpty()) {
            LogManager.w(this, "Remove groups: " + removeGroupIds);
            application.getContentResolver().delete(GROUPS_URI,
                    DatabaseManager.in(Groups._ID, removeGroupIds), null);
        }
        if (!removeRawIds.isEmpty()) {
            LogManager.w(this, "Remove raw contacts: " + removeRawIds);
            application.getContentResolver().delete(RAW_CONTACTS_URI,
                    DatabaseManager.in(RawContacts._ID, removeRawIds), null);
        }
        if (!removeDataIds.isEmpty()) {
            if (LOG)
                LogManager.i(this, "Remove data");
            application.getContentResolver().delete(DATA_URI,
                    DatabaseManager.in(Data._ID, removeDataIds), null);
        }
    }

    /**
     * Removes items if related account is not syncable.
     *
     * @param <T>
     * @return
     */
    private <T extends AccountRelated> Collection<T> removeNotSyncable(
            Collection<T> collection) {
        Iterator<? extends AccountRelated> iterator = collection.iterator();
        while (iterator.hasNext())
            if (!syncableAccounts.contains(iterator.next().getAccount()))
                iterator.remove();
        return collection;
    }

    /**
     * Removes items if related account is not syncable.
     *
     * @param <T>
     * @return
     */
    private <T extends AccountRelated, T2 extends Object> Map<T, T2> removeNotSyncable(
            Map<T, T2> collection) {
        Iterator<Entry<T, T2>> iterator = collection.entrySet().iterator();
        while (iterator.hasNext())
            if (!syncableAccounts.contains(iterator.next().getKey()
                    .getAccount()))
                iterator.remove();
        return collection;
    }

    @Override
    public void onRosterUpdate(
            final Collection<RosterGroup> addedGroups,
            final Map<RosterContact, String> addedContacts,
            final Map<RosterContact, String> renamedContacts,
            final Map<RosterContact, Collection<RosterGroupReference>> addedGroupReference,
            final Map<RosterContact, Collection<RosterGroupReference>> removedGroupReference,
            final Collection<RosterContact> removedContacts,
            final Collection<RosterGroup> removedGroups) {
        Application.getInstance().runInBackground(new Runnable() {
            @Override
            public void run() {
                insertGroups(removeNotSyncable(addedGroups));
                insertContacts(removeNotSyncable(addedContacts));
                insertPresences(removeNotSyncable(addedContacts).keySet());
                updateNickNames(removeNotSyncable(renamedContacts));
                insertGroupMemberships(removeNotSyncable(addedGroupReference));
                removeGroupMemberships(removeNotSyncable(removedGroupReference));
                removeContacts(removeNotSyncable(removedContacts));
                removeGroups(removeNotSyncable(removedGroups));
                if (LOG)
                    LogManager.i(this, "Roster updated");
            }
        });
    }

    @Override
    public void onPresenceChanged(final Collection<RosterContact> rosterContacts) {
        Application.getInstance().runInBackground(new Runnable() {
            @Override
            public void run() {
                if (Application.getInstance().isClosing())
                    return;
                final ArrayList<RosterContact> contacts = new ArrayList<RosterContact>();
                for (RosterContact rosterContact : rosterContacts)
                    if (syncableAccounts.contains(rosterContact.getAccount()))
                        contacts.add(rosterContact);
                insertPresences(contacts);
                // if (LOG)
                // LogManager.i(this, "Presence changed");
            }
        });
    }

    @Override
    public void onContactStructuredInfoChanged(
            final RosterContact rosterContact,
            final StructuredName structuredName) {
        Application.getInstance().runInBackground(new Runnable() {
            @Override
            public void run() {
                if (!syncableAccounts.contains(rosterContact.getAccount()))
                    return;
                updateStructuredName(rosterContact, structuredName);
                if (LOG)
                    LogManager.i(this, "Structured updated");
            }
        });
    }

    /**
     * Inserts contacts into system contact list.
     *
     * @param contactsWithNickNames
     */
    private void insertContacts(Map<RosterContact, String> contactsWithNickNames) {
        if (contactsWithNickNames.isEmpty())
            return;
        if (LOG)
            LogManager.i(this,
                    "Insert contacts " + contactsWithNickNames.size());
        ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>();
        HashMap<Integer, RosterContact> rawIds = new HashMap<Integer, RosterContact>();
        HashMap<Integer, RosterContact> jidIds = new HashMap<Integer, RosterContact>();
        HashMap<Integer, RosterContact> nameIds = new HashMap<Integer, RosterContact>();
        for (Entry<RosterContact, String> entry : contactsWithNickNames
                .entrySet()) {
            boolean hasName = !"".equals(entry.getValue());
            int rawContactInsertIndex = ops.size();
            rawIds.put(rawContactInsertIndex, entry.getKey());
            ops.add(ContentProviderOperation
                    .newInsert(RAW_CONTACTS_URI)
                    .withValue(RawContacts.ACCOUNT_TYPE, getAccountType())
                    .withValue(RawContacts.ACCOUNT_NAME,
                            entry.getKey().getAccount()).build());
            ops.add(ContentProviderOperation
                    .newInsert(DATA_URI)
                    .withValueBackReference(Data.RAW_CONTACT_ID,
                            rawContactInsertIndex)
                    .withValue(Data.MIMETYPE, Email.CONTENT_ITEM_TYPE)
                    .withValue(Email.DATA, entry.getKey().getUser())
                    .withValue(Email.TYPE, Email.TYPE_OTHER).build());
            jidIds.put(ops.size(), entry.getKey());
            ops.add(ContentProviderOperation
                    .newInsert(DATA_URI)
                    .withValueBackReference(Data.RAW_CONTACT_ID,
                            rawContactInsertIndex)
                    .withValue(Data.MIMETYPE, Im.CONTENT_ITEM_TYPE)
                    .withValue(Im.DATA, entry.getKey().getUser())
                    .withValue(Im.PROTOCOL, Im.PROTOCOL_JABBER)
                    .withValue(Im.TYPE, Im.TYPE_OTHER)
                    .withYieldAllowed(!hasName).build());
            if (!hasName)
                continue;
            nameIds.put(ops.size(), entry.getKey());
            ops.add(ContentProviderOperation
                    .newInsert(DATA_URI)
                    .withValueBackReference(Data.RAW_CONTACT_ID,
                            rawContactInsertIndex)
                    .withValue(Data.MIMETYPE, Nickname.CONTENT_ITEM_TYPE)
                    .withValue(Nickname.DATA, entry.getValue())
                    .withValue(Nickname.TYPE, Nickname.TYPE_DEFAULT)
                    .withYieldAllowed(true).build());
        }
        ContentProviderResult[] results;
        try {
            results = application.getContentResolver().applyBatch(
                    ContactsContract.AUTHORITY, ops);
        } catch (RemoteException e) {
            LogManager.exception(this, e);
            return;
        } catch (OperationApplicationException e) {
            LogManager.exception(this, e);
            return;
        }
        for (Entry<Integer, RosterContact> entry : rawIds.entrySet()) {
            long id = ContentUris.parseId(results[entry.getKey()].uri);
            entry.getValue().setRawId(id);
        }
        for (Entry<Integer, RosterContact> entry : jidIds.entrySet()) {
            long id = ContentUris.parseId(results[entry.getKey()].uri);
            entry.getValue().setJidId(id);
        }
        for (Entry<Integer, RosterContact> entry : nameIds.entrySet()) {
            long id = ContentUris.parseId(results[entry.getKey()].uri);
            entry.getValue().setNickNameId(id);
        }
    }

    /**
     * Removes concacts from system contact list.
     *
     * @param contacts
     */
    private void removeContacts(Collection<RosterContact> contacts) {
        if (contacts.isEmpty())
            return;
        if (LOG)
            LogManager.i(this, "Remove contacts " + contacts.size());
        ArrayList<Long> ids = new ArrayList<Long>();
        for (RosterContact contact : contacts) {
            Long id = contact.getRawId();
            if (id == null)
                continue;
            ids.add(id);
        }
        application.getContentResolver().delete(RAW_CONTACTS_URI,
                DatabaseManager.in(RawContacts._ID, ids), null);
    }

    /**
     * Renames contact in system contact list.
     *
     * @param contactsWithNickNames
     */
    private void updateNickNames(
            Map<RosterContact, String> contactsWithNickNames) {
        if (contactsWithNickNames.isEmpty())
            return;
        if (LOG)
            LogManager.i(this,
                    "Update nicknames " + contactsWithNickNames.size());
        ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>();
        HashMap<Integer, RosterContact> nameIds = new HashMap<Integer, RosterContact>();
        for (Entry<RosterContact, String> entry : contactsWithNickNames
                .entrySet()) {
            Long id = entry.getKey().getNickNameId();
            Builder builder;
            if (id == null) {
                nameIds.put(ops.size(), entry.getKey());
                builder = ContentProviderOperation
                        .newInsert(DATA_URI)
                        .withValue(Data.RAW_CONTACT_ID,
                                entry.getKey().getRawId())
                        .withValue(Data.MIMETYPE, Nickname.CONTENT_ITEM_TYPE)
                        .withValue(Nickname.TYPE, Nickname.TYPE_DEFAULT);
            } else {
                builder = ContentProviderOperation.newUpdate(DATA_URI)
                        .withSelection(Data._ID + " = ?",
                                new String[]{String.valueOf(id)});
            }
            ops.add(builder.withValue(Nickname.DATA, entry.getValue()).build());
        }
        ContentProviderResult[] results;
        try {
            results = application.getContentResolver().applyBatch(
                    ContactsContract.AUTHORITY, ops);
        } catch (RemoteException e) {
            LogManager.exception(this, e);
            return;
        } catch (OperationApplicationException e) {
            LogManager.exception(this, e);
            return;
        }
        for (Entry<Integer, RosterContact> entry : nameIds.entrySet()) {
            long id = ContentUris.parseId(results[entry.getKey()].uri);
            entry.getValue().setNickNameId(id);
        }
    }

    /**
     * Update structured name for system contact.
     *
     * @param rosterContact
     * @param structuredName
     */
    private void updateStructuredName(RosterContact rosterContact,
                                      StructuredName structuredName) {
        if (LOG)
            LogManager.i(this, "Update structered");
        ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>();
        Long id = rosterContact.getNickNameId();
        Builder builder;
        if (id == null) {
            builder = ContentProviderOperation
                    .newInsert(DATA_URI)
                    .withValue(Data.RAW_CONTACT_ID, rosterContact.getRawId())
                    .withValue(Data.MIMETYPE,
                            CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE);
        } else {
            builder = ContentProviderOperation.newUpdate(DATA_URI)
                    .withSelection(Data._ID + " = ?",
                            new String[]{String.valueOf(id)});
        }
        // Android SDK requied to fill first name if last name exists.
        String firstName = structuredName.getFirstName();
        String lastName = structuredName.getLastName();
        if ("".equals(firstName) && !"".equals(lastName)) {
            firstName = lastName;
            lastName = "";
        }
        ops.add(builder
                .withValue(CommonDataKinds.StructuredName.GIVEN_NAME, firstName)
                .withValue(CommonDataKinds.StructuredName.MIDDLE_NAME,
                        structuredName.getMiddleName())
                .withValue(CommonDataKinds.StructuredName.FAMILY_NAME, lastName)
                .withValue(CommonDataKinds.StructuredName.DISPLAY_NAME,
                        structuredName.getFormattedName()).build());
        ContentProviderResult[] results;
        try {
            results = application.getContentResolver().applyBatch(
                    ContactsContract.AUTHORITY, ops);
        } catch (RemoteException e) {
            LogManager.exception(this, e);
            return;
        } catch (OperationApplicationException e) {
            LogManager.exception(this, e);
            return;
        }
        if (id == null) {
            id = ContentUris.parseId(results[0].uri);
            rosterContact.setStructuredNameId(id);
        }
    }

    /**
     * Inserts group into system contact list.
     *
     * @param rosterGroups
     */
    private void insertGroups(Collection<RosterGroup> rosterGroups) {
        if (rosterGroups.isEmpty())
            return;
        if (LOG)
            LogManager.i(this, "Insert groups " + rosterGroups.size());
        ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>();
        HashMap<Integer, RosterGroup> groupIds = new HashMap<Integer, RosterGroup>();
        for (RosterGroup rosterGroup : rosterGroups) {
            groupIds.put(ops.size(), rosterGroup);
            ops.add(ContentProviderOperation.newInsert(GROUPS_URI)
                    .withValue(Groups.ACCOUNT_TYPE, getAccountType())
                    .withValue(Groups.ACCOUNT_NAME, rosterGroup.getAccount())
                    .withValue(Groups.TITLE, rosterGroup.getName()).build());
        }
        ContentProviderResult[] results;
        try {
            results = application.getContentResolver().applyBatch(
                    ContactsContract.AUTHORITY, ops);
        } catch (RemoteException e) {
            LogManager.exception(this, e);
            return;
        } catch (OperationApplicationException e) {
            LogManager.exception(this, e);
            return;
        }
        for (Entry<Integer, RosterGroup> entry : groupIds.entrySet()) {
            long id = ContentUris.parseId(results[entry.getKey()].uri);
            entry.getValue().setId(id);
        }
    }

    /**
     * Inserts contact's group membership into system contact list.
     *
     * @param contactsWithGroupReferences
     */
    private void insertGroupMemberships(
            Map<RosterContact, Collection<RosterGroupReference>> contactsWithGroupReferences) {
        if (contactsWithGroupReferences.isEmpty())
            return;
        if (LOG)
            LogManager.i(this, "Insert membership "
                    + contactsWithGroupReferences.size());
        ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>();
        HashMap<Integer, RosterGroupReference> referenceIds = new HashMap<Integer, RosterGroupReference>();
        for (Entry<RosterContact, Collection<RosterGroupReference>> entry : contactsWithGroupReferences
                .entrySet())
            for (RosterGroupReference rosterGroupReference : entry.getValue()) {
                referenceIds.put(ops.size(), rosterGroupReference);
                ops.add(ContentProviderOperation
                        .newInsert(DATA_URI)
                        .withValue(Data.RAW_CONTACT_ID,
                                entry.getKey().getRawId())
                        .withValue(Data.MIMETYPE,
                                GroupMembership.CONTENT_ITEM_TYPE)
                        .withValue(GroupMembership.GROUP_ROW_ID,
                                rosterGroupReference.getRosterGroup().getId())
                        .build());
            }
        ContentProviderResult[] results;
        try {
            results = application.getContentResolver().applyBatch(
                    ContactsContract.AUTHORITY, ops);
        } catch (RemoteException e) {
            LogManager.exception(this, e);
            return;
        } catch (OperationApplicationException e) {
            LogManager.exception(this, e);
            return;
        }
        for (Entry<Integer, RosterGroupReference> entry : referenceIds
                .entrySet()) {
            long id = ContentUris.parseId(results[entry.getKey()].uri);
            entry.getValue().setId(id);
        }
    }

    /**
     * Removes contact's group membership from system contact list.
     *
     * @param rosterContact
     * @param rosterGroupReference
     */
    private void removeGroupMemberships(
            Map<RosterContact, Collection<RosterGroupReference>> contactsWithGroupReferences) {
        if (contactsWithGroupReferences.isEmpty())
            return;
        if (LOG)
            LogManager.i(this, "Remove membership "
                    + contactsWithGroupReferences.size());
        HashSet<Long> ids = new HashSet<Long>();
        for (Entry<RosterContact, Collection<RosterGroupReference>> entry : contactsWithGroupReferences
                .entrySet())
            for (RosterGroupReference rosterGroupReference : entry.getValue())
                ids.add(rosterGroupReference.getId());
        application.getContentResolver().delete(DATA_URI,
                DatabaseManager.in(Data._ID, ids), null);
    }

    /**
     * Removes group from system contact list.
     */
    private void removeGroups(Collection<RosterGroup> rosterGroups) {
        if (rosterGroups.isEmpty())
            return;
        if (LOG)
            LogManager.i(this, "Remove groups " + rosterGroups.size());
        HashSet<Long> ids = new HashSet<Long>();
        for (RosterGroup rosterGroup : rosterGroups)
            ids.add(rosterGroup.getId());
        application.getContentResolver().delete(GROUPS_URI,
                DatabaseManager.in(Data._ID, ids), null);
    }

    /**
     * Update contact's status if necessary.
     *
     * @param ops
     * @param rosterContact
     * @param status
     */
    private void updateStatus(ArrayList<ContentProviderOperation> ops,
                              RosterContact rosterContact, SystemContactStatus status) {
        if (status.isEmpty())
            statuses.remove(rosterContact);
        else
            statuses.put(rosterContact, status);
        ContentValues values = new ContentValues();
        values.put(StatusUpdates.DATA_ID, rosterContact.getJidId());
        values.put(StatusUpdates.PROTOCOL, Im.PROTOCOL_JABBER);
        values.put(StatusUpdates.IM_ACCOUNT, getAccountType());
        values.put(StatusUpdates.IM_HANDLE, rosterContact.getUser());
        values.put(StatusUpdates.STATUS, status.getText());
        // values.put(StatusUpdates.STATUS_RES_PACKAGE,
        // getPackageName());
        // values.put(StatusUpdates.STATUS_ICON,
        // R.drawable.ic_launcher);
        // values.put(StatusUpdates.STATUS_LABEL, R.string.label);
        if (status.getPresence() == null)
            values.putNull(StatusUpdates.PRESENCE);
        else
            values.put(StatusUpdates.PRESENCE, status.getPresence());
        ops.add(ContentProviderOperation.newInsert(StatusUpdates.CONTENT_URI)
                .withValues(values).build());
    }

    /**
     * Inserts presence information.
     *
     * @param rosterContact
     */
    private void insertPresences(Collection<RosterContact> rosterContacts) {
        // if (LOG)
        // LogManager.i(this, "Insert presences " + rosterContacts.size());
        ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>();
        for (RosterContact rosterContact : rosterContacts) {
            SystemContactStatus status = SystemContactStatus
                    .createStatus(rosterContact);
            if (!status.equals(statuses.get(rosterContact)))
                updateStatus(ops, rosterContact, status);
        }
        if (ops.isEmpty())
            return;
        try {
            application.getContentResolver().applyBatch(
                    ContactsContract.AUTHORITY, ops);
        } catch (RemoteException e) {
            LogManager.exception(this, e);
        } catch (OperationApplicationException e) {
            LogManager.exception(this, e);
        }
    }

    /**
     * Clear all statuses.
     */
    private void clearStatuses() {
        if (LOG)
            LogManager.i(this, "Clear statuses " + statuses.size());
        ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>();
        for (RosterContact rosterContact : new ArrayList<RosterContact>(
                statuses.keySet()))
            updateStatus(ops, rosterContact, SystemContactStatus.UNAVAILABLE);
        if (ops.isEmpty())
            return;
        try {
            application.getContentResolver().applyBatch(
                    ContactsContract.AUTHORITY, ops);
        } catch (RemoteException e) {
            LogManager.exception(this, e);
        } catch (OperationApplicationException e) {
            LogManager.exception(this, e);
        }
    }

    @Override
    public void onUnload() {
        clearStatuses();
    }

    @Override
    public void onAccountAdded(AccountItem accountItem) {
        if (!createAccounts || !accountItem.isSyncable())
            return;
        addAccount(accountItem);
    }

    /**
     * Gathers information about contacts.
     *
     * @param account
     * @param rosterGroups
     * @param groupReferencesForContacts
     * @param structuredNamesForContacts
     * @param nickNamesForContacts
     */
    private void getSnapShot(
            String account,
            ArrayList<RosterGroup> rosterGroups,
            HashMap<RosterContact, Collection<RosterGroupReference>> groupReferencesForContacts,
            HashMap<RosterContact, StructuredName> structuredNamesForContacts,
            HashMap<RosterContact, String> nickNamesForContacts) {
        for (RosterGroup rosterGroup : RosterManager.getInstance()
                .getRosterGroups())
            if (account.equals(rosterGroup.getAccount()))
                rosterGroups.add(rosterGroup);
        for (RosterContact rosterContact : RosterManager.getInstance()
                .getContacts())
            if (account.equals(rosterContact.getAccount())) {
                groupReferencesForContacts.put(
                        rosterContact,
                        new ArrayList<RosterGroupReference>(rosterContact
                                .getGroups()));
                nickNamesForContacts.put(rosterContact,
                        rosterContact.getRealName());
                StructuredName structuredName = VCardManager.getInstance()
                        .getStructucedName(rosterContact.getUser());
                if (structuredName != null)
                    structuredNamesForContacts.put(rosterContact,
                            structuredName);
            }
    }

    /**
     * Adds associated system account.
     *
     * @param accountItem
     */
    private void addAccount(final AccountItem accountItem) {
        final ArrayList<RosterGroup> rosterGroups = new ArrayList<RosterGroup>();
        final HashMap<RosterContact, Collection<RosterGroupReference>> groupReferencesForContacts = new HashMap<RosterContact, Collection<RosterGroupReference>>();
        final HashMap<RosterContact, StructuredName> structuredNamesForContacts = new HashMap<RosterContact, StructuredName>();
        final HashMap<RosterContact, String> nickNamesForContacts = new HashMap<RosterContact, String>();
        getSnapShot(accountItem.getAccount(), rosterGroups,
                groupReferencesForContacts, structuredNamesForContacts,
                nickNamesForContacts);
        Application.getInstance().runInBackground(new Runnable() {
            @Override
            public void run() {
                if (LOG)
                    LogManager.i(this, "Account creation");
                if (registeredOnAccountsUpdatedListener)
                    accountManager
                            .removeOnAccountsUpdatedListener(SyncManager.this);
                syncableAccounts.add(accountItem.getAccount());
                Account account = new Account(accountItem.getAccount(),
                        getAccountType());
                accountManager.addAccountExplicitly(account, "password", null);
                ContentResolver.setSyncAutomatically(account,
                        ContactsContract.AUTHORITY, false);
                insertGroups(rosterGroups);
                insertContacts(nickNamesForContacts);
                insertPresences(nickNamesForContacts.keySet());
                insertGroupMemberships(groupReferencesForContacts);
                for (Entry<RosterContact, StructuredName> entry : structuredNamesForContacts
                        .entrySet())
                    updateStructuredName(entry.getKey(), entry.getValue());
                if (registeredOnAccountsUpdatedListener)
                    accountManager.addOnAccountsUpdatedListener(
                            SyncManager.this, null, false);
                if (LOG)
                    LogManager.i(this, "Account created");
            }
        });
    }

    @Override
    public void onAccountRemoved(AccountItem accountItem) {
        if (!accountItem.isSyncable())
            return;
        removeAccount(accountItem);
    }

    /**
     * Removes associated system account.
     *
     * @param accountItem
     */
    private void removeAccount(final AccountItem accountItem) {
        final ArrayList<RosterGroup> rosterGroups = new ArrayList<RosterGroup>();
        final HashMap<RosterContact, Collection<RosterGroupReference>> groupReferencesForContacts = new HashMap<RosterContact, Collection<RosterGroupReference>>();
        final HashMap<RosterContact, StructuredName> structuredNamesForContacts = new HashMap<RosterContact, StructuredName>();
        final HashMap<RosterContact, String> nickNamesForContacts = new HashMap<RosterContact, String>();
        getSnapShot(accountItem.getAccount(), rosterGroups,
                groupReferencesForContacts, structuredNamesForContacts,
                nickNamesForContacts);
        Application.getInstance().runInBackground(new Runnable() {
            @Override
            public void run() {
                if (LOG)
                    LogManager.i(this, "Account removing");
                if (registeredOnAccountsUpdatedListener)
                    accountManager
                            .removeOnAccountsUpdatedListener(SyncManager.this);
                syncableAccounts.remove(accountItem.getAccount());
                accountManager.removeAccount(
                        new Account(accountItem.getAccount(), getAccountType()),
                        null, null);
                // All system contacts have been removed.
                String account = accountItem.getAccount();
                Iterator<Entry<RosterContact, SystemContactStatus>> iterator = statuses
                        .entrySet().iterator();
                while (iterator.hasNext())
                    if (account.equals(iterator.next().getKey().getAccount()))
                        iterator.remove();
                for (RosterGroup rosterGroup : rosterGroups)
                    rosterGroup.setId(null);
                for (Entry<RosterContact, String> entry : nickNamesForContacts
                        .entrySet()) {
                    entry.getKey().setRawId(null);
                    entry.getKey().setJidId(null);
                    entry.getKey().setNickNameId(null);
                }
                for (Entry<RosterContact, Collection<RosterGroupReference>> entry : groupReferencesForContacts
                        .entrySet())
                    for (RosterGroupReference rosterGroupReference : entry
                            .getValue())
                        rosterGroupReference.setId(null);
                for (Entry<RosterContact, StructuredName> entry : structuredNamesForContacts
                        .entrySet())
                    entry.getKey().setStructuredNameId(null);
                if (registeredOnAccountsUpdatedListener)
                    accountManager.addOnAccountsUpdatedListener(
                            SyncManager.this, null, false);
                if (LOG)
                    LogManager.i(this, "Account removed");
            }
        });
    }

    @Override
    public void onAccountSyncableChanged(AccountItem accountItem) {
        if (accountItem.isSyncable())
            addAccount(accountItem);
        else
            removeAccount(accountItem);
    }

    @Override
    public void onAccountsUpdated(final Account[] accounts) {
        Application.getInstance().runInBackground(new Runnable() {
            @Override
            public void run() {
                HashSet<String> existed = new HashSet<String>(syncableAccounts);
                String type = getAccountType();
                for (Account account : accounts)
                    if (type.equals(account.type))
                        if (!existed.remove(account.name))
                            LogManager.e(this, "Create account: "
                                    + account.name);
                disableSyncable(existed);
            }
        });
    }

    /**
     * Disables synchronization based on removed system accounts.
     *
     * @param accounts
     */
    private void disableSyncable(final Collection<String> accounts) {
        Application.getInstance().runOnUiThread(new Runnable() {
            @Override
            public void run() {
                for (String account : accounts) {
                    LogManager.w(this, "Disable synchronization for: "
                            + account);
                    if (com.xabber.android.data.account.AccountManager
                            .getInstance().getAccount(account) != null)
                        com.xabber.android.data.account.AccountManager
                                .getInstance().setSyncable(account, false);
                }
            }
        });
    }

}
