/*
 * Copyright (C) 2014 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.build.gradle.integration.common.fixture;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

import com.android.SdkConstants;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.build.gradle.integration.common.fixture.app.AbstractAndroidTestApp;
import com.android.build.gradle.integration.common.fixture.app.AndroidTestApp;
import com.android.build.gradle.integration.common.fixture.app.TestSourceFile;
import com.android.build.gradle.integration.common.utils.FileHelper;
import com.android.build.gradle.integration.common.utils.JacocoAgent;
import com.android.build.gradle.integration.common.utils.SdkHelper;
import com.android.builder.core.BuilderConstants;
import com.android.builder.model.AndroidProject;
import com.android.builder.model.SyncIssue;
import com.android.builder.model.Version;
import com.android.io.StreamException;
import com.android.sdklib.internal.project.ProjectProperties;
import com.android.sdklib.internal.project.ProjectPropertiesWorkingCopy;
import com.android.sdklib.repository.FullRevision;
import com.google.common.base.Charsets;
import com.google.common.base.Joiner;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.io.Files;

import org.gradle.tooling.BuildAction;
import org.gradle.tooling.BuildActionExecuter;
import org.gradle.tooling.BuildLauncher;
import org.gradle.tooling.GradleConnectionException;
import org.gradle.tooling.GradleConnector;
import org.gradle.tooling.LongRunningOperation;
import org.gradle.tooling.ProjectConnection;
import org.gradle.tooling.ResultHandler;
import org.gradle.tooling.internal.consumer.DefaultGradleConnector;
import org.gradle.tooling.model.GradleProject;
import org.gradle.tooling.model.GradleTask;
import org.junit.Assume;
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.Charset;
import java.security.CodeSource;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.TimeUnit;

/**
 * JUnit4 test rule for integration test.
 *
 * This rule create a gradle project in a temporary directory.
 * It can be use with the @Rule or @ClassRule annotations.  Using this class with @Rule will create
 * a gradle project in separate directories for each unit test, whereas using it with @ClassRule
 * creates a single gradle project.
 *
 * The test directory is always deleted if it already exists at the start of the test to ensure a
 * clean environment.
 */
public class GradleTestProject implements TestRule {

    public static final File TEST_RES_DIR = new File("src/test/resources");

    public static final int DEFAULT_COMPILE_SDK_VERSION = 21;
    public static final String DEFAULT_BUILD_TOOL_VERSION;
    public static final String REMOTE_TEST_PROVIDER = System.getenv().get("REMOTE_TEST_PROVIDER");

    public static final String DEVICE_PROVIDER_NAME = REMOTE_TEST_PROVIDER != null ?
            REMOTE_TEST_PROVIDER : BuilderConstants.CONNECTED;

    public static final String GRADLE_TEST_VERSION = "2.2.1";
    public static final String GRADLE_EXP_TEST_VERSION = "2.5";

    public static final String ANDROID_GRADLE_PLUGIN_VERSION;

    public static final String CUSTOM_JACK;
    public static final boolean USE_JACK;

    private static final String RECORD_BENCHMARK_NAME = "com.android.benchmark.name";
    private static final String RECORD_BENCHMARK_MODE = "com.android.benchmark.mode";

    public enum BenchmarkMode {
        EVALUATION, SYNC, BUILD_FULL, BUILD_INC_JAVA, BUILD_INC_RES_EDIT, BUILD_INC_RES_ADD
    }

    static {
        String envBuildToolVersion = System.getenv("CUSTOM_BUILDTOOLS");
        DEFAULT_BUILD_TOOL_VERSION = !Strings.isNullOrEmpty(envBuildToolVersion) ?
                envBuildToolVersion : "22.0.1";
        String envVersion = System.getenv().get("CUSTOM_GRADLE");
        ANDROID_GRADLE_PLUGIN_VERSION = !Strings.isNullOrEmpty(envVersion) ? envVersion
                : Version.ANDROID_GRADLE_PLUGIN_VERSION;
        String envJack = System.getenv().get("CUSTOM_JACK");
        CUSTOM_JACK = !Strings.isNullOrEmpty(envJack) ? envJack : "false";
        USE_JACK = Boolean.parseBoolean(CUSTOM_JACK);
    }

    private static final String COMMON_HEADER = "commonHeader.gradle";
    private static final String COMMON_LOCAL_REPO = "commonLocalRepo.gradle";
    private static final String COMMON_BUILD_SCRIPT = "commonBuildScript.gradle";
    private static final String COMMON_BUILD_SCRIPT_EXP = "commonBuildScriptExperimental.gradle";
    private static final String COMMON_GRADLE_PLUGIN_VERSION = "commonGradlePluginVersion.gradle";
    private static final String DEFAULT_TEST_PROJECT_NAME = "project";

    public static class Builder {
        private static final File SAMPLE_PROJECT_DIR = new File("samples");
        private static final File TEST_PROJECT_DIR = new File("test-projects");

        @Nullable
        private String name;

        @Nullable
        private TestProject testProject = null;

        @Nullable
        File sdkDir = SdkHelper.findSdkDir();
        @Nullable
        File ndkDir = findNdkDir();
        boolean captureStdOut = false;
        boolean captureStdErr = false;
        boolean experimentalMode = false;
        @Nullable
        private String targetGradleVersion;

        boolean useJack = false;
        boolean useMinify = false;
        @NonNull
        private List<String> gradleProperties = Lists.newArrayList();
        @Nullable
        private String heapSize;

        /**
         * Create a GradleTestProject.
         */
        public GradleTestProject create() {
            if (targetGradleVersion == null) {
                targetGradleVersion =
                        experimentalMode ? GRADLE_EXP_TEST_VERSION : GRADLE_TEST_VERSION;
            }
            return new GradleTestProject(
                    name,
                    testProject,
                    experimentalMode,
                    useMinify,
                    useJack,
                    targetGradleVersion,
                    captureStdOut,
                    captureStdErr,
                    sdkDir,
                    ndkDir,
                    gradleProperties,
                    heapSize);
        }

        /**
         * Set the name of the project.
         *
         * Necessary if you have multiple projects in a test class.
         */
        public Builder withName(@NonNull String name) {
            this.name = name;
            return this;
        }

        public Builder captureStdOut(boolean captureStdOut) {
            this.captureStdOut = captureStdOut;
            return this;
        }

        public Builder captureStdErr(boolean captureStdErr) {
            this.captureStdErr = captureStdErr;
            return this;
        }

        /**
         * Use experimental plugin for the test project.
         */
        public Builder forExpermimentalPlugin(boolean mode) {
            this.experimentalMode = mode;
            return this;
        }

        /**
         * Use the gradle version for experimental plugin, but the test project do not necessarily
         * have to use experimental plugin.
         */
        public Builder useExperimentalGradleVersion(boolean mode) {
            if (mode) {
                targetGradleVersion = GRADLE_EXP_TEST_VERSION;
            }
            return this;
        }

        /**
         * Use the gradle version specified, e.g. "2.4".
         */
        public Builder useGradleVersion(String targetGradleVersion) {
            this.targetGradleVersion = targetGradleVersion;
            return this;
        }

        /**
         * Create a project without setting ndk.dir in local.properties.
         */
        public Builder withoutNdk() {
            this.ndkDir = null;
            return this;
        }

        /**
         * Create GradleTestProject from a TestProject.
         */
        public Builder fromTestApp(@NonNull TestProject testProject) {
            this.testProject = testProject;
            return this;
        }

        /**
         * Create GradleTestProject from an existing test project.
         */
        public Builder fromTestProject(@NonNull String project) {
            AndroidTestApp app = new EmptyTestApp();
            name = project;
            File projectDir = new File(TEST_PROJECT_DIR, project);
            addAllFiles(app, projectDir);
            return fromTestApp(app);
        }

        /**
         * Create GradleTestProject from an existing test project.
         */
        public Builder fromExternalProject(@NonNull String project) throws IOException {
            AndroidTestApp app = new EmptyTestApp();
            name = project;
            // compute the root folder of the checkout, based on test-projects.
            File parentDir = TEST_PROJECT_DIR.getCanonicalFile().getParentFile().getParentFile()
                    .getParentFile().getParentFile().getParentFile();
            parentDir = new File(parentDir, "external");
            File projectDir = new File(parentDir, project);
            addAllFiles(app, projectDir);
            return fromTestApp(app);
        }

        /**
         * Add gradle properties.
         */
        public Builder addGradleProperties(@NonNull String property) {
            gradleProperties.add(property);
            return this;
        }

        /**
         * Sets the test heap size requirement. Example values : 1024m, 2048m...
         *
         * @param heapSize the heap size in a format understood by the -Xmx JVM parameter
         * @return itself.
         */
        public Builder withHeap(String heapSize) {
            this.heapSize = heapSize;
            return this;
        }

        public Builder withJack(boolean useJack) {
            this.useJack = useJack;
            return this;
        }

        public Builder withMinify(boolean useMinify) {
            this.useMinify = useMinify;
            return this;
        }

        private static class EmptyTestApp extends AbstractAndroidTestApp {
            @Override
            public boolean containsFullBuildScript() {
                return true;
            }
        }
    }

    private final String name;
    private final File outDir;
    private File testDir;
    private File sourceDir;
    private File buildFile;
    private final File ndkDir;
    private final File sdkDir;

    private final ByteArrayOutputStream stdout;
    private final ByteArrayOutputStream stderr;

    private final Collection<String> gradleProperties;

    @Nullable
    private final TestProject testProject;

    private final boolean experimentalMode;
    private final String targetGradleVersion;

    private final boolean useJack;
    private final boolean minifyEnabled;

    @Nullable
    private String heapSize;

    private GradleTestProject(
            @Nullable String name,
            @Nullable TestProject testProject,
            boolean experimentalMode,
            boolean minifyEnabled,
            boolean useJack,
            String targetGradleVersion,
            boolean captureStdOut,
            boolean captureStdErr,
            @Nullable File sdkDir,
            @Nullable File ndkDir,
            @NonNull Collection<String> gradleProperties,
            @Nullable String heapSize) {
        String buildDir = System.getenv("PROJECT_BUILD_DIR");
        outDir = (buildDir == null) ? new File("build/tests") : new File(buildDir, "tests");
        testDir = null;
        buildFile = sourceDir = null;
        this.name = (name == null) ? DEFAULT_TEST_PROJECT_NAME : name;
        this.experimentalMode = experimentalMode;
        this.minifyEnabled = minifyEnabled;
        this.useJack = useJack;
        this.targetGradleVersion = targetGradleVersion;
        this.testProject = testProject;
        stdout = captureStdOut ? new ByteArrayOutputStream() : null;
        stderr = captureStdErr ? new ByteArrayOutputStream() : null;
        this.sdkDir = sdkDir;
        this.ndkDir = ndkDir;
        this.heapSize = heapSize;
        this.gradleProperties = gradleProperties;
    }

    /**
     * Create a GradleTestProject representing a subProject of another GradleTestProject.
     * @param subProject name of the subProject.
     * @param rootProject root GradleTestProject.
     */
    private GradleTestProject(
            @NonNull String subProject,
            @NonNull GradleTestProject rootProject) {
        name = subProject;
        outDir = rootProject.outDir;

        testDir = new File(rootProject.testDir, subProject);
        assertTrue("No subproject dir at " + testDir.toString(), testDir.isDirectory());

        buildFile = new File(testDir, "build.gradle");
        sourceDir = new File(testDir, "src");
        ndkDir = rootProject.ndkDir;
        sdkDir = rootProject.sdkDir;
        stdout = rootProject.stdout;
        stderr = rootProject.stdout;
        gradleProperties = ImmutableList.of();
        testProject = null;
        experimentalMode = rootProject.isExperimentalMode();
        targetGradleVersion = rootProject.getTargetGradleVersion();
        minifyEnabled = false;
        useJack = false;
    }

    String getTargetGradleVersion() {
        return targetGradleVersion;
    }

    boolean isExperimentalMode() {
        return experimentalMode;
    }

    public static Builder builder() {
        return new Builder();
    }

    /**
     * Recursively delete directory or file.
     *
     * @param root directory to delete
     */
    private static void deleteRecursive(File root) {
        if (root.exists()) {
            if (root.isDirectory()) {
                File files[] = root.listFiles();
                if (files != null) {
                    for (File file : files) {
                        deleteRecursive(file);
                    }
                }
            }
            assertTrue(root.delete());
        }
    }

    /**
     * Add all files in a directory to an AndroidTestApp.
     */
    private static void addAllFiles(AndroidTestApp app, File projectDir) {
        for (String filePath : FileHelper.listFiles(projectDir)) {
            File file = new File(filePath);
            try {
                app.addFile(
                        new TestSourceFile(
                                file.getParent(),
                                file.getName(),
                                Files.toByteArray(new File(projectDir, filePath))));
            } catch (IOException e) {
                fail(e.toString());
            }
        }
    }

    @Override
    public Statement apply(final Statement base, Description description) {
        testDir = new File(outDir, description.getTestClass().getName());

        // Create separate directory based on test method name if @Rule is used.
        // getMethodName() is null if this rule is used as a @ClassRule.
        if (description.getMethodName() != null) {
            String dirName = description.getMethodName();
            dirName = dirName.replaceAll("[^a-zA-Z0-9_]", "_");
            testDir = new File(testDir, dirName);
        }
        testDir = new File(testDir, name);

        buildFile = new File(testDir, "build.gradle");
        sourceDir = new File(testDir, "src");

        return new Statement() {
            @Override
            public void evaluate() throws Throwable {
                if (testDir.exists()) {
                    deleteRecursive(testDir);
                }
                assertTrue(testDir.mkdirs());
                assertTrue(sourceDir.mkdirs());

                Files.copy(
                        new File(Builder.TEST_PROJECT_DIR, COMMON_HEADER),
                        new File(testDir.getParent(), COMMON_HEADER));
                Files.copy(
                        new File(Builder.TEST_PROJECT_DIR, COMMON_LOCAL_REPO),
                        new File(testDir.getParent(), COMMON_LOCAL_REPO));
                Files.copy(
                        new File(Builder.TEST_PROJECT_DIR, COMMON_BUILD_SCRIPT),
                        new File(testDir.getParent(), COMMON_BUILD_SCRIPT));
                Files.copy(
                        new File(Builder.TEST_PROJECT_DIR, COMMON_BUILD_SCRIPT_EXP),
                        new File(testDir.getParent(), COMMON_BUILD_SCRIPT_EXP));
                Files.copy(
                        new File(Builder.TEST_PROJECT_DIR, COMMON_GRADLE_PLUGIN_VERSION),
                        new File(testDir.getParent(), COMMON_GRADLE_PLUGIN_VERSION));

                if (testProject != null) {
                    testProject.write(
                            testDir,
                            testProject.containsFullBuildScript() ? "" :getGradleBuildscript());
                } else {
                    Files.write(
                            getGradleBuildscript(),
                            buildFile,
                            Charsets.UTF_8);
                }

                createLocalProp(testDir, sdkDir, ndkDir);
                createGradleProp();
                base.evaluate();
            }
        };
    }

    /**
     * Create a GradleTestProject representing a subproject.
     */
    public GradleTestProject getSubproject(String name) {
        return new GradleTestProject(name, this);
    }

    /**
     * Return the name of the test project.
     */
    public String getName() {
        return name;
    }

    /**
     * Return the directory containing the test project.
     */
    public File getTestDir() {
        return testDir;
    }

    /**
     * Return the build.gradle of the test project.
     */
    public File getBuildFile() {
        return buildFile;
    }

    /**
     * Return the directory containing the source files of the test project.
     */
    public File getSourceDir() {
        return sourceDir;
    }

    /**
     * Return the output directory from Android plugins.
     */
    public File getOutputDir() {
        return new File(testDir,
                Joiner.on(File.separator).join("build", AndroidProject.FD_OUTPUTS));
    }

    /**
     * Return a File under the output directory from Android plugins.
     */
    public File getOutputFile(String path) {
        return new File(getOutputDir(), path);
    }

    /**
     * Return the output apk File from the application plugin for the given dimension.
     *
     * Expected dimensions orders are:
     *   - product flavors
     *   - build type
     *   - other modifiers (e.g. "unsigned", "aligned")
     */
    public File getApk(String ... dimensions) {
        // TODO: Add overload for tests and splits.
        List<String> dimensionList = Lists.newArrayListWithExpectedSize(1 + dimensions.length);
        dimensionList.add(getName());
        dimensionList.addAll(Arrays.asList(dimensions));
        return getOutputFile(
                "apk/" + Joiner.on("-").join(dimensionList) + SdkConstants.DOT_ANDROID_PACKAGE);
    }

    /**
     * Return the output aar File from the library plugin for the given dimension.
     *
     * Expected dimensions orders are:
     *   - product flavors
     *   - build type
     *   - other modifiers (e.g. "unsigned", "aligned")
     */
    public File getAar(String ... dimensions) {
        // TODO: Add overload for tests and splits.
        List<String> dimensionList = Lists.newArrayListWithExpectedSize(1 + dimensions.length);
        dimensionList.add(getName());
        dimensionList.addAll(Arrays.asList(dimensions));
        return getOutputFile("aar/" + Joiner.on("-").join(dimensionList) + SdkConstants.DOT_AAR);
    }

    /**
     * Returns the SDK dir
     */
    public File getSdkDir() {
        return sdkDir;
    }

    /**
     * Returns the NDK dir
     */
    public File getNdkDir() {
        return ndkDir;
    }

    /**
     * Return the directory of the repository containing the necessary plugins for testing.
     */
    private File getRepoDir() {
        CodeSource source = getClass().getProtectionDomain().getCodeSource();
        assert (source != null);
        URL location = source.getLocation();
        try {
            File dir = new File(location.toURI());
            assertTrue(dir.getPath(), dir.exists());

            File f = dir.getParentFile().getParentFile().getParentFile().getParentFile()
                    .getParentFile().getParentFile().getParentFile();
            return new File(f, "out" + File.separator + "repo");
        } catch (URISyntaxException e) {
            fail(e.getLocalizedMessage());
        }
        return null;
    }

    /**
     * Returns a string that contains the gradle buildscript content
     */
    public String getGradleBuildscript() {
        return "apply from: \"../commonHeader.gradle\"\n" +
               "buildscript { apply from: \"../commonBuildScript" +
               (experimentalMode ? "Experimental" : "") + ".gradle\", to: buildscript }\n" +
               "\n" +
               "apply from: \"../commonLocalRepo.gradle\"\n";
    }

    /**
     * Return a list of all task names of the project.
     */
    public List<String> getTaskList() {
        ProjectConnection connection = getProjectConnection();
        try {
            GradleProject project = connection.getModel(GradleProject.class);
            List<String> tasks = Lists.newArrayList();
            for (GradleTask gradleTask : project.getTasks()) {
                tasks.add(gradleTask.getName());
            }
            return tasks;
        } finally {
            connection.close();
        }

    }

    /**
     * Runs gradle on the project.  Throws exception on failure.
     *
     * @param tasks Variadic list of tasks to execute.
     */
    public void execute(String ... tasks) {
        execute(Collections.<String>emptyList(), false, false, ExpectedBuildResult.SUCCESS, tasks);
    }

    public void execute(@NonNull List<String> arguments, String ... tasks) {
        execute(arguments, false, false, ExpectedBuildResult.SUCCESS, tasks);
    }

    public void executeWithBenchmark(
            @NonNull String benchmarkName,
            @NonNull BenchmarkMode benchmarkMode,
            String ... tasks) {
        List<String> arguments = ImmutableList.of(
                "-P" + RECORD_BENCHMARK_NAME + "=" + benchmarkName,
                "-P" + RECORD_BENCHMARK_MODE + "=" + benchmarkMode.name().toLowerCase(Locale.US)
        );
        execute(arguments, false, false, ExpectedBuildResult.SUCCESS, tasks);
    }

    public void executeExpectingFailure(String... tasks) {
        executeExpectingFailure(Collections.<String>emptyList(), tasks);
    }

    public void executeExpectingFailure(@NonNull List<String> arguments, String... tasks) {
        execute(
                arguments,
                false /*returnModel*/,
                false /*emulateStudio_1_0*/,
                ExpectedBuildResult.FAILURE,
                tasks);
    }

    public void executeConnectedCheck() {
        executeConnectedCheck(Collections.<String>emptyList());
    }

    public void executeConnectedCheck(List<String> arguments) {
        execute(arguments, REMOTE_TEST_PROVIDER == null ? "connectedCheck" : "deviceCheck");
    }

    /**
     * Runs gradle on the project, and returns the project model.  Throws exception on failure.
     *
     * @param tasks Variadic list of tasks to execute.
     *
     * @return the AndroidProject model for the project.
     */
    @NonNull
    public AndroidProject executeAndReturnModel(String ... tasks) {
        return executeAndReturnModel(false, tasks);
    }

    /**
     * Runs gradle on the project, and returns the project model.  Throws exception on failure.
     *
     * @param emulateStudio_1_0 whether to emulate an older IDE (studio 1.0) querying the model.
     * @param tasks Variadic list of tasks to execute.
     *
     * @return the AndroidProject model for the project.
     */
    @NonNull
    public AndroidProject executeAndReturnModel(boolean emulateStudio_1_0, String ... tasks) {
        //noinspection ConstantConditions
        return execute(Collections.<String>emptyList(), true, emulateStudio_1_0,
                ExpectedBuildResult.SUCCESS, tasks);
    }

    /**
     * Runs gradle on the project, and returns a project model for each sub-project.
     * Throws exception on failure.
     *
     * @param tasks Variadic list of tasks to execute.
     *
     * @return the AndroidProject model for the project.
     */
    @NonNull
    public Map<String, AndroidProject> executeAndReturnMultiModel(String ... tasks) {
        return executeAndReturnMultiModel(false, tasks);
    }

    /**
     * Runs gradle on the project, and returns a project model for each sub-project.
     * Throws exception on failure.
     *
     * @param emulateStudio_1_0 whether to emulate an older IDE (studio 1.0) querying the model.
     * @param tasks Variadic list of tasks to execute.
     *
     * @return the AndroidProject model for the project.
     */
    @NonNull
    public Map<String, AndroidProject> executeAndReturnMultiModel(boolean emulateStudio_1_0, String ... tasks) {
        ProjectConnection connection = getProjectConnection();
        try {
            executeBuild(Collections.<String>emptyList(), connection, tasks,
                    ExpectedBuildResult.SUCCESS);

            return buildModel(connection, new GetAndroidModelAction(), emulateStudio_1_0, null, null);

        } finally {
            connection.close();
        }
    }

    /**
     * Returns the project model without building.
     *
     * This will fail if the project is a multi-project setup or if there are any sync issues
     * while loading the project.
     */
    @NonNull
    public AndroidProject getSingleModel() {
        return getSingleModel(false /* emulateStudio_1_0 */, true /*assertNodSyncIssues */);
    }

    /**
     * Returns the project model without building, querying it the way Studio 1.0 does.
     *
     * This will fail if the project is a multi-project setup or if there are any sync issues
     * while loading the project.
     */
    @NonNull
    public AndroidProject getSingleModelAsStudio1() {
        return getSingleModel(true /* emulateStudio_1_0 */, true /*assertNodSyncIssues */);
    }

    /**
     * Returns the project model without building.
     *
     * This will fail if the project is a multi-project setup.
     */
    @NonNull
    public AndroidProject getSingleModelIgnoringSyncIssues() {
        return getSingleModel(false /* emulateStudio_1_0 */, false /*assertNodSyncIssues */);
    }

    /**
     * Returns the project model without building, querying it the way Studio 1.0 does.
     *
     * This will fail if the project is a multi-project setup.
     */
    @NonNull
    public AndroidProject getSingleModelIgnoringSyncIssuesAsStudio1() {
        return getSingleModel(true /* emulateStudio_1_0 */, false /*assertNodSyncIssues */);
    }

    /**
     * Returns the project model without building.
     *
     * This will fail if the project is a multi-project setup.
     *
     * @param emulateStudio_1_0 whether to emulate an older IDE (studio 1.0) querying the model.
     * @param assertNoSyncIssues true if the presence of sync issues during the model evaluation
     *                           should raise a {@link AssertionError}s
     */
    @NonNull
    private AndroidProject getSingleModel(boolean emulateStudio_1_0, boolean assertNoSyncIssues) {
        ProjectConnection connection = getProjectConnection();
        try {
            Map<String, AndroidProject> modelMap = buildModel(
                    connection,
                    new GetAndroidModelAction(),
                    emulateStudio_1_0,
                    null,
                    null);

            // ensure there was only one project
            assertEquals("Quering GradleTestProject.getModel() with multi-project settings",
                    1, modelMap.size());

            AndroidProject androidProject = modelMap.get(":");
            if (assertNoSyncIssues) {
                assertNoSyncIssues(androidProject);
            }
            return androidProject;
        } finally {
            connection.close();
        }
    }

    /**
     * Returns a project model for each sub-project without building generating a
     * {@link AssertionError} if any sync issue is raised during the model loading.
     */
    @NonNull
    public Map<String, AndroidProject> getAllModels() {
        return getAllModelsWithBenchmark(null, null);
    }

    /**
     * Returns a project model for each sub-project without building generating a
     * @param benchmarkName optional benchmark name to pass to Gradle
     * @param benchmarkMode optional benchmark mode to pass to gradle.

     * {@link AssertionError} if any sync issue is raised during the model loading.
     */
    @NonNull
    public Map<String, AndroidProject> getAllModelsWithBenchmark(
            @Nullable String benchmarkName,
            @Nullable BenchmarkMode benchmarkMode) {
        Map<String, AndroidProject> allModels = getAllModels(new GetAndroidModelAction(), false, benchmarkName, benchmarkMode);
        for (AndroidProject project : allModels.values()) {
            assertNoSyncIssues(project);
        }
        return allModels;
    }


    private static void assertNoSyncIssues(AndroidProject project) {
        if (!project.getSyncIssues().isEmpty()) {
            StringBuilder msg = new StringBuilder();
            msg.append("Project ")
                    .append(project.getName())
                    .append(" had sync issues :\n");
            for (SyncIssue syncIssue : project.getSyncIssues()) {
                msg.append(syncIssue);
                msg.append("\n");
            }
            fail(msg.toString());
        }
    }

    /**
     * Returns a project model for each sub-project without building and ignoring potential sync
     * issues. Sync issues will still be returned for each {@link AndroidProject} that failed to
     * sync properly.
     */
    @NonNull
    public Map<String, AndroidProject> getAllModelsIgnoringSyncIssues() {
        return getAllModels(new GetAndroidModelAction(), false, null, null);
    }

    /**
     * Returns a project model for each sub-project without building.
     *
     * @param action the build action to gather the model
     */
    @NonNull
    public <K, V> Map<K, V> getAllModels(@NonNull BuildAction<Map<K, V>> action) {
        return getAllModels(action, false, null, null);
    }

    /**
     * Returns a project model for each sub-project without building.
     *
     * @param action the build action to gather the model
     * @param emulateStudio_1_0 whether to emulate an older IDE (studio 1.0) querying the model.
     * @param benchmarkName optional benchmark name to pass to Gradle
     * @param benchmarkMode optional benchmark mode to pass to gradle.
     */
    @NonNull
    public <K, V> Map<K, V> getAllModels(
            @NonNull BuildAction<Map<K, V>> action,
            boolean emulateStudio_1_0,
            @Nullable String benchmarkName,
            @Nullable BenchmarkMode benchmarkMode) {
        ProjectConnection connection = getProjectConnection();
        try {
            return buildModel(connection, action, emulateStudio_1_0, benchmarkName, benchmarkMode);

        } finally {
            connection.close();
        }
    }

    /**
     * Runs gradle on the project.  Throws exception on failure.
     *
     * @param arguments List of arguments for the gradle command.
     * @param returnModel whether the model should be queried and returned.
     * @param emulateStudio_1_0 whether to emulate an older IDE (studio 1.0) querying the model.
     * @param expectedBuildResult the expected result. If the build status does not match the
     *                            expected failure, then an exception will be thrown.
     * @param tasks Variadic list of tasks to execute.
     *
     * @return the model, if <var>returnModel</var> was true, null otherwise
     */
    @Nullable
    private AndroidProject execute(
            @NonNull List<String> arguments,
            boolean returnModel,
            boolean emulateStudio_1_0,
            ExpectedBuildResult expectedBuildResult,
            @NonNull String ... tasks) {
        ProjectConnection connection = getProjectConnection();
        try {
            executeBuild(arguments, connection, tasks, expectedBuildResult);

            if (returnModel) {
                Map<String, AndroidProject> modelMap = buildModel(
                        connection,
                        new GetAndroidModelAction(),
                        emulateStudio_1_0,
                        null,
                        null);

                // ensure there was only one project
                assertEquals("Quering GradleTestProject.getModel() with multi-project settings",
                        1, modelMap.size());

                return modelMap.get(":");
            }
        } finally {
            connection.close();
        }

        return null;
    }

    private enum ExpectedBuildResult {
        SUCCESS,
        FAILURE
    }

    private void executeBuild(final List<String> arguments, ProjectConnection connection,
            final String[] tasks, ExpectedBuildResult expectedBuildResult) {
        List<String> args = Lists.newArrayListWithCapacity(5 + arguments.size());
        args.add("-i");
        args.add("-u");
        args.add("-Pcom.android.build.gradle.integratonTest.useJack=" + Boolean.toString(useJack));
        args.add("-Pcom.android.build.gradle.integratonTest.minifyEnabled=" +
                Boolean.toString(minifyEnabled));
        args.add("-Pcom.android.build.gradle.integratonTest.useComponentModel=" +
                Boolean.toString(experimentalMode));
        args.addAll(arguments);

        BuildLauncher launcher = connection.newBuild()
                .forTasks(tasks)
                .withArguments(Iterables.toArray(args, String.class));

        setJvmArguments(launcher);

        if (stdout != null) {
            launcher.setStandardOutput(stdout);
        } else {
            launcher.setStandardOutput(System.out);
        }
        if (stderr != null) {
            launcher.setStandardError(stderr);
        } else {
            launcher.setStandardError(System.err);
        }
        if (expectedBuildResult == ExpectedBuildResult.SUCCESS) {
            launcher.run();
        } else {
            launcher.run(new ResultHandler<Void>() {
                @Override
                public void onComplete(Void aVoid) {
                    throw new AssertionError(
                            String.format(
                                    "Expecting build to fail:\n" +
                                            "    Tasks:     %s\n" +
                                            "    Arguments: %s",
                                    Joiner.on(' ').join(tasks),
                                    Joiner.on(' ').join(arguments)));
                }

                @Override
                public void onFailure(GradleConnectionException e) {
                    // Ignore, the test expects this build to fail.
                }
            });
        }
    }

    private void setJvmArguments(LongRunningOperation launcher) {
        List<String> jvmArguments = new ArrayList<String>();

        if (!Strings.isNullOrEmpty(heapSize)) {
            jvmArguments.add("-Xmx" + heapSize);
        }

        jvmArguments.add("-XX:MaxPermSize=1024m");

        String debugIntegrationTest = System.getenv("DEBUG_INNER_TEST");
        if (!Strings.isNullOrEmpty(debugIntegrationTest)) {
            jvmArguments.add("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5006");
        }

        if (JacocoAgent.isJacocoEnabled()) {
            jvmArguments.add(JacocoAgent.getJvmArg());
        }

        launcher.setJvmArguments(Iterables.toArray(jvmArguments, String.class));
    }

    /**
     * Returns a project model for each sub-project without building.
     * @param connection the opened ProjectConnection
     * @param action the build action to gather the model
     * @param emulateStudio_1_0 whether to emulate an older IDE (studio 1.0) querying the model.
     * @param benchmarkName optional benchmark name to pass to Gradle
     * @param benchmarkMode optional benchmark mode to pass to gradle.
     */
    @NonNull
    private <K,V> Map<K, V> buildModel(
            @NonNull ProjectConnection connection,
            @NonNull BuildAction<Map<K, V>> action,
            boolean emulateStudio_1_0,
            @Nullable String benchmarkName,
            @Nullable BenchmarkMode benchmarkMode) {

        BuildActionExecuter<Map<K, V>> executor = connection.action(action);

        List<String> arguments = Lists.newArrayListWithCapacity(5);
        arguments.add("-P" + AndroidProject.PROPERTY_BUILD_MODEL_ONLY + "=true");
        arguments.add("-P" + AndroidProject.PROPERTY_INVOKED_FROM_IDE + "=true");
        if (!emulateStudio_1_0) {
            arguments.add("-P" + AndroidProject.PROPERTY_BUILD_MODEL_ONLY_ADVANCED + "=true");
        }
        if (benchmarkName != null) {
            arguments.add("-P" + RECORD_BENCHMARK_NAME + "=" + benchmarkName);
            if (benchmarkMode != null) {
                arguments.add("-P" + RECORD_BENCHMARK_MODE + "=" + benchmarkMode.name().toLowerCase(Locale.US));
            }
        }

        setJvmArguments(executor);

        executor.withArguments(Iterables.toArray(arguments, String.class));

        executor.setStandardOutput(System.out);
        executor.setStandardError(System.err);

        return executor.run();
    }

    /**
     * Return the stdout from all execute command.
     */
    public ByteArrayOutputStream getStdout() {
        return stdout;
    }

    /**
     * Return the stderr from all execute command.
     */
    public ByteArrayOutputStream getStderr() {
        return stderr;
    }

    /**
     * Create a File object.  getTestDir will be the base directory if a relative path is supplied.
     *
     * @param path Full path of the file.  May be a relative path.
     */
    public File file(String path) {
        File result = new File(path);
        if (result.isAbsolute()) {
            return result;
        } else {
            return new File(testDir, path);
        }
    }

    /**
     * Returns the NDK folder as built from the Android source tree.
     */
    private static File findNdkDir() {
        String androidHome = System.getenv("ANDROID_NDK_HOME");
        if (androidHome != null) {
            File f = new File(androidHome);
            if (f.isDirectory()) {
                return f;
            } else {
                System.out.println("Failed to find NDK in ANDROID_NDK_HOME=" + androidHome);
            }
        }
        return null;
    }

    /**
     * Returns a Gradle project Connection
     */
    @NonNull
    private ProjectConnection getProjectConnection() {
        GradleConnector connector = GradleConnector.newConnector();

        // Limit daemon idle time for tests. 10 seconds is enough for another test
        // to start and reuse the daemon.
        ((DefaultGradleConnector) connector).daemonMaxIdleTime(10, TimeUnit.SECONDS);

        return connector
                .useGradleVersion(targetGradleVersion)
                .forProjectDirectory(testDir)
                .connect();
    }

    private static File createLocalProp(
            @NonNull File project,
            @NonNull File sdkDir,
            @Nullable File ndkDir) throws IOException, StreamException {
        ProjectPropertiesWorkingCopy localProp = ProjectProperties.create(
                project.getAbsolutePath(), ProjectProperties.PropertyType.LOCAL);
        localProp.setProperty(ProjectProperties.PROPERTY_SDK, sdkDir.getAbsolutePath());
        if (ndkDir != null) {
            localProp.setProperty(ProjectProperties.PROPERTY_NDK, ndkDir.getAbsolutePath());
        }
        localProp.save();

        return (File) localProp.getFile();
    }

    private void createGradleProp() throws IOException {
        if (gradleProperties.isEmpty()) {
            return;
        }
        File propertyFile = file("gradle.properties");
        Files.write(Joiner.on('\n').join(gradleProperties), propertyFile, Charset.defaultCharset());
    }


    public static void assumeLocalDevice() {
        Assume.assumeTrue(
                "Install task not run against device provider",
                GradleTestProject.REMOTE_TEST_PROVIDER == null);
    }

    public static void assumeBuildToolsAtLeast(int major) {
        assumeBuildToolsAtLeast(
                major, FullRevision.IMPLICIT_MINOR_REV, FullRevision.IMPLICIT_MICRO_REV);
    }

    public static void assumeBuildToolsAtLeast(int major, int minor, int micro) {
        FullRevision currentVersion = FullRevision.parseRevision(DEFAULT_BUILD_TOOL_VERSION);
        Assume.assumeTrue("Test is only applicable to build tools > " + major,
                new FullRevision(major, minor, micro).compareTo(currentVersion) < 0);
    }

    /**
     * Replace a line from a file with another line.
     * @param relativePath the relative path of the file from the root of the project
     * @param lineNumber the line number, starting at 1
     * @param line the line to replace with.
     * @throws IOException
     */
    public void replaceLine(
            String relativePath,
            int lineNumber,
            String line) throws IOException {
        File file = new File(testDir, relativePath.replace("/", File.separator));

        List<String> lines = Files.readLines(file, Charsets.UTF_8);

        lines.add(lineNumber, line);
        lines.remove(lineNumber - 1);

        Files.write(
                Joiner.on(System.getProperty("line.separator")).join(lines),
                file,
                Charsets.UTF_8);
    }
}
