/*
 * Copyright 2010 the original author or authors.
 *
 * 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 org.gradle.api.tasks.wrapper;

import com.google.common.collect.ImmutableList;
import com.google.common.io.ByteStreams;
import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;
import org.gradle.api.Incubating;
import org.gradle.api.UncheckedIOException;
import org.gradle.api.internal.file.FileLookup;
import org.gradle.api.internal.file.FileOperations;
import org.gradle.api.internal.file.FileResolver;
import org.gradle.api.internal.plugins.StartScriptGenerator;
import org.gradle.api.provider.Property;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.Optional;
import org.gradle.api.tasks.OutputFile;
import org.gradle.api.tasks.TaskAction;
import org.gradle.api.tasks.options.Option;
import org.gradle.api.tasks.options.OptionValues;
import org.gradle.api.tasks.wrapper.internal.DefaultWrapperVersionsResources;
import org.gradle.internal.util.PropertiesUtils;
import org.gradle.util.GradleVersion;
import org.gradle.util.internal.DistributionLocator;
import org.gradle.util.internal.GUtil;
import org.gradle.util.internal.WrapUtil;
import org.gradle.util.internal.WrapperDistributionUrlConverter;
import org.gradle.work.DisableCachingByDefault;
import org.gradle.wrapper.Download;
import org.gradle.wrapper.GradleWrapperMain;
import org.gradle.wrapper.Install;
import org.gradle.wrapper.Logger;
import org.gradle.wrapper.WrapperExecutor;

import javax.annotation.Nullable;
import javax.inject.Inject;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Properties;

/**
 * <p>Generates scripts (for *nix and windows) which allow you to build your project with Gradle, without having to
 * install Gradle.
 *
 * <p>When a user executes a wrapper script the first time, the script downloads and installs the appropriate Gradle
 * distribution and runs the build against this downloaded distribution. Any installed Gradle distribution is ignored
 * when using the wrapper scripts.
 *
 * <p>The scripts generated by this task are intended to be committed to your version control system. This task also
 * generates a small {@code gradle-wrapper.jar} bootstrap JAR file and properties file which should also be committed to
 * your VCS. The scripts delegates to this JAR.
 */
@DisableCachingByDefault(because = "Updating the wrapper is not worth caching")
public abstract class Wrapper extends DefaultTask {
    public static final String DEFAULT_DISTRIBUTION_PARENT_NAME = Install.DEFAULT_DISTRIBUTION_PATH;

    /**
     * Specifies the Gradle distribution type.
     */
    public enum DistributionType {
        /**
         * binary-only Gradle distribution without sources and documentation
         */
        BIN,
        /**
         * complete Gradle distribution with binaries, sources and documentation
         */
        ALL
    }

    /**
     * Specifies how the wrapper path should be interpreted.
     */
    public enum PathBase {
        PROJECT, GRADLE_USER_HOME
    }

    private Object scriptFile;
    private Object jarFile;
    private String distributionPath;
    private PathBase distributionBase = PathBase.GRADLE_USER_HOME;
    private String distributionUrl;
    private String distributionSha256Sum;
    private final GradleVersionResolver gradleVersionResolver = new GradleVersionResolver();
    private DistributionType distributionType = DistributionType.BIN;
    private String archivePath;
    private PathBase archiveBase = PathBase.GRADLE_USER_HOME;
    private final Property<Integer> networkTimeout = getProject().getObjects().property(Integer.class);
    private final DistributionLocator locator = new DistributionLocator();
    private final boolean isOffline;
    private boolean distributionUrlConfigured = false;

    public Wrapper() {
        scriptFile = "gradlew";
        jarFile = "gradle/wrapper/gradle-wrapper.jar";
        distributionPath = DEFAULT_DISTRIBUTION_PARENT_NAME;
        archivePath = DEFAULT_DISTRIBUTION_PARENT_NAME;
        isOffline = getProject().getGradle().getStartParameter().isOffline();
        getValidateDistributionUrl().convention(true);
    }

    @Inject
    protected FileLookup getFileLookup() {
        throw new UnsupportedOperationException();
    }

    @TaskAction
    void generate() {
        File jarFileDestination = getJarFile();
        File unixScript = getScriptFile();
        File propertiesFile = getPropertiesFile();
        FileResolver resolver = getFileLookup().getFileResolver(unixScript.getParentFile());
        String jarFileRelativePath = resolver.resolveAsRelativePath(jarFileDestination);
        Properties existingProperties = propertiesFile.exists() ? GUtil.loadProperties(propertiesFile) : null;

        checkProperties(existingProperties);
        validateDistributionUrl(propertiesFile.getParentFile());
        writeProperties(propertiesFile, existingProperties);
        writeWrapperTo(jarFileDestination);

        StartScriptGenerator generator = new StartScriptGenerator();
        generator.setApplicationName("Gradle");
        generator.setMainClassName(GradleWrapperMain.class.getName());
        generator.setClasspath(WrapUtil.toList(jarFileRelativePath));
        generator.setOptsEnvironmentVar("GRADLE_OPTS");
        generator.setExitEnvironmentVar("GRADLE_EXIT_CONSOLE");
        generator.setAppNameSystemProperty("org.gradle.appname");
        generator.setScriptRelPath(unixScript.getName());
        generator.setDefaultJvmOpts(ImmutableList.of("-Xmx64m", "-Xms64m"));
        generator.generateUnixScript(unixScript);
        generator.generateWindowsScript(getBatchScript());
    }

    private void checkProperties(Properties existingProperties) {
        String checksumProperty = existingProperties != null
            ? existingProperties.getProperty(WrapperExecutor.DISTRIBUTION_SHA_256_SUM, null)
            : null;

        if (!isCurrentVersion() &&
            distributionSha256Sum == null &&
            checksumProperty != null) {
            throw new GradleException("gradle-wrapper.properties contains distributionSha256Sum property, but the wrapper configuration does not have one. Specify one in the wrapped task configuration or with the --gradle-distribution-sha256-sum task option");
        }
    }

    private static final String DISTRIBUTION_URL_EXCEPTION_MESSAGE = "Test of distribution url %s failed. Please check the values set with --gradle-distribution-url and --gradle-version.";

    private void validateDistributionUrl(File uriRoot) {
        if (distributionUrlConfigured && getValidateDistributionUrl().get()) {
            String url = getDistributionUrl();
            URI uri = getDistributionUri(uriRoot, url);
            if (uri.getScheme().equals("file")) {
                if (!Files.exists(Paths.get(uri).toAbsolutePath())) {
                    throw new UncheckedIOException(String.format(DISTRIBUTION_URL_EXCEPTION_MESSAGE, url));
                }
            } else if (uri.getScheme().startsWith("http") && !isOffline) {
                try {
                    new Download(new Logger(true), "gradlew", Download.UNKNOWN_VERSION).sendHeadRequest(uri);
                } catch (Exception e) {
                    throw new UncheckedIOException(String.format(DISTRIBUTION_URL_EXCEPTION_MESSAGE, url), e);
                }
            }
        }
    }

    private static URI getDistributionUri(File uriRoot, String url) {
        try {
            return WrapperDistributionUrlConverter.convertDistributionUrl(url, uriRoot);
        } catch (URISyntaxException e) {
            throw new GradleException("Distribution URL String cannot be parsed: " + url, e);
        }
    }

    private void writeWrapperTo(File destination) {
        URL jarFileSource = Wrapper.class.getResource("/gradle-wrapper.jar");
        if (jarFileSource == null) {
            throw new GradleException("Cannot locate wrapper JAR resource.");
        }
        try (InputStream in = jarFileSource.openStream(); OutputStream out = new FileOutputStream(destination)) {
            ByteStreams.copy(in, out);
        } catch (IOException e) {
            throw new UncheckedIOException("Failed to write wrapper JAR to " + destination, e);
        }
    }

    private void writeProperties(File propertiesFileDestination, Properties existingProperties) {
        Properties wrapperProperties = new Properties();
        wrapperProperties.put(WrapperExecutor.DISTRIBUTION_URL_PROPERTY, getDistributionUrl());
        String distributionSha256Sum = getDistributionSha256Sum(existingProperties);
        if (distributionSha256Sum != null) {
            wrapperProperties.put(WrapperExecutor.DISTRIBUTION_SHA_256_SUM, distributionSha256Sum);
        }
        wrapperProperties.put(WrapperExecutor.DISTRIBUTION_BASE_PROPERTY, distributionBase.toString());
        wrapperProperties.put(WrapperExecutor.DISTRIBUTION_PATH_PROPERTY, distributionPath);
        wrapperProperties.put(WrapperExecutor.ZIP_STORE_BASE_PROPERTY, archiveBase.toString());
        wrapperProperties.put(WrapperExecutor.ZIP_STORE_PATH_PROPERTY, archivePath);
        if (networkTimeout.isPresent()) {
            wrapperProperties.put(WrapperExecutor.NETWORK_TIMEOUT_PROPERTY, String.valueOf(networkTimeout.get()));
        }
        wrapperProperties.put(WrapperExecutor.VALIDATE_DISTRIBUTION_URL, String.valueOf(getValidateDistributionUrl().get()));
        try {
            PropertiesUtils.store(wrapperProperties, propertiesFileDestination);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    private String getDistributionSha256Sum(Properties existingProperties) {
        if (distributionSha256Sum != null) {
            return distributionSha256Sum;
        } else if (isCurrentVersion() && existingProperties != null) {
            return existingProperties.getProperty(WrapperExecutor.DISTRIBUTION_SHA_256_SUM, null);
        } else {
            return null;
        }
    }

    private boolean isCurrentVersion() {
        return GradleVersion.current().equals(gradleVersionResolver.getGradleVersion());
    }

    /**
     * Returns the file to write the wrapper script to.
     */
    @OutputFile
    public File getScriptFile() {
        return getServices().get(FileOperations.class).file(scriptFile);
    }

    /**
     * The file to write the wrapper script to.
     *
     * @since 4.0
     */
    public void setScriptFile(File scriptFile) {
        this.scriptFile = scriptFile;
    }

    /**
     * The file to write the wrapper script to.
     */
    public void setScriptFile(Object scriptFile) {
        this.scriptFile = scriptFile;
    }

    /**
     * Returns the file to write the wrapper batch script to.
     */
    @OutputFile
    public File getBatchScript() {
        File scriptFile = getScriptFile();
        return new File(scriptFile.getParentFile(), scriptFile.getName().replaceFirst("(\\.[^\\.]+)?$", ".bat"));
    }

    /**
     * Returns the file to write the wrapper jar file to.
     */
    @OutputFile
    public File getJarFile() {
        return getServices().get(FileOperations.class).file(jarFile);
    }

    /**
     * The file to write the wrapper jar file to.
     *
     * @since 4.0
     */
    public void setJarFile(File jarFile) {
        this.jarFile = jarFile;
    }

    /**
     * The file to write the wrapper jar file to.
     */
    public void setJarFile(Object jarFile) {
        this.jarFile = jarFile;
    }

    /**
     * Returns the file to write the wrapper properties to.
     */
    @OutputFile
    public File getPropertiesFile() {
        File jarFileDestination = getJarFile();
        return new File(jarFileDestination.getParentFile(), jarFileDestination.getName().replaceAll("\\.jar$",
            ".properties"));
    }

    /**
     * Returns the path where the gradle distributions needed by the wrapper are unzipped. The path is relative to the
     * distribution base directory
     *
     * @see #setDistributionPath(String)
     */
    @Input
    public String getDistributionPath() {
        return distributionPath;
    }

    /**
     * Sets the path where the gradle distributions needed by the wrapper are unzipped. The path is relative to the
     * distribution base directory
     *
     * @see #setDistributionPath(String)
     */
    public void setDistributionPath(String distributionPath) {
        this.distributionPath = distributionPath;
    }

    /**
     * Set Wrapper versions resources.
     *
     * @since 8.1
     */
    @Incubating
    public void setWrapperVersionsResources(WrapperVersionsResources wrapperVersionsResources) {
        DefaultWrapperVersionsResources defaultWrapperVersionsResources = (DefaultWrapperVersionsResources) wrapperVersionsResources;
        gradleVersionResolver.setTextResources(defaultWrapperVersionsResources.getLatest(),
            defaultWrapperVersionsResources.getReleaseCandidate(),
            defaultWrapperVersionsResources.getNightly(),
            defaultWrapperVersionsResources.getReleaseNightly());
    }

    /**
     * Returns the gradle version for the wrapper.
     *
     * @see #setGradleVersion(String)
     *
     * @throws GradleException if the label that can be provided via {@link #setGradleVersion(String)} can not be resolved at the moment. For example, there is not a `release-candidate` available at all times.
     */
    @Input
    public String getGradleVersion() {
        return gradleVersionResolver.getGradleVersion().getVersion();
    }

    /**
     * The version of the gradle distribution required by the wrapper.
     * This is usually the same version of Gradle you use for building your project.
     * The following labels are allowed to specify a version: {@code latest}, {@code release-candidate}, {@code nightly}, and {@code release-nightly}
     *
     * <p>The resulting distribution url is validated before it is written to the gradle-wrapper.properties file.
     */
    @Option(option = "gradle-version", description = "The version of the Gradle distribution required by the wrapper. " +
        "The following labels are allowed: latest, release-candidate, nightly, and release-nightly.")
    public void setGradleVersion(String gradleVersion) {
        distributionUrlConfigured = true;
        this.gradleVersionResolver.setGradleVersionString(gradleVersion);
    }

    /**
     * Returns the type of the Gradle distribution to be used by the wrapper.
     *
     * @see #setDistributionType(DistributionType)
     */
    @Input
    public DistributionType getDistributionType() {
        return distributionType;
    }

    /**
     * The type of the Gradle distribution to be used by the wrapper. By default, this is {@link DistributionType#BIN},
     * which is the binary-only Gradle distribution without documentation.
     *
     * @see DistributionType
     */
    @Option(option = "distribution-type", description = "The type of the Gradle distribution to be used by the wrapper.")
    public void setDistributionType(DistributionType distributionType) {
        this.distributionType = distributionType;
    }

    /**
     * The list of available gradle distribution types.
     */
    @OptionValues("distribution-type")
    public List<DistributionType> getAvailableDistributionTypes() {
        return Arrays.asList(DistributionType.values());
    }

    /**
     * The URL to download the gradle distribution from.
     *
     * <p>If not set, the download URL is the default for the specified {@link #getGradleVersion()}.
     *
     * <p>If {@link #getGradleVersion()} is not set, will return null.
     *
     * <p>The wrapper downloads a certain distribution only once and caches it. If your distribution base is the
     * project, you might submit the distribution to your version control system. That way no download is necessary at
     * all. This might be in particular interesting, if you provide a custom gradle snapshot to the wrapper, because you
     * don't need to provide a download server then.
     */
    @Input
    public String getDistributionUrl() {
        if (distributionUrl != null) {
            return distributionUrl;
        } else if (gradleVersionResolver.getGradleVersion() != null) {
            return locator.getDistributionFor(gradleVersionResolver.getGradleVersion(), distributionType.name().toLowerCase(Locale.ENGLISH)).toASCIIString();
        } else {
            return null;
        }
    }

    /**
     * The URL to download the gradle distribution from.
     *
     * <p>If not set, the download URL is the default for the specified {@link #getGradleVersion()}.
     *
     * <p>If {@link #getGradleVersion()} is not set, will return null.
     *
     * <p>The wrapper downloads a certain distribution and caches it. If your distribution base is the
     * project, you might submit the distribution to your version control system. That way no download is necessary at
     * all. This might be in particular interesting, if you provide a custom gradle snapshot to the wrapper, because you
     * don't need to provide a download server then.
     *
     * <p>The distribution url is validated before it is written to the gradle-wrapper.properties file.
     */
    @Option(option = "gradle-distribution-url", description = "The URL to download the Gradle distribution from.")
    public void setDistributionUrl(String url) {
        distributionUrlConfigured = true;
        this.distributionUrl = url;
    }

    /**
     * The SHA-256 hash sum of the gradle distribution.
     *
     * <p>If not set, the hash sum of the gradle distribution is not verified.
     *
     * <p>The wrapper allows for verification of the downloaded Gradle distribution via SHA-256 hash sum comparison.
     * This increases security against targeted attacks by preventing a man-in-the-middle attacker from tampering with
     * the downloaded Gradle distribution.
     *
     * @since 4.5
     */
    @Nullable
    @Optional
    @Input
    public String getDistributionSha256Sum() {
        return distributionSha256Sum;
    }

    /**
     * The SHA-256 hash sum of the gradle distribution.
     *
     * <p>If not set, the hash sum of the gradle distribution is not verified.
     *
     * <p>The wrapper allows for verification of the downloaded Gradle distribution via SHA-256 hash sum comparison.
     * This increases security against targeted attacks by preventing a man-in-the-middle attacker from tampering with
     * the downloaded Gradle distribution.
     *
     * @since 4.5
     */
    @Option(option = "gradle-distribution-sha256-sum", description = "The SHA-256 hash sum of the gradle distribution.")
    public void setDistributionSha256Sum(@Nullable String distributionSha256Sum) {
        this.distributionSha256Sum = distributionSha256Sum;
    }

    /**
     * The distribution base specifies whether the unpacked wrapper distribution should be stored in the project or in
     * the gradle user home dir.
     */
    @Input
    public PathBase getDistributionBase() {
        return distributionBase;
    }

    /**
     * The distribution base specifies whether the unpacked wrapper distribution should be stored in the project or in
     * the gradle user home dir.
     */
    public void setDistributionBase(PathBase distributionBase) {
        this.distributionBase = distributionBase;
    }

    /**
     * Returns the path where the gradle distributions archive should be saved (i.e. the parent dir). The path is
     * relative to the archive base directory.
     */
    @Input
    public String getArchivePath() {
        return archivePath;
    }

    /**
     * Set's the path where the gradle distributions archive should be saved (i.e. the parent dir). The path is relative
     * to the parent dir specified with {@link #getArchiveBase()}.
     */
    public void setArchivePath(String archivePath) {
        this.archivePath = archivePath;
    }

    /**
     * The archive base specifies whether the unpacked wrapper distribution should be stored in the project or in the
     * gradle user home dir.
     */
    @Input
    public PathBase getArchiveBase() {
        return archiveBase;
    }

    /**
     * The archive base specifies whether the unpacked wrapper distribution should be stored in the project or in the
     * gradle user home dir.
     */
    public void setArchiveBase(PathBase archiveBase) {
        this.archiveBase = archiveBase;
    }

    /**
     * The network timeout specifies how many ms to wait for when the wrapper is performing network operations, such
     * as downloading the wrapper jar.
     *
     * @since 7.6
     */
    @Input
    @Incubating
    @Optional
    @Option(option = "network-timeout", description = "Timeout in ms to use when the wrapper is performing network operations.")
    public Property<Integer> getNetworkTimeout() {
        return networkTimeout;
    }

    /**
     * Indicates if this task will validate the distribution url that has been configured.
     *
     * @since 8.2
     * @return whether this task will validate the distribution url
     */
    @Incubating
    @Input
    @Option(option = "validate-url", description = "Sets task to validate the configured distribution url.")
    public abstract Property<Boolean> getValidateDistributionUrl();
}
