package com.atlassian.bitbucket.mesh.git.merge;

import com.atlassian.bitbucket.mesh.annotation.Profiled;
import com.atlassian.bitbucket.mesh.event.EventPublisher;
import com.atlassian.bitbucket.mesh.git.GitAgent;
import com.atlassian.bitbucket.mesh.git.GitTimers;
import com.atlassian.bitbucket.mesh.git.binary.GitBinary;
import com.atlassian.bitbucket.mesh.git.builder.GitProcess;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.builder.mergetree.MergeTreeCompletionHandler;
import com.atlassian.bitbucket.mesh.git.builder.mergetree.MergeTreeResult;
import com.atlassian.bitbucket.mesh.git.builder.mergetree.MergeTreeStdioHandler;
import com.atlassian.bitbucket.mesh.git.exception.InvalidMergeException;
import com.atlassian.bitbucket.mesh.git.exception.MergeConflictedException;
import com.atlassian.bitbucket.mesh.git.exception.MergeException;
import com.atlassian.bitbucket.mesh.git.fetch.ObjectRefFetchStrategy;
import com.atlassian.bitbucket.mesh.git.fetch.RefFetchParameters;
import com.atlassian.bitbucket.mesh.git.fetch.TimedRefFetchStrategy;
import com.atlassian.bitbucket.mesh.git.porcelain.GitPorcelain;
import com.atlassian.bitbucket.mesh.git.porcelain.WorkTreeParameters;
import com.atlassian.bitbucket.mesh.git.ref.Branch;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.repository.RefsUpdatedEvent;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.util.Person;
import com.atlassian.bitbucket.mesh.util.Version;
import com.atlassian.util.profiling.Ticker;
import io.grpc.Status;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.nio.file.Path;

import static com.atlassian.bitbucket.mesh.rpc.v1.RpcInvalidMerge.Reason.*;
import static java.util.Objects.requireNonNull;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.isEmpty;

@Profiled
public class DefaultMergeManager implements MergeManager {

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

    private final GitAgent agent;
    private final GitProcessBuilderFactory builderFactory;
    private final EventPublisher eventPublisher;
    private final MergeStrategyFactory mergeStrategyFactory;
    private final GitPorcelain porcelain;
    private final boolean gitSupportsMergeTree;

    public DefaultMergeManager(@Nonnull GitAgent agent, @Nonnull GitProcessBuilderFactory builderFactory,
                               @Nonnull EventPublisher eventPublisher, @Nonnull GitBinary gitBinary,
                               @Nonnull MergeStrategyFactory mergeStrategyFactory, @Nonnull GitPorcelain porcelain) {
        this.agent = requireNonNull(agent, "agent");
        this.builderFactory = requireNonNull(builderFactory, "builderFactory");
        this.eventPublisher = requireNonNull(eventPublisher, "eventPublisher");
        this.mergeStrategyFactory = requireNonNull(mergeStrategyFactory, "mergeStrategyFactory");
        this.porcelain = requireNonNull(porcelain, "porcelain");
        gitSupportsMergeTree = gitBinary.getVersion().compareTo(new Version(2, 38)) >= 0;
    }

    @Nonnull
    @Override
    public String merge(@Nonnull MergeParameters parameters) {
        requireNonNull(parameters, "parameters");

        String mergeHash = doMerge(parameters, false);
        if (mergeHash == null) {
            throw new MergeException("The merge could not be completed automatically", Status.ABORTED);
        }
        return mergeHash;
    }

    @Override
    public void tryMerge(@Nonnull MergeParameters parameters) {
        requireNonNull(parameters, "parameters");

        // Git 2.38 introduced two changes:
        // - A significant (but intentional) performance regression in `git merge`. See BSERV-13691 for details; and
        // - Support via `git merge-tree` to perform the tryMerge functionality in a manner that is substantially faster
        //   than doing it via `git merge` ever was.
        // Once Git versions prior to 2.38 are no longer supported, the `git merge` implementation of tryMerge can
        // be removed
        if (gitSupportsMergeTree) {
            MergeTreeResult results = doMergeTree(parameters);
            // Mimic the behaviour of doMerge, throwing an exception when merge conflicts exists. This can change
            // when Git versions prior to 2.38 are no longer supported and this method can return the actual
            // MergeTreeResult.
            if (!results.getConflicts().isEmpty()) {
                MergeConflictedException exception = new MergeConflictedException(
                        "The merge could not be completed due to file conflicts",
                        results.getConflicts(), results.getBinaryPaths());
                exception.setFromCommitId(parameters.getFrom().getLatestCommit());
                throw exception;
            }
        } else {
            doMerge(parameters, true);
        }
    }

    @Nonnull
    @Override
    public Branch updateRef(@Nonnull MergeParameters parameters, @Nonnull String mergeHash) {
        requireNonNull(parameters, "parameters");
        requireNonNull(mergeHash, "mergeHash");

        Repository repository = parameters.getRepository();
        String targetRef = parameters.getTargetRef();

        //If the merge wasn't canceled, update the target ref. This may fail if the target ref was updated
        //simultaneously by other processing, but that's no different than if we had fetched to it directly
        log.debug("{}: Updating target ref {} to {}", repository, targetRef, mergeHash);
        TimedRefFetchStrategy.time(new ObjectRefFetchStrategy(agent, builderFactory))
                .updateRef(new RefFetchParameters.Builder(parameters).build(), mergeHash);

        eventPublisher.publish(new RefsUpdatedEvent(repository));
        Branch branch = agent.resolveBranch(repository, targetRef, true);
        if (branch == null) {
            log.debug("{}: Ref {} could not be resolved as absolute; trying as relative", repository, targetRef);
            branch = agent.resolveBranch(repository, targetRef);
        }
        if (branch == null) {
            log.error("{}: Ref {} could not be resolved", repository, targetRef);
            throw new MergeException("Failed to resolve branch");
        }
        return branch;
    }

    private static void validate(MergeParameters parameters, boolean dryRun) {
        Person author = parameters.getAuthor();
        if (isBlank(author.getName())) {
            throw new InvalidMergeException(AUTHOR_NAME_BLANK);
        }
        if (isBlank(author.getEmail())) {
            throw new InvalidMergeException(AUTHOR_EMAIL_BLANK);
        }
        if (!dryRun && isEmpty(parameters.getMessage())) {
            throw new InvalidMergeException(COMMIT_MESSAGE_BLANK);
        }
    }

    private String doFetch(MergeParameters parameters, Path workTree) {
        //If we make it here, the merge ran without conflicts and the target branch in the work tree has been
        //updated to reference the new merge commit. The last step, if we're doing this for real, is to fetch
        //that commit into the repository.
        log.debug("{}: Fetching successful merge to {} from {}", parameters.getRepository(),
                parameters.getTargetRef(), workTree);
        return TimedRefFetchStrategy.time(new ObjectRefFetchStrategy(agent, builderFactory))
                .fetch(new RefFetchParameters.Builder(parameters).build(), workTree);
    }

    private String doMerge(MergeParameters parameters, boolean dryRun) {
        validate(parameters, dryRun);

        MergeStrategy mergeStrategy = TimedMergeStrategy.time(mergeStrategyFactory.create(parameters.getMergeStrategy()));

        Path workTree = null;
        try (Ticker ignored = GitTimers.MERGE.start(parameters)) {
            WorkTreeParameters.Builder builder = new WorkTreeParameters.Builder(parameters);
            WorkTreeParameters workTreeParameters = mergeStrategy.customizeWorkTree(builder).build();
            workTree = porcelain.workTree(workTreeParameters);

            return doWithWorkTree(workTree, parameters, mergeStrategy, dryRun);
        } finally {
            if (workTree != null) {
                MoreFiles.deleteQuietly(workTree);
            }
        }
    }

    private MergeTreeResult doMergeTree(MergeParameters parameters) {
        Path workTree = null;
        try (Ticker ignored = GitTimers.MERGE_TREE.start(parameters)) {
            WorkTreeParameters workTreeParameters = new WorkTreeParameters.Builder(parameters).build();
            workTree = porcelain.workTree(workTreeParameters);

            GitProcess<MergeTreeResult> builder = builderFactory.builder(parameters.getRepository()).mergeTree()
                    .allowUnrelatedHistories(true)
                    .branch1(parameters.getFrom().getLatestCommit())
                    .branch2(parameters.getTo().getLatestCommit())
                    .messages(true)
                    .completionHandler(new MergeTreeCompletionHandler())
                    .build(new MergeTreeStdioHandler());

            return parameters.configureAndRun(builder);
        } catch (RuntimeException e) {
            throw new MergeException("The merge could not be completed automatically",
                    e, parameters.getFrom().getLatestCommit());
        } finally {
            if (workTree != null) {
                MoreFiles.deleteQuietly(workTree);
            }
        }
    }

    private String doWithWorkTree(Path workTree, MergeParameters parameters, MergeStrategy strategy, boolean dryRun) {
        try {
            //Perform the merge in the temporary work tree
            if (log.isDebugEnabled()) {
                log.debug("{}: Merging {} in {}", parameters.getRepository(), parameters, workTree);
            }
            strategy.merge(parameters, workTree);

            if (dryRun) {
                log.debug("{}: Successfully merged {}. The dry run merge will be discarded",
                        parameters.getRepository(), parameters);
                return null;
            }
            return doFetch(parameters, workTree);
        } catch (InvalidMergeException e) {
            throw e;
        } catch (MergeException e) {
            e.setFromCommitId(parameters.getFrom().getLatestCommit());
            throw e;
        } catch (RuntimeException e) {
            throw new MergeException("The merge could not be completed automatically",
                    e, parameters.getFrom().getLatestCommit());
        }
    }
}
