/*
 * Copyright (C) 2017 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;

import android.content.ClipData;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.provider.DocumentsContract;
import android.view.DragEvent;
import android.view.KeyEvent;
import android.view.View;

import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import com.android.documentsui.MenuManager.SelectionDetails;
import com.android.documentsui.base.DocumentInfo;
import com.android.documentsui.base.DocumentStack;
import com.android.documentsui.base.MimeTypes;
import com.android.documentsui.base.RootInfo;
import com.android.documentsui.clipping.DocumentClipper;
import com.android.documentsui.dirlist.IconHelper;
import com.android.documentsui.services.FileOperationService;
import com.android.documentsui.services.FileOperationService.OpType;
import com.android.documentsui.services.FileOperations;

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

/**
 * Manager that tracks control key state, calculates the default file operation (move or copy)
 * when user drops, and updates drag shadow state.
 */
public interface DragAndDropManager {

    @IntDef({ STATE_NOT_ALLOWED, STATE_UNKNOWN, STATE_MOVE, STATE_COPY })
    @Retention(RetentionPolicy.SOURCE)
    @interface State {}
    int STATE_UNKNOWN = 0;
    int STATE_NOT_ALLOWED = 1;
    int STATE_MOVE = 2;
    int STATE_COPY = 3;

    /**
     * Intercepts and handles a {@link KeyEvent}. Used to track the state of Ctrl key state.
     */
    void onKeyEvent(KeyEvent event);

    /**
     * Starts a drag and drop.
     *
     * @param v the view which
     *          {@link View#startDragAndDrop(ClipData, View.DragShadowBuilder, Object, int)} will be
     *          called.
     * @param srcs documents that are dragged
     * @param root the root in which documents being dragged are
     * @param invalidDest destinations that don't accept this drag and drop
     * @param iconHelper used to load document icons
     * @param parent {@link DocumentInfo} of the container of srcs
     */
    void startDrag(
            View v,
            List<DocumentInfo> srcs,
            RootInfo root,
            List<Uri> invalidDest,
            SelectionDetails selectionDetails,
            IconHelper iconHelper,
            @Nullable DocumentInfo parent);

    /**
     * Checks whether the document can be spring opened.
     * @param root the root in which the document is
     * @param doc the document to check
     * @return true if policy allows spring opening it; false otherwise
     */
    boolean canSpringOpen(RootInfo root, DocumentInfo doc);

    /**
     * Updates the state to {@link #STATE_NOT_ALLOWED} without any further checks. This is used when
     * the UI component that handles the drag event already has enough information to disallow
     * dropping by itself.
     *
     * @param v the view which {@link View#updateDragShadow(View.DragShadowBuilder)} will be called.
     */
    void updateStateToNotAllowed(View v);

    /**
     * Updates the state according to the destination passed.
     * @param v the view which {@link View#updateDragShadow(View.DragShadowBuilder)} will be called.
     * @param destRoot the root of the destination document.
     * @param destDoc the destination document. Can be null if this is TBD. Must be a folder.
     * @return the new state. Can be any state in {@link State}.
     */
    @State int updateState(
            View v, RootInfo destRoot, @Nullable DocumentInfo destDoc);

    /**
     * Resets state back to {@link #STATE_UNKNOWN}. This is used when user drags items leaving a UI
     * component.
     * @param v the view which {@link View#updateDragShadow(View.DragShadowBuilder)} will be called.
     */
    void resetState(View v);

    /**
     * Checks whether the drag was initiated from FilesApp.
     * @return true if initiated from Files app.
     */
    boolean isDragFromSameApp();

    /**
     * Drops items onto the a root.
     *
     * @param clipData the clip data that contains sources information.
     * @param localState used to determine if this is a multi-window drag and drop.
     * @param destRoot the target root
     * @param actions {@link ActionHandler} used to load root document.
     * @param callback callback called when file operation is rejected or scheduled.
     * @return true if target accepts this drop; false otherwise
     */
    boolean drop(ClipData clipData, Object localState, RootInfo destRoot, ActionHandler actions,
            FileOperations.Callback callback);

    /**
     * Drops items onto the target.
     *
     * @param clipData the clip data that contains sources information.
     * @param localState used to determine if this is a multi-window drag and drop.
     * @param dstStack the document stack pointing to the destination folder.
     * @param callback callback called when file operation is rejected or scheduled.
     * @return true if target accepts this drop; false otherwise
     */
    boolean drop(ClipData clipData, Object localState, DocumentStack dstStack,
            FileOperations.Callback callback);

    /**
     * Called when drag and drop ended.
     *
     * This can be called multiple times as multiple {@link View.OnDragListener} might delegate
     * {@link DragEvent#ACTION_DRAG_ENDED} events to this class so any work inside needs to be
     * idempotent.
     */
    void dragEnded();

    static DragAndDropManager create(Context context, DocumentClipper clipper) {
        return new RuntimeDragAndDropManager(context, clipper);
    }

    class RuntimeDragAndDropManager implements DragAndDropManager {
        private static final String SRC_ROOT_KEY = "dragAndDropMgr:srcRoot";

        private final Context mContext;
        private final DocumentClipper mClipper;
        private final DragShadowBuilder mShadowBuilder;
        private final Drawable mDefaultShadowIcon;

        private @State int mState = STATE_UNKNOWN;
        private boolean mDragInitiated = false;

        // Key events info. This is used to derive state when user drags items into a view to derive
        // type of file operations.
        private boolean mIsCtrlPressed;

        // Drag events info. These are used to derive state and update drag shadow when user changes
        // Ctrl key state.
        private View mView;
        private List<Uri> mInvalidDest;
        private ClipData mClipData;
        private RootInfo mDestRoot;
        private DocumentInfo mDestDoc;

        // Boolean flag for current drag and drop operation. Returns true if the files can only
        // be copied (ie. files that don't support delete or remove).
        private boolean mMustBeCopied;

        private RuntimeDragAndDropManager(Context context, DocumentClipper clipper) {
            this(
                    context.getApplicationContext(),
                    clipper,
                    new DragShadowBuilder(context),
                    IconUtils.loadMimeIcon(context, MimeTypes.GENERIC_TYPE));
        }

        @VisibleForTesting
        RuntimeDragAndDropManager(Context context, DocumentClipper clipper,
                DragShadowBuilder builder, Drawable defaultShadowIcon) {
            mContext = context;
            mClipper = clipper;
            mShadowBuilder = builder;
            mDefaultShadowIcon = defaultShadowIcon;
        }

        @Override
        public void onKeyEvent(KeyEvent event) {
            switch (event.getKeyCode()) {
                case KeyEvent.KEYCODE_CTRL_LEFT:
                case KeyEvent.KEYCODE_CTRL_RIGHT:
                    adjustCtrlKeyCount(event);
            }
        }

        private void adjustCtrlKeyCount(KeyEvent event) {
            assert(event.getKeyCode() == KeyEvent.KEYCODE_CTRL_LEFT
                    || event.getKeyCode() == KeyEvent.KEYCODE_CTRL_RIGHT);

            mIsCtrlPressed = event.isCtrlPressed();

            // There is an ongoing drag and drop if mView is not null.
            if (mView != null) {
                // There is no need to update the state if current state is unknown or not allowed.
                if (mState == STATE_COPY || mState == STATE_MOVE) {
                    updateState(mView, mDestRoot, mDestDoc);
                }
            }
        }

        @Override
        public void startDrag(
                View v,
                List<DocumentInfo> srcs,
                RootInfo root,
                List<Uri> invalidDest,
                SelectionDetails selectionDetails,
                IconHelper iconHelper,
                @Nullable DocumentInfo parent) {

            mDragInitiated = true;
            mView = v;
            mInvalidDest = invalidDest;
            mMustBeCopied = !selectionDetails.canDelete();

            List<Uri> uris = new ArrayList<>(srcs.size());
            for (DocumentInfo doc : srcs) {
                uris.add(doc.derivedUri);
            }
            mClipData = (parent == null)
                    ? mClipper.getClipDataForDocuments(uris, FileOperationService.OPERATION_UNKNOWN)
                    : mClipper.getClipDataForDocuments(
                            uris, FileOperationService.OPERATION_UNKNOWN, parent);
            mClipData.getDescription().getExtras()
                    .putString(SRC_ROOT_KEY, root.getUri().toString());

            updateShadow(srcs, iconHelper);

            int flag = View.DRAG_FLAG_GLOBAL | View.DRAG_FLAG_OPAQUE;
            if (!selectionDetails.containsFilesInArchive()) {
                flag |= View.DRAG_FLAG_GLOBAL_URI_READ
                        | View.DRAG_FLAG_GLOBAL_URI_WRITE;
            }
            startDragAndDrop(
                    v,
                    mClipData,
                    mShadowBuilder,
                    this, // Used to detect multi-window drag and drop
                    flag);
        }

        private void updateShadow(List<DocumentInfo> srcs, IconHelper iconHelper) {
            final String title;
            final Drawable icon;

            final int size = srcs.size();
            if (size == 1) {
                DocumentInfo doc = srcs.get(0);
                title = doc.displayName;
                icon = iconHelper.getDocumentIcon(mContext, doc);
            } else {
                title = mContext.getResources()
                        .getQuantityString(R.plurals.elements_dragged, size, size);
                icon = mDefaultShadowIcon;
            }

            mShadowBuilder.updateTitle(title);
            mShadowBuilder.updateIcon(icon);

            mShadowBuilder.onStateUpdated(STATE_UNKNOWN);
        }

        /**
         * A workaround of that
         * {@link View#startDragAndDrop(ClipData, View.DragShadowBuilder, Object, int)} is final.
         */
        @VisibleForTesting
        void startDragAndDrop(View v, ClipData clipData, DragShadowBuilder builder,
                Object localState, int flags) {
            v.startDragAndDrop(clipData, builder, localState, flags);
        }

        @Override
        public boolean canSpringOpen(RootInfo root, DocumentInfo doc) {
            return isValidDestination(root, doc.derivedUri);
        }

        @Override
        public void updateStateToNotAllowed(View v) {
            mView = v;
            updateState(STATE_NOT_ALLOWED);
        }

        @Override
        public @State int updateState(
                View v, RootInfo destRoot, @Nullable DocumentInfo destDoc) {

            mView = v;
            mDestRoot = destRoot;
            mDestDoc = destDoc;

            if (!destRoot.supportsCreate()) {
                updateState(STATE_NOT_ALLOWED);
                return STATE_NOT_ALLOWED;
            }

            if (destDoc == null) {
                updateState(STATE_UNKNOWN);
                return STATE_UNKNOWN;
            }

            assert(destDoc.isDirectory());

            if (!destDoc.isCreateSupported() || mInvalidDest.contains(destDoc.derivedUri)) {
                updateState(STATE_NOT_ALLOWED);
                return STATE_NOT_ALLOWED;
            }

            @State int state;
            final @OpType int opType = calculateOpType(mClipData, destRoot);
            switch (opType) {
                case FileOperationService.OPERATION_COPY:
                    state = STATE_COPY;
                    break;
                case FileOperationService.OPERATION_MOVE:
                    state = STATE_MOVE;
                    break;
                default:
                    // Should never happen
                    throw new IllegalStateException("Unknown opType: " + opType);
            }

            updateState(state);
            return state;
        }

        @Override
        public void resetState(View v) {
            mView = v;

            updateState(STATE_UNKNOWN);
        }

        @Override
        public boolean isDragFromSameApp() {
            return mDragInitiated;
        }

        private void updateState(@State int state) {
            mState = state;

            mShadowBuilder.onStateUpdated(state);
            updateDragShadow(mView);
        }

        /**
         * A workaround of that {@link View#updateDragShadow(View.DragShadowBuilder)} is final.
         */
        @VisibleForTesting
        void updateDragShadow(View v) {
            v.updateDragShadow(mShadowBuilder);
        }

        @Override
        public boolean drop(ClipData clipData, Object localState, RootInfo destRoot,
                ActionHandler action, FileOperations.Callback callback) {

            final Uri rootDocUri =
                    DocumentsContract.buildDocumentUri(destRoot.authority, destRoot.documentId);
            if (!isValidDestination(destRoot, rootDocUri)) {
                return false;
            }

            // Calculate the op type now just in case user releases Ctrl key while we're obtaining
            // root document in the background.
            final @OpType int opType = calculateOpType(clipData, destRoot);
            action.getRootDocument(
                    destRoot,
                    TimeoutTask.DEFAULT_TIMEOUT,
                    (DocumentInfo doc) -> {
                        dropOnRootDocument(clipData, localState, destRoot, doc, opType, callback);
                    });

            return true;
        }

        private void dropOnRootDocument(
                ClipData clipData,
                Object localState,
                RootInfo destRoot,
                @Nullable DocumentInfo destRootDoc,
                @OpType int opType,
                FileOperations.Callback callback) {
            if (destRootDoc == null) {
                callback.onOperationResult(
                        FileOperations.Callback.STATUS_FAILED,
                        opType,
                        0);
            } else {
                dropChecked(
                        clipData,
                        localState,
                        new DocumentStack(destRoot, destRootDoc),
                        opType,
                        callback);
            }
        }

        @Override
        public boolean drop(ClipData clipData, Object localState, DocumentStack dstStack,
                FileOperations.Callback callback) {

            if (!canCopyTo(dstStack)) {
                return false;
            }

            dropChecked(
                    clipData,
                    localState,
                    dstStack,
                    calculateOpType(clipData, dstStack.getRoot()),
                    callback);
            return true;
        }

        private void dropChecked(ClipData clipData, Object localState, DocumentStack dstStack,
                @OpType int opType, FileOperations.Callback callback) {

            // Recognize multi-window drag and drop based on the fact that localState is not
            // carried between processes. It will stop working when the localsState behavior
            // is changed. The info about window should be passed in the localState then.
            // The localState could also be null for copying from Recents in single window
            // mode, but Recents doesn't offer this functionality (no directories).
            Metrics.logUserAction(
                    localState == null ? MetricConsts.USER_ACTION_DRAG_N_DROP_MULTI_WINDOW
                            : MetricConsts.USER_ACTION_DRAG_N_DROP);

            mClipper.copyFromClipData(dstStack, clipData, opType, callback);
        }

        @Override
        public void dragEnded() {
            // Multiple drag listeners might delegate drag ended event to this method, so anything
            // in this method needs to be idempotent. Otherwise we need to designate one listener
            // that always exists and only let it notify us when drag ended, which will further
            // complicate code and introduce one more coupling. This is a Android framework
            // limitation.

            mView = null;
            mInvalidDest = null;
            mClipData = null;
            mDestDoc = null;
            mDestRoot = null;
            mMustBeCopied = false;
            mDragInitiated = false;
        }

        private @OpType int calculateOpType(ClipData clipData, RootInfo destRoot) {
            if (mMustBeCopied) {
                return FileOperationService.OPERATION_COPY;
            }

            final String srcRootUri = clipData.getDescription().getExtras().getString(SRC_ROOT_KEY);
            final String destRootUri = destRoot.getUri().toString();

            assert(srcRootUri != null);
            assert(destRootUri != null);

            if (srcRootUri.equals(destRootUri)) {
                return mIsCtrlPressed
                        ? FileOperationService.OPERATION_COPY
                        : FileOperationService.OPERATION_MOVE;
            } else {
                return mIsCtrlPressed
                        ? FileOperationService.OPERATION_MOVE
                        : FileOperationService.OPERATION_COPY;
            }
        }

        private boolean canCopyTo(DocumentStack dstStack) {
            final RootInfo root = dstStack.getRoot();
            final DocumentInfo dst = dstStack.peek();
            return isValidDestination(root, dst.derivedUri);
        }

        private boolean isValidDestination(RootInfo root, Uri dstUri) {
            return root.supportsCreate()  && !mInvalidDest.contains(dstUri);
        }
    }
}
