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

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

import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Handler;
import android.os.IBinder;
import android.os.PowerManager;
import android.os.UserManager;
import android.util.Log;

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

import com.android.documentsui.R;
import com.android.documentsui.base.Features;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

import javax.annotation.concurrent.GuardedBy;

public class FileOperationService extends Service implements Job.Listener {

    public static final String TAG = "FileOperationService";

    // Extra used for OperationDialogFragment, Notifications and picking copy destination.
    public static final String EXTRA_OPERATION_TYPE = "com.android.documentsui.OPERATION_TYPE";

    // Extras used for OperationDialogFragment...
    public static final String EXTRA_DIALOG_TYPE = "com.android.documentsui.DIALOG_TYPE";
    public static final String EXTRA_SRC_LIST = "com.android.documentsui.SRC_LIST";

    public static final String EXTRA_FAILED_URIS = "com.android.documentsui.FAILED_URIS";
    public static final String EXTRA_FAILED_DOCS = "com.android.documentsui.FAILED_DOCS";

    // Extras used to start or cancel a file operation...
    public static final String EXTRA_JOB_ID = "com.android.documentsui.JOB_ID";
    public static final String EXTRA_OPERATION = "com.android.documentsui.OPERATION";
    public static final String EXTRA_CANCEL = "com.android.documentsui.CANCEL";

    @IntDef({
            OPERATION_UNKNOWN,
            OPERATION_COPY,
            OPERATION_COMPRESS,
            OPERATION_EXTRACT,
            OPERATION_MOVE,
            OPERATION_DELETE
    })
    @Retention(RetentionPolicy.SOURCE)
    public @interface OpType {}
    public static final int OPERATION_UNKNOWN = -1;
    public static final int OPERATION_COPY = 1;
    public static final int OPERATION_EXTRACT = 2;
    public static final int OPERATION_COMPRESS = 3;
    public static final int OPERATION_MOVE = 4;
    public static final int OPERATION_DELETE = 5;

    @IntDef({
            MESSAGE_PROGRESS,
            MESSAGE_FINISH
    })
    @Retention(RetentionPolicy.SOURCE)
    public @interface MessageType {}
    public static final int MESSAGE_PROGRESS = 0;
    public static final int MESSAGE_FINISH = 1;

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

    static final String NOTIFICATION_CHANNEL_ID = "channel_id";

    // This is a temporary solution, we will gray out the UI when a transaction is in progress to
    // not enable users to make a transaction.
    private static final int POOL_SIZE = 1;  // Allow only 1 executor operation

    @VisibleForTesting static final int NOTIFICATION_ID_PROGRESS = 1;
    private static final int NOTIFICATION_ID_FAILURE = 2;
    private static final int NOTIFICATION_ID_WARNING = 3;

    // The executor and job factory are visible for testing and non-final
    // so we'll have a way to inject test doubles from the test. It's
    // a sub-optimal arrangement.
    @VisibleForTesting ExecutorService executor;

    // Use a separate thread pool to prioritize deletions.
    @VisibleForTesting ExecutorService deletionExecutor;

    // Use a handler to schedule monitor tasks.
    @VisibleForTesting Handler handler;

    // Use a foreground manager to change foreground state of this service.
    @VisibleForTesting ForegroundManager foregroundManager;

    // Use a notification manager to post and cancel notifications for jobs.
    @VisibleForTesting NotificationManager notificationManager;

    // Use a features to determine if notification channel is enabled.
    @VisibleForTesting Features features;

    @GuardedBy("mJobs")
    private final Map<String, JobRecord> mJobs = new LinkedHashMap<>();

    // The job whose notification is used to keep the service in foreground mode.
    @GuardedBy("mJobs")
    private Job mForegroundJob;

    private PowerManager mPowerManager;
    private PowerManager.WakeLock mWakeLock;  // the wake lock, if held.

    private int mLastServiceId;

    @Override
    public void onCreate() {
        // Allow tests to pre-set these with test doubles.
        if (executor == null) {
            executor = Executors.newFixedThreadPool(POOL_SIZE);
        }

        if (deletionExecutor == null) {
            deletionExecutor = Executors.newCachedThreadPool();
        }

        if (handler == null) {
            // Monitor tasks are small enough to schedule them on main thread.
            handler = new Handler();
        }

        if (foregroundManager == null) {
            foregroundManager = createForegroundManager(this);
        }

        if (notificationManager == null) {
            notificationManager = getSystemService(NotificationManager.class);
        }

        UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE);
        features = new Features.RuntimeFeatures(getResources(), userManager);
        setUpNotificationChannel();

        if (DEBUG) {
            Log.d(TAG, "Created.");
        }
        mPowerManager = getSystemService(PowerManager.class);
    }

    private void setUpNotificationChannel() {
        if (features.isNotificationChannelEnabled()) {
            NotificationChannel channel = new NotificationChannel(
                    NOTIFICATION_CHANNEL_ID,
                    getString(R.string.app_label),
                    NotificationManager.IMPORTANCE_LOW);
            notificationManager.createNotificationChannel(channel);
        }
    }

    @Override
    public void onDestroy() {
        if (DEBUG) {
            Log.d(TAG, "Shutting down executor.");
        }

        List<Runnable> unfinishedCopies = executor.shutdownNow();
        List<Runnable> unfinishedDeletions = deletionExecutor.shutdownNow();
        List<Runnable> unfinished =
                new ArrayList<>(unfinishedCopies.size() + unfinishedDeletions.size());
        unfinished.addAll(unfinishedCopies);
        unfinished.addAll(unfinishedDeletions);
        if (!unfinished.isEmpty()) {
            Log.w(TAG, "Shutting down, but executor reports running jobs: " + unfinished);
        }

        executor = null;
        deletionExecutor = null;
        handler = null;

        if (DEBUG) {
            Log.d(TAG, "Destroyed.");
        }
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int serviceId) {
        // TODO: Ensure we're not being called with retry or redeliver.
        // checkArgument(flags == 0);  // retry and redeliver are not supported.

        String jobId = intent.getStringExtra(EXTRA_JOB_ID);
        assert(jobId != null);

        if (DEBUG) {
            Log.d(TAG, "onStartCommand: " + jobId + " with serviceId " + serviceId);
        }

        if (intent.hasExtra(EXTRA_CANCEL)) {
            handleCancel(intent);
        } else {
            FileOperation operation = intent.getParcelableExtra(EXTRA_OPERATION);
            handleOperation(jobId, operation);
        }

        // Track the service supplied id so we can stop the service once we're out of work to do.
        mLastServiceId = serviceId;

        return START_NOT_STICKY;
    }

    private void handleOperation(String jobId, FileOperation operation) {
        synchronized (mJobs) {
            if (mWakeLock == null) {
                mWakeLock = mPowerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG);
            }

            if (mJobs.containsKey(jobId)) {
                Log.w(TAG, "Duplicate job id: " + jobId
                        + ". Ignoring job request for operation: " + operation + ".");
                return;
            }

            Job job = operation.createJob(this, this, jobId, features);

            if (job == null) {
                return;
            }

            assert (job != null);
            if (DEBUG) {
                Log.d(TAG, "Scheduling job " + job.id + ".");
            }
            Future<?> future = getExecutorService(operation.getOpType()).submit(job);
            mJobs.put(jobId, new JobRecord(job, future));

            // Acquire wake lock to keep CPU running until we finish all jobs. Acquire wake lock
            // after we create a job and put it in mJobs to avoid potential leaking of wake lock
            // in case where job creation fails.
            mWakeLock.acquire();
        }
    }

    /**
     * Cancels the operation corresponding to job id, identified in "EXTRA_JOB_ID".
     *
     * @param intent The cancellation intent.
     */
    private void handleCancel(Intent intent) {
        assert(intent.hasExtra(EXTRA_CANCEL));
        assert(intent.getStringExtra(EXTRA_JOB_ID) != null);

        String jobId = intent.getStringExtra(EXTRA_JOB_ID);

        if (DEBUG) {
            Log.d(TAG, "handleCancel: " + jobId);
        }

        synchronized (mJobs) {
            // 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.
            JobRecord record = mJobs.get(jobId);
            if (record != null) {
                record.job.cancel();
                updateForegroundState(record.job);
            }
        }

        // Dismiss the progress notification here rather than in the copy loop. This preserves
        // interactivity for the user in case the copy loop is stalled.
        // Try to cancel it even if we don't have a job id...in case there is some sad
        // orphan notification.
        notificationManager.cancel(jobId, NOTIFICATION_ID_PROGRESS);

        // TODO: Guarantee the job is being finalized
    }

    private ExecutorService getExecutorService(@OpType int operationType) {
        switch (operationType) {
            case OPERATION_COPY:
            case OPERATION_COMPRESS:
            case OPERATION_EXTRACT:
            case OPERATION_MOVE:
                return executor;
            case OPERATION_DELETE:
                return deletionExecutor;
            default:
                throw new UnsupportedOperationException();
        }
    }

    @GuardedBy("mJobs")
    private void deleteJob(Job job) {
        if (DEBUG) {
            Log.d(TAG, "deleteJob: " + job.id);
        }

        // Release wake lock before clearing jobs just in case we fail to clean them up.
        mWakeLock.release();
        if (!mWakeLock.isHeld()) {
            mWakeLock = null;
        }

        JobRecord record = mJobs.remove(job.id);
        assert(record != null);
        record.job.cleanup();

        // Delay the shutdown until we've cleaned up all notifications. shutdown() is now posted in
        // onFinished(Job job) to main thread.
    }

    /**
     * Most likely shuts down. Won't shut down if service has a pending
     * message. Thread pool is deal with in onDestroy.
     */
    private void shutdown() {
        if (DEBUG) {
            Log.d(TAG, "Shutting down. Last serviceId was " + mLastServiceId);
        }
        assert(mWakeLock == null);

        // Turns out, for us, stopSelfResult always returns false in tests,
        // so we can't guard executor shutdown. For this reason we move
        // executor shutdown to #onDestroy.
        boolean gonnaStop = stopSelfResult(mLastServiceId);
        if (DEBUG) {
            Log.d(TAG, "Stopping service: " + gonnaStop);
        }
        if (!gonnaStop) {
            Log.w(TAG, "Service should be stopping, but reports otherwise.");
        }
    }

    @VisibleForTesting
    boolean holdsWakeLock() {
        return mWakeLock != null && mWakeLock.isHeld();
    }

    @Override
    public void onStart(Job job) {
        if (DEBUG) {
            Log.d(TAG, "onStart: " + job.id);
        }

        Notification notification = job.getSetupNotification();
        // If there is no foreground job yet, set this job to foreground job.
        synchronized (mJobs) {
            if (mForegroundJob == null) {
                if (DEBUG) {
                    Log.d(TAG, "Set foreground job to " + job.id);
                }
                mForegroundJob = job;
                foregroundManager.startForeground(NOTIFICATION_ID_PROGRESS, notification);
            } else {
                // Show start up notification
                if (DEBUG) {
                    Log.d(TAG, "Posting notification for " + job.id);
                }
                notificationManager.notify(
                        mForegroundJob == job ? null : job.id,
                        NOTIFICATION_ID_PROGRESS,
                        notification);
            }
        }

        // Set up related monitor
        JobMonitor monitor = new JobMonitor(job);
        monitor.start();
    }

    @Override
    public void onFinished(Job job) {
        assert(job.isFinished());
        if (DEBUG) {
            Log.d(TAG, "onFinished: " + job.id);
        }

        synchronized (mJobs) {
            // Delete the job from mJobs first to avoid this job being selected as the foreground
            // task again if we need to swap the foreground job.
            deleteJob(job);

            // Update foreground state before cleaning up notification. If the finishing job is the
            // foreground job, we would need to switch to another one or go to background before
            // we can clean up notifications.
            updateForegroundState(job);

            // Use the same thread of monitors to tackle notifications to avoid race conditions.
            // Otherwise we may fail to dismiss progress notification.
            handler.post(() -> cleanUpNotification(job));

            // Post the shutdown message to main thread after cleanUpNotification() to give it a
            // chance to run. Otherwise this process may be torn down by Android before we've
            // cleaned up the notifications of the last job.
            if (mJobs.isEmpty()) {
                handler.post(this::shutdown);
            }
        }
    }

    @GuardedBy("mJobs")
    private void updateForegroundState(Job job) {
        Job candidate = getCandidateForegroundJob();

        // If foreground job is retiring and there is still work to do, we need to set it to a new
        // job.
        if (mForegroundJob == job) {
            mForegroundJob = candidate;
            if (candidate == null) {
                if (DEBUG) {
                    Log.d(TAG, "Stop foreground");
                }
                // Remove the notification here just in case we're torn down before we have the
                // chance to clean up notifications.
                foregroundManager.stopForeground(true);
            } else {
                if (DEBUG) {
                    Log.d(TAG, "Switch foreground job to " + candidate.id);
                }

                notificationManager.cancel(candidate.id, NOTIFICATION_ID_PROGRESS);
                Notification notification = (candidate.getState() == Job.STATE_STARTED)
                        ? candidate.getSetupNotification()
                        : candidate.getProgressNotification();
                notificationManager.notify(NOTIFICATION_ID_PROGRESS, notification);
            }
        }
    }

    private void cleanUpNotification(Job job) {

        if (DEBUG) {
            Log.d(TAG, "Canceling notification for " + job.id);
        }
        // Dismiss the ongoing copy notification when the copy is done.
        notificationManager.cancel(job.id, NOTIFICATION_ID_PROGRESS);

        if (job.hasFailures()) {
            if (!job.failedUris.isEmpty()) {
                Log.e(TAG, "Job failed to resolve uris: " + job.failedUris + ".");
            }
            if (!job.failedDocs.isEmpty()) {
                Log.e(TAG, "Job failed to process docs: " + job.failedDocs + ".");
            }
            notificationManager.notify(
                    job.id, NOTIFICATION_ID_FAILURE, job.getFailureNotification());
        }

        if (job.hasWarnings()) {
            if (DEBUG) {
                Log.d(TAG, "Job finished with warnings.");
            }
            notificationManager.notify(
                    job.id, NOTIFICATION_ID_WARNING, job.getWarningNotification());
        }
    }

    @GuardedBy("mJobs")
    private Job getCandidateForegroundJob() {
        if (mJobs.isEmpty()) {
            return null;
        }
        for (JobRecord rec : mJobs.values()) {
            if (!rec.job.isFinished()) {
                return rec.job;
            }
        }
        return null;
    }

    private static final class JobRecord {
        private final Job job;
        private final Future<?> future;

        public JobRecord(Job job, Future<?> future) {
            this.job = job;
            this.future = future;
        }
    }

    /**
     * A class used to periodically polls state of a job.
     *
     * <p>It's possible that jobs hang because underlying document providers stop responding. We
     * still need to update notifications if jobs hang, so instead of jobs pushing their states,
     * we poll states of jobs.
     */
    private final class JobMonitor implements Runnable {
        private static final long PROGRESS_INTERVAL_MILLIS = 500L;

        private final Job mJob;

        private JobMonitor(Job job) {
            mJob = job;
        }

        private void start() {
            handler.post(this);
        }

        @Override
        public void run() {
            synchronized (mJobs) {
                if (mJob.isFinished()) {
                    // Finish notification is already shown. Progress notification is removed.
                    // Just finish itself.
                    return;
                }

                // Only job in set up state has progress bar
                if (mJob.getState() == Job.STATE_SET_UP) {
                    notificationManager.notify(
                            mForegroundJob == mJob ? null : mJob.id,
                            NOTIFICATION_ID_PROGRESS,
                            mJob.getProgressNotification());
                }

                handler.postDelayed(this, PROGRESS_INTERVAL_MILLIS);
            }
        }
    }

    @Override
    public IBinder onBind(Intent intent) {
        return null;  // Boilerplate. See super#onBind
    }

    private static ForegroundManager createForegroundManager(final Service service) {
        return new ForegroundManager() {
            @Override
            public void startForeground(int id, Notification notification) {
                service.startForeground(id, notification);
            }

            @Override
            public void stopForeground(boolean removeNotification) {
                service.stopForeground(removeNotification);
            }
        };
    }

    @VisibleForTesting
    interface ForegroundManager {
        void startForeground(int id, Notification notification);
        void stopForeground(boolean removeNotification);
    }
}
