package com.atlassian.stash.internal.scm.git;

import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.i18n.KeyedMessage;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.bitbucket.scm.git.GitException;
import com.atlassian.bitbucket.scm.git.GitNotFoundException;
import com.atlassian.bitbucket.scm.git.GitScmConfig;
import com.atlassian.bitbucket.scm.git.GitUnsupportedVersionException;
import com.atlassian.bitbucket.server.ApplicationPropertiesService;
import com.atlassian.bitbucket.util.FileUtils;
import com.atlassian.bitbucket.util.Version;
import com.atlassian.fugue.Either;
import com.atlassian.fugue.Eithers;
import com.atlassian.stash.internal.scm.git.binary.GitBinary;
import com.atlassian.stash.internal.scm.git.binary.GitBinaryHelper;
import com.atlassian.stash.internal.scm.git.binary.RejectedGitBinary;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import org.apache.commons.lang3.SystemUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;

import static com.google.common.base.Preconditions.checkNotNull;

/**
 * A default implementation of {@link GitScmConfig}.
 * <p>
 * Properties are drawn from the {@link ApplicationPropertiesService}, with {@link #PROP_PREFIX} prepended.
 */
public class DefaultGitScmConfig implements InternalGitScmConfig {

    public static final Set<String> CORE_BINARIES = ImmutableSet.of("http-backend", "upload-pack");

    private static final String GIT_ASK_PASS_SCRIPT = "ask-pass.pl";
    private static final String GIT_SSH_SCRIPT = "ssh-git";

    private static final long DEFAULT_AUTO_MERGE_TIMEOUT = TimeUnit.MINUTES.toSeconds(2);
    private static final long DEFAULT_BACKEND_IDLE_TIMEOUT_SECONDS = TimeUnit.MINUTES.toSeconds(30);
    private static final long DEFAULT_BACKEND_TIMELIMIT_SECONDS = TimeUnit.DAYS.toSeconds(1);
    private static final int DEFAULT_ENVIRONMENT_VARIABLE_SIZE = 2000;
    private static final long DEFAULT_GC_DELAY_SECONDS = 30;
    private static final long DEFAULT_GC_INTERVAL_MINUTES = TimeUnit.HOURS.toMinutes(16);
    private static final int DEFAULT_GC_MAX_ATTEMPTS = 5;
    private static final int DEFAULT_GC_THREADS = 3;
    private static final int DEFAULT_GC_THRESHOLD_LOOSE = 6700; // # of loose files
    private static final int DEFAULT_GC_THRESHOLD_PACK = 50; // # of pack files
    private static final long DEFAULT_GC_TIMEOUT_SECONDS = TimeUnit.MINUTES.toSeconds(30);
    private static final String DEFAULT_SSH_BINARY = "ssh";
    private static final long MINIMUM_BACKEND_IDLE_TIMEOUT_SECONDS = 30;
    private static final long MINIMUM_BACKEND_TIMELIMIT_SECONDS = TimeUnit.MINUTES.toSeconds(1);
    private static final int MINIMUM_ENVIRONMENT_VARIABLE_SIZE = 2048;
    private static final long MINIMUM_GC_DELAY_SECONDS = 0;
    private static final long MINIMUM_GC_INTERVAL_MINUTES = 30;
    private static final int MINIMUM_GC_THRESHOLD_LOOSE = 1000;
    private static final int MINIMUM_GC_THRESHOLD_PACK = 1;
    private static final long MINIMUM_GC_TIMEOUT_SECONDS = 120;

    // PROP_PREFIX is automatically prepended to these properties
    public static final String PROP_AUTO_MERGE_TIMEOUT = "pullrequest.merge.auto.timeout";
    public static final String PROP_BACKEND_EXECUTION_TIMEOUT = "backend.timeout.execution";
    public static final String PROP_BACKEND_IDLE_TIMEOUT = "backend.timeout.idle";
    public static final String PROP_BINARY_PATH = "path.executable";
    public static final String PROP_ENVIRONMENT_VARIABLE_SIZE = "environment.variablesize";
    public static final String PROP_GC_DELAY = "gc.delay";
    public static final String PROP_GC_INTERVAL = "gc.interval";
    public static final String PROP_GC_MAX_ATTEMPTS = "gc.max.attempts";
    public static final String PROP_GC_THREADS = "gc.threads";
    public static final String PROP_GC_THRESHOLD_LOOSE = "gc.threshold.loose";
    public static final String PROP_GC_THRESHOLD_PACK = "gc.threshold.pack";
    public static final String PROP_GC_TIMEOUT = "gc.timeout";
    public static final String PROP_LIBEXEC_PATH = "path.libexec";
    public static final String PROP_PREFIX = "plugin.bitbucket-git.";
    public static final String PROP_SIZE_TIMEOUT = "repository.size.timeout";
    public static final String PROP_SSH_BINARY = "ssh.binary";
    public static final String PROP_THROTTLE_REF_ADVERTISEMENT = "throttle.ref.advertisement";
    public static final String PROP_TRANSCODE_ADDRESS = "transcode.socket.address";
    public static final String PROP_TRANSCODE_PORT = "transcode.socket.port";
    public static final String PROP_USE_ALTERNATES = "forks.usealternates";

    private static final Logger log = LoggerFactory.getLogger(DefaultGitScmConfig.class);

    private final Either<RejectedGitBinary, GitBinary> binary;
    private final GitBinaryHelper binaryHelper;
    private final String coreBinaryPrefix;
    private final I18nService i18nService;
    private final ApplicationPropertiesService propertiesService;
    private final File tempDir;

    public DefaultGitScmConfig(@Nonnull ApplicationPropertiesService propertiesService,
                               @Nonnull I18nService i18nService,
                               @Nonnull GitBinaryHelper binaryHelper) {
        this.binaryHelper = binaryHelper;
        this.i18nService = i18nService;
        this.propertiesService = propertiesService;

        binary = findBinary();
        if (binary.isRight()) {
            log.debug("git executable has been set to {}", binary.right().get());
        }

        coreBinaryPrefix = configureLibexec();
        tempDir = FileUtils.mkdir(propertiesService.getTempDir(), "git");

        installAskPassScript();
        installSshScript();
    }

    @Override
    public File getAskPassScript() {
        return FileUtils.construct(propertiesService.getBinDir(), GIT_ASK_PASS_SCRIPT);
    }

    @Override
    public long getAutoMergeTimeout() {
        return getProperty(PROP_AUTO_MERGE_TIMEOUT, DEFAULT_AUTO_MERGE_TIMEOUT);
    }

    @Override
    public long getBackendExecutionTimeLimit() {
        return Math.max(getProperty(PROP_BACKEND_EXECUTION_TIMEOUT, DEFAULT_BACKEND_TIMELIMIT_SECONDS),
                MINIMUM_BACKEND_TIMELIMIT_SECONDS);
    }

    @Override
    public long getBackendIdleTimeout() {
        return Math.max(getProperty(PROP_BACKEND_IDLE_TIMEOUT, DEFAULT_BACKEND_IDLE_TIMEOUT_SECONDS),
                MINIMUM_BACKEND_IDLE_TIMEOUT_SECONDS);
    }

    @Nonnull
    @Override
    public String getBinary() {
        return Eithers.getOrThrow(resolveException()).getPath();
    }

    @Nonnull
    @Override
    public List<String> getCoreBinary(@Nonnull String command) {
        checkNotNull(command, "command");
        if (coreBinaryPrefix != null && CORE_BINARIES.contains(command)) {
            return Lists.newArrayList(binaryHelper.applyExtension(coreBinaryPrefix + command));
        }

        //When we don't have a libexec path, or if any core binaries are missing, we have to assume it's a built-in
        //command or rely on git's internal ability to find its core binaries automatically. Compiled-in constants
        //constants in the internals of the git binary will automatically locate such externals (usually)
        log.trace("Invoking {} {} because no core binary is available", getBinary(), command);
        return Lists.newArrayList(getBinary(), command);
    }

    @Override
    public int getEnvironmentVariableSize() {
        return Math.max(MINIMUM_ENVIRONMENT_VARIABLE_SIZE,
                getProperty(PROP_ENVIRONMENT_VARIABLE_SIZE, DEFAULT_ENVIRONMENT_VARIABLE_SIZE));
    }

    @Override
    public long getGcDelayInSeconds() {
        return Math.max(getProperty(PROP_GC_DELAY, DEFAULT_GC_DELAY_SECONDS), MINIMUM_GC_DELAY_SECONDS);
    }

    @Override
    public long getGcIntervalInSeconds() {
        return TimeUnit.MINUTES.toSeconds(Math.max(getProperty(PROP_GC_INTERVAL, DEFAULT_GC_INTERVAL_MINUTES),
                MINIMUM_GC_INTERVAL_MINUTES));
    }

    @Override
    public int getGcMaxAttempts() {
        return getProperty(PROP_GC_MAX_ATTEMPTS, DEFAULT_GC_MAX_ATTEMPTS);
    }

    @Override
    public long getGcTimeoutInSeconds() {
        return Math.max(getProperty(PROP_GC_TIMEOUT, DEFAULT_GC_TIMEOUT_SECONDS), MINIMUM_GC_TIMEOUT_SECONDS);
    }

    @Override
    public int getGcThreads() {
        return getProperty(PROP_GC_THREADS, DEFAULT_GC_THREADS);
    }

    @Override
    public int getGcThresholdLoose() {
        return Math.max(getProperty(PROP_GC_THRESHOLD_LOOSE, DEFAULT_GC_THRESHOLD_LOOSE), MINIMUM_GC_THRESHOLD_LOOSE);
    }

    @Override
    public int getGcThresholdPack() {
        return Math.max(getProperty(PROP_GC_THRESHOLD_PACK, DEFAULT_GC_THRESHOLD_PACK), MINIMUM_GC_THRESHOLD_PACK);
    }

    @Nonnull
    @Override
    public File getObjectsDir(@Nonnull Repository repository) {
        return new File(getRepositoryDir(repository), InternalGitConstants.PATH_OBJECTS);
    }

    @Override
    public String getProperty(@Nonnull String propertyName) {
        return propertiesService.getPluginProperty(PROP_PREFIX + checkNotNull(propertyName, "propertyName"));
    }

    @Override
    public boolean getProperty(@Nonnull String propertyName, boolean defaultValue) {
        return propertiesService.getPluginProperty(PROP_PREFIX + checkNotNull(propertyName, "propertyName"),
                defaultValue);
    }

    @Override
    public int getProperty(@Nonnull String propertyName, int defaultValue) {
        return propertiesService.getPluginProperty(PROP_PREFIX + checkNotNull(propertyName, "propertyName"),
                defaultValue);
    }

    @Override
    public long getProperty(@Nonnull String propertyName, long defaultValue) {
        return propertiesService.getPluginProperty(PROP_PREFIX + checkNotNull(propertyName, "propertyName"),
                defaultValue);
    }

    @Nonnull
    @Override
    public String getProperty(@Nonnull String propertyName, @Nonnull String defaultValue) {
        //noinspection ConstantConditions
        return propertiesService.getPluginProperty(PROP_PREFIX + checkNotNull(propertyName, "propertyName"),
                checkNotNull(defaultValue, "defaultValue"));
    }

    @Nonnull
    @Override
    public String getRelativePath(@Nonnull Repository repository, @Nonnull Repository relativeTo) {
        return ".." + File.separator + repository.getId();
    }

    @Nonnull
    @Override
    public File getRepositoriesDir() {
        return propertiesService.getRepositoriesDir();
    }

    @Nonnull
    @Override
    public File getRepositoryDir(@Nonnull Repository repository) {
        checkNotNull(repository, "repository");

        return propertiesService.getRepositoryDir(repository);
    }

    @Override
    public long getSizeTimeout() {
        return getProperty(PROP_SIZE_TIMEOUT, 75L);
    }

    @Nonnull
    @Override
    public String getSshBinary() {
        return getProperty(PROP_SSH_BINARY, DEFAULT_SSH_BINARY);
    }

    @Nonnull
    @Override
    public File getSshScript() {
        return FileUtils.construct(propertiesService.getBinDir(), getSshScriptName());
    }

    @Nonnull
    @Override
    public File getTempDir() {
        return tempDir;
    }

    @Override
    public String getTranscodeAddress() {
        return getProperty(PROP_TRANSCODE_ADDRESS);
    }

    @Override
    public int getTranscodePort() {
        return Math.max(getProperty(PROP_TRANSCODE_PORT, 0), 0); //Rewrite negative ports as 0
    }

    @Nonnull
    @Override
    public Version getVersion() {
        return Eithers.getOrThrow(resolveException()).getVersion();
    }

    public boolean isRefAdvertisementThrottled() {
        return getProperty(PROP_THROTTLE_REF_ADVERTISEMENT, false);
    }

    @Override
    public boolean useAlternates() {
        return getProperty(PROP_USE_ALTERNATES, true);
    }

    private Either<RejectedGitBinary, GitBinary> findBinary() {
        return binaryHelper.find(getProperty(PROP_BINARY_PATH));
    }

    private String configureLibexec() {
        String libexec = getProperty(PROP_LIBEXEC_PATH);
        if (libexec == null) {
            log.debug("libexec has not been configured; core binaries will not be available");
            return null;
        }

        //For efficiency, optimize getCoreBinary by preemptively ensuring all of the whitelisted core binaries
        //exist and are executable. If any are missing, ignore the libexec setting
        String prefix = libexec + File.separator + InternalGitConstants.PATH_GIT_CORE + File.separator + "git-";
        for (String binary : CORE_BINARIES) {
            File file = new File(prefix + binary);
            if (!file.isFile() || !file.canExecute()) {
                log.warn("libexec path {} is not valid and will be ignored; git-{} is missing or not executable",
                        libexec, binary);
                return null;
            }
        }
        log.debug("libexec has been set to {}; core binaries will be executed when available", libexec);
        return prefix;
    }

    private String getSshScriptName() {
        return GIT_SSH_SCRIPT + (SystemUtils.IS_OS_WINDOWS ? ".bat" : ".sh");
    }

    private void installAskPassScript() {
        try {
            installScript(GIT_ASK_PASS_SCRIPT, getAskPassScript());
        } catch (Exception e) {
            log.error("Could not create askPass script. Git operations accessing external repositories that require a " +
                    "password will fail", e);
        }
    }

    private void installScript(String scriptName, File scriptFile) throws IOException {
        Files.copy(DefaultGitScmConfig.class.getResourceAsStream("/scripts/" + scriptName),
                scriptFile.toPath(), StandardCopyOption.REPLACE_EXISTING);

        if (!scriptFile.setExecutable(true)) {
            log.error(scriptFile.getAbsolutePath() + " could not be set executable");
        }
    }

    private void installSshScript() {
        try {
            installScript(getSshScriptName(), getSshScript());
        } catch (Exception e) {
            log.error("Could not create SSH script. Git operations involving external repositories over SSH will fail", e);
        }
    }

    // During the constructor the plugins (and thus the i18n values) haven't been enabled yet, do it at late as possible
    private Either<GitException, GitBinary> resolveException() {
        return binary.left().map(binaryError -> {
            KeyedMessage keyedMessage = i18nService.getKeyedText(binaryError.getI18nKey());

            //noinspection ConstantConditions
            return binaryError.isFound() ?
                    new GitUnsupportedVersionException(keyedMessage, binaryError.getBinary().getVersion()) :
                    new GitNotFoundException(keyedMessage);
        });
    }
}
