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

import com.atlassian.bitbucket.mesh.git.GitAgent;
import com.atlassian.bitbucket.mesh.git.exception.CommandFailedException;
import com.atlassian.bitbucket.mesh.git.exception.RemoteAuthenticationException;
import com.atlassian.bitbucket.mesh.git.exception.RemoteAuthorizationException;
import com.atlassian.bitbucket.mesh.git.push.PushCallback;
import com.atlassian.bitbucket.mesh.git.ref.Branch;
import com.atlassian.bitbucket.mesh.git.ref.Ref;
import com.atlassian.bitbucket.mesh.git.ref.RefUpdate;
import com.atlassian.bitbucket.mesh.process.OsProcess;
import com.atlassian.bitbucket.mesh.process.ProcessCompletionHandler;
import com.atlassian.bitbucket.mesh.process.ProcessSummary;
import com.atlassian.bitbucket.mesh.process.handler.AbstractProcessStdioHandler;
import com.atlassian.bitbucket.mesh.process.handler.LineDecoder;
import com.atlassian.bitbucket.mesh.process.handler.LineOutputAdapter;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.google.common.annotations.VisibleForTesting;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

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

import static com.atlassian.bitbucket.mesh.git.builder.push.PushProgressStateMachine.ProgressStep.*;
import static com.atlassian.bitbucket.mesh.util.ShaUtils.NULL_SHA1;

/**
 * Handler for a {@code git push} command which parse out the added, updated and deleted refs and pass them to the
 * provided {@link PushCallback}.
 * <p>
 * The parsing applied here makes a few assumptions on how the command is executed:
 * <ul>
 *     <li>{@code --porcelain} must be used</li>
 *     <li>{@code --progress} may be used to support reporting progress via the callback</li>
 *     <li>{@code --prune} may be used, so branches and tags may be added or 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>
 * Note that {@link PushStdioHandler} does <em>not</em> call {@link PushCallback#onStart()} or
 * {@link PushCallback#onEnd(ProcessSummary)} because a push operation may be split into a number of fetches to
 * avoid exceeding command line limits. When this happens, the caller should ensure that {@code onStart} and
 * {@code onEnd} are called exactly once.
 */
public class PushStdioHandler extends AbstractProcessStdioHandler<ProcessSummary> implements ProcessCompletionHandler {

    private static final int MAX_ERROR_TEXT_SIZE = 64 * 1024;
    // For documentation of this format, see https://git-scm.com/docs/git-push#_output
    private static final Pattern REF_UPDATE_PATTERN = Pattern.compile(
            "^" +
            "(?<hint>[ +*!=-])" +
            "\t" +
            "(?<from>.*?):(?<to>.*?)" +
            "\t" +
            "(?:(?<fromhash>[0-9a-f]+)\\.\\.\\.?(?<tohash>[0-9a-f]+)|\\[(?<summary>.*?)\\])" +
            "(?:\\s+\\((?<reason>.*?)\\))?" +
            "$");
    private static final Logger log = LoggerFactory.getLogger(PushStdioHandler.class);

    private final GitAgent agent;
    private final PushCallback callback;
    private final ErrorText errors;
    private final Map<String, Ref> preFetchRefs;
    private final PushProgressHelper progressHelper;
    private final String remoteUrl;
    private final Repository repository;
    private final LineOutputAdapter stderrAdapter;
    private final LineOutputAdapter stdoutAdapter;

    private int refCount;
    private ProcessSummary summary;

    public PushStdioHandler(GitAgent agent, PushCallback callback, List<Ref> refs, String remoteUrl,
                            Repository repository) {
        this.agent = agent;
        this.callback = callback;
        this.remoteUrl = remoteUrl;
        this.repository = repository;

        errors = new ErrorText();
        preFetchRefs = new HashMap<>();
        refs.forEach(ref -> {
            preFetchRefs.put(ref.getId(), ref);
        });
        //Push sends progress information and errors to stderr, so we redirect both stdout and stderr processing to
        //onLine, but use separate LineOutputAdapter instances for them to make sure we don't mix bytes from different
        //streams in a single line. We can't simply use the same adapter instance because the adapter buffers internally
        //to handle split multibyte characters and split lines. Mixing bytes from different unrelated streams can result
        //in garbage output.
        LineOutputAdapter.Builder adapterBuilder = LineOutputAdapter.newBuilder()
                .charset(StandardCharsets.UTF_8)
                .lineMode(LineDecoder.Mode.CRLF_CR_OR_LF)
                .lineSink(this::onLine);
        stderrAdapter = adapterBuilder.build();
        stdoutAdapter = adapterBuilder.build();
        progressHelper = new PushProgressHelper(new PushProgressStateMachine.Builder()
                .step(ENUMERATING_OBJECTS, 0.02f)
                .step(COUNTING_OBJECTS, 0.08f)
                .step(COMPRESSING_OBJECTS, 0.1f)
                .step(WRITING_OBJECTS, 0.75f)
                .step(RESOLVING_DELTAS, 0.05f)
                .build(), callback::onProgress);
    }

    @Override
    public ProcessSummary getResult() {
        return summary;
    }

    @Override
    public void onComplete(@Nonnull ProcessSummary summary) {
        if (summary.getExitCode() != 0) {
            errors.getAuthenticationError().ifPresent(err -> {
                throw new RemoteAuthenticationException(err, remoteUrl);
            });
            errors.getAuthorisationOrVisibilityError().ifPresent(err -> {
                throw new RemoteAuthorizationException(err, remoteUrl);
            });
            this.summary = new ProcessSummary.Builder(summary)
                    .stderr(errors.toString())
                    .build();
            if (refCount == 0) {
                throw new CommandFailedException("Fetch failed", this.summary);
            }
        } else {
            this.summary = summary;
        }
    }

    @Override
    public void onPreStart(@Nonnull OsProcess process) {
        stderrAdapter.attach(process);
        stdoutAdapter.attach(process);

        super.onPreStart(process);
    }

    @Override
    public void onStderr(@Nonnull ByteBuffer buffer, boolean closed) {
        stderrAdapter.onBytes(buffer, closed);
    }

    @Override
    public void onStdout(@Nonnull ByteBuffer buffer, boolean closed) {
        stdoutAdapter.onBytes(buffer, closed);
    }

    @VisibleForTesting
    String getErrors() {
        return errors.toString();
    }

    private boolean onLine(@Nonnull String line, boolean truncated) {
        if (!processRefChange(line) && !progressHelper.processLine(line)) {
            //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
            errors.add(line);
        }
        return true;
    }

    private void onRefFailure(String refId) {
        refCount++;
        callback.onFailedUpdate(refId);
    }

    private void onRefUpdate(String refId, String oldHash, String newHash) {
        refCount++;
        callback.onRefUpdate(RefUpdate.of(refId, oldHash, newHash));
    }

    private boolean processRefChange(String line) {
        Matcher m = REF_UPDATE_PATTERN.matcher(line);
        if (!m.find()) {
            return false;
        }

        char hint = m.group("hint").charAt(0);
        String refId = m.group("to");

        if (hint == '*') {
            onRefUpdate(refId, NULL_SHA1, resolveRef(refId));
        } else if (hint == '!') {
            //This ref could not be updated.
            onRefFailure(refId);
            log.warn("{}: Ref {} could not be updated", repository, refId);
        } else if (hint == '-') {
            onRefUpdate(refId, NULL_SHA1, NULL_SHA1);
        } else if (hint == '=') {
            //Ref is up to date, ignore
            log.debug("{}: Ref {} already up to date", repository, refId);
        } else {
            String fromHash = m.group("fromhash");
            String toHash = m.group("tohash");
            onRefUpdate(refId, fromHash, toHash);
        }
        return true;
    }

    private String resolveRef(String refId) {
        Branch ref = agent.resolveBranch(repository, refId, true);
        return ref != null ? ref.getLatestCommit() : null;
    }

    /**
     * A collection of error lines with a limit applied to the overall length to avoid leaking memory
     * through accidental accumulation of progress lines etc
     */
    private static class ErrorText {

        private final Deque<String> lines;

        private int length;

        ErrorText() {
            lines = new ArrayDeque<>();
        }

        public void add(String line) {
            if (StringUtils.isBlank(line)) {
                return;
            }

            int newLineLength = line.length();
            while (!lines.isEmpty() && (length + newLineLength > MAX_ERROR_TEXT_SIZE)) {
                length -= lines.removeFirst().length();
            }
            lines.addLast(line);
            length += newLineLength;
        }

        @Override
        public String toString() {
            return String.join("\n", lines);
        }

        private Optional<String> getAuthenticationError() {
            return lines.stream()
                    .filter(it -> it.contains("Host key verification failed") || // SSH
                                  it.contains("Permission denied (") ||          // SSH
                                  it.contains("fatal: Authentication failed") || // HTTP
                                  it.contains("fatal: could not read Password")) // HTTP - no password specified
                    .findFirst();
        }

        private Optional<String> getAuthorisationOrVisibilityError() {
            return lines.stream()
                    .filter(it -> it.contains("Could not read from remote repository") ||
                                  it.contains("Repository not found") ||
                                  it.contains("The requested URL returned error: 403")) // HTTP - 403 status returned
                    .findFirst();
        }
    }
}
