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

import com.atlassian.bitbucket.mesh.git.content.Blame;
import com.atlassian.bitbucket.mesh.process.handler.AbstractLineStdioHandler;
import com.atlassian.bitbucket.mesh.process.handler.LineDecoder;
import com.atlassian.bitbucket.mesh.rpc.v1.RpcPerson;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Maps;
import com.google.protobuf.ByteString;

import javax.annotation.Nonnull;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static org.apache.commons.lang3.StringUtils.defaultString;

public abstract class AbstractBlameStdioHandler<T> extends AbstractLineStdioHandler<T> {

    public static final String AUTHOR_PREFIX = "author";
    public static final String COMMITTER_PREFIX = "committer";
    public static final String FILENAME_KEY = "filename";
    /**
     * Pattern for matching the header output by {@code git blame} at the beginning of a span of lines all related to
     * the same commit. The first line for both Porcelain and Incremental output from {@code git blame} will always
     * follow this format, according to the {@code git} documentation.
     * <p>
     * Pattern details:
     * <ul>
     * <li>Full commit hash</li>
     * <li>Span starting line in original file</li>
     * <li>Span starting line in current file</li>
     * <li>Number of lines spanned</li>
     * </ul>
     */
    public static final Pattern SPAN_HEADER_PATTERN = Pattern.compile("([0-9a-f]{40})\\s(\\d+)\\s(\\d+)\\s(\\d+)\\s*");
    public static final String SUFFIX_EMAIL = "-mail";
    public static final String SUFFIX_TIMESTAMP = "-time";
    public static final String SUMMARY_KEY = "summary";

    /**
     * A set of the keys to retain for {@link #commitProperties}. These keys are based on which properties get used
     * in {@link #createCommit}.
     */
    private static final Set<String> RETAINED_PROPERTIES = ImmutableSet.of(
            AUTHOR_PREFIX, AUTHOR_PREFIX + SUFFIX_EMAIL, AUTHOR_PREFIX + SUFFIX_TIMESTAMP, //Author details
            COMMITTER_PREFIX, COMMITTER_PREFIX + SUFFIX_EMAIL, COMMITTER_PREFIX + SUFFIX_TIMESTAMP, //Committer details
            FILENAME_KEY, SUMMARY_KEY); //Other commit properties

    private final Map<String, CommitDetails> commitsByHash;

    private Map<String, String> commitProperties;

    protected AbstractBlameStdioHandler() {
        super(StandardCharsets.UTF_8, LineDecoder.Mode.CRLF_OR_LF);

        commitsByHash = new HashMap<>();
    }

    protected static Span createSpan(Matcher matcher) {
        String hash = matcher.group(1);
        int lineNumber = Integer.parseInt(matcher.group(3));

        if (matcher.groupCount() == 4) {
            return new Span(hash, lineNumber, Integer.parseInt(matcher.group(4)));
        }
        return new Span(hash, lineNumber);
    }

    protected List<Blame> generateBlame(List<Span> spans) {
        List<Blame> blames = new ArrayList<>(spans.size());
        Blame pending = null;
        for (Span span : spans) {
            CommitDetails commit = commitsByHash.get(span.getHash());
            ContributorDetails author = commit.getAuthor();
            ContributorDetails committer = commit.getCommitter();

            Blame blame = new Blame.Builder()
                    .author(author.toPerson())
                    .authorTimestamp(author.toDate())
                    .committer(committer.toPerson())
                    .committerTimestamp(committer.toDate())
                    .commitId(span.getHash())
                    .fileName(commit.getFileName())
                    .lineNumber(span.getLineNumber())
                    .spannedLines(span.getSpannedLines())
                    .build();

            // If the last span has the same commit hash, the two should be merged
            if (pending != null && pending.getCommitId().equals(blame.getCommitId())) {
                pending = new Blame.Builder(pending)
                        .spannedLines(pending.getSpannedLines() + blame.getSpannedLines())
                        .build();
            } else {
                if (pending != null) {
                    blames.add(pending);
                }
                pending = blame;
            }
        }
        if (pending != null) {
            blames.add(pending);
        }

        return blames;
    }

    protected boolean isNewCommit(String hash) {
        return !commitsByHash.containsKey(hash);
    }

    protected boolean readCommit(String hash, String commitLine) {
        if (commitProperties == null) {
            commitProperties = new HashMap<>();
        }

        Map.Entry<String, String> metadata = parseMetadata(commitLine);
        if (RETAINED_PROPERTIES.contains(metadata.getKey())) {
            commitProperties.put(metadata.getKey(), metadata.getValue());
        }
        if (FILENAME_KEY.equals(metadata.getKey())) {
            commitsByHash.computeIfAbsent(hash, k -> createCommit(commitProperties));
            commitProperties = null;
            return true;
        }

        return false;
    }

    private static CommitDetails createCommit(Map<String, String> properties) {
        ContributorDetails author = createContributor(AUTHOR_PREFIX, properties);
        ContributorDetails committer = createContributor(COMMITTER_PREFIX, properties);
        String summary = properties.get(SUMMARY_KEY);
        String filename = properties.get(FILENAME_KEY);

        return new CommitDetails(author, committer, summary, filename);
    }

    private static ContributorDetails createContributor(String prefix, Map<String, String> properties) {
        String name = properties.get(prefix);
        String email = properties.get(prefix + SUFFIX_EMAIL);
        long timestamp = Long.parseLong(properties.get(prefix + SUFFIX_TIMESTAMP));

        return new ContributorDetails(name, email.substring(1, email.length() - 1), //Drop < and > around email
                TimeUnit.SECONDS.toMillis(timestamp)); //Timestamps are in seconds since the epoch
    }

    private static Map.Entry<String, String> parseMetadata(String line) {
        int index = line.indexOf(' '); //Find the _first_ space (some values, like names, may include spaces)
        if (index == -1) {
            //There are some directives, like "boundary", that do not have a value part. Treat those like flags
            return Maps.immutableEntry(line, "true");
        }

        return Maps.immutableEntry(line.substring(0, index), line.substring(index + 1));
    }

    protected static class Span implements Comparable<Span> {

        private final String hash;
        private final int lineNumber;

        //Not final because it's too much effort for too little payoff
        private int spannedLines;

        public Span(String hash, int lineNumber) {
            this(hash, lineNumber, 1);
        }

        public Span(String hash, int lineNumber, int spannedLines) {
            this.hash = hash;
            this.lineNumber = lineNumber;
            this.spannedLines = spannedLines;
        }

        /**
         * Orders spans by their {@link #getLineNumber() line number}.
         * <p>
         * When requesting incremental output from {@code git blame}, the spans are returned in reverse commit order,
         * regardless of their line ordering. This means the spans related to the most recent commits are always the
         * first spans, followed by those from older commits. To ensure {@link Blame} is returned in the correct order,
         * the spans created by incremental output must first be sorted.
         *
         * @param span the span to compare with the current span
         * @return negative, zero or positive depending on whether this span's line number is less than, equal to or
         * greater than (respectively) the provided span
         */
        @Override
        public int compareTo(@Nonnull Span span) {
            return lineNumber - span.lineNumber;
        }

        public String getHash() {
            return hash;
        }

        public int getLineNumber() {
            return lineNumber;
        }

        public int getSpannedLines() {
            return spannedLines;
        }

        /**
         * Determines whether this span includes any lines.
         * <p>
         * In practice, the only time this can ever return {@code true} is with an empty file. There is no line range
         * which can be specified to {@code git blame} that is capable of producing an empty span.
         *
         * @return {@code} true if this span includes no lines; otherwise {@code false}
         */
        public boolean isEmpty() {
            return spannedLines == 0;
        }

        /**
         * Trims the last page from the span.
         * <p>
         * When requesting Porcelain output from {@code git blame}, an extra line is requested for pagination concerns.
         * That extra line can manifest itself in two ways:
         * <ul>
         * <li>A one-line span, if the extra line happens at the start of a new commit boundary</li>
         * <li>An extra page on the last span, if the extra line falls within the same commit as one ore more lines
         * before it</li>
         * </ul>
         * This method addresses the latter situation, where the span ends up being one page too long.
         */
        public void trimLastPage() {
            --spannedLines;
            if (isEmpty()) {
                throw new IllegalStateException("The span for " + hash + " has been trimmed to empty");
            }
        }
    }

    private static class CommitDetails {

        private final ContributorDetails author;
        private final ContributorDetails committer;
        private final String fileName;
        private final String summary;

        CommitDetails(ContributorDetails author, ContributorDetails committer, String summary, String fileName) {
            this.author = author;
            this.committer = committer;
            this.fileName = fileName;
            this.summary = summary;
        }

        public ContributorDetails getAuthor() {
            return author;
        }

        public ContributorDetails getCommitter() {
            return committer;
        }

        public String getFileName() {
            return fileName;
        }

        public String getSummary() {
            return summary;
        }
    }

    private static class ContributorDetails {

        private final String email;
        private final String name;
        private final long timestamp;

        ContributorDetails(String name, String email, long timestamp) {
            this.email = email;
            this.name = name;
            this.timestamp = timestamp;
        }

        @Override
        public String toString() {
            return name + " <" + email + "> @ " + timestamp;
        }

        @Nonnull
        Date toDate() {
            return new Date(timestamp);
        }

        @Nonnull
        RpcPerson toPerson() {
            return RpcPerson.newBuilder()
                    .setEmail(defaultString(email))
                    .setName(ByteString.copyFromUtf8(name))
                    .build();
        }
    }
}
