/*******************************************************************************
 *      Copyright (C) 2012 Google Inc.
 *      Licensed to The Android Open Source Project.
 *
 *      Licensed under the Apache License, Version 2.0 (the "License");
 *      you may not use this file except in compliance with the License.
 *      You may obtain a copy of the License at
 *
 *           http://www.apache.org/licenses/LICENSE-2.0
 *
 *      Unless required by applicable law or agreed to in writing, software
 *      distributed under the License is distributed on an "AS IS" BASIS,
 *      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *      See the License for the specific language governing permissions and
 *      limitations under the License.
 *******************************************************************************/

package com.android.mail.ui;

import android.os.Parcel;
import android.os.Parcelable;

import com.android.mail.browse.ConversationCursor;
import com.android.mail.providers.Conversation;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.BiMap;
import com.google.common.collect.HashBiMap;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Set;

/**
 * A simple thread-safe wrapper over a set of conversations representing a
 * selection set (e.g. in a conversation list). This class dispatches changes
 * when the set goes empty, and when it becomes unempty. For simplicity, this
 * class <b>does not allow modifications</b> to the collection in observers when
 * responding to change events.
 */
public class ConversationCheckedSet implements Parcelable {
    public static final ClassLoaderCreator<ConversationCheckedSet> CREATOR =
            new ClassLoaderCreator<ConversationCheckedSet>() {

        @Override
        public ConversationCheckedSet createFromParcel(Parcel source) {
            return new ConversationCheckedSet(source, null);
        }

        @Override
        public ConversationCheckedSet createFromParcel(Parcel source, ClassLoader loader) {
            return new ConversationCheckedSet(source, loader);
        }

        @Override
        public ConversationCheckedSet[] newArray(int size) {
            return new ConversationCheckedSet[size];
        }

    };

    private final Object mLock = new Object();
    /** Map of conversation ID to conversation objects. Every selected conversation is here. */
    private final HashMap<Long, Conversation> mInternalMap = new HashMap<Long, Conversation>();
    /** Map of Conversation URI to Conversation ID. */
    private final BiMap<String, Long> mConversationUriToIdMap = HashBiMap.create();
    /** All objects that are interested in changes to the selected set. */
    @VisibleForTesting
    final Set<ConversationSetObserver> mObservers = new HashSet<ConversationSetObserver>();

    /**
     * Create a new object,
     */
    public ConversationCheckedSet() {
        // Do nothing.
    }

    private ConversationCheckedSet(Parcel source, ClassLoader loader) {
        Parcelable[] conversations = source.readParcelableArray(loader);
        for (Parcelable parceled : conversations) {
            Conversation conversation = (Conversation) parceled;
            put(conversation.id, conversation);
        }
    }

    /**
     * Registers an observer to listen for interesting changes on this set.
     *
     * @param observer the observer to register.
     */
    public void addObserver(ConversationSetObserver observer) {
        synchronized (mLock) {
            mObservers.add(observer);
        }
    }

    /**
     * Clear the selected set entirely.
     */
    public void clear() {
        synchronized (mLock) {
            boolean initiallyNotEmpty = !mInternalMap.isEmpty();
            mInternalMap.clear();
            mConversationUriToIdMap.clear();

            if (mInternalMap.isEmpty() && initiallyNotEmpty) {
                ArrayList<ConversationSetObserver> observersCopy = Lists.newArrayList(mObservers);
                dispatchOnChange(observersCopy);
                dispatchOnEmpty(observersCopy);
            }
        }
    }

    /**
     * Returns true if the given key exists in the conversation selection set. This assumes
     * the internal representation holds conversation.id values.
     * @param key the id of the conversation
     * @return true if the key exists in this selected set.
     */
    private boolean containsKey(Long key) {
        synchronized (mLock) {
            return mInternalMap.containsKey(key);
        }
    }

    /**
     * Returns true if the given conversation is stored in the selection set.
     * @param conversation
     * @return true if the conversation exists in the selected set.
     */
    public boolean contains(Conversation conversation) {
        synchronized (mLock) {
            return containsKey(conversation.id);
        }
    }

    @Override
    public int describeContents() {
        return 0;
    }

    private void dispatchOnBecomeUnempty(ArrayList<ConversationSetObserver> observers) {
        synchronized (mLock) {
            for (ConversationSetObserver observer : observers) {
                observer.onSetPopulated(this);
            }
        }
    }

    private void dispatchOnChange(ArrayList<ConversationSetObserver> observers) {
        synchronized (mLock) {
            // Copy observers so that they may unregister themselves as listeners on
            // event handling.
            for (ConversationSetObserver observer : observers) {
                observer.onSetChanged(this);
            }
        }
    }

    private void dispatchOnEmpty(ArrayList<ConversationSetObserver> observers) {
        synchronized (mLock) {
            for (ConversationSetObserver observer : observers) {
                observer.onSetEmpty();
            }
        }
    }

    /**
     * Is this conversation set empty?
     * @return true if the conversation selection set is empty. False otherwise.
     */
    public boolean isEmpty() {
        synchronized (mLock) {
            return mInternalMap.isEmpty();
        }
    }

    private void put(Long id, Conversation info) {
        synchronized (mLock) {
            final boolean initiallyEmpty = mInternalMap.isEmpty();
            mInternalMap.put(id, info);
            mConversationUriToIdMap.put(info.uri.toString(), id);

            final ArrayList<ConversationSetObserver> observersCopy = Lists.newArrayList(mObservers);
            dispatchOnChange(observersCopy);
            if (initiallyEmpty) {
                dispatchOnBecomeUnempty(observersCopy);
            }
        }
    }

    /** @see java.util.HashMap#remove */
    private void remove(Long id) {
        synchronized (mLock) {
            removeAll(Collections.singleton(id));
        }
    }

    private void removeAll(Collection<Long> ids) {
        synchronized (mLock) {
            final boolean initiallyNotEmpty = !mInternalMap.isEmpty();

            final BiMap<Long, String> inverseMap = mConversationUriToIdMap.inverse();

            for (Long id : ids) {
                mInternalMap.remove(id);
                inverseMap.remove(id);
            }

            ArrayList<ConversationSetObserver> observersCopy = Lists.newArrayList(mObservers);
            dispatchOnChange(observersCopy);
            if (mInternalMap.isEmpty() && initiallyNotEmpty) {
                dispatchOnEmpty(observersCopy);
            }
        }
    }

    /**
     * Unregisters an observer for change events.
     *
     * @param observer the observer to unregister.
     */
    public void removeObserver(ConversationSetObserver observer) {
        synchronized (mLock) {
            mObservers.remove(observer);
        }
    }

    /**
     * Returns the number of conversations that are currently selected
     * @return the number of selected conversations.
     */
    public int size() {
        synchronized (mLock) {
            return mInternalMap.size();
        }
    }

    /**
     * Toggles the existence of the given conversation in the selection set. If the conversation is
     * currently selected, it is deselected. If it doesn't exist in the selection set, then it is
     * selected.
     * @param conversation
     */
    public void toggle(Conversation conversation) {
        final long conversationId = conversation.id;
        if (containsKey(conversationId)) {
            // We must not do anything with view here.
            remove(conversationId);
        } else {
            put(conversationId, conversation);
        }
    }

    /** @see java.util.HashMap#values */
    public Collection<Conversation> values() {
        synchronized (mLock) {
            return mInternalMap.values();
        }
    }

    /** @see java.util.HashMap#keySet() */
    public Set<Long> keySet() {
        synchronized (mLock) {
            return mInternalMap.keySet();
        }
    }

    /**
     * Puts all conversations given in the input argument into the selection set. If there are
     * any listeners they are notified once after adding <em>all</em> conversations to the selection
     * set.
     * @see java.util.HashMap#putAll(java.util.Map)
     */
    public void putAll(ConversationCheckedSet other) {
        if (other == null) {
            return;
        }

        final boolean initiallyEmpty = mInternalMap.isEmpty();
        mInternalMap.putAll(other.mInternalMap);

        final ArrayList<ConversationSetObserver> observersCopy = Lists.newArrayList(mObservers);
        dispatchOnChange(observersCopy);
        if (initiallyEmpty) {
            dispatchOnBecomeUnempty(observersCopy);
        }
    }

    @Override
    public void writeToParcel(Parcel dest, int flags) {
        Conversation[] values = values().toArray(new Conversation[size()]);
        dest.writeParcelableArray(values, flags);
    }

    /**
     * @param deletedRows an arraylist of conversation IDs which have been deleted.
     */
    public void delete(ArrayList<Integer> deletedRows) {
        for (long id : deletedRows) {
            remove(id);
        }
    }

    /**
     * Iterates through a cursor of conversations and ensures that the current set is present
     * within the result set denoted by the cursor. Any conversations not foun in the result set
     * is removed from the collection.
     */
    public void validateAgainstCursor(ConversationCursor cursor) {
        synchronized (mLock) {
            if (isEmpty()) {
                return;
            }

            if (cursor == null) {
                clear();
                return;
            }

            // First ask the ConversationCursor for the list of conversations that have been deleted
            final Set<String> deletedConversations = cursor.getDeletedItems();
            // For each of the uris in the deleted set, add the conversation id to the
            // itemsToRemoveFromBatch set.
            final Set<Long> itemsToRemoveFromBatch = Sets.newHashSet();
            for (String conversationUri : deletedConversations) {
                final Long conversationId = mConversationUriToIdMap.get(conversationUri);
                if (conversationId != null) {
                    itemsToRemoveFromBatch.add(conversationId);
                }
            }

            // Get the set of the items that had been in the batch
            final Set<Long> batchConversationToCheck = new HashSet<Long>(keySet());

            // Remove all of the items that we know are missing.  This will leave the items where
            // we need to check for existence in the cursor
            batchConversationToCheck.removeAll(itemsToRemoveFromBatch);
            // At this point batchConversationToCheck contains the conversation ids for the
            // conversations that had been in the batch selection, with the items we know have been
            // deleted removed.

            // This set contains the conversation ids that are in the conversation cursor
            final Set<Long> cursorConversationIds = cursor.getConversationIds();

            // We want to remove all of the valid items that are in the conversation cursor, from
            // the batchConversations to check.  The goal is after this block, anything remaining
            // would be items that don't exist in the conversation cursor anymore.
            if (!batchConversationToCheck.isEmpty() && cursorConversationIds != null) {
                batchConversationToCheck.removeAll(cursorConversationIds);
            }

            // At this point any of the item that are remaining in the batchConversationToCheck set
            // are to be removed from the selected conversation set
            itemsToRemoveFromBatch.addAll(batchConversationToCheck);

            removeAll(itemsToRemoveFromBatch);
        }
    }

    @Override
    public String toString() {
        synchronized (mLock) {
            return String.format("%s:%s", super.toString(), mInternalMap);
        }
    }
}
