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

import com.atlassian.bitbucket.content.ChangeType;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.bitbucket.scm.git.command.GitCommand;
import com.atlassian.bitbucket.scm.git.command.GitCommandBuilderFactory;
import com.atlassian.bitbucket.scm.git.command.GitScmCommandBuilder;
import com.atlassian.bitbucket.scm.git.command.LoggingCommandOutputHandler;
import com.atlassian.bitbucket.scm.git.command.add.GitAddBuilder;
import com.atlassian.bitbucket.scm.git.command.merge.conflict.*;
import com.atlassian.bitbucket.scm.git.command.reset.GitResetBuilder;
import com.atlassian.stash.internal.scm.git.GitRepositoryConfig;
import com.atlassian.stash.internal.scm.git.command.diff.DiffConflictHelper;
import com.atlassian.stash.internal.scm.git.command.lsfiles.StagedFile;
import com.atlassian.stash.internal.scm.git.command.lsfiles.StagedLsFilesOutputHandler;
import com.atlassian.stash.internal.scm.git.command.merge.ResetCommandHandler;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.common.io.Files;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.BufferedWriter;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Predicate;

import static java.nio.charset.StandardCharsets.UTF_8;

/**
 * Handles {@link GitMergeConflict conflicts}, performing the necessary {@code git} and filesystem operations necessary
 * to prepare the conflicts to be committed. As conflicts as handled, {@link MergeConflictNoteWriter notes are written}
 * about how the conflict and the paths and {@link ChangeType changes} involved.
 * <p>
 * Operations to update the index in preparation for the commit are batched, to try and strike a balance between the
 * number of {@code git} invocations and the size of each invocation. Because of this batching, when all conflicts have
 * been handled {@link #flush()} must be called to ensure all pending operations are performed. Interim flushes may be
 * performed automatically as conflicts are handled.
 *
 * @since 1.3
 */
public class MergeConflictHandler implements GitMergeConflictVisitor {

    private static final Predicate<Set<String>> FULL = new Predicate<Set<String>>() {

        @Override
        public boolean test(Set<String> input) {
            return input.size() >= MAX_PENDING_PATHS;
        }

        @Override
        public String toString() {
            return "FULL";
        }
    };
    private static final Predicate<Set<String>> NOT_EMPTY = new Predicate<Set<String>>() {

        @Override
        public boolean test(Set<String> paths) {
            return !paths.isEmpty();
        }

        @Override
        public String toString() {
            return "NOT_EMPTY";
        }
    };

    private static final int MAX_PENDING_PATHS = 100;
    // When a file has no conflicts the index stage is 0
    // When conflicted there are three stages in the index: 1 is base, 2 is ours, 3 is theirs
    private static final int OUR_STAGE = 2;
    private static final int THEIR_STAGE = 3;
    private static final String SUFFIX_THEIRS = ".theirs";

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

    private final Operation add;
    private final GitCommandBuilderFactory builderFactory;
    private final I18nService i18nService;
    private final MergeConflictNoteWriter noteWriter;
    private final Set<String> pendingAdd;
    private final Set<String> pendingRemove;
    private final Set<String> pendingReset;
    private final Operation remove;
    private final Operation reset;
    private final File workingDirectory;

    public MergeConflictHandler(GitCommandBuilderFactory builderFactory, I18nService i18nService,
                                MergeConflictNoteWriter noteWriter, Repository repository,
                                GitRepositoryConfig repositoryConfig, File workingDirectory) {
        this.builderFactory = builderFactory;
        this.i18nService = i18nService;
        this.noteWriter = noteWriter;
        this.workingDirectory = workingDirectory;

        pendingAdd = Sets.newHashSetWithExpectedSize(MAX_PENDING_PATHS);
        pendingRemove = Sets.newHashSetWithExpectedSize(MAX_PENDING_PATHS);
        pendingReset = Sets.newHashSetWithExpectedSize(MAX_PENDING_PATHS);

        boolean forceAdd = repositoryConfig.isForceAddEnabled(repository);

        add = new AddOperation(false, forceAdd);
        remove = new RemoveOperation();
        reset = new ResetOperation();
    }

    public void flush() {
        flushIf(reset, pendingReset, NOT_EMPTY);
        flushIf(remove, pendingRemove, NOT_EMPTY);
        flushIf(add, pendingAdd, NOT_EMPTY);
    }

    @Override
    public void visit(@Nonnull AddAddGitMergeConflict conflict) {
        writePathConflict(conflict);

        if (conflict.isBinary()) {
            checkoutTheirs(conflict.getPath());
        }

        add(conflict.getPath());
    }

    @Override
    public void visit(@Nonnull AddRenameGitMergeConflict conflict) {
        writeConflict(conflict);
        writeChange(conflict.getOurChange(), conflict.getPath());

        //In all cases, we'll need to add the conflict's primary path (the added path and the rename destination path)
        add(conflict.getPath());
        if (conflict.getOurTmpPath() == null) {
            //The renamed file was merged into the added file, so there is no temporary file involved
            writeChange(conflict.getTheirChange(), conflict.getPath(), conflict.getTheirSrcPath());
        } else {
            //Our added file was written to a temporary location and their rename was written into where our file was
            //intended to be added. If we leave the output like this, a useless diff showing how "our" added file was
            //changed into "their" renamed file will be shown
            String theirPath = conflict.getPath() + SUFFIX_THEIRS;

            writeChange(conflict.getTheirChange(), withTmp(conflict.getPath(), theirPath), conflict.getTheirSrcPath());

            //First, rename "their" file (which is sitting on our intended path) with a .theirs suffix
            File ours = new File(workingDirectory, conflict.getPath());
            File theirs = new File(workingDirectory, theirPath);
            if (rename(ours, theirs)) {
                //Now that "their" file is out of the way, rename our temporary file back to its intended path
                File oursTmp = new File(workingDirectory, conflict.getOurTmpPath());
                if (rename(oursTmp, ours)) {
                    //Add "their" path, which is the new temporary file we created. This will cause the diff-tree
                    //to show the change as a rename from theirSrcPath to the temporary file, instead of the real
                    //target for the rename. The conflict information will provide the real target.
                    add(theirPath);
                } else {
                    throw new IllegalStateException("Failed to rename \"our\" temporary file from [" +
                            oursTmp.getPath() + "] back to [" + ours.getPath() + "]");
                }
            } else {
                throw new IllegalStateException("Failed to rename \"their\" file from [" + ours.getPath() +
                        "] to [" + theirs.getPath() + "] before restoring \"our\" file");
            }
        }
    }

    @Override
    public void visit(@Nonnull ContentGitMergeConflict conflict) {
        writePathConflict(conflict);

        if (conflict.isBinary()) {
            checkoutTheirs(conflict.getPath());
        }

        add(conflict.getPath());
    }

    @Override
    public void visit(@Nonnull SubmoduleGitMergeConflict conflict) {
        String path = conflict.getPath();

        List<StagedFile> stagedTreeNodes = builderFactory
                .builder()
                .lsFiles()
                .workingDirectory(workingDirectory)
                .stage(true)
                .path(path)
                .build(new StagedLsFilesOutputHandler())
                .call();

        Map<Integer, StagedFile> treeNodeStageIndex = Maps.uniqueIndex(stagedTreeNodes, StagedFile::getStage);

        writeConflict(conflict);
        writeChange(conflict.getOurChange(), conflict.getPath());
        writeChange(conflict.getTheirChange(), conflict.getPath());

        try (BufferedWriter writer = Files.newWriter(new File(workingDirectory, path), UTF_8)) {
            // Since 'git' does not version directories we replace it with a file describing the conflict in a diff
            writer.write(DiffConflictHelper.MARKER_OURS);
            writer.write('\n');

            StagedFile ourSubmodule = treeNodeStageIndex.get(OUR_STAGE);
            writer.write(String.format("Subproject commit %s", ourSubmodule.getContentId()));
            writer.write('\n');

            writer.write(DiffConflictHelper.MARKER_DIVIDER);
            writer.write('\n');

            StagedFile theirSubmodule = treeNodeStageIndex.get(THEIR_STAGE);
            writer.write(String.format("Subproject commit %s", theirSubmodule.getContentId()));
            writer.write('\n');

            writer.write(DiffConflictHelper.MARKER_THEIRS);
            writer.write('\n');
        } catch (IOException e) {
            throw new IllegalStateException("Could not create conflict file for submodule in path: " + path);
        }

        add(path);
    }

    @Override
    public void visit(@Nonnull ConvergentRenameGitMergeConflict conflict) {
        String theirPath = conflict.getPath() + SUFFIX_THEIRS;

        writeConflict(conflict);
        writeChange(conflict.getOurChange(), conflict.getPath(), conflict.getOurSrcPath());
        writeChange(conflict.getTheirChange(), withTmp(conflict.getPath(), theirPath), conflict.getTheirSrcPath());

        //First, rename "our" temp file to the desired end name. This will cause it to not show up in the diff as
        //being modified
        File ours = new File(workingDirectory, conflict.getPath());
        File oursTmp = new File(workingDirectory, conflict.getOurTmpPath());
        if (rename(oursTmp, ours)) {
            //If the rename goes well, the next step is to rename "their" temp file to a name that is a little less
            //obtuse than the one git came up with
            File theirs = new File(workingDirectory, theirPath);
            File theirsTmp = new File(workingDirectory, conflict.getTheirTmpPath());
            if (rename(theirsTmp, theirs)) {
                add(conflict.getPath());
                add(theirPath);
            } else {
                throw new IllegalStateException("Failed to rename \"their\" temporary file from [" +
                        theirsTmp.getPath() + "] to [" + theirs.getPath() + "]");
            }
        } else {
            throw new IllegalStateException("Failed to rename \"our\" temporary file from [" +
                    oursTmp.getPath() + "] back to [" + ours.getPath() + "]");
        }
    }

    @Override
    public void visit(@Nonnull DeleteModifyGitMergeConflict conflict) {
        writePathConflict(conflict);

        //They've modified the file, so we add their modified version in place of our deletion. That will make the
        //addition appear in the merge's diff tree so we can mark it as conflicted
        add(conflict.getPath());
    }

    @Override
    public void visit(@Nonnull DeleteRenameGitMergeConflict conflict) {
        //Depending on the version of git, we _may_ have the source path information for the rename
        if (conflict.getTheirSrcPath() == null) {
            //git 1.7.7+ uses a new form of output for rename/delete conflicts that does not include the source path
            //for the rename. It _also_ does not include the _correct_ path for the deletion; it shows the deletion
            //as happening to the _rename destination_, which is not what really happened. How naughty is that?
            //
            //To sum up: The path written here for the delete is _wrong_. The rename will have no source path.
            writePathConflict(conflict);
        } else {
            //git 1.7.6.x and prior use a form of output for rename/delete conflicts that includes the real path for
            //the delete, which is also the source path for the rename.
            writeConflict(conflict);
            writeChange(conflict.getOurChange(), conflict.getTheirSrcPath()); //Their source path is our delete
            writeChange(conflict.getTheirChange(), conflict.getPath(), conflict.getTheirSrcPath());
        }

        add(conflict.getPath());
    }

    @Override
    public void visit(@Nonnull DirectoryFileGitMergeConflict conflict) {
        String theirPath = conflict.getPath() + SUFFIX_THEIRS;

        //Our path is a directory, and theirs is a file in a temp location
        writeConflict(conflict);
        writeChange(conflict.getOurChange(), asDirectory(conflict.getPath()));
        writeChange(conflict.getTheirChange(), withTmp(conflict.getPath(), theirPath));

        File theirs = new File(workingDirectory, theirPath);
        File theirsTmp = new File(workingDirectory, conflict.getTheirTmpFile());
        if (rename(theirsTmp, theirs)) {
            add(theirPath);
            reset(conflict.getPath());
        } else {
            throw new IllegalStateException("Failed to rename \"their\" temporary file from [" +
                    theirsTmp.getPath() + "] to [" + theirs.getPath() + "]");
        }
    }

    @Override
    public void visit(@Nonnull DivergentRenameGitMergeConflict conflict) {
        writeConflict(conflict);
        writeChange(conflict.getOurChange(), conflict.getOurPath(), conflict.getSrcPath());
        writeChange(conflict.getTheirChange(), conflict.getTheirPath(), conflict.getSrcPath());

        //Remove the srcPath, because it's been removed on both sides, but also remove "our" path. Add "their" path.
        //This will cause the diff-tree to display the change as a rename from "our" path to "their" path, which will
        //enable the user to view a diff between "our" version and "their" version. If we added "our" path, it would
        //not appear in the diff-tree at all, and "their" version would show up as a straight add with no useful diff.
        remove(conflict.getSrcPath());
        remove(conflict.getOurPath());
        add(conflict.getTheirPath());
    }

    @Override
    public void visit(@Nonnull FileDirectoryGitMergeConflict conflict) {
        String theirPath = conflict.getPath() + SUFFIX_THEIRS;

        //Our path is a file, and theirs path is a directory (note the trailing /s) in a temp location
        writeConflict(conflict);
        writeChange(conflict.getOurChange(), conflict.getPath());
        writeChange(conflict.getTheirChange(), withTmp(asDirectory(conflict.getPath()), asDirectory(theirPath)));

        //git adds their directory with its desired name, and moves our file to a temporary name. We want the reverse
        //to happen, so we need to rename both targets. First, we rename the directory out of the way
        File theirs = new File(workingDirectory, theirPath);
        File theirDirectory = new File(workingDirectory, conflict.getPath());
        if (theirDirectory.renameTo(theirs)) { //Not calling rename(File, File) because it's working with directories
            //Once we've moved their directory out of the way, move our file back to where it used to be. This will
            //effectively remove it from the diff tree
            File ours = new File(workingDirectory, conflict.getPath());
            File oursTmp = new File(workingDirectory, conflict.getOurTmpFile());
            if (rename(oursTmp, ours)) {
                add(conflict.getPath());
                add(theirPath);
            } else {
                throw new IllegalStateException("Failed to rename \"our\" temporary file from [" +
                        oursTmp.getPath() + "] back to [" + ours.getPath() + "]");
            }
        } else {
            throw new IllegalStateException("Failed to rename \"their\" directory from [" +
                    theirDirectory.getPath() + "] to [" + theirs.getPath() + "]");
        }
    }

    @Override
    public void visit(@Nonnull ModifyDeleteGitMergeConflict conflict) {
        writePathConflict(conflict);

        //They've removed the file, so we confirm their removal. That will make the deletion appear in the merge's
        //diff tree so we can mark it as conflicted
        remove(conflict.getPath());
    }

    @Override
    public void visit(@Nonnull RenameAddGitMergeConflict conflict) {
        writeConflict(conflict);
        writeChange(conflict.getOurChange(), conflict.getPath(), conflict.getOurSrcPath());

        if (conflict.getTheirTmpPath() == null) {
            //Their added file was merged into our renamed file, so the end result will need to be added (and may
            //contain conflict markers)
            writeChange(conflict.getTheirChange(), conflict.getPath());

            log.trace("Rename/add conflict was merged in place; adding {}", conflict.getPath());
            add(conflict.getPath());
        } else {
            //Our rename has been left in place, meaning it will not show up in the index at all, and their added
            //file has been added in a temporary location instead
            String theirPath = conflict.getPath() + SUFFIX_THEIRS;

            writeChange(conflict.getTheirChange(), withTmp(conflict.getPath(), theirPath));

            File theirs = new File(workingDirectory, theirPath);
            File theirsTmp = new File(workingDirectory, conflict.getTheirTmpPath());
            if (rename(theirsTmp, theirs)) { //Sanitise the temporary location filename a bit
                log.trace("Rename/add conflict was not auto-merged; adding {} and resetting {}",
                        theirPath, conflict.getPath());
                //Add their file, in its temporary location, to the index. It will show up in the diff-tree as an add
                add(theirPath);
                //Reset our file so that it is no longer marked as conflicted
                reset(conflict.getPath());
            } else {
                throw new IllegalStateException("Failed to rename \"their\" temporary file from [" +
                        theirsTmp.getPath() + "] to [" + theirs.getPath() + "]");
            }
        }
    }

    @Override
    public void visit(@Nonnull RenameDeleteGitMergeConflict conflict) {
        //Depending on the version of git, we _may_ have the source path information for the rename
        if (conflict.getOurSrcPath() == null) {
            //git 1.7.7+ uses a new form of output for rename/delete conflicts that does not include the source path
            //for the rename. It _also_ does not include the _correct_ path for the deletion; it shows the deletion
            //as happening to the _rename destination_, which is not what really happened. How naughty is that?
            //
            //To sum up: The path written here for the delete is _wrong_. The rename will have no source path.
            writePathConflict(conflict);
        } else {
            //git 1.7.6.x and prior use a form of output for rename/delete conflicts that includes the source path for
            //the rename, which is also the real path for the delete.
            writeConflict(conflict);
            writeChange(conflict.getOurChange(), conflict.getPath(), conflict.getOurSrcPath());
            writeChange(conflict.getTheirChange(), conflict.getOurSrcPath()); //Our source path is their delete
        }

        remove(conflict.getPath());
    }

    @Override
    public void visit(@Nonnull UnknownGitMergeConflict conflict) {
        log.warn("Unhandled merge conflict: {}. The assembled message was:\n{}",
                conflict.getReason(), conflict.getMessage());
        throw new IllegalStateException("[" + conflict.getReason() + "] conflicts cannot yet be handled");
    }

    private void add(String path) {
        pendingAdd.add(path);

        flushIf(add, pendingAdd, FULL);
    }

    private void checkoutTheirs(String path) {
        log.debug("Replacing binary conflicted file {} with source branch version", path);

        // git checkout --theirs -- conflict.getPath()
        // no batching for simplicity
        LoggingCommandOutputHandler outputHandler = new LoggingCommandOutputHandler(log);
        outputHandler.setCommand(builderFactory.builder()
                .workingDirectory(workingDirectory)
                .command("checkout")
                .argument("--theirs")
                .argument("--")
                .argument(path)
                .build(outputHandler))
                .call();
    }

    private void flushIf(Operation operation, Set<String> paths, Predicate<Set<String>> predicate) {
        //As a little sanity check, for the punishment case where we merge a commit with hundreds of conflicts,
        //batch up the operations to a reasonable size. The goal here is to strike a balance between how often
        //git is forked off and how big we allow a single command to become.
        if (predicate.test(paths)) {
            log.debug("Flushing {} for {} paths", operation, paths.size());
            GitScmCommandBuilder builder = builderFactory.builder()
                    .workingDirectory(workingDirectory);

            operation.build(builder, paths).call();
            // Otherwise we are guaranteed to apply this for every subsequent flush
            paths.clear();
        } else {
            log.trace("Not flushing {}; declined by {} check", operation, predicate);
        }
    }

    private void remove(String path) {
        pendingRemove.add(path);

        flushIf(remove, pendingRemove, FULL);
    }

    /**
     * Renames the {@code source} to the {@code destination}. If the {@code destination} already exists, it will
     * be deleted first. If it cannot be deleted, a warning is logged.
     * <p>
     * This helper specifically targets Windows, which will not allow a file to be renamed over another existing
     * file. Unfortunately, the Java specification for {@code File.renameTo(File)} does not explicitly specify
     * how such a situation should be handled (in fact, instead, it explicitly documents that it does <i>not</i>
     * explicitly specify collision handling, stating it is inherently platform-dependent).
     * 
     * @param source      the {@code File} to be renamed
     * @param destination the {@code File} describing what the {@code source} should be renamed to
     * @return {@code true} if the rename was performed successfully; otherwise, {@code false}
     */
    private boolean rename(File source, File destination) {
        if (destination.exists() && !destination.delete()) {
            //Windows will not rename a file over another file, so if the target path already exists and we can't
            //delete it, the rename that happens next will likely fail.
            log.warn("Rename destination path {} already exists and cannot be removed.", destination.getPath());
        }
        return source.renameTo(destination);
    }

    private void reset(String path) {
        pendingReset.add(path);

        flushIf(reset, pendingReset, FULL);
    }

    private void writeChange(ChangeType type, String path) {
        writeChange(type, path, null);
    }

    private void writeChange(ChangeType type, String path, String srcPath) {
        try {
            noteWriter.writeChange(type, path, srcPath);
        } catch (IOException e) {
            throw new IllegalStateException("Failed to write change details for " + type + " at [" +
                    path + "]", e);
        }
    }

    private void writeConflict(GitMergeConflict conflict) {
        try {
            noteWriter.writeConflict(conflict);
        } catch (IOException e) {
            throw new IllegalStateException("Failed to write conflict details for (" +
                    conflict.getType().getReason() + "): " + conflict.getMessage(), e);
        }
    }

    private void writePathConflict(AbstractPathGitMergeConflict conflict) {
        writeConflict(conflict);

        ChangeType ourChange = conflict.getOurChange();
        ChangeType theirChange = conflict.getTheirChange();
        writeChange(ourChange, conflict.getPath());
        if (ourChange != theirChange) {
            writeChange(theirChange, conflict.getPath());
        }
    }

    private static String asDirectory(String path) {
        return path + "/";
    }

    private static String withTmp(String path, String tmpPath) {
        return path + "->" + tmpPath;
    }

    /**
     * Describes a strategy for different index operations that may be performed to prepare to commit conflicts.
     */
    private interface Operation {

        GitCommand<Void> build(GitScmCommandBuilder builder, Set<String> paths);
    }

    private static class AddOperation implements Operation {

        private final boolean all;
        private final boolean force;

        private AddOperation(boolean all, boolean force) {
            this.all = all;
            this.force = force;
        }

        @Override
        public GitCommand<Void> build(GitScmCommandBuilder builder, Set<String> paths) {
            GitAddBuilder addBuilder = builder.add()
                    .paths(paths);
            if (all) {
                addBuilder.all(true);
            }
            if (force) {
                addBuilder.force(true);
            }
            return addBuilder.build();
        }
    }

    private static class RemoveOperation implements Operation {

        @Override
        public GitCommand<Void> build(GitScmCommandBuilder builder, Set<String> paths) {
            return builder.rm()
                    .quiet(true)
                    .paths(paths)
                    .build();
        }
    }

    private class ResetOperation implements Operation {

        @Override
        public GitCommand<Void> build(GitScmCommandBuilder builder, Set<String> paths) {
            GitResetBuilder resetBuilder = builder.reset()
                    .quiet(true)
                    .paths(paths);

            return new ResetCommandHandler(i18nService).build(resetBuilder);
        }
    }
}
