/*
 * Copyright (C) 2011 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.tradefed.build;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.when;

import com.android.tradefed.log.LogUtil.CLog;
import com.android.tradefed.result.error.InfraErrorIdentifier;
import com.android.tradefed.util.FileUtil;
import com.android.tradefed.util.RunUtil;
import com.android.tradefed.util.StreamUtil;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;

import java.io.File;
import java.io.FileInputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;

/** Longer running, concurrency based tests for {@link FileDownloadCache}. */
@RunWith(JUnit4.class)
public class FileDownloadCacheFuncTest {

    private static final String REMOTE_PATH = "path";
    private static final String DOWNLOADED_CONTENTS = "downloaded contents";

    @Mock IFileDownloader mMockDownloader;

    private FileDownloadCache mCache;
    private File mTmpDir;
    private List<File> mReturnedFiles;

    @Before
    public void setUp() throws Exception {
        MockitoAnnotations.initMocks(this);

        mTmpDir = FileUtil.createTempDir("functest");
        mCache = new FileDownloadCache(mTmpDir);
        mReturnedFiles = new ArrayList<File>(2);
    }

    @After
    public void tearDown() throws Exception {
        for (File file : mReturnedFiles) {
            file.delete();
        }
        FileUtil.recursiveDelete(mTmpDir);
    }

    /**
     * Test {@link FileDownloadCache#fetchRemoteFile(IFileDownloader, String)} being called
     * concurrently by two separate threads.
     */
    @Test
    public void testFetchRemoteFile_concurrent() throws Exception {
        // Simulate a relatively slow file download
        Answer<Object> slowDownloadAnswer =
                invocation -> {
                    Thread.sleep(500);
                    File fileArg = (File) invocation.getArguments()[1];
                    FileUtil.writeToFile(DOWNLOADED_CONTENTS, fileArg);
                    return null;
                };
        // Download is only called once, second thread will wait on synchronized until the download
        // is done, then link the downloaded file.
        doAnswer(slowDownloadAnswer)
                .when(mMockDownloader)
                .downloadFile(Mockito.eq(REMOTE_PATH), Mockito.<File>any());
        when(mMockDownloader.isFresh(Mockito.any(), Mockito.eq(REMOTE_PATH))).thenReturn(true);

        Thread downloadThread1 = createDownloadThread(mMockDownloader, REMOTE_PATH);
        downloadThread1.setName("FileDownloadCacheFuncTest#testFetchRemoteFile_concurrent-1");
        Thread downloadThread2 = createDownloadThread(mMockDownloader, REMOTE_PATH);
        downloadThread2.setName("FileDownloadCacheFuncTest#testFetchRemoteFile_concurrent-2");
        downloadThread1.start();
        downloadThread2.start();
        downloadThread1.join();
        downloadThread2.join();
        assertNotNull(mCache.getCachedFile(REMOTE_PATH));
        assertEquals(2, mReturnedFiles.size());
        // returned files should be identical in content, but be different files
        assertTrue(!mReturnedFiles.get(0).equals(mReturnedFiles.get(1)));
        assertEquals(
                DOWNLOADED_CONTENTS,
                StreamUtil.getStringFromStream(new FileInputStream(mReturnedFiles.get(0))));
        assertEquals(
                DOWNLOADED_CONTENTS,
                StreamUtil.getStringFromStream(new FileInputStream(mReturnedFiles.get(1))));
        InOrder inOrder = Mockito.inOrder(mMockDownloader);
        inOrder.verify(mMockDownloader).downloadFile(Mockito.eq(REMOTE_PATH), Mockito.<File>any());
    }

    /**
     * Test {@link FileDownloadCache#fetchRemoteFile(IFileDownloader, String)} being called
     * concurrently by multiple threads trying to download different files.
     */
    @Test
    public void testFetchRemoteFile_multiConcurrent() throws Exception {
        IFileDownloader mockDownloader1 = Mockito.mock(IFileDownloader.class);
        IFileDownloader mockDownloader2 = Mockito.mock(IFileDownloader.class);
        IFileDownloader mockDownloader3 = Mockito.mock(IFileDownloader.class);
        String remotePath1 = "path1";
        String remotePath2 = "path2";
        String remotePath3 = "path3";

        // Block first download, but allow other downloads to pass.
        final AtomicBoolean startedDownload = new AtomicBoolean(false);
        final AtomicBoolean blockDownload = new AtomicBoolean(true);
        mCache.setMaxCacheSize(DOWNLOADED_CONTENTS.length() + 1);
        Answer<Void> blockedAnswer =
                new Answer<Void>() {
                    @Override
                    public Void answer(InvocationOnMock invocation) throws Throwable {
                        if (!startedDownload.get()) {
                            startedDownload.set(true);
                            while (blockDownload.get()) {
                                RunUtil.getDefault().sleep(10);
                            }
                        }
                        File fileArg = (File) invocation.getArguments()[1];
                        FileUtil.writeToFile(DOWNLOADED_CONTENTS, fileArg);
                        return null;
                    }
                };

        // Download is called once per files since they are different files.
        Mockito.doAnswer(blockedAnswer)
                .when(mockDownloader1)
                .downloadFile(Mockito.eq(remotePath1), Mockito.any());
        Mockito.doAnswer(blockedAnswer)
                .when(mockDownloader2)
                .downloadFile(Mockito.eq(remotePath2), Mockito.any());
        Mockito.doAnswer(blockedAnswer)
                .when(mockDownloader3)
                .downloadFile(Mockito.eq(remotePath3), Mockito.any());

        Thread downloadThread1 = createDownloadThread(mockDownloader1, remotePath1);
        downloadThread1.setName("FileDownloadCacheFuncTest#testFetchRemoteFile_multiConcurrent-1");
        Thread downloadThread2 = createDownloadThread(mockDownloader2, remotePath2);
        downloadThread2.setName("FileDownloadCacheFuncTest#testFetchRemoteFile_multiConcurrent-2");
        Thread downloadThread3 = createDownloadThread(mockDownloader3, remotePath3);
        downloadThread3.setName("FileDownloadCacheFuncTest#testFetchRemoteFile_multiConcurrent-3");

        // Start first thread, and wait for download to begin
        downloadThread1.start();
        while (!startedDownload.get()) {
            RunUtil.getDefault().sleep(10);
        }

        // Start the other threads, which should run to completion. The cache should be adjusted,
        // but the file in the first thread should not be deleted since it is still being
        // downloaded.
        downloadThread2.start();
        downloadThread3.start();
        downloadThread2.join(2000);
        downloadThread3.join(2000);
        assertFalse(downloadThread2.isAlive());
        assertFalse(downloadThread3.isAlive());
        assertNotNull(mCache.getCachedFile(remotePath1));

        // Complete download of first thread, and let the thread run to completion. What files are
        // left in the cache can depend on implementation, so we're not testing for it.
        blockDownload.set(false);
        downloadThread1.join(2000);
        assertFalse(downloadThread1.isAlive());

        Mockito.verify(mockDownloader1).downloadFile(Mockito.eq(remotePath1), Mockito.any());
        Mockito.verify(mockDownloader2).downloadFile(Mockito.eq(remotePath2), Mockito.any());
        Mockito.verify(mockDownloader3).downloadFile(Mockito.eq(remotePath3), Mockito.any());
    }

    /**
     * Test {@link FileDownloadCache#fetchRemoteFile(IFileDownloader, String)} being called
     * concurrently by multiple threads trying to download the same file, with one thread failing.
     */
    @Test
    public void testFetchRemoteFile_concurrentFail() throws Exception {
        // Block first download, and later raise an error, but allow other downloads to pass.
        final AtomicBoolean startedDownload = new AtomicBoolean(false);
        final AtomicBoolean throwException = new AtomicBoolean(false);
        Answer<Object> blockedDownloadAnswer =
                invocation -> {
                    if (!startedDownload.get()) {
                        startedDownload.set(true);
                        while (!throwException.get()) {
                            Thread.sleep(10);
                        }
                        throw new BuildRetrievalError(
                                "download error", InfraErrorIdentifier.ARTIFACT_DOWNLOAD_ERROR);
                    }
                    File fileArg = (File) invocation.getArguments()[1];
                    FileUtil.writeToFile(DOWNLOADED_CONTENTS, fileArg);
                    return null;
                };

        // Download should be called twice. The first call will result in an error, and the second
        // will run to completion.
        doAnswer(blockedDownloadAnswer)
                .when(mMockDownloader)
                .downloadFile(Mockito.eq(REMOTE_PATH), Mockito.<File>any());
        when(mMockDownloader.isFresh(Mockito.any(), Mockito.eq(REMOTE_PATH))).thenReturn(true);

        Thread downloadThread1 = createDownloadThread(mMockDownloader, REMOTE_PATH);
        downloadThread1.setName("FileDownloadCacheFuncTest#testFetchRemoteFile_concurrentFail-1");
        Thread downloadThread2 = createDownloadThread(mMockDownloader, REMOTE_PATH);
        downloadThread2.setName("FileDownloadCacheFuncTest#testFetchRemoteFile_concurrentFail-2");
        Thread downloadThread3 = createDownloadThread(mMockDownloader, REMOTE_PATH);
        downloadThread3.setName("FileDownloadCacheFuncTest#testFetchRemoteFile_concurrentFail-3");

        // Start first thread, and wait for download to begin
        downloadThread1.start();
        while (!startedDownload.get()) {
            Thread.sleep(10);
        }

        // Start second thread, and allow it to attempt to obtain the file lock.
        downloadThread2.start();
        Thread.sleep(100);

        // Throw a BuildRetrievalError, and allow both threads to run to completion.
        throwException.set(true);
        downloadThread1.join(2000);
        downloadThread2.join(2000);
        assertFalse(downloadThread1.isAlive());
        assertFalse(downloadThread2.isAlive());
        assertNotNull(mCache.getCachedFile(REMOTE_PATH));

        // A third attempt to retrive the file should not result in another download call.
        downloadThread3.start();
        downloadThread3.join(2000);

        InOrder inOrder = Mockito.inOrder(mMockDownloader);
        inOrder.verify(mMockDownloader, times(2))
                .downloadFile(Mockito.eq(REMOTE_PATH), Mockito.<File>any());
    }

    /** Verify the cache is built from disk contents on creation */
    @Test
    public void testConstructor_createCache() throws Exception {
        // create cache contents on disk
        File cacheRoot = FileUtil.createTempDir("constructorTest");
        try {
            final String filecontents = "these are the file contents";
            File file1 = new File(cacheRoot, REMOTE_PATH);
            FileUtil.writeToFile(filecontents, file1);
            // this is lame, but sleep for a small amount to ensure nestedFile has later timestamp
            // TODO: use mock File instead
            Thread.sleep(1000);
            File nestedDir = new File(cacheRoot, "aa");
            nestedDir.mkdir();
            File nestedFile = new File(nestedDir, "anotherpath");
            FileUtil.writeToFile(filecontents, nestedFile);

            FileDownloadCache cache = new FileDownloadCache(cacheRoot);
            assertNotNull(cache.getCachedFile(REMOTE_PATH));
            assertNotNull(cache.getCachedFile("aa/anotherpath"));
            assertEquals(REMOTE_PATH, cache.getOldestEntry());
        } finally {
            FileUtil.recursiveDelete(cacheRoot);
        }
    }

    /** Test scenario where an already too large cache is built from disk contents. */
    @Test
    public void testConstructor_cacheExceeded() throws Exception {
        File cacheRoot = FileUtil.createTempDir("testConstructor_cacheExceeded");
        try {
            // create a couple existing files in cache
            final String filecontents = "these are the file contents";
            final File file1 = new File(cacheRoot, REMOTE_PATH);
            FileUtil.writeToFile(filecontents, file1);
            // sleep for a small amount to ensure file2 has later timestamp
            // TODO: use mock File instead
            Thread.sleep(1000);
            final File file2 = new File(cacheRoot, "anotherpath");
            FileUtil.writeToFile(filecontents, file2);

            new FileDownloadCache(cacheRoot) {
                @Override
                long getMaxFileCacheSize() {
                    return file2.length() + 1;
                }
            };
            // expect cache to be cleaned on startup, with oldest file1 deleted, but newest file
            // retained
            assertFalse(file1.exists());
            assertTrue(file2.exists());
        } finally {
            FileUtil.recursiveDelete(cacheRoot);
        }
    }

    /** Utility method to create thread that calls fetchRemoteFile. */
    private Thread createDownloadThread(IFileDownloader downloader, String remotePath) {
        return new Thread() {
            @Override
            public void run() {
                try {
                    mReturnedFiles.add(mCache.fetchRemoteFile(downloader, remotePath));
                } catch (BuildRetrievalError e) {
                    CLog.e(e);
                }
            }
        };
    }
}
