/*
 * Copyright (C) 2015 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.documentsui.selection;

import static com.android.documentsui.base.Shared.DEBUG;

import android.annotation.IntDef;
import android.support.v7.widget.RecyclerView;
import android.util.Log;

import com.android.documentsui.dirlist.DocumentsAdapter;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.List;

import javax.annotation.Nullable;

/**
 * MultiSelectManager provides support traditional multi-item selection support to RecyclerView.
 * Additionally it can be configured to restrict selection to a single element, @see
 * #setSelectMode.
 */
public final class SelectionManager {

    @IntDef(flag = true, value = {
            MODE_MULTIPLE,
            MODE_SINGLE
    })
    @Retention(RetentionPolicy.SOURCE)
    public @interface SelectionMode {}
    public static final int MODE_MULTIPLE = 0;
    public static final int MODE_SINGLE = 1;

    @IntDef({
            RANGE_REGULAR,
            RANGE_PROVISIONAL
    })
    @Retention(RetentionPolicy.SOURCE)
    public @interface RangeType {}
    public static final int RANGE_REGULAR = 0;
    public static final int RANGE_PROVISIONAL = 1;

    static final String TAG = "SelectionManager";

    private final Selection mSelection = new Selection();

    private final List<Callback> mCallbacks = new ArrayList<>(1);
    private final List<ItemCallback> mItemCallbacks = new ArrayList<>(1);

    private @Nullable DocumentsAdapter mAdapter;
    private @Nullable Range mRanger;
    private boolean mSingleSelect;

    private RecyclerView.AdapterDataObserver mAdapterObserver;
    private SelectionPredicate mCanSetState;

    public SelectionManager(@SelectionMode int mode) {
        mSingleSelect = mode == MODE_SINGLE;
    }

    public SelectionManager reset(DocumentsAdapter adapter, SelectionPredicate canSetState) {

        mCallbacks.clear();
        mItemCallbacks.clear();
        if (mAdapter != null && mAdapterObserver != null) {
            mAdapter.unregisterAdapterDataObserver(mAdapterObserver);
        }

        clearSelectionQuietly();

        assert(adapter != null);
        assert(canSetState != null);

        mAdapter = adapter;
        mCanSetState = canSetState;

        mAdapterObserver = new RecyclerView.AdapterDataObserver() {

            private List<String> mModelIds;

            @Override
            public void onChanged() {
                mModelIds = mAdapter.getModelIds();

                // Update the selection to remove any disappeared IDs.
                mSelection.cancelProvisionalSelection();
                mSelection.intersect(mModelIds);

                notifyDataChanged();
            }

            @Override
            public void onItemRangeChanged(
                    int startPosition, int itemCount, Object payload) {
                // No change in position. Ignoring.
            }

            @Override
            public void onItemRangeInserted(int startPosition, int itemCount) {
                mSelection.cancelProvisionalSelection();
            }

            @Override
            public void onItemRangeRemoved(int startPosition, int itemCount) {
                assert(startPosition >= 0);
                assert(itemCount > 0);

                mSelection.cancelProvisionalSelection();
                // Remove any disappeared IDs from the selection.
                mSelection.intersect(mModelIds);
            }

            @Override
            public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) {
                throw new UnsupportedOperationException();
            }
        };

        mAdapter.registerAdapterDataObserver(mAdapterObserver);
        return this;
    }

    void bindContoller(BandController controller) {
        // Provides BandController with access to private mSelection state.
        controller.bindSelection(mSelection);
    }

    /**
     * Adds {@code callback} such that it will be notified when {@code MultiSelectManager}
     * events occur.
     *
     * @param callback
     */
    public void addCallback(Callback callback) {
        assert(callback != null);
        mCallbacks.add(callback);
    }

    public void addItemCallback(ItemCallback itemCallback) {
        assert(itemCallback != null);
        mItemCallbacks.add(itemCallback);
    }

    public boolean hasSelection() {
        return !mSelection.isEmpty();
    }

    /**
     * Returns a Selection object that provides a live view
     * on the current selection.
     *
     * @see #getSelection(Selection) on how to get a snapshot
     *     of the selection that will not reflect future changes
     *     to selection.
     *
     * @return The current selection.
     */
    public Selection getSelection() {
        return mSelection;
    }

    /**
     * Updates {@code dest} to reflect the current selection.
     * @param dest
     *
     * @return The Selection instance passed in, for convenience.
     */
    public Selection getSelection(Selection dest) {
        dest.copyFrom(mSelection);
        return dest;
    }

    public void replaceSelection(Iterable<String> ids) {
        clearSelection();
        setItemsSelected(ids, true);
    }

    /**
     * Restores the selected state of specified items. Used in cases such as restore the selection
     * after rotation etc.
     */
    public void restoreSelection(Selection other) {
        setItemsSelectedQuietly(other.mSelection, true);
        // NOTE: We intentionally don't restore provisional selection. It's provisional.
        notifySelectionRestored();
    }

    /**
     * Sets the selected state of the specified items. Note that the callback will NOT
     * be consulted to see if an item can be selected.
     *
     * @param ids
     * @param selected
     * @return
     */
    public boolean setItemsSelected(Iterable<String> ids, boolean selected) {
        final boolean changed = setItemsSelectedQuietly(ids, selected);
        notifySelectionChanged();
        return changed;
    }

    private boolean setItemsSelectedQuietly(Iterable<String> ids, boolean selected) {
        boolean changed = false;
        for (String id: ids) {
            final boolean itemChanged =
                    selected
                    ? canSetState(id, true) && mSelection.add(id)
                    : canSetState(id, false) && mSelection.remove(id);
            if (itemChanged) {
                notifyItemStateChanged(id, selected);
            }
            changed |= itemChanged;
        }
        return changed;
    }

    /**
     * Clears the selection and notifies (if something changes).
     */
    public void clearSelection() {
        if (!hasSelection()) {
            return;
        }

        clearSelectionQuietly();
        notifySelectionChanged();
    }

    /**
     * Clears the selection, without notifying selection listeners. UI elements still need to be
     * notified about state changes so that they can update their appearance.
     */
    private void clearSelectionQuietly() {
        mRanger = null;

        if (!hasSelection()) {
            return;
        }

        Selection oldSelection = getSelection(new Selection());
        mSelection.clear();

        for (String id: oldSelection.mSelection) {
            notifyItemStateChanged(id, false);
        }
        for (String id: oldSelection.mProvisionalSelection) {
            notifyItemStateChanged(id, false);
        }
    }

    /**
     * Toggles selection on the item with the given model ID.
     *
     * @param modelId
     */
    public void toggleSelection(String modelId) {
        assert(modelId != null);

        final boolean changed = mSelection.contains(modelId)
                ? attemptDeselect(modelId)
                : attemptSelect(modelId);

        if (changed) {
            notifySelectionChanged();
        }
    }

    /**
     * Starts a range selection. If a range selection is already active, this will start a new range
     * selection (which will reset the range anchor).
     *
     * @param pos The anchor position for the selection range.
     */
    public void startRangeSelection(int pos) {
        attemptSelect(mAdapter.getModelId(pos));
        setSelectionRangeBegin(pos);
    }

    public void snapRangeSelection(int pos) {
        snapRangeSelection(pos, RANGE_REGULAR);
    }

    void snapProvisionalRangeSelection(int pos) {
        snapRangeSelection(pos, RANGE_PROVISIONAL);
    }

    /*
     * Starts and extends range selection in one go. This assumes item at startPos is not selected
     * beforehand.
     */
    public void formNewSelectionRange(int startPos, int endPos) {
        assert(!mSelection.contains(mAdapter.getModelId(startPos)));
        startRangeSelection(startPos);
        snapRangeSelection(endPos);
    }

    /**
     * Sets the end point for the current range selection, started by a call to
     * {@link #startRangeSelection(int)}. This function should only be called when a range selection
     * is active (see {@link #isRangeSelectionActive()}. Items in the range [anchor, end] will be
     * selected or in provisional select, depending on the type supplied. Note that if the type is
     * provisional select, one should do {@link Selection#applyProvisionalSelection()} at some point
     * before calling on {@link #endRangeSelection()}.
     *
     * @param pos The new end position for the selection range.
     * @param type The type of selection the range should utilize.
     */
    private void snapRangeSelection(int pos, @RangeType int type) {
        if (!isRangeSelectionActive()) {
            throw new IllegalStateException("Range start point not set.");
        }

        mRanger.snapSelection(pos, type);

        // We're being lazy here notifying even when something might not have changed.
        // To make this more correct, we'd need to update the Ranger class to return
        // information about what has changed.
        notifySelectionChanged();
    }

    void cancelProvisionalSelection() {
        for (String id : mSelection.mProvisionalSelection) {
            notifyItemStateChanged(id, false);
        }
        mSelection.cancelProvisionalSelection();
    }

    /**
     * Stops an in-progress range selection. All selection done with
     * {@link #snapRangeSelection(int, int)} with type RANGE_PROVISIONAL will be lost if
     * {@link Selection#applyProvisionalSelection()} is not called beforehand.
     */
    public void endRangeSelection() {
        mRanger = null;
        // Clean up in case there was any leftover provisional selection
        cancelProvisionalSelection();
    }

    /**
     * @return Whether or not there is a current range selection active.
     */
    public boolean isRangeSelectionActive() {
        return mRanger != null;
    }

    /**
     * Sets the magic location at which a selection range begins (the selection anchor). This value
     * is consulted when determining how to extend, and modify selection ranges. Calling this when a
     * range selection is active will reset the range selection.
     */
    public void setSelectionRangeBegin(int position) {
        if (position == RecyclerView.NO_POSITION) {
            return;
        }

        if (mSelection.contains(mAdapter.getModelId(position))) {
            mRanger = new Range(this::updateForRange, position);
        }
    }

    /**
     * @param modelId
     * @return True if the update was applied.
     */
    private boolean selectAndNotify(String modelId) {
        boolean changed = mSelection.add(modelId);
        if (changed) {
            notifyItemStateChanged(modelId, true);
        }
        return changed;
    }

    /**
     * @param id
     * @return True if the update was applied.
     */
    private boolean attemptDeselect(String id) {
        assert(id != null);
        if (canSetState(id, false)) {
            mSelection.remove(id);
            notifyItemStateChanged(id, false);
            if (DEBUG) Log.d(TAG, "Selection after deselect: " + mSelection);
            return true;
        } else {
            if (DEBUG) Log.d(TAG, "Select cancelled by listener.");
            return false;
        }
    }

    /**
     * @param id
     * @return True if the update was applied.
     */
    private boolean attemptSelect(String id) {
        assert(id != null);
        boolean canSelect = canSetState(id, true);
        if (!canSelect) {
            return false;
        }
        if (mSingleSelect && hasSelection()) {
            clearSelectionQuietly();
        }

        selectAndNotify(id);
        return true;
    }

    boolean canSetState(String id, boolean nextState) {
        return mCanSetState.test(id, nextState);
    }

    private void notifyDataChanged() {
        final int lastListener = mItemCallbacks.size() - 1;

        for (int i = lastListener; i >= 0; i--) {
            mItemCallbacks.get(i).onSelectionReset();
        }

        for (String id : mSelection) {
            if (!canSetState(id, true)) {
                attemptDeselect(id);
            } else {
                for (int i = lastListener; i >= 0; i--) {
                    mItemCallbacks.get(i).onItemStateChanged(id, true);
                }
            }
        }
    }

    /**
     * Notifies registered listeners when the selection status of a single item
     * (identified by {@code position}) changes.
     */
    void notifyItemStateChanged(String id, boolean selected) {
        assert(id != null);
        int lastListener = mItemCallbacks.size() - 1;
        for (int i = lastListener; i >= 0; i--) {
            mItemCallbacks.get(i).onItemStateChanged(id, selected);
        }
        mAdapter.onItemSelectionChanged(id);
    }

    /**
     * Notifies registered listeners when the selection has changed. This
     * notification should be sent only once a full series of changes
     * is complete, e.g. clearingSelection, or updating the single
     * selection from one item to another.
     */
    void notifySelectionChanged() {
        int lastListener = mCallbacks.size() - 1;
        for (int i = lastListener; i > -1; i--) {
            mCallbacks.get(i).onSelectionChanged();
        }
    }

    private void notifySelectionRestored() {
        int lastListener = mCallbacks.size() - 1;
        for (int i = lastListener; i > -1; i--) {
            mCallbacks.get(i).onSelectionRestored();
        }
    }

    void updateForRange(int begin, int end, boolean selected, @RangeType int type) {
        switch (type) {
            case RANGE_REGULAR:
                updateForRegularRange(begin, end, selected);
                break;
            case RANGE_PROVISIONAL:
                updateForProvisionalRange(begin, end, selected);
                break;
            default:
                throw new IllegalArgumentException("Invalid range type: " + type);
        }
    }

    private void updateForRegularRange(int begin, int end, boolean selected) {
        assert(end >= begin);
        for (int i = begin; i <= end; i++) {
            String id = mAdapter.getModelId(i);
            if (id == null) {
                continue;
            }

            if (selected) {
                boolean canSelect = canSetState(id, true);
                if (canSelect) {
                    if (mSingleSelect && hasSelection()) {
                        clearSelectionQuietly();
                    }
                    selectAndNotify(id);
                }
            } else {
                attemptDeselect(id);
            }
        }
    }

    private void updateForProvisionalRange(int begin, int end, boolean selected) {
        assert (end >= begin);
        for (int i = begin; i <= end; i++) {
            String id = mAdapter.getModelId(i);
            if (id == null) {
                continue;
            }

            boolean changedState = false;
            if (selected) {
                boolean canSelect = canSetState(id, true);
                if (canSelect && !mSelection.mSelection.contains(id)) {
                    mSelection.mProvisionalSelection.add(id);
                    changedState = true;
                }
            } else {
                mSelection.mProvisionalSelection.remove(id);
                changedState = true;
            }

            // Only notify item callbacks when something's state is actually changed in provisional
            // selection.
            if (changedState) {
                notifyItemStateChanged(id, selected);
            }
        }
        notifySelectionChanged();
    }

    public interface ItemCallback {
        void onItemStateChanged(String id, boolean selected);

        void onSelectionReset();
    }

    public interface Callback {
        /**
         * Called immediately after completion of any set of changes.
         */
        void onSelectionChanged();

        /**
         * Called immediately after selection is restored.
         */
        void onSelectionRestored();
    }

    @FunctionalInterface
    public interface SelectionPredicate {
        boolean test(String id, boolean nextState);
    }
}
