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

import com.atlassian.bitbucket.mesh.git.commit.Commit;
import com.atlassian.bitbucket.mesh.util.Person;
import org.apache.commons.lang3.StringUtils;

import javax.annotation.Nonnull;
import java.text.MessageFormat;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;

import static org.apache.commons.lang3.StringUtils.chomp;
import static org.apache.commons.lang3.StringUtils.isBlank;

public class CommitPump {

    private static final String BASE_FORMAT = "%H|%P|%aN|%aE|%at|%cN|%cE|%ct".replace("|", "%x02");
    private static final int EXPECTED_TOKENS = 8;
    private static final String MESSAGE_FORMAT = BASE_FORMAT + "%n{0}%n%x03END%x04";
    private static final String OBJECT_END = "\u0003END\u0004";
    private static final Pattern PARENT_SEPARATORS = Pattern.compile("\\s");
    private static final char SEPARATOR_CHAR = '\u0002';
    private static final String UNSPECIFIED_NAME = "Unspecified";

    private final int maxMessageLength;
    private final CommitPumpSettings settings;
    private final CommitSink sink;

    private Commit.Builder builder;
    private StringBuilder message;

    /**
     * @param settings contains the settings for reading the commits from a repository
     * @param sink     is receiving end of the parsed commits and in general - a communication channel
     *                 between {@link CommitPump} and supplier of stdout.
     */
    public CommitPump(@Nonnull CommitPumpSettings settings, @Nonnull CommitSink sink) {
        this.settings = settings;
        this.sink = sink;

        maxMessageLength = settings.isMessageLengthUnlimited() ? Integer.MAX_VALUE : settings.getMaxMessageLength();
    }

    /**
     * Retrieves the format that should be applied to the executed Git command to produce output formatted for this
     * parser.
     *
     * @param settings contains the settings for reading the commits from a repository
     * @return the commit format
     */
    @Nonnull
    public static String getFormat(@Nonnull CommitPumpSettings settings) {
        return settings.getMessageMode().getPlaceholder()
                .map(placeholder -> MessageFormat.format(MESSAGE_FORMAT, placeholder))
                .orElse(BASE_FORMAT);
    }

    /**
     * Retrieves the format that should be applied to the executed Git command to produce output formatted for this
     * parser.
     *
     * @return the commit format
     */
    @Nonnull
    public String getFormat() {
        return getFormat(settings);
    }

    /**
     * Attempts to read a {@link Commit commit} from supplied output.
     * Commit is spread across multiple lines which this method receives one by one.
     *
     * @param line a line from git output
     * @throws IllegalStateException if the output is not {@link #getFormat() formatted} correctly
     */
    public void onStdout(@Nonnull String line) {
        if (builder == null && line.startsWith("commit")) {
            // first line starts with 'commit', we ignore it
            return;
        }

        if (builder == null && line.contains(String.valueOf(SEPARATOR_CHAR))) {
            //Second line: Commit details, separated by ASCII STX (start-of-text) characters. Preserve tokens so that
            //             empty pieces do not throw off the indexes.
            String[] pieces = StringUtils.splitPreserveAllTokens(line, SEPARATOR_CHAR);
            if (pieces.length == EXPECTED_TOKENS) {
                //Set out all the pieces that don't require any parsing
                builder = new Commit.Builder()
                        .id(pieces[0])
                        .author(parsePerson(pieces[2], pieces[3]))
                        .authorTimestamp(parseTimestamp(pieces[4]))
                        .committer(parsePerson(pieces[5], pieces[6]))
                        .committerTimestamp(parseTimestamp(pieces[7]));

                //Parents are a little more complicated. First, we need to ensure there actually are parents to parse
                String rawFullHashes = pieces[1];
                if (StringUtils.isNotBlank(rawFullHashes)) {
                    for (String fullHash : PARENT_SEPARATORS.split(rawFullHashes)) {
                        builder.parent(fullHash);
                    }
                }

                if (maxMessageLength == 0) {
                    build();
                } else {
                    message = new StringBuilder();
                    sink.onPartialMessage(maxMessageLength);
                }
            } else {
                //This should only be possible if the format string was updated and the parsing logic wasn't
                throw new IllegalStateException("[" + line + "]: Found " + pieces.length +
                                                " tokens where " + EXPECTED_TOKENS + " were expected");
            }
        } else if (message != null) {
            readMessage(line);
        } else {
            // line didn't start from 'commit' and there were no expected separators in it
            throw new IllegalStateException("[" + line + "]: Unexpected output; expected a 'commit' object");
        }
    }

    private static Person parsePerson(String name, String email) {
        if (isBlank(name)) {
            name = UNSPECIFIED_NAME;
            email = null; //Should already be the case, but enforce it.
        }

        return new Person(name, email);
    }

    private static long parseTimestamp(String timestamp) {
        if (isBlank(timestamp)) {
            //This is going to look really weird in the UI no matter what timestamp
            //we default it to. The current timestamp is going to be strange, intermixed with other commits that are
            //likely older. This will default to the epoch, which will also look strange.
            timestamp = "0";
        }

        return TimeUnit.SECONDS.toMillis(Long.parseLong(timestamp));
    }

    private void build() {
        sink.onCommit(builder.build());
        reset(); // ready for next commit
    }

    private void readMessage(@Nonnull String line) {
        if (line.equals(OBJECT_END)) {
            builder = builder.message(chomp(message.toString()));
            build();
            return;
        }

        if (maxMessageLength == Integer.MAX_VALUE) {
            message.append(line);
            message.append('\n');
        } else {
            int remaining = Math.max(maxMessageLength - message.length(), 0);
            if (remaining > 0) {
                message.append(line, 0, Math.min(line.length(), remaining));
                message.append('\n');

                sink.onPartialMessage(Math.max(remaining - line.length(), OBJECT_END.length()));
            }
        }
    }

    private void reset() {
        builder = null;
        message = null;

        sink.onPartialMessage(Integer.MAX_VALUE);
    }
}
