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

import com.atlassian.bitbucket.scm.git.command.merge.GitMergeException;
import com.atlassian.bitbucket.Product;
import com.atlassian.bitbucket.scm.CommandFailedException;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.stash.internal.scm.git.GitRepositoryConfig;
import com.atlassian.stash.internal.scm.git.merge.FetchStrategy;
import com.atlassian.stash.internal.scm.git.merge.MergeCommand;
import com.atlassian.stash.internal.scm.git.merge.MergeRequest;
import com.atlassian.stash.internal.scm.git.merge.MergeStrategy;
import com.atlassian.stash.internal.scm.git.porcelain.GitPorcelain;
import com.atlassian.bitbucket.pull.PullRequest;
import com.atlassian.bitbucket.scm.git.GitAgent;
import com.atlassian.bitbucket.scm.git.command.GitCommandBuilderFactory;
import com.atlassian.bitbucket.scm.git.GitScmConfig;
import com.atlassian.bitbucket.scm.git.command.merge.conflict.GitMergeConflict;
import com.atlassian.bitbucket.util.FileUtils;
import com.atlassian.bitbucket.util.Timer;
import com.atlassian.bitbucket.util.TimerUtils;
import com.google.common.base.Charsets;
import com.google.common.io.Files;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.ExecutorService;

import static com.atlassian.stash.internal.scm.git.InternalGitConstants.*;
import static com.google.common.base.Preconditions.checkNotNull;

/**
 * @since 2.9
 */
public class PullRequestAutoMergeCommand extends MergeCommand {

    private static final String[] PATH_GIT_INFO = new String[]{PATH_GIT, PATH_INFO};

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

    private final GitCommandBuilderFactory builderFactory;
    private final GitScmConfig config;
    private final PullRequest pullRequest;
    private final GitRepositoryConfig repositoryConfig;

    private PullRequestMergeType mergeType;

    public PullRequestAutoMergeCommand(@Nonnull ExecutorService executorService, @Nonnull GitPorcelain porcelain,
                                       @Nonnull GitAgent agent, @Nonnull FetchStrategy fetchStrategy,
                                       @Nonnull I18nService i18nService, @Nonnull MergeStrategy mergeStrategy,
                                       @Nonnull MergeRequest request, @Nonnull GitCommandBuilderFactory builderFactory,
                                       @Nonnull GitScmConfig config, @Nonnull PullRequest pullRequest,
                                       @Nonnull GitRepositoryConfig repositoryConfig) {
        super(executorService, porcelain, agent, checkNotNull(fetchStrategy, "fetchStrategy"),
                i18nService, mergeStrategy, request);

        this.builderFactory = checkNotNull(builderFactory, "builderFactory");
        this.config = checkNotNull(config, "config");
        this.pullRequest = checkNotNull(pullRequest, "pullRequest");
        this.repositoryConfig = checkNotNull(repositoryConfig, "repositoryConfig");
    }

    //TODO: Refactor note handling into a single place and unpack old-style git notes
    public static File conflictNotesFile(@Nonnull PullRequest pullRequest, @Nonnull File repositoryDir) {
        return PullRequestRefBuilder.darkRef()
                .id(pullRequest)
                .name("notes")
                .toFile(repositoryDir);
    }

    @Nonnull
    public PullRequestMergeType getMergeType() {
        if (mergeType == null) {
            //As long as doMerge is called it should be impossible for this to happen
            throw new CommandFailedException(i18nService.createKeyedMessage("bitbucket.git.pullrequest.merge.unknowntype"));
        }
        return mergeType;
    }

    @Override
    protected void doMerge(@Nonnull File workTree) {
        configure(workTree);

        try {
            super.doMerge(workTree);

            //If a previous merge had conflicts but the current merge does not, the conflict notes are no longer
            //accurate and should be removed
            File notesFile = conflictNotesFile(pullRequest, config.getRepositoryDir(request.getToRepository()));
            if (notesFile.isFile() && !notesFile.delete()) {
                log.warn("{}: Notes from a previous conflicted merge could not be removed",
                        request.getToRepository());
            }

            //The merge was clean, so push it to the clean ref
            mergeType = PullRequestMergeType.CLEAN;
        } catch (GitMergeException e) {
            List<GitMergeConflict> conflicts = e.getConflicts();

            try (Timer ignored = TimerUtils.start("git: process " + conflicts.size() + " conflicts")) {
                commitConflicts(workTree, conflicts);
            }

            //If we make it here, we were able to handle all of the conflicts. This does not mean they were _resolved_;
            //rather the opposite. It means we were able to commit all the conflict markers so that we can show them in
            //the diff. That means, for the sake of the overall command, there was no error but we need to push to the
            //conflicted ref rather than the clean one.
            mergeType = PullRequestMergeType.CONFLICTED;
        }
    }

    private void commitAs(File temporary) {
        builderFactory.builder().commit()
                .author(request.getAuthor())
                .message(request.getMessage())
                .workingDirectory(temporary)
                .build()
                .call();
    }

    private void commitConflicts(File temporary, List<GitMergeConflict> conflicts) {
        try (Timer ignored = TimerUtils.start("git: handle " + conflicts.size() + " conflicts")) {
            handleConflicts(temporary, conflicts);
        } catch (IOException e) {
            throw new CommandFailedException(
                    i18nService.createKeyedMessage("bitbucket.git.pullrequest.merge.writenotesfailed"),
                    e);
        }

        try (Timer ignored = TimerUtils.start("git: commit " + conflicts.size() + " conflicts")) {
            commitAs(temporary);
        }
    }

    private void configure(File workTree) {
        //The standard templates included with git distributions contain a placeholder "info" directory, which is
        //where the attributes file used to adjust conflict markers is stored. However, git will still run without
        //access to those templates (or customers may have changed them manually; that's part of their purpose),
        //so, rather than relying on the directory to always be present, create it if it is missing
        File info = FileUtils.construct(workTree, PATH_GIT_INFO);
        try {
            FileUtils.mkdir(info);
        } catch (IllegalStateException e) {
            String path = StringUtils.join(PATH_GIT_INFO, File.separatorChar);
            log.error("git clone did not create {}, and it could not be manually created. Please ensure git's " +
                    "templates are installed and accessible to the user running {}. Without access to an info " +
                    "directory automatic merges cannot be performed, as conflict markers cannot be adjusted",
                    path, Product.NAME);

            throwMarkersNotConfigured(e);
        }

        //Once the clone has been created, tweak the git attributes for it so that conflict markers will be more
        //distinctive. This will help us find them again later with fewer false positives
        File attributes = new File(info, PATH_ATTRIBUTES);
        try {
            //Instead of the usual 7 <'s, ='s or >'s, this will output 250 of them. The goal here is to make the
            //markers unlikely to ever occur "naturally" in the file
            Files.write("* conflict-marker-size=250", attributes, Charsets.UTF_8);
        } catch (IOException e) {
            log.error("Failed to adjust conflict marker settings for automatic merge", e);

            throwMarkersNotConfigured(e);
        }
    }

    private MergeConflictNoteWriter createNoteWriter() throws IOException {
        //As of 2.9, notes are written directly into the target repository. For previous releases, git notes were
        //used to store them. See DefaultGitPullRequestCommandFactory.readConflicts for details
        File notesFile = conflictNotesFile(pullRequest, config.getRepositoryDir(request.getToRepository()));
        FileUtils.mkdir(notesFile.getParentFile()); //Ensure the note file's containing directory exists

        return new MergeConflictNoteWriter(notesFile);
    }

    private void handleConflicts(File temporary, List<GitMergeConflict> conflicts) throws IOException {
        MergeConflictNoteWriter noteWriter = createNoteWriter();
        try {
            noteWriter.writeBranches(pullRequest.getFromRef().getDisplayId(), pullRequest.getToRef().getDisplayId());

            MergeConflictHandler handler = new MergeConflictHandler(builderFactory, i18nService, noteWriter,
                    pullRequest.getToRef().getRepository(), repositoryConfig, temporary);
            for (GitMergeConflict conflict : conflicts) {
                try (Timer ignored = TimerUtils.start("git: handle " + conflict.getType() + " conflict")) {
                    conflict.accept(handler);
                }
            }
            handler.flush();
        } finally {
            IOUtils.closeQuietly(noteWriter);
        }
    }

    private void throwMarkersNotConfigured(Exception e) {
        throw new CommandFailedException(
                i18nService.createKeyedMessage("bitbucket.git.pullrequest.merge.conflictmarkers"),
                e);
    }
}
