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

import com.atlassian.bitbucket.mesh.annotation.Profiled;
import com.atlassian.bitbucket.mesh.config.GitConfig;
import com.atlassian.bitbucket.mesh.git.GitAgent;
import com.atlassian.bitbucket.mesh.git.GitUtils;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.builder.diff.GitDiffRenames;
import com.atlassian.bitbucket.mesh.git.builder.diff.GitDiffTreeBuilder;
import com.atlassian.bitbucket.mesh.git.builder.difftree.CallbackChangeDiffTreeStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.merge.FirstMergeBaseStdioHandler;
import com.atlassian.bitbucket.mesh.git.builder.merge.MergeBaseCompletionHandler;
import com.atlassian.bitbucket.mesh.git.builder.updateref.UpdateRefStdioHandler;
import com.atlassian.bitbucket.mesh.git.content.ChangeCallback;
import com.atlassian.bitbucket.mesh.git.exception.DeletePullRequestFailedException;
import com.atlassian.bitbucket.mesh.git.porcelain.FetchRefParameters;
import com.atlassian.bitbucket.mesh.git.porcelain.GitPorcelain;
import com.atlassian.bitbucket.mesh.git.ref.Branch;
import com.atlassian.bitbucket.mesh.git.ref.RefUpdate;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.repository.ManagedRepository;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.repository.RepositoryHashService;
import com.atlassian.bitbucket.mesh.repository.SharedObjectsRepository;
import com.atlassian.bitbucket.mesh.rpc.v1.RpcDeletePullRequestRequest;
import com.atlassian.bitbucket.mesh.rpc.v1.RpcRepositoryIntegrityIssue;
import com.atlassian.bitbucket.mesh.rpc.v1.RpcTransaction;
import com.atlassian.bitbucket.mesh.transaction.PrepareResult;
import com.atlassian.bitbucket.mesh.transaction.PreparedAction;
import com.atlassian.bitbucket.mesh.transaction.TransactionManager;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.DirectoryNotEmptyException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.*;
import java.util.stream.Stream;

import static com.atlassian.bitbucket.mesh.git.GitConstants.SYSTEM_GIT_IDENT;
import static com.atlassian.bitbucket.mesh.io.MoreFiles.dirExists;
import static com.atlassian.bitbucket.mesh.rpc.v1.RepositoryIntegrityIssueType.INTEGRITY_ISSUE_EXTRA_PRS;
import static com.atlassian.bitbucket.mesh.rpc.v1.RepositoryIntegrityIssueType.INTEGRITY_ISSUE_MISSING_PRS;
import static com.atlassian.bitbucket.mesh.util.ShaUtils.NULL_SHA1;
import static java.util.Objects.requireNonNull;
import static java.util.Optional.ofNullable;
import static java.util.stream.Collectors.joining;

@Profiled
public class DefaultPullRequestManager implements PullRequestManager {

    private static final Set<PullRequestRefType> LINK_REFS = ImmutableSet.of(
            //Order matters, here, so don't change these without adjusting deletePullRequest
            PullRequestRefType.FROM,
            PullRequestRefType.MERGE //No longer created, but needs to be deleted if it exists
    );

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

    private final GitAgent agent;
    private final GitProcessBuilderFactory builderFactory;
    private final GitConfig config;
    private final RepositoryHashService hashService;
    private final GitPorcelain porcelain;
    private final TransactionManager transactionManager;

    public DefaultPullRequestManager(GitAgent agent, GitProcessBuilderFactory builderFactory, GitConfig config,
                                     RepositoryHashService hashService, GitPorcelain porcelain,
                                     TransactionManager transactionManager) {
        this.agent = agent;
        this.builderFactory = builderFactory;
        this.config = config;
        this.hashService = hashService;
        this.porcelain = porcelain;
        this.transactionManager = transactionManager;
    }

    @Nonnull
    @Override
    public Optional<RpcRepositoryIntegrityIssue> checkIntegrity(@Nonnull Repository repository,
                                                                long latestPullRequestId) {
        requireNonNull(repository, "repository");

        Path repositoryDir = repository.getPath();
        PullRequestRefBuilder builder = PullRequestRefBuilder.darkRef(repository);
        Path pullRequestDir = builder.id(latestPullRequestId).toPath(repositoryDir);
        boolean extraPullRequests = false;
        List<Long> mismatchedPullRequests = new ArrayList<>();

        if (latestPullRequestId == 0L || dirExists(pullRequestDir)) {
            // If the pull request dark ref exists, let's advance to the next pull request ID,
            // which would indicate a pull request ID that should not be there does, in fact, exist.
            for (long prId = latestPullRequestId + 1L; ; prId++) {
                if (!dirExists(builder.id(prId).toPath(repositoryDir))) {
                    // Stop as soon as we don't encounter a dark ref.
                    break;
                }
                extraPullRequests = true;
                mismatchedPullRequests.add(prId);
            }
        } else {
            // If the pull request dark ref does not exist let's step backward and find the first
            // that exists, reporting the missing dark refs as we go.
            mismatchedPullRequests.add(latestPullRequestId);

            for (long prId = latestPullRequestId - 1L; prId > 0L; prId--) {
                if (dirExists(builder.id(prId).toPath(repositoryDir))) {
                    // Stop as soon as we encounter a dark ref.
                    break;
                }
                mismatchedPullRequests.add(prId);
            }
        }

        return ofNullable(mismatchedPullRequests.isEmpty() ? null : RpcRepositoryIntegrityIssue.newBuilder()
                .addAllPullRequestIds(mismatchedPullRequests)
                .setIssueType(extraPullRequests ? INTEGRITY_ISSUE_EXTRA_PRS : INTEGRITY_ISSUE_MISSING_PRS)
                .setRepository(repository.getId())
                .build());
    }

    @Nonnull
    @Override
    public List<RefUpdate> delete(@Nonnull PullRequest pullRequest) {
        requireNonNull(pullRequest, "pullRequest");

        PreparedDelete operation = transactionManager.execute(transaction -> {
            PreparedDelete delete = new PreparedDelete(pullRequest);
            delete.prepare();
            PrepareResult result = transaction.prepared(delete);
            if (!result.isSuccess()) {
                throw new DeletePullRequestFailedException(pullRequest);
            }
            return delete;
        });
        return operation.getRefUpdates();
    }

    @Nonnull
    @Override
    public String findMergeBase(@Nonnull PullRequest pullRequest, boolean allowUnrelated) {
        PullRequestRef fromRef = requireNonNull(pullRequest, "pullRequest").getFromRef();
        PullRequestRef toRef = pullRequest.getToRef();

        String fromCommit = fromRef.getLatestCommit();
        String toCommit = toRef.getLatestCommit();
        if (fromCommit.equals(toCommit)) {
            //If, somehow, the pull request has gotten into a state where both refs are referring to the same
            //commit, don't try and run merge-base; it'll just fail. Rather than throwing, which will render
            //the pull request unusable, we return the shared commit, which will make it render empty
            return fromCommit;
        }

        String mergeBase = builderFactory.builder(toRef.getRepository()).mergeBase()
                .between(fromCommit, toCommit)
                .alternate(fromRef.getRepository())
                .completionHandler(new MergeBaseCompletionHandler(!allowUnrelated))
                //If "git merge-base" returns multiple potential merge bases they're all considered equally
                //desirable by git, so we just use the first one
                .build(new FirstMergeBaseStdioHandler())
                .run();
        if (mergeBase == null) {
            //This is only possible if allowUnrelated is true. In this case, we treat the null tree as the
            //common ancestor, which will result in a diff showing every file in the pull request as added
            log.debug("{}: Pull request {} is between unrelated refs", toRef.getRepository(), pullRequest);
            return GitUtils.NULL_TREE;
        }

        log.debug("{}: Resolved merge-base {} for pull request {}", toRef.getRepository(), mergeBase, pullRequest);
        return mergeBase;
    }

    @Nonnull
    @Override
    public PullRequestEffectiveDiff getEffectiveDiff(@Nonnull GetPullRequestEffectiveDiffParameters parameters) {
        PullRequest pullRequest = requireNonNull(parameters, "parameters").getPullRequest();

        PullRequestRefUpdates updates = maybeUpdateRefs(pullRequest);

        String mergeBase = findMergeBase(pullRequest, parameters.isAllowUnrelated());
        String fromCommit = pullRequest.getFromRef().getLatestCommit();

        PullRequestEffectiveDiff.Builder builder = new PullRequestEffectiveDiff.Builder(mergeBase, fromCommit);
        if (updates != null) {
            if (updates.getFromRefUpdate() != null) {
                builder.refUpdate(updates.getFromRefUpdate());
            }
            if (updates.getMergeRefUpdate() != null) {
                builder.refUpdate(updates.getMergeRefUpdate());
            }
        }
        return builder.build();
    }

    @Override
    public void rescope(@Nonnull BatchRescopePullRequestsParameters parameters,
                        @Nonnull PullRequestRescopeCallback callback) {
        //If no maximum message length has been specified, fall back to the configured default
        int maxMessageLength = parameters.getMaxMessageLength().orElse(config.getMaxMessageLength());
        new BatchRescopeOperation(builderFactory, callback, parameters, maxMessageLength).call();
    }

    @Override
    public void streamChanges(@Nonnull StreamPullRequestChangesParameters parameters,
                              @Nonnull ChangeCallback callback) {
        PullRequest pullRequest = parameters.getPullRequest();
        String mergeBase = findMergeBase(pullRequest, parameters.isAllowUnrelated());

        CallbackChangeDiffTreeStdioHandler handler = new CallbackChangeDiffTreeStdioHandler(callback, parameters.getLimit());

        PullRequestRef fromRef = pullRequest.getFromRef();
        PullRequestRef toRef = pullRequest.getToRef();
        GitDiffTreeBuilder builder = builderFactory.builder(toRef.getRepository()).diffTree()
                .alternate(fromRef.getRepository())
                .ancestor(mergeBase)
                .format(handler.getFormat())
                .merges(false)
                .paths(parameters.getPaths())
                .recursive(true)
                .rev(fromRef.getLatestCommit());

        GitDiffRenames renames = parameters.getRenames();
        if (renames.supportsThreshold()) {
            builder.renames(renames, parameters.getRenameThreshold());
        } else {
            builder.renames(renames);
        }

        builder.build(handler)
                .run();
    }

    @Nonnull
    @Override
    public List<RefUpdate> updateRefs(@Nonnull PullRequest pullRequest) {
        requireNonNull(pullRequest, "pullRequest");

        ImmutableList.Builder<RefUpdate> builder = ImmutableList.builder();
        PullRequestRefUpdates updates = maybeUpdateRefs(pullRequest);
        if (updates != null) {
            if (updates.getFromRefUpdate() != null) {
                builder.add(updates.getFromRefUpdate());
            }
            if (updates.getMergeRefUpdate() != null) {
                builder.add(updates.getMergeRefUpdate());
            }
        }
        return builder.build();
    }

    @Nonnull
    @Override
    public List<PullRequestRefUpdates> updateRefs(@Nonnull Collection<PullRequest> pullRequests) {
        requireNonNull(pullRequests, "pullRequests");

        ImmutableList.Builder<PullRequestRefUpdates> builder = ImmutableList.builder();
        RuntimeException exception = null;
        for (PullRequest pullRequest : pullRequests) {
            try {
                PullRequestRefUpdates updates = maybeUpdateRefs(pullRequest);
                if (updates != null) {
                    builder.add(updates);
                }
            } catch (RuntimeException e) {
                log.warn("[{}] Failed to update public refs for pull request #{}",
                        pullRequest.getToRef().getRepository(), pullRequest.getId(), e);
                if (exception == null) {
                    exception = e;
                } else {
                    exception.addSuppressed(e);
                }
            }
        }
        if (exception != null) {
            throw exception;
        }
        return builder.build();
    }

    private static void deleteRefDirIfEmpty(PullRequest pullRequest, String refDir) {
        Repository repository = pullRequest.getToRef().getRepository();
        try {
            Files.deleteIfExists(repository.getPath().resolve(refDir));
        } catch (DirectoryNotEmptyException e) {
            log.warn("[{}] Pull request {} is closed but ref directory {} is not empty",
                    repository, pullRequest.getId(), refDir);
        } catch (IOException e) {
            log.warn("{}:{}: Failed to delete ref directory {}", repository, pullRequest.getId(), refDir, e);
        }
    }

    /**
     * Fetches the {@link PullRequest#getFromRef() from} ref into the {@link PullRequest#getToRef() to} ref's
     * repository without updating the {@link PullRequestRefs#getDarkFrom() "dark" from ref} that describes the
     * effective diff.
     * <p>
     * The dark ref is not updated because {@code git fetch} doesn't support fetching directly to a "dark" ref,
     * because they're not refs to Git.
     *
     * @param pullRequest the pull request to fetch the {@link PullRequest#getFromRef from ref} for
     */
    private void fetchFromRef(@Nonnull PullRequest pullRequest, @Nonnull PullRequestRefs refs) {
        PullRequestRef fromRef = pullRequest.getFromRef();
        PullRequestRef toRef = pullRequest.getToRef();
        log.debug("{}: Fetching {} from {} for cross-repository pull request {}",
                toRef.getRepository(), fromRef.getId(), fromRef.getRepository(), pullRequest);

        Path remote = fromRef.getRepository().getPath();
        porcelain.fetchRef(new FetchRefParameters.Builder(toRef.getRepository(), remote, SYSTEM_GIT_IDENT)
                //Rather than incur the cost of a full ref negotiation, hint to git fetch with some
                //relevant hashes. The dark and public from refs may not exist and will be ignored
                //here if they don't. This may result in a larger pack, in some situations, but it
                //should result in significantly less ref processing (especially on NFS)
                .negotiationTips(toRef.getLatestCommit(), refs.resolveDarkFrom(), refs.resolvePublicFrom())
                .source(fromRef.getLatestCommit())
                //No target, just update FETCH_HEAD
                .build());
    }

    private PullRequestRefUpdates maybeUpdateRefs(PullRequest pullRequest) {
        //Resolve the public from and merge refs, to facilitate creating RefUpdates if either changes.
        //In theory, we "know" what these values should be from the "dark" refs and the hashes on the
        //pull request, but it's risky to _rely_ on that.
        //For example, if a pull request is declined and then reopened, its public refs will first be
        //deleted and then recreated. But it's not possible to tell that that happened just by looking
        //deleted and then recreated. But it's not possible to tell that that happened just by looking
        //at the "dark" refs, which are not removed unless a pull request is _deleted_.
        PullRequestRefs refs = new DefaultPullRequestRefs(agent, pullRequest);
        String publicFrom = refs.resolvePublicFrom();
        String publicMerge = refs.resolvePublicMerge();

        String fromCommit = pullRequest.getFromRef().getLatestCommit();
        RefUpdate fromRefUpdate = null;
        RefUpdate mergeRefUpdate = null;

        UpdateRefStdinBuilder updateRefsBuilder = new UpdateRefStdinBuilder(pullRequest.getRepository());
        if (refs.isDarkFromOutdated()) {
            if (pullRequest.isCrossRepository()) {
                //For cross-repository pull requests, the commits on the incoming branch must be fetched
                //into the target repository. From a purely Git perspective this isn't necessary, since
                //it can use alternates to access objects in other repositories, but some functionality
                //in Bitbucket Server assumes it can run "normal" diffs using only the toRef repository
                //and they'll work.
                fetchFromRef(pullRequest, refs);
            }
            updateRefsBuilder.update(refs.getDarkFrom(), fromCommit);
        }

        if (pullRequest.isOpen()) {
            //If the pull request is open, we need to ensure its public from ref is correct.
            if (refs.isPublicFromOutdated()) {
                //Update the public from ref and attach a RefUpdate to the effective diff for the change
                updateRefsBuilder.update(refs.getPublicFrom(), fromCommit);
                fromRefUpdate = new RefUpdate.Builder(refs.getPublicFrom())
                        .newHash(fromCommit)
                        //oldHash == NULL -> created; otherwise, updated
                        .oldHash(publicFrom == null ? NULL_SHA1 : publicFrom)
                        .build();
            }
        } else if (publicFrom != null) {
            //The pull request has been closed, remove the public from ref
            updateRefsBuilder.delete(refs.getPublicFrom());
            fromRefUpdate = new RefUpdate.Builder(refs.getPublicFrom())
                    .newHash(NULL_SHA1)
                    .oldHash(publicFrom)
                    .build();
        }

        if (publicMerge != null) {
            //Public merge refs are no longer maintained. If this pull request was previously viewed
            //with a version of the system that still managed them, delete it and attach a RefUpdate
            //to the effective diff for the change
            updateRefsBuilder.delete(refs.getPublicMerge());
            mergeRefUpdate = new RefUpdate.Builder(refs.getPublicMerge())
                    .newHash(NULL_SHA1)
                    .oldHash(publicMerge)
                    .build();
        }
        if (!updateRefsBuilder.isEmpty()) {
            transactionManager.executeWithoutResult(tx -> updateRefsBuilder.buildAndRun());
            if (!pullRequest.isOpen()) {
                //ref updates have completed, delete the empty public ref dir if it exists
                deleteRefDirIfEmpty(pullRequest, PullRequestRefBuilder.publicRef().id(pullRequest).toRef());
            }
        }

        if (mergeRefUpdate == null && fromRefUpdate == null) {
            return null;
        }

        return new PullRequestRefUpdates.Builder(pullRequest)
                .fromRef(fromRefUpdate)
                .mergeRef(mergeRefUpdate)
                .build();
    }

    private Branch resolvePublicRef(PullRequest pullRequest, PullRequestRefType refType) {
        String refName = PullRequestRefBuilder.publicRef().id(pullRequest).type(refType).toRef();
        return agent.resolveBranch(pullRequest.getToRef().getRepository(), refName, true);
    }

    private class PreparedDelete implements PreparedAction {

        private final PullRequest pullRequest;
        private final ImmutableList.Builder<RefUpdate> refUpdates;

        PreparedDelete(@Nonnull PullRequest pullRequest) {
            this.pullRequest = requireNonNull(pullRequest, "pullRequest");

            refUpdates = ImmutableList.builder();
        }

        @Override
        public void commit() {
            Repository repository = pullRequest.getToRef().getRepository();

            Path repositoryDir = repository.getPath();
            Path refDir = PullRequestRefBuilder.publicRef().id(pullRequest).toPath(repositoryDir);

            String fromHash = hashService.getContentHash(repository);
            for (PullRequestRefType refType : LINK_REFS) {
                Branch publicRef = resolvePublicRef(pullRequest, refType);

                try {
                    transactionManager.executeLocalOnlyWithoutResult(tx -> {
                        builderFactory.builder(repository).updateRef()
                                .delete(PullRequestRefBuilder.publicRef().id(pullRequest).type(refType).toRef())
                                .build()
                                .run();
                    });
                    if (publicRef != null) {
                        refUpdates.add(buildRefUpdate(publicRef));
                    }
                } catch (RuntimeException e) {
                    log.warn("{}: The {} ref could not be deleted for pull request {}",
                            repository, refType, pullRequest.getId(), e);
                }
            }

            hashService.compareAndUpdateContentHash(repository, fromHash, hashService.calculateTransactionHash(new HashSet<>(refUpdates.build())));

            try {
                Files.deleteIfExists(refDir);
            } catch (DirectoryNotEmptyException e) {
                log.warn("{}: The ref directory for pull request {} isn't empty", repository, pullRequest.getId());
                if (log.isDebugEnabled()) {
                    try (Stream<Path> files = Files.list(refDir)) {
                        log.debug("{}: The ref directory for pull request {} contains the following: {}",
                                repository, pullRequest.getId(),
                                files.limit(10L).map(path -> path.getFileName().toString()).collect(joining(", ")));
                    } catch (IOException ioe) {
                        log.warn("{}: Ref directory contents for pull request {} could not be listed",
                                repository, pullRequest.getId(), ioe);
                    }
                }
            } catch (IOException e) {
                log.warn("{}: The ref directory for pull request {} could not be deleted",
                        repository, pullRequest.getId(), e);
            }

            PullRequestRefBuilder.stream(pullRequest)
                    .forEach(builder -> deleteRefDir(pullRequest, builder.toRef()));
            maybeDeletedSharedReflogs(pullRequest);
        }

        @Nonnull
        @Override
        public Repository getRepository() {
            return pullRequest.getRepository();
        }

        @Nonnull
        @Override
        public RpcTransaction.Builder populateTransaction(@Nonnull RpcTransaction.Builder builder) {
            return builder.setDeletePullRequest(RpcDeletePullRequestRequest.newBuilder()
                    .setPullRequest(pullRequest.toPullRequest()));
        }

        @Nonnull
        List<RefUpdate> getRefUpdates() {
            return refUpdates.build();
        }

        void prepare() {
            //Nothing to prepare at the moment, but the method is here in case we decide to add locking in the future
        }

        private RefUpdate buildRefUpdate(@Nonnull Branch branch) {
            requireNonNull(branch, "branch");

            return new RefUpdate.Builder(branch.getId())
                    .newHash(NULL_SHA1)
                    .oldHash(branch.getLatestCommit())
                    .build();
        }

        private void deleteRefDir(PullRequest pullRequest, String refDir) {
            Repository repository = pullRequest.getToRef().getRepository();
            try {
                MoreFiles.deleteRecursively(repository.getPath().resolve(refDir));
            } catch (FileNotFoundException | NoSuchFileException ignored) {
                //If the ref directory doesn't exist, move on
            } catch (IOException e) {
                log.warn("{}#{}: Failed to delete ref directory {}", repository, pullRequest.getId(), refDir, e);
            }
        }

        private void maybeDeletedSharedReflogs(PullRequest pullRequest) {
            Repository repository = pullRequest.getToRef().getRepository();
            if (repository instanceof ManagedRepository) {
                ManagedRepository managed = (ManagedRepository) repository;
                SharedObjectsRepository sharedObjects = managed.getSharedObjects();
                Path sharedReflogDir = sharedObjects.getReflogDir(managed);
                Stream.of(PullRequestRefBuilder.darkRef(repository), PullRequestRefBuilder.publicRef())
                        .map(builder -> builder.id(pullRequest).toRef())
                        .map(sharedReflogDir::resolve) // the PR reflog dirs in the shared-objects directory
                        .forEach(MoreFiles::deleteQuietly);
            }
        }
    }

    private class UpdateRefStdinBuilder {

        private final List<String> instructions;
        private final Repository repository;

        UpdateRefStdinBuilder(Repository repository) {
            this.repository = repository;
            instructions = new ArrayList<>();
        }

        void buildAndRun() {
            builderFactory.builder(repository).updateRef()
                    .stdin()
                    .author(SYSTEM_GIT_IDENT)
                    .deref(false)
                    .nullTerminated(true)
                    .build(new UpdateRefStdioHandler(instructions.stream(), true))
                    .run();
        }

        @Nonnull
        UpdateRefStdinBuilder delete(@Nonnull String refId) {
            instructions.add("delete " + requireNonNull(refId, "refId") + '\0');
            return this;
        }

        boolean isEmpty() {
            return instructions.isEmpty();
        }

        @Nonnull
        UpdateRefStdinBuilder update(@Nonnull String refId, @Nonnull String newHash) {
            instructions.add("update " + requireNonNull(refId, "refId") + '\0' +
                    requireNonNull(newHash, "newHash") + '\0');
            return this;
        }
    }
}
