/*
 * 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;

import static com.android.documentsui.model.DocumentInfo.getCursorLong;
import static com.android.documentsui.model.DocumentInfo.getCursorString;

import android.app.IntentService;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.ContentProviderClient;
import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
import android.database.Cursor;
import android.net.Uri;
import android.os.CancellationSignal;
import android.os.ParcelFileDescriptor;
import android.os.Parcelable;
import android.os.PowerManager;
import android.os.RemoteException;
import android.os.SystemClock;
import android.provider.DocumentsContract;
import android.provider.DocumentsContract.Document;
import android.text.format.DateUtils;
import android.util.Log;
import android.widget.Toast;

import com.android.documentsui.model.DocumentInfo;
import com.android.documentsui.model.DocumentStack;

import libcore.io.IoUtils;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public class CopyService extends IntentService {
    public static final String TAG = "CopyService";

    private static final String EXTRA_CANCEL = "com.android.documentsui.CANCEL";
    public static final String EXTRA_SRC_LIST = "com.android.documentsui.SRC_LIST";
    public static final String EXTRA_STACK = "com.android.documentsui.STACK";
    public static final String EXTRA_FAILURE = "com.android.documentsui.FAILURE";

    // TODO: Move it to a shared file when more operations are implemented.
    public static final int FAILURE_COPY = 1;

    private PowerManager mPowerManager;

    private NotificationManager mNotificationManager;
    private Notification.Builder mProgressBuilder;

    // Jobs are serialized but a job ID is used, to avoid mixing up cancellation requests.
    private String mJobId;
    private volatile boolean mIsCancelled;
    // Parameters of the copy job. Requests to an IntentService are serialized so this code only
    // needs to deal with one job at a time.
    private final ArrayList<DocumentInfo> mFailedFiles;
    private long mBatchSize;
    private long mBytesCopied;
    private long mStartTime;
    private long mLastNotificationTime;
    // Speed estimation
    private long mBytesCopiedSample;
    private long mSampleTime;
    private long mSpeed;
    private long mRemainingTime;
    // Provider clients are acquired for the duration of each copy job. Note that there is an
    // implicit assumption that all srcs come from the same authority.
    private ContentProviderClient mSrcClient;
    private ContentProviderClient mDstClient;

    public CopyService() {
        super("CopyService");

        mFailedFiles = new ArrayList<DocumentInfo>();
    }

    /**
     * Starts the service for a copy operation.
     *
     * @param context Context for the intent.
     * @param srcDocs A list of src files to copy.
     * @param dstStack The copy destination stack.
     */
    public static void start(Context context, List<DocumentInfo> srcDocs, DocumentStack dstStack) {
        final Resources res = context.getResources();
        final Intent copyIntent = new Intent(context, CopyService.class);
        copyIntent.putParcelableArrayListExtra(
                EXTRA_SRC_LIST, new ArrayList<DocumentInfo>(srcDocs));
        copyIntent.putExtra(EXTRA_STACK, (Parcelable) dstStack);

        Toast.makeText(context,
                res.getQuantityString(R.plurals.copy_begin, srcDocs.size(), srcDocs.size()),
                Toast.LENGTH_SHORT).show();
        context.startService(copyIntent);
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        if (intent.hasExtra(EXTRA_CANCEL)) {
            handleCancel(intent);
        }
        return super.onStartCommand(intent, flags, startId);
    }

    @Override
    protected void onHandleIntent(Intent intent) {
        if (intent.hasExtra(EXTRA_CANCEL)) {
            handleCancel(intent);
            return;
        }

        final PowerManager.WakeLock wakeLock = mPowerManager
                .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG);
        final ArrayList<DocumentInfo> srcs = intent.getParcelableArrayListExtra(EXTRA_SRC_LIST);
        final DocumentStack stack = intent.getParcelableExtra(EXTRA_STACK);

        try {
            wakeLock.acquire();

            // Acquire content providers.
            mSrcClient = DocumentsApplication.acquireUnstableProviderOrThrow(getContentResolver(),
                    srcs.get(0).authority);
            mDstClient = DocumentsApplication.acquireUnstableProviderOrThrow(getContentResolver(),
                    stack.peek().authority);

            setupCopyJob(srcs, stack);

            for (int i = 0; i < srcs.size() && !mIsCancelled; ++i) {
                copy(srcs.get(i), stack.peek());
            }
        } catch (Exception e) {
            // Catch-all to prevent any copy errors from wedging the app.
            Log.e(TAG, "Exceptions occurred during copying", e);
        } finally {
            ContentProviderClient.releaseQuietly(mSrcClient);
            ContentProviderClient.releaseQuietly(mDstClient);

            wakeLock.release();

            // Dismiss the ongoing copy notification when the copy is done.
            mNotificationManager.cancel(mJobId, 0);

            if (mFailedFiles.size() > 0) {
                final Context context = getApplicationContext();
                final Intent navigateIntent = new Intent(context, DocumentsActivity.class);
                navigateIntent.putExtra(EXTRA_STACK, (Parcelable) stack);
                navigateIntent.putExtra(EXTRA_FAILURE, FAILURE_COPY);
                navigateIntent.putParcelableArrayListExtra(EXTRA_SRC_LIST, mFailedFiles);

                final Notification.Builder errorBuilder = new Notification.Builder(this)
                        .setContentTitle(context.getResources().
                                getQuantityString(R.plurals.copy_error_notification_title,
                                        mFailedFiles.size(), mFailedFiles.size()))
                        .setContentText(getString(R.string.notification_touch_for_details))
                        .setContentIntent(PendingIntent.getActivity(context, 0, navigateIntent,
                                PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_ONE_SHOT))
                        .setCategory(Notification.CATEGORY_ERROR)
                        .setSmallIcon(R.drawable.ic_menu_copy)
                        .setAutoCancel(true);
                mNotificationManager.notify(mJobId, 0, errorBuilder.build());
            }
        }
    }

    @Override
    public void onCreate() {
        super.onCreate();
        mPowerManager = getSystemService(PowerManager.class);
        mNotificationManager = getSystemService(NotificationManager.class);
    }

    /**
     * Sets up the CopyService to start tracking and sending notifications for the given batch of
     * files.
     *
     * @param srcs A list of src files to copy.
     * @param stack The copy destination stack.
     * @throws RemoteException
     */
    private void setupCopyJob(ArrayList<DocumentInfo> srcs, DocumentStack stack)
            throws RemoteException {
        // Create an ID for this copy job. Use the timestamp.
        mJobId = String.valueOf(SystemClock.elapsedRealtime());
        // Reset the cancellation flag.
        mIsCancelled = false;

        final Context context = getApplicationContext();
        final Intent navigateIntent = new Intent(context, DocumentsActivity.class);
        navigateIntent.putExtra(EXTRA_STACK, (Parcelable) stack);

        mProgressBuilder = new Notification.Builder(this)
                .setContentTitle(getString(R.string.copy_notification_title))
                .setContentIntent(PendingIntent.getActivity(context, 0, navigateIntent, 0))
                .setCategory(Notification.CATEGORY_PROGRESS)
                .setSmallIcon(R.drawable.ic_menu_copy)
                .setOngoing(true);

        final Intent cancelIntent = new Intent(this, CopyService.class);
        cancelIntent.putExtra(EXTRA_CANCEL, mJobId);
        mProgressBuilder.addAction(R.drawable.ic_cab_cancel,
                getString(android.R.string.cancel), PendingIntent.getService(this, 0,
                        cancelIntent,
                        PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_CANCEL_CURRENT));

        // Send an initial progress notification.
        mProgressBuilder.setProgress(0, 0, true); // Indeterminate progress while setting up.
        mProgressBuilder.setContentText(getString(R.string.copy_preparing));
        mNotificationManager.notify(mJobId, 0, mProgressBuilder.build());

        // Reset batch parameters.
        mFailedFiles.clear();
        mBatchSize = calculateFileSizes(srcs);
        mBytesCopied = 0;
        mStartTime = SystemClock.elapsedRealtime();
        mLastNotificationTime = 0;
        mBytesCopiedSample = 0;
        mSampleTime = 0;
        mSpeed = 0;
        mRemainingTime = 0;

        // TODO: Check preconditions for copy.
        // - check that the destination has enough space and is writeable?
        // - check MIME types?
    }

    /**
     * Calculates the cumulative size of all the documents in the list. Directories are recursed
     * into and totaled up.
     *
     * @param srcs
     * @return Size in bytes.
     * @throws RemoteException
     */
    private long calculateFileSizes(List<DocumentInfo> srcs) throws RemoteException {
        long result = 0;
        for (DocumentInfo src : srcs) {
            if (Document.MIME_TYPE_DIR.equals(src.mimeType)) {
                // Directories need to be recursed into.
                result += calculateFileSizesHelper(src.derivedUri);
            } else {
                result += src.size;
            }
        }
        return result;
    }

    /**
     * Calculates (recursively) the cumulative size of all the files under the given directory.
     *
     * @throws RemoteException
     */
    private long calculateFileSizesHelper(Uri uri) throws RemoteException {
        final String authority = uri.getAuthority();
        final Uri queryUri = DocumentsContract.buildChildDocumentsUri(authority,
                DocumentsContract.getDocumentId(uri));
        final String queryColumns[] = new String[] {
                Document.COLUMN_DOCUMENT_ID,
                Document.COLUMN_MIME_TYPE,
                Document.COLUMN_SIZE
        };

        long result = 0;
        Cursor cursor = null;
        try {
            cursor = mSrcClient.query(queryUri, queryColumns, null, null, null);
            while (cursor.moveToNext()) {
                if (Document.MIME_TYPE_DIR.equals(
                        getCursorString(cursor, Document.COLUMN_MIME_TYPE))) {
                    // Recurse into directories.
                    final Uri subdirUri = DocumentsContract.buildDocumentUri(authority,
                            getCursorString(cursor, Document.COLUMN_DOCUMENT_ID));
                    result += calculateFileSizesHelper(subdirUri);
                } else {
                    // This may return -1 if the size isn't defined. Ignore those cases.
                    long size = getCursorLong(cursor, Document.COLUMN_SIZE);
                    result += size > 0 ? size : 0;
                }
            }
        } finally {
            IoUtils.closeQuietly(cursor);
        }

        return result;
    }

    /**
     * Cancels the current copy job, if its ID matches the given ID.
     *
     * @param intent The cancellation intent.
     */
    private void handleCancel(Intent intent) {
        final String cancelledId = intent.getStringExtra(EXTRA_CANCEL);
        // Do nothing if the cancelled ID doesn't match the current job ID. This prevents racey
        // cancellation requests from affecting unrelated copy jobs.  However, if the current job ID
        // is null, the service most likely crashed and was revived by the incoming cancel intent.
        // In that case, always allow the cancellation to proceed.
        if (Objects.equals(mJobId, cancelledId) || mJobId == null) {
            // Set the cancel flag. This causes the copy loops to exit.
            mIsCancelled = true;
            // Dismiss the progress notification here rather than in the copy loop. This preserves
            // interactivity for the user in case the copy loop is stalled.
            mNotificationManager.cancel(cancelledId, 0);
        }
    }

    /**
     * Logs progress on the current copy operation. Displays/Updates the progress notification.
     *
     * @param bytesCopied
     */
    private void makeProgress(long bytesCopied) {
        mBytesCopied += bytesCopied;
        double done = (double) mBytesCopied / mBatchSize;
        String percent = NumberFormat.getPercentInstance().format(done);

        // Update time estimate
        long currentTime = SystemClock.elapsedRealtime();
        long elapsedTime = currentTime - mStartTime;

        // Send out progress notifications once a second.
        if (currentTime - mLastNotificationTime > 1000) {
            updateRemainingTimeEstimate(elapsedTime);
            mProgressBuilder.setProgress(100, (int) (done * 100), false);
            mProgressBuilder.setContentInfo(percent);
            if (mRemainingTime > 0) {
                mProgressBuilder.setContentText(getString(R.string.copy_remaining,
                        DateUtils.formatDuration(mRemainingTime)));
            } else {
                mProgressBuilder.setContentText(null);
            }
            mNotificationManager.notify(mJobId, 0, mProgressBuilder.build());
            mLastNotificationTime = currentTime;
        }
    }

    /**
     * Generates an estimate of the remaining time in the copy.
     *
     * @param elapsedTime The time elapsed so far.
     */
    private void updateRemainingTimeEstimate(long elapsedTime) {
        final long sampleDuration = elapsedTime - mSampleTime;
        final long sampleSpeed = ((mBytesCopied - mBytesCopiedSample) * 1000) / sampleDuration;
        if (mSpeed == 0) {
            mSpeed = sampleSpeed;
        } else {
            mSpeed = ((3 * mSpeed) + sampleSpeed) / 4;
        }

        if (mSampleTime > 0 && mSpeed > 0) {
            mRemainingTime = ((mBatchSize - mBytesCopied) * 1000) / mSpeed;
        } else {
            mRemainingTime = 0;
        }

        mSampleTime = elapsedTime;
        mBytesCopiedSample = mBytesCopied;
    }

    /**
     * Copies a the given documents to the given location.
     *
     * @param srcInfo DocumentInfos for the documents to copy.
     * @param dstDirInfo The destination directory.
     * @throws RemoteException
     */
    private void copy(DocumentInfo srcInfo, DocumentInfo dstDirInfo) throws RemoteException {
        final Uri dstUri = DocumentsContract.createDocument(mDstClient, dstDirInfo.derivedUri,
                srcInfo.mimeType, srcInfo.displayName);
        if (dstUri == null) {
            // If this is a directory, the entire subdir will not be copied over.
            Log.e(TAG, "Error while copying " + srcInfo.displayName);
            mFailedFiles.add(srcInfo);
            return;
        }

        if (Document.MIME_TYPE_DIR.equals(srcInfo.mimeType)) {
            copyDirectoryHelper(srcInfo.derivedUri, dstUri);
        } else {
            copyFileHelper(srcInfo.derivedUri, dstUri);
        }
    }

    /**
     * Handles recursion into a directory and copying its contents. Note that in linux terms, this
     * does the equivalent of "cp src/* dst", not "cp -r src dst".
     *
     * @param srcDirUri URI of the directory to copy from. The routine will copy the directory's
     *            contents, not the directory itself.
     * @param dstDirUri URI of the directory to copy to. Must be created beforehand.
     * @throws RemoteException
     */
    private void copyDirectoryHelper(Uri srcDirUri, Uri dstDirUri) throws RemoteException {
        // Recurse into directories. Copy children into the new subdirectory.
        final String queryColumns[] = new String[] {
                Document.COLUMN_DISPLAY_NAME,
                Document.COLUMN_DOCUMENT_ID,
                Document.COLUMN_MIME_TYPE,
                Document.COLUMN_SIZE
        };
        final Uri queryUri = DocumentsContract.buildChildDocumentsUri(srcDirUri.getAuthority(),
                DocumentsContract.getDocumentId(srcDirUri));
        Cursor cursor = null;
        try {
            // Iterate over srcs in the directory; copy to the destination directory.
            cursor = mSrcClient.query(queryUri, queryColumns, null, null, null);
            while (cursor.moveToNext()) {
                final String childMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE);
                final Uri dstUri = DocumentsContract.createDocument(mDstClient, dstDirUri,
                        childMimeType, getCursorString(cursor, Document.COLUMN_DISPLAY_NAME));
                final Uri childUri = DocumentsContract.buildDocumentUri(srcDirUri.getAuthority(),
                        getCursorString(cursor, Document.COLUMN_DOCUMENT_ID));
                if (Document.MIME_TYPE_DIR.equals(childMimeType)) {
                    copyDirectoryHelper(childUri, dstUri);
                } else {
                    copyFileHelper(childUri, dstUri);
                }
            }
        } finally {
            IoUtils.closeQuietly(cursor);
        }
    }

    /**
     * Handles copying a single file.
     *
     * @param srcUri URI of the file to copy from.
     * @param dstUri URI of the *file* to copy to. Must be created beforehand.
     * @throws RemoteException
     */
    private void copyFileHelper(Uri srcUri, Uri dstUri) throws RemoteException {
        // Copy an individual file.
        CancellationSignal canceller = new CancellationSignal();
        ParcelFileDescriptor srcFile = null;
        ParcelFileDescriptor dstFile = null;
        InputStream src = null;
        OutputStream dst = null;

        IOException copyError = null;
        try {
            srcFile = mSrcClient.openFile(srcUri, "r", canceller);
            dstFile = mDstClient.openFile(dstUri, "w", canceller);
            src = new ParcelFileDescriptor.AutoCloseInputStream(srcFile);
            dst = new ParcelFileDescriptor.AutoCloseOutputStream(dstFile);

            byte[] buffer = new byte[8192];
            int len;
            while (!mIsCancelled && ((len = src.read(buffer)) != -1)) {
                dst.write(buffer, 0, len);
                makeProgress(len);
            }

            srcFile.checkError();
        } catch (IOException e) {
            copyError = e;
            try {
                dstFile.closeWithError(copyError.getMessage());
            } catch (IOException closeError) {
                Log.e(TAG, "Error closing destination", closeError);
            }
        } finally {
            // This also ensures the file descriptors are closed.
            IoUtils.closeQuietly(src);
            IoUtils.closeQuietly(dst);
        }

        if (copyError != null) {
            // Log errors.
            Log.e(TAG, "Error while copying " + srcUri.toString(), copyError);
            try {
                mFailedFiles.add(DocumentInfo.fromUri(getContentResolver(), srcUri));
            } catch (FileNotFoundException ignore) {
                Log.w(TAG, "Source file gone: " + srcUri, copyError);
              // The source file is gone.
            }
        }

        if (copyError != null || mIsCancelled) {
            // Clean up half-copied files.
            canceller.cancel();
            try {
                DocumentsContract.deleteDocument(mDstClient, dstUri);
            } catch (RemoteException e) {
                Log.w(TAG, "Failed to clean up: " + srcUri, e);
                // RemoteExceptions usually signal that the connection is dead, so there's no point
                // attempting to continue. Propagate the exception up so the copy job is cancelled.
                throw e;
            }
        }
    }
}
