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

import com.atlassian.bitbucket.scm.git.GitException;
import com.atlassian.bitbucket.scm.git.GitRefPattern;
import com.atlassian.bitbucket.scm.git.GitScmConfig;
import com.atlassian.bitbucket.scm.git.command.GitCommand;
import com.atlassian.bitbucket.scm.git.command.GitCommandBuilderFactory;
import com.atlassian.bitbucket.util.FileUtils;
import com.atlassian.bitbucket.scm.CommandFailedException;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.stash.internal.scm.git.InternalGitAgent;
import com.atlassian.stash.internal.scm.git.InternalGitConstants;
import com.atlassian.stash.internal.scm.git.WindowsGitUtils;
import com.atlassian.stash.internal.scm.git.command.clone.WindowsForkCommandExitHandler;
import com.atlassian.stash.internal.scm.git.command.merge.FetchCommandExitHandler;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.bitbucket.scm.git.command.clone.GitCloneBuilder;
import com.atlassian.bitbucket.scm.git.command.LoggingCommandOutputHandler;
import com.atlassian.bitbucket.scm.git.command.reset.GitResetBuilder;
import com.atlassian.bitbucket.util.Timer;
import com.atlassian.bitbucket.util.TimerUtils;
import org.apache.commons.lang3.SystemUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.File;
import java.io.IOException;

import static com.atlassian.bitbucket.util.FileUtils.createTempDir;

/**
 * @since 2.9
 */
public class ShinyGitPorcelain implements GitPorcelain {

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

    private final InternalGitAgent agent;
    private final GitCommandBuilderFactory builderFactory;
    private final GitScmConfig config;
    private final I18nService i18nService;

    public ShinyGitPorcelain(InternalGitAgent agent, GitCommandBuilderFactory builderFactory,
                             GitScmConfig config, I18nService i18nService) {
        this.agent = agent;
        this.builderFactory = builderFactory;
        this.config = config;
        this.i18nService = i18nService;
    }

    public void fetch(@Nonnull FetchRequest request) {
        //Note: Do _not_ use --quiet with fetch, as it eliminates _all_ output, even in error situations. That
        //      in turn, prevents detecting rejected forced updates in the FetchCommandExitHandler
        LoggingCommandOutputHandler outputHandler = new LoggingCommandOutputHandler();
        GitCommand<Void> command = builderFactory.builder(request.getRepository()).fetch()
                .author(request.getAuthor())
                //Track the stderr of the fetch command to see if it was rejected. Note that hooks are not run for
                //fetches, so it should not be possible for a hook to prevent forced fetches
                .exitHandler(new FetchCommandExitHandler(i18nService, request))
                .refspec(request.buildRefspec())
                .repository(request.getRemote())
                .build(outputHandler);
        outputHandler.setCommand(command);
        request.configureAndCall(command);
    }

    /**
     * Creates a work tree for the requested {@link WorkTreeRequest#getRepository() repository}, allowing the use of
     * Git commands like {@code git merge} which require a work tree.
     * <p>
     * The returned work tree:
     * <ul>
     *     <li>has a ref for the specified branch, or {@code refs/heads/master} if no branch was specified</li>
     *     <ul>
     *         <li>Regardless of what that ref points to upstream, it points to the requested commit in the work
     *         tree</li>
     *     </ul>
     *     <li>has a clean index (no staged changes)</li>
     *     <li>has alternates to the {@link WorkTreeRequest#getRepository upstream repository}, as well as for any
     *     {@link WorkTreeRequest#getAlternates extra repositories} requested</li>
     *     <li>has no checked out files</li>
     * </ul>
     *
     * @param request the request describing the work tree to create
     * @return the location of the created work tree on disk
     */
    @Nonnull
    public File workTree(@Nonnull WorkTreeRequest request) {
        File workTree = createWorkTree(request.getRepository());

        log.debug("{}: Cloning to {}", request.getRepository().getId(), workTree);
        try (Timer ignored = TimerUtils.start("git: clone " + request.getRepository().getId())) {
            clone(workTree, request);
        }

        log.debug("{}: Resetting {} to clean the index", request.getRepository().getId(), workTree);
        try (Timer ignored = TimerUtils.start("git: reset " + (request.hasCommit() ? request.getCommit() : "HEAD"))) {
            reset(workTree, request);
        }

        return workTree;
    }

    /**
     * Appends an alternate for the specified {@link Repository} in the work tree, allowing it to access objects
     * from that repository. This method can be called multiple times to append alternates, allowing the created
     * work tree to borrow objects from several repositories at once.
     *
     * @param workTree   the temporary folder containing the non-bare clone in which the merge was performed
     * @param repository the repository the alternate should borrow from
     * @param cygwin     {@code true} if Cygwin {@code git} has been detected; otherwise, {@code false}
     */
    private void addAlternateIn(File workTree, Repository repository, boolean cygwin) {
        File fromDir = new File(config.getRepositoryDir(repository), InternalGitConstants.PATH_OBJECTS);
        String alternate = cygwin ? WindowsGitUtils.cygpath(fromDir) : fromDir.getAbsolutePath();
        try {
            //workTree is non-bare, so the alternate goes under its .git directory
            agent.addAlternate(new File(workTree, InternalGitConstants.PATH_GIT), alternate);
        } catch (GitException e) {
            log.error("Could not add an alternate for " + alternate + " to work tree in " +
                    workTree.getAbsolutePath() + File.separator + InternalGitConstants.PATH_GIT +
                    ". A cross-repository work tree could not be prepared", e);

            throw e;
        }
    }

    /**
     * Use {@code git clone} to create a non-bare repository in the specified {@code workTree}. Alternates are added
     * for any additional repositories specified in the {@link WorkTreeRequest request}.
     *
     * @param workTree the work tree the repository should be created in
     * @param request  the request describing the repository to create the work tree for, the branch to checkout
     *                 and any alternates that should be added
     */
    private void clone(File workTree, WorkTreeRequest request) {
        File originDir = config.getRepositoryDir(request.getRepository());

        GitCloneBuilder cloneBuilder = builderFactory.builder().clone()
                .normal() //Normal clone, with a work tree, to allow using commands like merge that require one
                .checkout(false) //For speed, don't perform a full checkout of the branch
                .directory(workTree)
                .origin(originDir)
                .shared(true); //Use alternates to point to the objects directly in the cloned repository
        if (request.hasBranch()) {
            //If a branch is provided, set it as HEAD for the work tree. This _must_ be an unqualified branch;
            //Git doesn't try to resolve ambiguous refs in this context and will never look at tags.
            //
            //noinspection ConstantConditions
            cloneBuilder.branch(GitRefPattern.HEADS.unqualify(request.getBranch()));
        }

        WindowsForkCommandExitHandler exitHandler = null;
        if (SystemUtils.IS_OS_WINDOWS) {
            //Still use a shared clone, even on the retry for Cygwin. This clone is short-lived, so the fact that
            //the alternate wouldn't get rewritten for a home move is acceptable
            exitHandler = new WindowsForkCommandExitHandler(cloneBuilder, i18nService, workTree, originDir, true);
        }
        request.configureAndCall(cloneBuilder.build());

        //Work around Cygwin path issues!
        boolean cygwin = exitHandler != null && exitHandler.isCygwin();
        for (Repository alternate : request.getAlternates()) {
            //Creating the shared clone will automatically create an alternate for the "to" repository. However, for
            //cross-repository pull requests, we need to add an alternate for the "from" repository as well, to ensure
            //the merge has access to all referenced objects for both branches
            addAlternateIn(workTree, alternate, cygwin);
        }
    }

    /**
     * {@link FileUtils#createTempDir(String, String, File) Creates a work tree} for the
     * clone to be written to.
     *
     * @param repository the repository the work tree will be {@link #clone cloned} from
     * @return the created temporary directory
     */
    private File createWorkTree(Repository repository) {
        try {
            return createTempDir(repository.getSlug() + "-work", ".git", config.getTempDir());
        } catch (IOException e) {
            throw new CommandFailedException(i18nService.createKeyedMessage("bitbucket.git.porcelain.worktreenotcreated",
                    repository.getProject().getKey(), repository.getSlug()), e);
        }
    }

    /**
     * Reset the index in the work tree so that the deletions for all the files we didn't checkout are unstaged.
     * If a specific {@link WorkTreeRequest#getCommit() commit} was requested, the index is read from that
     * commit. This can be useful to ensure that a specific commit on a branch is checked out, rather than
     * whatever commit happened to be the tip of that branch when the work tree was created.
     *
     * @param workTree the work tree to reset, unstaging deletions from using {@code --no-checkout}
     * @param request  the request describing the commit to reset to
     * @see #workTree(WorkTreeRequest)
     */
    private void reset(File workTree, WorkTreeRequest request) {
        GitResetBuilder resetBuilder = builderFactory.builder().reset()
                .commit(request.getCommit())
                .quiet(true) //Suppress the normal status output after the reset
                .workingDirectory(workTree);

        request.configureAndCall(resetBuilder.build());
    }
}
