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

import com.atlassian.bitbucket.Product;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.bitbucket.scm.*;
import com.atlassian.bitbucket.scm.git.GitAgent;
import com.atlassian.bitbucket.scm.git.GitException;
import com.atlassian.bitbucket.scm.git.GitScm;
import com.atlassian.bitbucket.scm.git.command.*;
import com.atlassian.bitbucket.scm.hook.PluginHookHandlerFactory;
import com.atlassian.bitbucket.scm.mirror.PluginMirrorCommandFactory;
import com.atlassian.bitbucket.util.Drainable;
import com.atlassian.bitbucket.util.Version;
import com.atlassian.stash.internal.scm.git.binary.GitBinaryHelper;
import com.atlassian.stash.internal.scm.git.merge.GitMergeStrategy;
import com.google.common.collect.ImmutableSet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.File;
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Collections;
import java.util.Set;
import java.util.concurrent.TimeUnit;

/**
 * @since 1.3
 */
public class DefaultGitScm extends AbstractScm implements GitScm, Drainable {

    private static final Set<ScmFeature> FEATURES = ImmutableSet.of(ScmFeature.BULK_CONTENT,
            ScmFeature.COMMAND_BUILDERS, ScmFeature.COMPARE, ScmFeature.CROSS_REPOSITORY, ScmFeature.FORK,
            ScmFeature.HOOKS, ScmFeature.MERGE_STRATEGIES, ScmFeature.MERGE, ScmFeature.MIRRORS,
            ScmFeature.PULL_REQUESTS, ScmFeature.REFS, ScmFeature.UPDATE_DEFAULT_BRANCH);

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

    private final GitAgent agent;
    private final GitCommandBuilderFactory builderFactory;
    private final GitBulkContentCommandFactory bulkContentCommandFactory;
    private final GitCompareCommandFactory compareCommandFactory;
    private final GitCommandFactory commandFactory;
    private final InternalGitScmConfig config;
    private final PluginHookHandlerFactory hookHandlerFactory;
    private final I18nService i18nService;
    private final GitMergeStrategies mergeStrategies;
    private final GitMirrorCommandFactory mirrorCommandFactory;
    private final GitExtendedCommandFactory extendedCommandFactory;
    private final GitPullRequestCommandFactory pullRequestCommandFactory;
    private final GitRefCommandFactory refCommandFactory;
    private final GitWriteTracker writeTracker;

    private volatile ScmStatus status;

    public DefaultGitScm(GitAgent agent, GitCommandBuilderFactory builderFactory,
                         GitBulkContentCommandFactory bulkContentCommandFactory, GitCommandFactory commandFactory,
                         GitCompareCommandFactory compareCommandFactory, InternalGitScmConfig config,
                         GitExtendedCommandFactory extendedCommandFactory, PluginHookHandlerFactory hookHandlerFactory,
                         I18nService i18nService, GitMirrorCommandFactory mirrorCommandFactory,
                         GitPullRequestCommandFactory pullRequestCommandFactory,
                         GitRefCommandFactory refCommandFactory, GitWriteTracker writeTracker) {
        super(FEATURES);

        this.agent = agent;
        this.builderFactory = builderFactory;
        this.bulkContentCommandFactory = bulkContentCommandFactory;
        this.commandFactory = commandFactory;
        this.compareCommandFactory = compareCommandFactory;
        this.config = config;
        this.extendedCommandFactory = extendedCommandFactory;
        this.hookHandlerFactory = hookHandlerFactory;
        this.i18nService = i18nService;
        this.mirrorCommandFactory = mirrorCommandFactory;
        this.pullRequestCommandFactory = pullRequestCommandFactory;
        this.refCommandFactory = refCommandFactory;
        this.writeTracker = writeTracker;

        mergeStrategies = new GitMergeStrategies();
    }

    @Override
    public boolean drain(long timeout, @Nonnull TimeUnit unit) {
        return writeTracker.drain(timeout, unit);
    }

    /**
     * @since 4.2
     */
    @Nonnull
    @Override
    public GitBulkContentCommandFactory getBulkContentCommandFactory() {
        return bulkContentCommandFactory;
    }

    @Nonnull
    @Override
    public GitCommandBuilderFactory getCommandBuilderFactory() {
        return builderFactory;
    }

    @Nonnull
    @Override
    public GitCommandFactory getCommandFactory() {
        return commandFactory;
    }

    @Nonnull
    @Override
    public GitCompareCommandFactory getCompareCommandFactory() {
        return compareCommandFactory;
    }

    /**
     * @since 4.6
     */
    @Nonnull
    @Override
    public GitExtendedCommandFactory getExtendedCommandFactory() {
        return extendedCommandFactory;
    }

    @Nonnull
    @Override
    public PluginHookHandlerFactory getHookHandlerFactory() {
        return hookHandlerFactory;
    }

    @Nonnull
    @Override
    public String getId() {
        return ID;
    }

    /**
     * @since 4.9
     */
    @Nonnull
    @Override
    public PluginMergeStrategies getMergeStrategies() {
        return mergeStrategies;
    }

    @Override
    @Nonnull
    public Version getMinimumVersion() {
        return GitBinaryHelper.MINIMUM_VERSION;
    }

    /**
     * @since 4.1
     */
    @Nonnull
    @Override
    public PluginMirrorCommandFactory getMirrorCommandFactory() {
        return mirrorCommandFactory;
    }

    @Nonnull
    @Override
    public String getName() {
        return NAME;
    }

    @Nonnull
    @Override
    public GitPullRequestCommandFactory getPullRequestCommandFactory() {
        return pullRequestCommandFactory;
    }

    @Nonnull
    @Override
    public GitRefCommandFactory getRefCommandFactory() {
        return refCommandFactory;
    }

    /**
     * Calculates the size of all packs to estimate repository size. If no packs exist, the size of loose objects is
     * calculated instead. Because a repository can contain a large number of loose objects, a timeout is applied to
     * the size calculation. That timeout is <i>not</i> applied when sizing packs.
     * <p>
     * Other repository files, like loose and packed refs, are not included in the calculated size. It is assumed
     * that their combined size will have little overall impact compared to the size of the repository's objects,
     * be those objects loose or packed.
     *
     * @param repository the repository whose size should be calculated
     * @return the size of the repository's packs, if any, or the size of its loose objects
     * @since 3.9
     */
    @Override
    public long getSize(@Nonnull final Repository repository) {
        File repositoryDir = config.getRepositoryDir(repository);
        File objectsDir = new File(repositoryDir, InternalGitConstants.PATH_OBJECTS);
        File packDir = new File(objectsDir, InternalGitConstants.PATH_PACK);

        //First, try to size up the packs. If at least one pack exists
        SizeFileVisitor visitor = new SizeFileVisitor();
        try {
            //The objects/pack layout is flat, so we don't need to traverse into any subdirectories. Technically
            //none should exist. But if they do, they will be ignored
            Files.walkFileTree(packDir.toPath(), Collections.emptySet(), 2, visitor);

            long size = visitor.getSize();
            if (size == 0) {
                log.debug("{}: No packs; will fall back on sizing loose objects", repository);
            } else {
                return size;
            }
        } catch (IOException e) {
            log.warn("{}: Failed to calculate pack size. Will fall back on sizing loose objects", repository, e);
        }

        //If there were no packs, or calculating their size failed, try to size up the loose objects.
        visitor = new TimeLimitedSizeFileVisitor(repository, config.getSizeTimeout());
        try {
            //The objects layout has one level of nested directories for loose objects, so we need to be willing
            //to traverse down one layer deeper than when sizing packs. If any deeper directories exist, they
            Files.walkFileTree(objectsDir.toPath(), Collections.emptySet(), 3, visitor);
        } catch (IOException e) {
            log.warn("{}: Failed to calculate the size of loose objects", repository, e);
        }
        return visitor.getSize();
    }

    @Nonnull
    @Override
    public ScmStatus getStatus() {
        if (status == null) {
            try {
                Version version = config.getVersion();

                status = SimpleScmStatus.available(i18nService.createKeyedMessage("bitbucket.git.info",
                        Product.NAME, config.getBinary(), version));
            } catch (GitException e) {
                //If git is not installed, or does not meet the minimum version, an exception will be thrown when
                //trying to check the version (or get the binary)
                status = SimpleScmStatus.unavailable(e.getKeyedMessage());
            }
        }
        return status;
    }

    @Override
    public boolean isEmpty(@Nonnull Repository repository) {
        return agent.isEmpty(repository);
    }

    private static class GitMergeStrategies implements PluginMergeStrategies {

        private final Set<PluginMergeStrategy> strategies;

        public GitMergeStrategies() {
            //This list is created manually, rather than using GitMergeStrategy.values(), to control order
            strategies = ImmutableSet.of(
                    GitMergeStrategy.NO_FF, GitMergeStrategy.FF, GitMergeStrategy.FF_ONLY,
                    GitMergeStrategy.SQUASH, GitMergeStrategy.SQUASH_FF_ONLY);
            if (strategies.size() != GitMergeStrategy.values().length) {
                //Note that, if this is triggered, it'll break the Git unit tests (which instantiate DefaultGitScm),
                //so it should get picked up in our builds without any risk of shipping broken
                throw new IllegalStateException("One or more GitMergeStrategy types have not been included " +
                        "in the strategies set, and will not be available for configuration.");
            }
        }

        @Nonnull
        @Override
        public String getHelpTopic() {
            return "bitbucket.git.help.mergestrategies";
        }

        @Nonnull
        @Override
        public Set<PluginMergeStrategy> getStrategies() {
            return strategies;
        }
    }

    /**
     * A simple {@code FileVisitor} implementation which sums the size of each visited file. Any
     * {@code NoSuchFileException}s thrown during the walk are ignored.
     *
     * @since 3.9
     */
    private static class SizeFileVisitor extends SimpleFileVisitor<Path> {

        private long size;

        public long getSize() {
            return size;
        }

        @Override
        public FileVisitResult visitFileFailed(Path file, IOException e) throws IOException {
            if (e instanceof NoSuchFileException) {
                // File systems like NFS can cache stat() results, so reading directories can return phantom
                // files which result in exceptions when actually actually accessed
                return FileVisitResult.CONTINUE;
            }
            throw e;
        }

        @Override
        public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
            //Note that, when the depth limit we apply is exceeded, visitFile is also called for directories.
            //That means we need to check the "file"'s type
            if (attrs.isRegularFile()) {
                size += attrs.size();
            }

            return FileVisitResult.CONTINUE;
        }
    }

    /**
     * Extends {@link SizeFileVisitor} and adds a timeout to limit the walk's duration. Since calculating
     * repository size is often done while a user waits, bounding its duration helps keep the application
     * responsive. This is even more important when STP is generating support information as that includes
     * calculating the size of <i>every repository</i>.
     *
     * @since 3.9
     */
    private static class TimeLimitedSizeFileVisitor extends SizeFileVisitor {

        private final Repository repository;
        private final long timeoutNs;

        public TimeLimitedSizeFileVisitor(Repository repository, long timeoutMs) {
            this.repository = repository;

            timeoutNs = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs);
        }

        @Override
        public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
            if (System.nanoTime() > timeoutNs) {
                log.debug("{}: Timed out sizing loose objects", repository);

                return FileVisitResult.TERMINATE;
            }

            return super.visitFile(file, attrs);
        }
    }
}
