package com.atlassian.stash.internal.repository.sync.command;

import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.repository.*;
import com.atlassian.bitbucket.repository.sync.RejectedRefState;
import com.atlassian.bitbucket.scm.CommandErrorHandler;
import com.atlassian.bitbucket.scm.CommandOutputHandler;
import com.atlassian.bitbucket.scm.git.GitAgent;
import com.atlassian.bitbucket.scm.git.command.GitCommand;
import com.atlassian.bitbucket.scm.git.GitUtils;
import com.atlassian.bitbucket.scm.git.command.GitCommandExitHandler;
import com.atlassian.bitbucket.scm.git.command.fetch.GitFetchBuilder;
import com.atlassian.utils.process.LineOutputHandler;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Applies error, exit and output handlers for a {@code git fetch} command which parse out the added, updated and
 * rejected refs and add them to the {@link FetchCallback}.
 * <p>
 * The parsing applied here makes a few assumptions on how the command is executed:
 * <ul>
 *     <li>Tags will <i>never</i> be fetched</li>
 *     <li>{@code --prune} will not be used, so branches may be added or updated but never deleted</li>
 *     <li>The refspec applied will copy refs <i>to the same place</i>, meaning refs/heads/master from upstream
 *     will update refs/heads/master in this repository</li>
 * </ul>
 *
 * @since 2.6
 */
public class FetchCommandHandler {

    public static final Pattern PATTERN_LOCK_FAILED = Pattern.compile("fatal: Unable to create '.+/(refs/heads/[^']+)\\.lock': File exists\\.");
    public static final Pattern PATTERN_REMOTE_REF = Pattern.compile("fatal: Couldn't find remote ref (refs/heads/.*)");

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

    private final GitAgent agent;
    private final FetchCallback context;
    private final StringBuilder errors;
    private final I18nService i18nService;
    private final Repository repository;

    private int rejected;

    public FetchCommandHandler(@Nonnull GitAgent agent, @Nonnull I18nService i18nService,
                               @Nonnull FetchCallback context, @Nonnull Repository repository) {
        this.agent = agent;
        this.context = context;
        this.i18nService = i18nService;
        this.repository = repository;

        errors = new StringBuilder();
    }

    public GitCommand<Void> build(GitFetchBuilder builder) {
        //git fetch writes its progress information to stderr, not stdout, but for a little added certainty we
        //apply the same handler to both streams to pick up ref output from either
        return builder.errorHandler(new RefOutputHandler())
                .exitHandler(new FetchExitHandler(i18nService, repository))
                .build(new RefOutputHandler());
    }

    private void processRef(String line) {
        //Blank lines aren't really expected, but are ignored if present. Sometimes git fetch outputs lines like:
        //From ssh://stash.dev.internal.atlassian.com:7999/STASH/stash
        //While that is also not really expected here, such lines are ignored as well.
        if (StringUtils.isBlank(line) || line.charAt(0) != ' ') {
            //Since we are an error handler as well as an output handler, and git fetch outputs progress to stderr,
            //buffer up any lines that we don't process so that they can be provided to our exit handler side
            if (errors.length() > 0) {
                errors.append('\n');
            }
            errors.append(line);

            return;
        }

        //Parse out the hint, which is always the second character in the line and one character long
        char hint = line.charAt(1); //Possible hints are *, x, -, ! and space

        //Parse out the change details
        int changeEnd = line.indexOf(' ', 20); //3 + 17, where 17 -> (7*2 + 3), where 7 is the short hash length
        String change = line.substring(3, changeEnd).trim(); //Some messages are shorter than the 17 minimum

        //Parse out the ref name
        String name = line.substring(changeEnd);
        int split = name.indexOf(" -> ");
        if (split != -1) {
            name = name.substring(0, split); //Strip off the destination ref; it will _always_ match the source
        }
        String refId = agent.qualifyBranch(name.trim());

        if (hint == '*') { //* identifies added refs
            //This branch did not exist within this repository and has been added.
            //
            //Caveat: "[pruned]" can happen for deleted branches, but it's not possible with how we're using fetch
            context.onChanged(buildAddRefChange(refId));
        } else if (hint == '!') {
            //This branch could not be updated, which means this repository has diverged from upstream. The divergent
            //changes should always be retained; sync is only intended to carry forward, never overwrite
            context.onRejected(refId, RejectedRefState.DIVERGED);
            ++rejected;
        } else if (hint == '-' || hint == 'x') { //- identifies updated tags, 'x' identifies deleted refs
            //Caveat: This should not be possible with how we use fetch, since we don't fetch tags or delete branches
            throw new IllegalStateException("Unexpected ref change: " + line);
        } else {
            //This branch already existed in the repository and the changes made upstream were fast-forward, so they
            //have been carried forward
            split = change.indexOf("..");
            String fromHash = change.substring(0, split);
            String toHash = change.substring(split + 2); //Drop the .. between the two hashes

            //This will build a RefChange that has short hashes for getFromHash() and getToHash(). That violates the
            //contract for RefChange, and is known to cause problems in some plugins (Technical Insights for Stash,
            //for example). The RefChange constructed here will be replaced later with one using full hashes. It is
            //delayed out of here for two reasons:
            //1. To not delay processing for the git fetch process this handler is attached to
            //2. To allow _all_ such short hashes to be resolved in a single pass
            //
            //See AbstractFetchSynchronizeRefsCommand.resolveRefs for the logic that replaces short hashes.
            context.onChanged(buildUpdateRefChange(refId, fromHash, toHash));
        }
    }

    private RefChange buildAddRefChange(String refId) {
        Branch branch = agent.resolveBranch(repository, refId, true);
        if (branch == null) {
            throw new IllegalStateException("git fetch updated " + refId + ", but the ref does not exist");
        }

        return buildRefChange(refId, RefChangeType.ADD, GitUtils.NULL_SHA1, branch.getLatestCommit());
    }

    private RefChange buildUpdateRefChange(String refId, String fromHash, String toHash) {
        return buildRefChange(refId, RefChangeType.UPDATE, fromHash, toHash);
    }

    private static RefChange buildRefChange(String id, RefChangeType type, String fromHash, String toHash) {
        return new SimpleRefChange.Builder()
                .fromHash(fromHash)
                // ref-sync only fetches branches
                .ref(new SimpleMinimalRef.Builder().id(id).displayId(id).type(StandardRefType.BRANCH).build())
                .toHash(toHash)
                .type(type)
                .build();
    }

    private class FetchExitHandler extends GitCommandExitHandler {

        private FetchExitHandler(I18nService i18nService, Repository repository) {
            super(i18nService, repository);
        }

        @Override
        public void onExit(@Nonnull String command, int exitCode, String stdErr, Throwable thrown) {
            //Because we are a CommandErrorHandler as well, we know stdErr will always be empty. However,
            //processRef(String) buffers any lines that aren't refs, so we replace stdErr with that
            super.onExit(command, exitCode, errors.toString(), thrown);
        }

        @Override
        protected boolean isError(@Nonnull String command, int exitCode, String stdErr, Throwable thrown) {
            if (rejected > 0 && (exitCode != 0 || isNonZeroExitCodeException(thrown))) {
                return false;
            }

            Matcher matcher = PATTERN_LOCK_FAILED.matcher(stdErr);
            if (matcher.find()) { //find() rather than matches() so we don't have to worry about the extra text
                String ref = matcher.group(1);
                log.warn("{}: Refs could not be synchronized; {} is locked by another process", repository, ref);

                return false;
            }

            matcher = PATTERN_REMOTE_REF.matcher(stdErr);
            if (matcher.find()) {
                String ref = matcher.group(1);
                log.warn("{}: Refs could not be synchronized; {} no longer exists", repository, ref);

                return false;
            }

            return super.isError(command, exitCode, stdErr, thrown);
        }
    }

    private class RefOutputHandler
            extends LineOutputHandler
            implements CommandErrorHandler, CommandOutputHandler<Void> {

        private RefOutputHandler() {
            super("UTF-8");
        }

        @Override
        public Void getOutput() {
            return null;
        }

        @Override
        protected void processLine(int lineNum, String line) {
            processRef(line);
        }
    }
}
