package com.atlassian.bitbucket.mesh.git;

import com.atlassian.bitbucket.mesh.git.exception.*;
import com.atlassian.bitbucket.mesh.process.ProcessCompletionHandler;
import com.atlassian.bitbucket.mesh.process.ProcessSummary;
import com.google.common.base.Throwables;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

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

public class GitProcessCompletionHandler implements ProcessCompletionHandler {

    /**
     * Used to filter out any advice Git might print on stderr when a command fails. These hints might interfere
     * with command output parsing. This Regex can be used to remove them if required. Generally,
     * this should be done in subclasses that require exact output matching.
     */
    public static final Pattern ADVICE_PATTERN = Pattern.compile("(?:^|\n)hint: .*?(?:\n|$)", Pattern.MULTILINE);
    public static final String ERROR_REPOSITORY_READ_ONLY = "bitbucket: read-only";
    public static final Pattern PATTERN_BAD_OBJECT = Pattern.compile("fatal: bad object ([0-9a-f]+)");
    /**
     * Note: For an ambiguous commit, git will print out all of the candidates first in a long multi-line error message
     */
    public static final Pattern PATTERN_BAD_REVISION = Pattern.compile("(?s).*fatal: bad revision '\\^?(.+)'");
    /**
     * Used to match output for both invalid objects and non-existing file paths prior to Git v2.36
     */
    public static final Pattern PATTERN_NOT_FOUND = Pattern.compile("fatal: Not a valid object name (?:([^:]*):?(.*))");
    /**
     * Used to match output for invalid objects for Git 2.36+
     */
    public static final Pattern PATTERN_NOT_FOUND_OBJECT = Pattern.compile("fatal: invalid object name '(.*)'\\.");
    /**
     * Used to match output for non-existing file paths for Git 2.36+
     */
    public static final Pattern PATTERN_NOT_FOUND_PATH = Pattern.compile("fatal: path '(.*)' does not exist in '(.*)'");
    public static final String USAGE = "usage:";

    protected final Logger log;

    public GitProcessCompletionHandler() {
        log = LoggerFactory.getLogger(getClass());
    }

    @Override
    public void onComplete(@Nonnull ProcessSummary summary) {
        if (summary.isCanceled()) {
            onCancel(summary);
        } else if (summary.isTimedOut()) {
            onTimeout(summary);
        } else if (isError(summary)) {
            onError(summary);
        }
    }

    /**
     * Evaluates the provided {@code stderr} for any known patterns and throws exceptions. If this method returns,
     * it's assumed {@code stderr} doesn't contain anything fatal.
     *
     * @param summary the summary to evaluate
     * @param stderr  the buffered {@code stderr}
     */
    protected void evaluateStderr(@Nonnull ProcessSummary summary, @Nonnull String stderr) {
        if (stderr.startsWith(USAGE)) {
            throw new UsageException(summary.getCommandLine(), stderr);
        }

        Matcher matcher = PATTERN_BAD_OBJECT.matcher(stderr);
        if (matcher.matches()) {
            throw new CommitNotFoundException(matcher.group(1));
        }

        matcher = PATTERN_BAD_REVISION.matcher(stderr);
        if (matcher.matches()) {
            throw new CommitNotFoundException(matcher.group(1));
        }

        matcher = PATTERN_NOT_FOUND.matcher(stderr);
        if (matcher.matches()) {
            String objectId = matcher.group(1);
            String path = matcher.group(2);
            if (path == null || path.isEmpty()) {
                throw new ObjectNotFoundException(objectId);
            }

            throw new PathNotFoundException(objectId, path);
        }

        matcher = PATTERN_NOT_FOUND_OBJECT.matcher(stderr);
        if (matcher.matches()) {
            throw new ObjectNotFoundException(matcher.group(1));
        }

        matcher = PATTERN_NOT_FOUND_PATH.matcher(stderr);
        if (matcher.matches()) {
            throw new PathNotFoundException(matcher.group(2), matcher.group(1));
        }

        if (stderr.contains(ERROR_REPOSITORY_READ_ONLY) || stderr.contains(ERR_MIGRATED_TO_MESH)) {
            throw new RepositoryReadOnlyException();
        }
    }

    /**
     * Evaluates the provided {@link Throwable}. The exit code and {@code stderr} are provided for additional
     * context, but implementations may not use them.
     * <p>
     * <b>The default implementation will never return.</b> It will either rethrow the provided exception or
     * its root cause, if either is a {@link Error} or {@link RuntimeException}, or it will wrap the provided
     * {@link Throwable} in a {@link CommandFailedException} and throw that.
     *
     * @param summary the summary to evaluate
     * @param thrown  the previously-thrown exception
     */
    protected void evaluateThrowable(@Nonnull ProcessSummary summary, @Nonnull Throwable thrown) {
        //Try to rethrow the top-level exception
        Throwables.throwIfUnchecked(thrown);
        //If that fails, try to rethrow its cause
        Throwables.throwIfUnchecked(Throwables.getRootCause(thrown));

        //If all else fails, throw our own exception
        throw new CommandFailedException("[" + summary.getCommandLine() + "] failed", thrown, summary);
    }

    /**
     * Performs a preliminary check of the provided {@code exitCode}, {@code stderr} and exception to determine
     * whether any of them represent an error case.
     * <p>
     * The default implementation will return {@code true} if any of the following are true:
     * <ul>
     *     <li>The exit code is non-zero</li>
     *     <li>{@code stderr} {@link #hasErrors has errors}</li>
     *     <li>An exception was thrown</li>
     * </ul>
     *
     * @param summary the summary to evaluate
     * @return {@code true} if {@link #onError} should be invoked; otherwise, {@code false}
     */
    protected boolean isError(@Nonnull ProcessSummary summary) {
        return summary.getExitCode() != 0 ||
                summary.getThrown() != null ||
                hasErrors(summary.getCommandLine(), summary.getStderr());
    }

    /**
     * Evaluates a single line of {@code stderr} and decides whether it represents an error.
     * <p>
     * The default implementation will return {@code true} if the provided line starts with "error" or "fatal",
     * or {@link #USAGE "usage"}. Notably, this means "warning" lines aren't considered errors.
     *
     * @param line a line of {@code stderr}
     * @return {@code true} if the line represents an error; otherwise, {@code false}
     */
    protected boolean isError(@Nonnull String line) {
        return line.startsWith("error") || line.startsWith("fatal") || line.startsWith(USAGE);
    }

    /**
     * Evaluate the provided {@code exitCode}, {@code stderr} and any thrown exception.
     * <p>
     * When a process is canceled, exit codes, {@code stderr} and exceptions may not "mean" anything to the handler;
     * they may just be side effects of the cancellation process. Handlers can override this method to apply their
     * own interpretation. The default implementation will:
     * <ul>
     *     <li>Ignore the exit code</li>
     *     <li>{@link #evaluateStderr Evaluate {@code stderr}} for any known errors</li>
     *     <li>{@link #evaluateThrowable Evaluate} and rethrow any previously-thrown exception</li>
     * </ul>
     *
     * @param summary the summary to evaluate
     */
    protected void onCancel(@Nonnull ProcessSummary summary) {
        if (StringUtils.isNotBlank(summary.getStderr())) {
            evaluateStderr(summary, summary.getStderr());
        }

        if (summary.getThrown() != null) {
            evaluateThrowable(summary, summary.getThrown());
        }
    }

    /**
     * Evaluate the provided {@code exitCode}, {@code stderr} and any thrown exception.
     * <p>
     * When a process is canceled, exit codes, {@code stderr} and exceptions may not "mean" anything to the handler;
     * they may just be side effects of the cancellation process. Handlers can override this method to apply their
     * own interpretation. The default implementation will:
     * <ul>
     *     <li>Ignore the exit code</li>
     *     <li>{@link #evaluateStderr Evaluate {@code stderr}} for any known errors</li>
     *     <li>{@link #evaluateThrowable Evaluate} and rethrow any previously-thrown exception</li>
     * </ul>
     *
     * @param summary the summary to evaluate
     */
    protected void onError(@Nonnull ProcessSummary summary) {
        boolean hasStderr = StringUtils.isNotBlank(summary.getStderr());
        if (hasStderr) {
            evaluateStderr(summary, summary.getStderr());
        }

        if (summary.getThrown() != null) {
            evaluateThrowable(summary, summary.getThrown());
        }

        StringBuilder message = new StringBuilder()
                .append('[')
                .append(summary.getCommandLine())
                .append("] exited with code ")
                .append(summary.getExitCode());
        if (hasStderr) {
            message.append(" saying: ")
                    .append(summary.getStderr());
        }

        throw new CommandFailedException(message.toString(), summary);
    }

    protected void onTimeout(ProcessSummary summary) {
        throw new CommandTimeoutException(summary);
    }

    private boolean hasErrors(String commandLine, String stderr) {
        if (StringUtils.isNotBlank(stderr)) {
            //git outputs a level indicating how bad the message is. To try and make sure we don't bomb out on
            //"warning" lines, look for "error" and "fatal" and "usage" prefixes.
            for (String line : stderr.toLowerCase().split("\\n")) {
                if (isError(line)) {
                    return true;
                }
            }
            log.debug("{} did not fail, but the following was written to stderr:\n{}", commandLine, stderr);
        }

        return false;
    }
}
