/*
 * Copyright (C) 2018 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 static com.android.documentsui.base.SharedMinimal.DEBUG;

import android.app.ActivityManager;
import android.content.ContentProviderClient;
import android.content.Context;
import android.database.Cursor;
import android.database.CursorWrapper;
import android.database.MatrixCursor;
import android.database.MergeCursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.FileUtils;
import android.provider.DocumentsContract;
import android.provider.DocumentsContract.Document;
import android.util.Log;

import androidx.annotation.GuardedBy;
import androidx.annotation.NonNull;
import androidx.loader.content.AsyncTaskLoader;

import com.android.documentsui.base.DocumentInfo;
import com.android.documentsui.base.FilteringCursorWrapper;
import com.android.documentsui.base.Lookup;
import com.android.documentsui.base.RootInfo;
import com.android.documentsui.base.State;
import com.android.documentsui.roots.ProvidersAccess;
import com.android.documentsui.roots.RootCursorWrapper;

import com.google.common.util.concurrent.AbstractFuture;

import java.io.Closeable;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

/**
 * The abstract class to query multiple roots from {@link android.provider.DocumentsProvider}
 * and return the combined result.
 */
public abstract class MultiRootDocumentsLoader extends AsyncTaskLoader<DirectoryResult> {

    private static final String TAG = "MultiRootDocsLoader";

    // TODO: clean up cursor ownership so background thread doesn't traverse
    // previously returned cursors for filtering/sorting; this currently races
    // with the UI thread.

    private static final int MAX_OUTSTANDING_TASK = 4;
    private static final int MAX_OUTSTANDING_TASK_SVELTE = 2;

    /**
     * Time to wait for first pass to complete before returning partial results.
     */
    private static final int MAX_FIRST_PASS_WAIT_MILLIS = 500;

    protected final State mState;

    private final Semaphore mQueryPermits;
    private final ProvidersAccess mProviders;
    private final Lookup<String, Executor> mExecutors;
    private final Lookup<String, String> mFileTypeMap;
    private LockingContentObserver mObserver;

    @GuardedBy("mTasks")
    /* A authority -> QueryTask map */
    private final Map<String, QueryTask> mTasks = new HashMap<>();

    private CountDownLatch mFirstPassLatch;
    private volatile boolean mFirstPassDone;

    private DirectoryResult mResult;

    /**
     * Create the loader to query roots from {@link android.provider.DocumentsProvider}.
     *
     * @param context the context
     * @param providers the providers
     * @param state current state
     * @param executors the executors of authorities
     * @param fileTypeMap the map of mime types and file types.
     */
    public MultiRootDocumentsLoader(Context context, ProvidersAccess providers, State state,
            Lookup<String, Executor> executors, Lookup<String, String> fileTypeMap) {

        super(context);
        mProviders = providers;
        mState = state;
        mExecutors = executors;
        mFileTypeMap = fileTypeMap;

        // Keep clients around on high-RAM devices, since we'd be spinning them
        // up moments later to fetch thumbnails anyway.
        final ActivityManager am = (ActivityManager) getContext().getSystemService(
                Context.ACTIVITY_SERVICE);
        mQueryPermits = new Semaphore(
                am.isLowRamDevice() ? MAX_OUTSTANDING_TASK_SVELTE : MAX_OUTSTANDING_TASK);
    }

    @Override
    public DirectoryResult loadInBackground() {
        try {
            synchronized (mTasks) {
                return loadInBackgroundLocked();
            }
        } catch (InterruptedException e) {
            Log.w(TAG, "loadInBackground is interrupted: ", e);
            return null;
        }
    }

    public void setObserver(LockingContentObserver observer) {
        mObserver = observer;
    }

    private DirectoryResult loadInBackgroundLocked() throws InterruptedException {
        if (mFirstPassLatch == null) {
            // First time through we kick off all the recent tasks, and wait
            // around to see if everyone finishes quickly.
            Map<String, List<RootInfo>> rootsIndex = indexRoots();

            for (Map.Entry<String, List<RootInfo>> rootEntry : rootsIndex.entrySet()) {
                mTasks.put(rootEntry.getKey(),
                        getQueryTask(rootEntry.getKey(), rootEntry.getValue()));
            }

            if (isLoadInBackgroundCanceled()) {
                // Loader is cancelled (e.g. about to be reset), preempt loading.
                throw new InterruptedException("Loading is cancelled!");
            }

            mFirstPassLatch = new CountDownLatch(mTasks.size());
            for (QueryTask task : mTasks.values()) {
                mExecutors.lookup(task.authority).execute(task);
            }

            try {
                mFirstPassLatch.await(MAX_FIRST_PASS_WAIT_MILLIS, TimeUnit.MILLISECONDS);
                mFirstPassDone = true;
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }

        final long rejectBefore = getRejectBeforeTime();

        // Collect all finished tasks
        boolean allDone = true;
        int totalQuerySize = 0;
        List<Cursor> cursors = new ArrayList<>(mTasks.size());
        for (QueryTask task : mTasks.values()) {
            if (isLoadInBackgroundCanceled()) {
                // Loader is cancelled (e.g. about to be reset), preempt loading.
                throw new InterruptedException("Loading is cancelled!");
            }

            if (task.isDone()) {
                try {
                    final Cursor[] taskCursors = task.get();
                    if (taskCursors == null || taskCursors.length == 0) {
                        continue;
                    }

                    totalQuerySize += taskCursors.length;
                    for (Cursor cursor : taskCursors) {
                        if (cursor == null) {
                            // It's possible given an authority, some roots fail to return a cursor
                            // after a query.
                            continue;
                        }

                        final FilteringCursorWrapper filteredCursor =
                                new FilteringCursorWrapper(cursor) {
                                    @Override
                                    public void close() {
                                        // Ignored, since we manage cursor lifecycle internally
                                    }
                                };
                        filteredCursor.filterHiddenFiles(mState.showHiddenFiles);
                        filteredCursor.filterMimes(mState.acceptMimes, getRejectMimes());
                        filteredCursor.filterLastModified(rejectBefore);

                        cursors.add(filteredCursor);
                    }

                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                } catch (ExecutionException e) {
                    // We already logged on other side
                } catch (Exception e) {
                    // Catch exceptions thrown when we read the cursor.
                    Log.e(TAG, "Failed to query documents for authority: " + task.authority
                            + ". Skip this authority.", e);
                }
            } else {
                allDone = false;
            }
        }

        if (DEBUG) {
            Log.d(TAG,
                    "Found " + cursors.size() + " of " + totalQuerySize + " queries done");
        }

        final DirectoryResult result = new DirectoryResult();
        result.doc = new DocumentInfo();

        final Cursor merged;
        if (cursors.size() > 0) {
            merged = new MergeCursor(cursors.toArray(new Cursor[cursors.size()]));
        } else {
            // Return something when nobody is ready
            merged = new MatrixCursor(new String[0]);
        }

        final Cursor sorted;
        if (isDocumentsMovable()) {
            sorted = mState.sortModel.sortCursor(merged, mFileTypeMap);
        } else {
            final Cursor notMovableMasked = new NotMovableMaskCursor(merged);
            sorted = mState.sortModel.sortCursor(notMovableMasked, mFileTypeMap);
        }

        // Tell the UI if this is an in-progress result. When loading is complete, another update is
        // sent with EXTRA_LOADING set to false.
        Bundle extras = new Bundle();
        extras.putBoolean(DocumentsContract.EXTRA_LOADING, !allDone);
        sorted.setExtras(extras);

        result.setCursor(sorted);

        return result;
    }

    /**
     * Returns a map of Authority -> rootInfos.
     */
    private Map<String, List<RootInfo>> indexRoots() {
        final Collection<RootInfo> roots = mProviders.getMatchingRootsBlocking(mState);
        HashMap<String, List<RootInfo>> rootsIndex = new HashMap<>();
        for (RootInfo root : roots) {
            // ignore the root with authority is null. e.g. Recent
            if (root.authority == null || shouldIgnoreRoot(root)
                    || !mState.canInteractWith(root.userId)) {
                continue;
            }

            if (!rootsIndex.containsKey(root.authority)) {
                rootsIndex.put(root.authority, new ArrayList<>());
            }
            rootsIndex.get(root.authority).add(root);
        }

        return rootsIndex;
    }

    protected long getRejectBeforeTime() {
        return -1;
    }

    protected String[] getRejectMimes() {
        return null;
    }

    protected boolean shouldIgnoreRoot(RootInfo root) {
        return false;
    }

    protected boolean isDocumentsMovable() {
        return false;
    }

    protected abstract QueryTask getQueryTask(String authority, List<RootInfo> rootInfos);

    @Override
    public void deliverResult(DirectoryResult result) {
        if (isReset()) {
            FileUtils.closeQuietly(result);
            return;
        }
        DirectoryResult oldResult = mResult;
        mResult = result;

        if (isStarted() && !isAbandoned() && !isLoadInBackgroundCanceled()) {
            super.deliverResult(result);
        }

        if (oldResult != null && oldResult != result) {
            FileUtils.closeQuietly(oldResult);
        }
    }

    @Override
    protected void onStartLoading() {
        boolean isCursorStale = checkIfCursorStale(mResult);
        if (mResult != null && !isCursorStale) {
            deliverResult(mResult);
        }
        if (takeContentChanged() || mResult == null || isCursorStale) {
            forceLoad();
        }
    }

    @Override
    protected void onStopLoading() {
        cancelLoad();
    }

    @Override
    public void onCanceled(DirectoryResult result) {
        FileUtils.closeQuietly(result);
    }

    @Override
    protected void onReset() {
        super.onReset();

        synchronized (mTasks) {
            for (QueryTask task : mTasks.values()) {
                mExecutors.lookup(task.authority).execute(() -> FileUtils.closeQuietly(task));
            }
        }
        FileUtils.closeQuietly(mResult);
        mResult = null;
    }

    // TODO: create better transfer of ownership around cursor to ensure its
    // closed in all edge cases.

    private static class NotMovableMaskCursor extends CursorWrapper {
        private static final int NOT_MOVABLE_MASK =
                ~(Document.FLAG_SUPPORTS_DELETE
                        | Document.FLAG_SUPPORTS_REMOVE
                        | Document.FLAG_SUPPORTS_MOVE);

        private NotMovableMaskCursor(Cursor cursor) {
            super(cursor);
        }

        @Override
        public int getInt(int index) {
            final int flagIndex = getWrappedCursor().getColumnIndex(Document.COLUMN_FLAGS);
            final int value = super.getInt(index);
            return (index == flagIndex) ? (value & NOT_MOVABLE_MASK) : value;
        }
    }

    protected abstract class QueryTask extends AbstractFuture<Cursor[]> implements Runnable,
            Closeable {
        public final String authority;
        public final List<RootInfo> rootInfos;

        private Cursor[] mCursors;
        private boolean mIsClosed = false;

        public QueryTask(String authority, List<RootInfo> rootInfos) {
            this.authority = authority;
            this.rootInfos = rootInfos;
        }

        @Override
        public void run() {
            if (isCancelled()) {
                return;
            }

            try {
                mQueryPermits.acquire();
            } catch (InterruptedException e) {
                return;
            }

            try {
                runInternal();
            } finally {
                mQueryPermits.release();
            }
        }

        protected abstract Uri getQueryUri(RootInfo rootInfo);

        protected abstract RootCursorWrapper generateResultCursor(RootInfo rootInfo,
                Cursor oriCursor);

        protected void addQueryArgs(@NonNull Bundle queryArgs) {
        }

        private synchronized void runInternal() {
            if (mIsClosed) {
                return;
            }

            final int rootInfoCount = rootInfos.size();
            final Cursor[] res = new Cursor[rootInfoCount];
            mCursors = new Cursor[rootInfoCount];

            for (int i = 0; i < rootInfoCount; i++) {
                final RootInfo rootInfo = rootInfos.get(i);
                try (ContentProviderClient client =
                             DocumentsApplication.acquireUnstableProviderOrThrow(
                                     rootInfo.userId.getContentResolver(getContext()),
                                     authority)) {
                    final Uri uri = getQueryUri(rootInfo);
                    try {
                        final Bundle queryArgs = new Bundle();
                        mState.sortModel.addQuerySortArgs(queryArgs);
                        addQueryArgs(queryArgs);
                        res[i] = client.query(uri, null, queryArgs, null);
                        if (mObserver != null) {
                            res[i].registerContentObserver(mObserver);
                        }
                        mCursors[i] = generateResultCursor(rootInfo, res[i]);
                    } catch (Exception e) {
                        Log.w(TAG, "Failed to load " + authority + ", " + rootInfo.rootId, e);
                    }

                } catch (Exception e) {
                    Log.w(TAG, "Failed to acquire content resolver for authority: " + authority);
                }
            }

            set(mCursors);

            mFirstPassLatch.countDown();
            if (mFirstPassDone) {
                onContentChanged();
            }
        }

        @Override
        public synchronized void close() throws IOException {
            if (mCursors == null) {
                return;
            }

            for (Cursor cursor : mCursors) {
                if (mObserver != null && cursor != null) {
                    cursor.unregisterContentObserver(mObserver);
                }
                FileUtils.closeQuietly(cursor);
            }

            mIsClosed = true;
        }
    }

    private boolean checkIfCursorStale(DirectoryResult result) {
        if (result == null || result.getCursor() == null || result.getCursor().isClosed()) {
            return true;
        }
        Cursor cursor = result.getCursor();
        try {
            cursor.moveToPosition(-1);
            for (int pos = 0; pos < cursor.getCount(); ++pos) {
                if (!cursor.moveToNext()) {
                    return true;
                }
            }
        } catch (Exception e) {
            return true;
        }
        return false;
    }
}
