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

import android.annotation.TargetApi;
import android.app.job.JobInfo;
import android.app.job.JobParameters;
import android.app.job.JobScheduler;
import android.app.job.JobService;
import android.app.job.JobWorkItem;
import android.content.ClipData;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Process;
import android.util.Log;

import junit.framework.Assert;

import java.util.ArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

/**
 * Handles callback from the framework {@link android.app.job.JobScheduler}. The behaviour of this
 * class is configured through the static
 * {@link TestEnvironment}.
 */
@TargetApi(21)
public class MockJobService extends JobService {
    private static final String TAG = "MockJobService";

    /** Wait this long before timing out the test. */
    private static final long DEFAULT_TIMEOUT_MILLIS = 30000L; // 30 seconds.

    private JobParameters mParams;

    ArrayList<JobWorkItem> mReceivedWork = new ArrayList<>();

    ArrayList<JobWorkItem> mPendingCompletions = new ArrayList<>();

    private boolean mWaitingForStop;

    @Override
    public void onDestroy() {
        super.onDestroy();
        Log.i(TAG, "Destroying test service");
        if (TestEnvironment.getTestEnvironment().getExpectedWork() != null) {
            TestEnvironment.getTestEnvironment().notifyExecution(mParams, 0, 0, mReceivedWork,
                    null);
        }
    }

    @Override
    public void onCreate() {
        super.onCreate();
        Log.i(TAG, "Created test service.");
    }

    @Override
    public boolean onStartJob(JobParameters params) {
        Log.i(TAG, "Test job executing: " + params.getJobId());
        mParams = params;

        int permCheckRead = PackageManager.PERMISSION_DENIED;
        int permCheckWrite = PackageManager.PERMISSION_DENIED;
        ClipData clip = params.getClipData();
        if (clip != null) {
            permCheckRead = checkUriPermission(clip.getItemAt(0).getUri(), Process.myPid(),
                    Process.myUid(), Intent.FLAG_GRANT_READ_URI_PERMISSION);
            permCheckWrite = checkUriPermission(clip.getItemAt(0).getUri(), Process.myPid(),
                    Process.myUid(), Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
        }

        TestWorkItem[] expectedWork = TestEnvironment.getTestEnvironment().getExpectedWork();
        if (expectedWork != null) {
            try {
                if (!TestEnvironment.getTestEnvironment().awaitDoWork()) {
                    TestEnvironment.getTestEnvironment().notifyExecution(params, permCheckRead,
                            permCheckWrite, null, "Spent too long waiting to start executing work");
                    return false;
                }
            } catch (InterruptedException e) {
                TestEnvironment.getTestEnvironment().notifyExecution(params, permCheckRead,
                        permCheckWrite, null, "Failed waiting for work: " + e);
                return false;
            }
            JobWorkItem work;
            int index = 0;
            while ((work = params.dequeueWork()) != null) {
                final Intent intent = work.getIntent();
                Log.i(TAG, "Received work #" + index + ": " + intent);
                mReceivedWork.add(work);

                int flags = 0;

                if (index < expectedWork.length) {
                    TestWorkItem expected = expectedWork[index];
                    int grantFlags = intent == null ? 0 : intent.getFlags();
                    if (expected.requireUrisGranted != null) {
                        for (int ui = 0; ui < expected.requireUrisGranted.length; ui++) {
                            if ((grantFlags & Intent.FLAG_GRANT_READ_URI_PERMISSION) != 0) {
                                if (checkUriPermission(expected.requireUrisGranted[ui],
                                        Process.myPid(), Process.myUid(),
                                        Intent.FLAG_GRANT_READ_URI_PERMISSION)
                                        != PackageManager.PERMISSION_GRANTED) {
                                    TestEnvironment.getTestEnvironment().notifyExecution(params,
                                            permCheckRead, permCheckWrite, null,
                                            "Expected read permission but not granted: "
                                                    + expected.requireUrisGranted[ui]
                                                    + " @ #" + index);
                                    return false;
                                }
                            }
                            if ((grantFlags & Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != 0) {
                                if (checkUriPermission(expected.requireUrisGranted[ui],
                                        Process.myPid(), Process.myUid(),
                                        Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
                                        != PackageManager.PERMISSION_GRANTED) {
                                    TestEnvironment.getTestEnvironment().notifyExecution(params,
                                            permCheckRead, permCheckWrite, null,
                                            "Expected write permission but not granted: "
                                                    + expected.requireUrisGranted[ui]
                                                    + " @ #" + index);
                                    return false;
                                }
                            }
                        }
                    }
                    if (expected.requireUrisNotGranted != null) {
                        // XXX note no delay here, current impl will have fully revoked the
                        // permission by the time we return from completing the last work.
                        for (int ui = 0; ui < expected.requireUrisNotGranted.length; ui++) {
                            if ((grantFlags & Intent.FLAG_GRANT_READ_URI_PERMISSION) != 0) {
                                if (checkUriPermission(expected.requireUrisNotGranted[ui],
                                        Process.myPid(), Process.myUid(),
                                        Intent.FLAG_GRANT_READ_URI_PERMISSION)
                                        != PackageManager.PERMISSION_DENIED) {
                                    TestEnvironment.getTestEnvironment().notifyExecution(params,
                                            permCheckRead, permCheckWrite, null,
                                            "Not expected read permission but granted: "
                                                    + expected.requireUrisNotGranted[ui]
                                                    + " @ #" + index);
                                    return false;
                                }
                            }
                            if ((grantFlags & Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != 0) {
                                if (checkUriPermission(expected.requireUrisNotGranted[ui],
                                        Process.myPid(), Process.myUid(),
                                        Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
                                        != PackageManager.PERMISSION_DENIED) {
                                    TestEnvironment.getTestEnvironment().notifyExecution(params,
                                            permCheckRead, permCheckWrite, null,
                                            "Not expected write permission but granted: "
                                                    + expected.requireUrisNotGranted[ui]
                                                    + " @ #" + index);
                                    return false;
                                }
                            }
                        }
                    }

                    flags = expected.flags;

                    if ((flags & TestWorkItem.FLAG_WAIT_FOR_STOP) != 0) {
                        Log.i(TAG, "Now waiting to stop");
                        mWaitingForStop = true;
                        TestEnvironment.getTestEnvironment().notifyWaitingForStop();
                        return true;
                    }

                    if ((flags & TestWorkItem.FLAG_COMPLETE_NEXT) != 0) {
                        if (!processNextPendingCompletion()) {
                            TestEnvironment.getTestEnvironment().notifyExecution(params,
                                    0, 0, null,
                                    "Expected to complete next pending work but there was none: "
                                            + " @ #" + index);
                            return false;
                        }
                    }
                }

                if ((flags & TestWorkItem.FLAG_DELAY_COMPLETE_PUSH_BACK) != 0) {
                    mPendingCompletions.add(work);
                } else if ((flags & TestWorkItem.FLAG_DELAY_COMPLETE_PUSH_TOP) != 0) {
                    mPendingCompletions.add(0, work);
                } else {
                    mParams.completeWork(work);
                }

                if (index < expectedWork.length) {
                    TestWorkItem expected = expectedWork[index];
                    if (expected.subitems != null) {
                        final TestWorkItem[] sub = expected.subitems;
                        final JobInfo ji = expected.jobInfo;
                        final JobScheduler js = (JobScheduler) getSystemService(
                                Context.JOB_SCHEDULER_SERVICE);
                        for (int subi = 0; subi < sub.length; subi++) {
                            js.enqueue(ji, new JobWorkItem(sub[subi].intent));
                        }
                    }
                }

                index++;
            }

            if (processNextPendingCompletion()) {
                // We had some pending completions, clean them all out...
                while (processNextPendingCompletion()) {
                }
                // ...and we need to do a final dequeue to complete the job, which should not
                // return any remaining work.
                if ((work = params.dequeueWork()) != null) {
                    TestEnvironment.getTestEnvironment().notifyExecution(params,
                            0, 0, null,
                            "Expected no remaining work after dequeue pending, but got: " + work);
                }
            }

            Log.i(TAG, "Done with all work at #" + index);
            // We don't notifyExecution here because we want to make sure the job properly
            // stops itself.
            return true;
        } else {
            boolean continueAfterStart
                    = TestEnvironment.getTestEnvironment().handleContinueAfterStart();
            try {
                if (!TestEnvironment.getTestEnvironment().awaitDoJob()) {
                    TestEnvironment.getTestEnvironment().notifyExecution(params, permCheckRead,
                            permCheckWrite, null, "Spent too long waiting to start job");
                    return false;
                }
            } catch (InterruptedException e) {
                TestEnvironment.getTestEnvironment().notifyExecution(params, permCheckRead,
                        permCheckWrite, null, "Failed waiting to start job: " + e);
                return false;
            }
            TestEnvironment.getTestEnvironment().notifyExecution(params, permCheckRead,
                    permCheckWrite, null, null);
            return continueAfterStart;
        }
    }

    boolean processNextPendingCompletion() {
        if (mPendingCompletions.size() <= 0) {
            return false;
        }

        JobWorkItem next = mPendingCompletions.remove(0);
        mParams.completeWork(next);
        return true;
    }

    @Override
    public boolean onStopJob(JobParameters params) {
        Log.i(TAG, "Received stop callback");
        TestEnvironment.getTestEnvironment().notifyStopped();
        return mWaitingForStop;
    }

    public static final class TestWorkItem {
        /**
         * Stop processing work for now, waiting for the service to be stopped.
         */
        public static final int FLAG_WAIT_FOR_STOP = 1<<0;
        /**
         * Don't complete this work now, instead push it on the back of the stack of
         * pending completions.
         */
        public static final int FLAG_DELAY_COMPLETE_PUSH_BACK = 1<<1;
        /**
         * Don't complete this work now, instead insert to the top of the stack of
         * pending completions.
         */
        public static final int FLAG_DELAY_COMPLETE_PUSH_TOP = 1<<2;
        /**
         * Complete next pending completion on the stack before completing this one.
         */
        public static final int FLAG_COMPLETE_NEXT = 1<<3;

        public final Intent intent;
        public final JobInfo jobInfo;
        public final int flags;
        public final int deliveryCount;
        public final TestWorkItem[] subitems;
        public final Uri[] requireUrisGranted;
        public final Uri[] requireUrisNotGranted;

        public TestWorkItem(Intent _intent) {
            intent = _intent;
            jobInfo = null;
            flags = 0;
            deliveryCount = 1;
            subitems = null;
            requireUrisGranted = null;
            requireUrisNotGranted = null;
        }

        public TestWorkItem(Intent _intent, int _flags) {
            intent = _intent;
            jobInfo = null;
            flags = _flags;
            deliveryCount = 1;
            subitems = null;
            requireUrisGranted = null;
            requireUrisNotGranted = null;
        }

        public TestWorkItem(Intent _intent, int _flags, int _deliveryCount) {
            intent = _intent;
            jobInfo = null;
            flags = _flags;
            deliveryCount = _deliveryCount;
            subitems = null;
            requireUrisGranted = null;
            requireUrisNotGranted = null;
        }

        public TestWorkItem(Intent _intent, JobInfo _jobInfo, TestWorkItem[] _subitems) {
            intent = _intent;
            jobInfo = _jobInfo;
            flags = 0;
            deliveryCount = 1;
            subitems = _subitems;
            requireUrisGranted = null;
            requireUrisNotGranted = null;
        }

        public TestWorkItem(Intent _intent, Uri[] _requireUrisGranted,
                Uri[] _requireUrisNotGranted) {
            intent = _intent;
            jobInfo = null;
            flags = 0;
            deliveryCount = 1;
            subitems = null;
            requireUrisGranted = _requireUrisGranted;
            requireUrisNotGranted = _requireUrisNotGranted;
        }

        @Override
        public String toString() {
            return "TestWorkItem { " + intent + " dc=" + deliveryCount + " }";
        }
    }

    /**
     * Configures the expected behaviour for each test. This object is shared across consecutive
     * tests, so to clear state each test is responsible for calling
     * {@link TestEnvironment#setUp()}.
     */
    public static final class TestEnvironment {

        private static TestEnvironment kTestEnvironment;
        //public static final int INVALID_JOB_ID = -1;

        private CountDownLatch mLatch;
        private CountDownLatch mWaitingForStopLatch;
        private CountDownLatch mDoJobLatch;
        private CountDownLatch mStoppedLatch;
        private CountDownLatch mDoWorkLatch;
        private TestWorkItem[] mExpectedWork;
        private boolean mContinueAfterStart;
        private JobParameters mExecutedJobParameters;
        private int mExecutedPermCheckRead;
        private int mExecutedPermCheckWrite;
        private ArrayList<JobWorkItem> mExecutedReceivedWork;
        private String mExecutedErrorMessage;

        public static TestEnvironment getTestEnvironment() {
            if (kTestEnvironment == null) {
                kTestEnvironment = new TestEnvironment();
            }
            return kTestEnvironment;
        }

        public TestWorkItem[] getExpectedWork() {
            return mExpectedWork;
        }

        public JobParameters getLastJobParameters() {
            return mExecutedJobParameters;
        }

        public int getLastPermCheckRead() {
            return mExecutedPermCheckRead;
        }

        public int getLastPermCheckWrite() {
            return mExecutedPermCheckWrite;
        }

        public ArrayList<JobWorkItem> getLastReceivedWork() {
            return mExecutedReceivedWork;
        }

        public String getLastErrorMessage() {
            return mExecutedErrorMessage;
        }

        /**
         * Block the test thread, waiting on the JobScheduler to execute some previously scheduled
         * job on this service.
         */
        public boolean awaitExecution() throws InterruptedException {
            return awaitExecution(DEFAULT_TIMEOUT_MILLIS);
        }

        public boolean awaitExecution(long timeoutMillis) throws InterruptedException {
            final boolean executed = mLatch.await(timeoutMillis, TimeUnit.MILLISECONDS);
            if (getLastErrorMessage() != null) {
                Assert.fail(getLastErrorMessage());
            }
            return executed;
        }

        /**
         * Block the test thread, expecting to timeout but still listening to ensure that no jobs
         * land in the interim.
         * @return True if the latch timed out waiting on an execution.
         */
        public boolean awaitTimeout() throws InterruptedException {
            return awaitTimeout(DEFAULT_TIMEOUT_MILLIS);
        }

        public boolean awaitTimeout(long timeoutMillis) throws InterruptedException {
            return !mLatch.await(timeoutMillis, TimeUnit.MILLISECONDS);
        }

        public boolean awaitWaitingForStop() throws InterruptedException {
            return mWaitingForStopLatch.await(DEFAULT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
        }

        public boolean awaitDoWork() throws InterruptedException {
            return mDoWorkLatch.await(DEFAULT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
        }

        public boolean awaitDoJob() throws InterruptedException {
            if (mDoJobLatch == null) {
                return true;
            }
            return mDoJobLatch.await(DEFAULT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
        }

        public boolean awaitStopped() throws InterruptedException {
            return mStoppedLatch.await(DEFAULT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
        }

        private void notifyExecution(JobParameters params, int permCheckRead, int permCheckWrite,
                ArrayList<JobWorkItem> receivedWork, String errorMsg) {
            //Log.d(TAG, "Job executed:" + params.getJobId());
            mExecutedJobParameters = params;
            mExecutedPermCheckRead = permCheckRead;
            mExecutedPermCheckWrite = permCheckWrite;
            mExecutedReceivedWork = receivedWork;
            mExecutedErrorMessage = errorMsg;
            mLatch.countDown();
        }

        private void notifyWaitingForStop() {
            mWaitingForStopLatch.countDown();
        }

        private void notifyStopped() {
            if (mStoppedLatch != null) {
                mStoppedLatch.countDown();
            }
        }

        public void setExpectedExecutions(int numExecutions) {
            // For no executions expected, set count to 1 so we can still block for the timeout.
            if (numExecutions == 0) {
                mLatch = new CountDownLatch(1);
            } else {
                mLatch = new CountDownLatch(numExecutions);
            }
            mWaitingForStopLatch = null;
            mDoJobLatch = null;
            mStoppedLatch = null;
            mDoWorkLatch = null;
            mExpectedWork = null;
            mContinueAfterStart = false;
        }

        public void setExpectedWaitForStop() {
            mWaitingForStopLatch = new CountDownLatch(1);
        }

        public void setExpectedWork(TestWorkItem[] work) {
            mExpectedWork = work;
            mDoWorkLatch = new CountDownLatch(1);
        }

        public void setExpectedStopped() {
            mStoppedLatch = new CountDownLatch(1);
        }

        public void readyToWork() {
            mDoWorkLatch.countDown();
        }

        public void setExpectedWaitForRun() {
            mDoJobLatch = new CountDownLatch(1);
        }

        public void readyToRun() {
            mDoJobLatch.countDown();
        }

        public void setContinueAfterStart() {
            mContinueAfterStart = true;
        }

        public boolean handleContinueAfterStart() {
            boolean res = mContinueAfterStart;
            mContinueAfterStart = false;
            return res;
        }

        /** Called in each testCase#setup */
        public void setUp() {
            mLatch = null;
            mExecutedJobParameters = null;
        }

    }
}