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

import com.atlassian.bitbucket.mesh.git.builder.GitProcess;
import com.atlassian.bitbucket.mesh.git.exception.MergeConflictedException;
import com.atlassian.bitbucket.mesh.git.merge.GitMergeConflict;
import com.atlassian.bitbucket.mesh.process.handler.AbstractLineStdioHandler;
import com.atlassian.bitbucket.mesh.process.handler.LineDecoder;

import javax.annotation.Nonnull;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Parses {@link GitMergeConflict conflicts} from the output of {@code git merge}.
 * <p>
 * If any conflicts are detected in the output, a {@link MergeConflictedException} is thrown containing the conflicts
 * after the full output stream has been processed. This ensures the exception details <i>all</i> of the conflicts
 * that exist in the merge, rather than only including the first one.
 */
public class MergeStdioHandler extends AbstractLineStdioHandler<Void> {

    private static final String AUTOMATIC_MERGE_FAILED = "Automatic merge failed";
    private static final String CONFLICT = "CONFLICT ";
    /**
     * Pattern describing the warning message when a conflict occurs for a binary file.
     * <p>
     * Groups:
     * <ol>
     *     <li>The path to the binary file with the merge conflict</li>
     * </ol>
     */
    private static final Pattern PATTERN_BINARY_FILE = Pattern.compile("warning: Cannot merge binary files: (.+) \\(.+\\)$");
    private static final String REMOVING = "Removing ";

    private final Set<String> binaryPaths;
    private final List<GitMergeConflict> conflicts;

    private GitMergeConflict.Builder conflict;
    private int conflictLineCount;
    private LineDecoder stderrDecoder;

    public MergeStdioHandler() {
        super(StandardCharsets.UTF_8);

        binaryPaths = new HashSet<>();
        conflicts = new ArrayList<>();
    }

    @Nonnull
    public GitProcess<Void> build(@Nonnull GitMergeBuilderSupport<?> builder) {
        return builder.build(this);
    }

    /**
     * Always returns {@code null}.
     * <p>
     * Conflict information, if any, is retrieved from the output and produces a {@link MergeConflictedException}
     * containing the conflict details.
     *
     * @return {@code null}
     */
    @Override
    public Void getResult() {
        return null;
    }

    /**
     * If there are any {@link GitMergeConflict conflicts}, performs post-processing and throws a
     * {@link MergeConflictedException}. This is done in {@code onExit} which is only called when both {@code stdout}
     * and {@code stderr} have been closed.
     *
     * @param exitCode the process's exit code, <i>or a marker value in some cases</i>
     */
    @Override
    public void onExit(int exitCode) {
        maybeAddConflict();

        if (!conflicts.isEmpty()) {
            throw new MergeConflictedException("The merge could not be completed due to file conflicts", conflicts,
                    binaryPaths);
        }
    }

    @Override
    public void onStderr(@Nonnull ByteBuffer buffer, boolean closed) {
        if (stderrDecoder == null) {
            if (closed && !buffer.hasRemaining()) {
                //No need to create a decoder if there's no stderr
                return;
            }
            stderrDecoder = new LineDecoder(StandardCharsets.UTF_8, LineDecoder.Mode.LF);
        }

        //We want the GitProcess underpinnings to _also_ process stderr, so it will be buffered up and
        //provided to the completion handler, so we use mark()/reset() around our line handling to ensure we
        //return the buffer in the same position it started at. This way we can process all the stderr
        //text, and the StdioNuProcessHandler will also create its own buffer of it all.
        buffer.mark();
        try {
            stderrDecoder.forEach(buffer, closed, (line, truncated) -> {
                maybeParseBinaryFile(line);
                return true;
            });
        } finally {
            buffer.reset();
        }
    }

    /**
     * Receives lines from {@code stdout} and creates {@link GitMergeConflict conflicts} from the relevant messages.
     *
     * @param line      the line
     * @param truncated true if the line has been truncated
     * @return always {@code true}, to fully process the {@code git merge} output
     */
    @Override
    protected boolean onStdout(@Nonnull String line, boolean truncated) {
        // Since Git 2.36+ warnings for binary files are sent to stdout instead of stderr
        maybeParseBinaryFile(line);

        // Is it the start of a new conflict?
        if (line.startsWith(CONFLICT)) {
            // Add the current conflict.
            maybeAddConflict();
            // Start the new conflict.
            conflict = new GitMergeConflict.Builder(line);
            return true;
        }

        // Not currently processing a conflict.
        if (conflict == null) {
            return true;
        }

        // End of the current conflict.
        if (conflictLineCount >= 2 || line.startsWith(AUTOMATIC_MERGE_FAILED) || line.startsWith(REMOVING)) {
            maybeAddConflict();
            return true;
        }

        // Append line to the conflict.
        conflict.message(line);
        conflictLineCount++;
        return true;
    }

    /**
     * After {@code stdout} has been closed, adds the current conflict to the list.
     */
    @Override
    protected void onStdoutClosed() {
        maybeAddConflict();
    }

    private void maybeAddConflict() {
        if (conflict == null) {
            return;
        }

        // Add the current conflict to the list.
        conflicts.add(conflict.build());

        // Reset the conflict.
        conflict = null;
        conflictLineCount = 0;
    }

    private void maybeParseBinaryFile(String line) {
        Matcher binaryMatcher = PATTERN_BINARY_FILE.matcher(line);
        if (binaryMatcher.matches()) {
            binaryPaths.add(binaryMatcher.group(1));
        }
    }
}