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

import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderFactory;
import com.atlassian.bitbucket.mesh.git.builder.commit.GitCommitBuilder;
import com.atlassian.bitbucket.mesh.git.builder.commit.GitCommitVerify;
import com.atlassian.bitbucket.mesh.git.builder.merge.GitMerge;
import com.atlassian.bitbucket.mesh.git.builder.merge.GitMergeFastForward;
import com.atlassian.bitbucket.mesh.git.builder.merge.GitSquashMergeBuilder;
import com.atlassian.bitbucket.mesh.git.builder.merge.MergeStdioHandler;
import com.atlassian.bitbucket.mesh.git.exception.EmptySquashMergeException;
import com.atlassian.bitbucket.mesh.io.LineReader;
import com.atlassian.bitbucket.mesh.io.ReaderLineReader;
import com.atlassian.bitbucket.mesh.process.handler.AbstractLineStdioHandler;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;

import static com.atlassian.bitbucket.mesh.git.GitConstants.PATH_GIT;

public class SquashMergeStrategy extends AbstractMergeStrategy<GitSquashMergeBuilder> {

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

    /**
     * The minimum number of {@code SQUASH_MSG} characters to include, and the minimum number of characters
     * from each squashed commit's details and message to include <i>per commit</i>. This is used to gate
     * both whether {@code SQUASH_MSG} is appended at all <i>and</i> how many commits are included.
     */
    private static final int MIN_MESSAGE_LENGTH = 200;
    private static final String NO_CHANGES = "no changes added to commit"; //TODO: BSERVDEV-15155
    private static final String UP_TO_DATE = "Your branch is up-to-date with";

    public SquashMergeStrategy(GitProcessBuilderFactory builderFactory, GitMergeFastForward fastForward) {
        super(builderFactory, fastForward);
    }

    @Override
    public void merge(@Nonnull MergeParameters parameters, @Nonnull Path workTree) {
        //First, perform the merge with --squash. This recreates the file state of performing the merge, but does not
        //actually commit it. It also writes a commit message into .git/SQUASH_MSG describing all of the commits that
        //were squashed down.
        MergeStdioHandler handler = new MergeStdioHandler();
        parameters.configureAndRun(handler.build(mergeBuilder(workTree, parameters)));

        //Next, prepare a separate builder to commit the merge's changes, which are staged to the work tree and index.
        String message = parameters.getMessage();
        GitCommitBuilder commitBuilder = builder(workTree).commit()
                .author(parameters.getAuthor())
                .committer(parameters.getCommitter())
                .message(message)
                .verify(GitCommitVerify.OFF);

        int remainingMessage = parameters.getMaxMessageLength() - message.length(); //Count the user-provided message
        if (remainingMessage < MIN_MESSAGE_LENGTH) {
            //If the user-provided message is very long, or the configured maxMessageLength is very short there
            //won't be enough room to include even the first commit from SQUASH_MSG, so just skip it entirely
            log.debug("Not appending SQUASH_MSG; the remaining message is too short ({})", remainingMessage);
        } else {
            if (parameters.getLog() > 0) {
                int maxCommits = parameters.getLog();
                Path gitDir = workTree.resolve(PATH_GIT);
                Path squashMsg = gitDir.resolve("SQUASH_MSG");

                //The details in SQUASH_MSG are useful, and similar to the output produced by --log when performing a
                //normal merge, so we'll want to keep them. However, we also want to apply the message supplied when
                //the merge was requested, so we need to combine it with the SQUASH_MSG. Additionally, since we don't
                //know how many commits were squashed, we may need to trim SQUASH_MSG to ensure we don't commit with
                //a combined message that's excessively long (which can lead to memory issues down the line)
                try (BufferedReader reader = Files.newBufferedReader(squashMsg, StandardCharsets.UTF_8)) {
                    //This file is created _inside_ the temporary clone, so it will automatically be deleted after
                    //the overall merge operation completes; we don't need to clean it up here explicitly
                    Path msg = Files.createTempFile(gitDir, "msg", ".txt");

                    //Write the user-supplied commit message to the file, and then copy in SQUASH_MSG lines
                    try (BufferedWriter writer = Files.newBufferedWriter(msg, StandardCharsets.UTF_8)) {
                        writer.write(message);
                        writer.write("\n\n");

                        appendSquashMsg(reader, writer, maxCommits, remainingMessage - 2); //-2 for \n\n
                    }

                    commitBuilder.message(msg.toFile()); //Replace the String message with a pointer to the msg file
                } catch (FileNotFoundException | NoSuchFileException e) {
                    //SQUASH_MSG doesn't exist so there's nothing to copy
                } catch (IOException | UncheckedIOException e) {
                    //If we cannot merge the message and the SQUASH_MSG, we just go with the message provided with the
                    //merge parameters. That was set when the builder was constructed, so there's nothing to do here.
                    log.warn("Could not prepend the pull request merge message to the git-generated squash message", e);
                }
            }
        }

        //Lastly, commit the changes
        parameters.configureAndRun(commitBuilder.build(new SquashMergeCommitStdioHandler()));
    }

    @Override
    protected GitSquashMergeBuilder chooseMode(GitMerge merge) {
        return merge.squash();
    }

    static void appendSquashMsg(Reader reader, Writer writer, int maxCommits, int remainingMessage) throws IOException {
        remainingMessage -= 28; //Reserve a little room for "... and NNNNNN more commits\n"

        int commits = 0;
        int perCommitMessage = 0; //Calculation deferred until we find the first "commit " line

        LineReader lineReader = new ReaderLineReader(LineReader.Mode.MODE_PC_UNIX_MIXED, reader);
        for (String line = lineReader.readLine(5000); line != null; line = lineReader.readLine(5000)) {
            if (line.startsWith("commit ")) {
                ++commits;
                if (commits == 1) {
                    //On the first commit, calculate how much of the remaining overall message should be
                    //allocated to each commit
                    if (remainingMessage < (maxCommits * MIN_MESSAGE_LENGTH)) {
                        //For each commit we include, we require at least 200 characters in the overall message
                        //to include its ID, author, date and at least a little bit of its message. If we don't
                        //have that much space left, reduce maxCommits accordingly. Math.max(1, ...) is used to
                        //ensure we always include at least 1 commit but for, say, 500 characters remaining we
                        //don't try to include 3 commits. (In other words, don't round up or always add 1.)
                        maxCommits = Math.max(1, remainingMessage / MIN_MESSAGE_LENGTH);
                    }
                    perCommitMessage = remainingMessage / maxCommits;
                }
                //Reset the number of characters remaining for the new commit
                remainingMessage = perCommitMessage;
            }
            if (commits > maxCommits || remainingMessage < 1) {
                //If we've written as many commits as we're allowed to, or if this commit's message is "too long",
                //drop the line and continue reading (so we can count the total commits)
                continue;
            }

            int length = line.length() + 1; //+1 for the trailing \n
            if (length > remainingMessage) {
                //If replacing some part of the line with a "..." will make it _shorter_, then do so. However,
                //if length is only 1 or 2 greater than remainingMessage, it's actually shorter to use the line
                //as-is than it is to "shorten" it
                if (length > (remainingMessage + 3)) {
                    //Commit messages are indented using 4 spaces, so if the line starts with a space we use
                    //Math.max(4, ...) to ensure the leading 4 spaces are preserved. This means we might go
                    //slightly over the remainingMessage limit, but it makes the output cleaner
                    int end = line.startsWith(" ") ? Math.max(4, remainingMessage) : remainingMessage;

                    line = line.substring(0, end) + "...";
                }
                //SQUASH_MSG includes a blank line after each commit's message and before the next commit, but
                //since we've run out of remainingMessage that blank line will be dropped. This includes it
                line += "\n";
                remainingMessage = 0;
            } else {
                remainingMessage -= length;
            }
            writer.write(line + "\n");
        }

        //After we've processed the full file, if we squashed more than maxCommits, append a line
        //indicating how many we dropped messages for
        int excess = commits - maxCommits;
        if (excess > 0) {
            writer.write("... and " + excess + " more commit" + (excess == 1 ? "" : "s") + "\n");
        }
    }

    private static class SquashMergeCommitStdioHandler extends AbstractLineStdioHandler<Void> {

        private SquashMergeCommitStdioHandler() {
            super(StandardCharsets.UTF_8);
        }

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

        @Override
        protected boolean onStdout(@Nonnull String line, boolean truncated) {
            if (StringUtils.startsWithAny(line, UP_TO_DATE, NO_CHANGES)) {
                throw new EmptySquashMergeException();
            }

            return true;
        }
    }
}
