package com.atlassian.stash.internal.scm.git.protocol.http;

import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.bitbucket.scm.CommandExitHandler;
import com.atlassian.bitbucket.util.RequestUtils;
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 javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.SocketTimeoutException;

public abstract class GitSmartExitHandler implements CommandExitHandler {

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

    private final I18nService i18nService;
    private final Repository repository;
    private final HttpServletRequest request;
    private final HttpServletResponse response;
    private final boolean write;

    public GitSmartExitHandler(I18nService i18nService, Repository repository, HttpServletRequest request,
                               HttpServletResponse response, boolean write) {
        this.i18nService = i18nService;
        this.repository = repository;
        this.request = request;
        this.response = response;
        this.write = write;
    }

    @Override
    public void onCancel(@Nonnull String command, int exitCode, String stdErr, Throwable thrown) {
        onExit(command, exitCode, stdErr, thrown);
    }

    @Override
    public void onExit(@Nonnull String command, int exitCode, String stdErr, Throwable thrown) {
        boolean successful;

        if (handleUnexpectedClientHangup(stdErr)) {
            successful = true;
        } else if (handleMissingObjectForShallow(stdErr) ||
                handleMissingRef(stdErr) ||
                handleThrowable(stdErr, thrown)) {
            successful = false;
        } else if (log.isDebugEnabled() && StringUtils.isNotEmpty(stdErr)) {
            log.debug("{}: {} request from {} succeeded, but the following was written to stderr:\n{}",
                    repository, getRequestType(), RequestUtils.getRemoteHost(request), stdErr);
            successful = true;
        } else {
            successful = true;
        }

        if (successful) {
            onSuccess();
        }
    }

    /**
     * @return "Write" or "Read" depending on {@link #write}
     * @since 3.4
     */
    private String getRequestType() {
        return write ? "Write" : "Read";
    }

    /**
     * "fatal: did not find object for shallow" will occur (for example) when a client (Bamboo being the common culprit)
     * performs a fetch into a shallow clone where the remote branch has been rebased or rewritten in such a manner that
     * the client mentions commits that the server has no knowledge of.
     * <p>
     * When this happens we log a message at WARN (as the client's shallow clone will need to be  be forcibly reset to
     * "fix" it) and flush an informative message to the client.
     * <p>
     * This is technically a protocol violation, as the client will be expecting a shallow/unshallow, but there is no
     * "proper" way to send an error to the client at this stage of pack negotiation. In practice the client will write
     * the write the message to stderr and die.
     *
     * @param stdErr the error output from {@code git-http-backend}. Must not be null.
     * @return {@code true} if the output suggests the client is asking for a non-existent ref; otherwise, {@code false}
     */
    private boolean handleMissingObjectForShallow(String stdErr) {
        if (StringUtils.startsWith(stdErr, "fatal: did not find object for shallow")) {
            log.warn("{}: {} request from {} failed. The client mentioned a shallow object that is not present on " +
                            "the server. This may be triggered by rebases, or other forced updates on the server. " +
                            "The client should recreate their shallow clone.",
                    repository, getRequestType(), RequestUtils.getRemoteHost(request));
            log.debug("Error stream --> {}", stdErr);
            if (!response.isCommitted()) {
                try {
                    GitHttpUtils.sendErrorAsRawPacket(response,
                            i18nService.getMessage("bitbucket.scm.git.client.request.missing.shallow.object"));
                } catch (IOException e) {
                    log.warn("Failed to write error message to git client", e);
                }
            }

            return true;
        }

        return false;
    }

    /**
     * "fatal: git upload-pack: not our ref" will occur (for example) when a client requests a SHA-1 that was advertised
     * as a ref tip but is no longer a tip by the time the request is received. This is a race condition inherent to the
     * git protocol, which states that a client <strong>must not</strong> request objects that are not the tip of a ref,
     * or that the server doesn't know about.
     * <p>
     * When this happens we log a message (at INFO, as there is not much a Stash administrator can do about it and
     * retrying the hosting operation should succeed) and flush an informative message to the client.
     * <p>
     * This is technically a protocol violation, as the client will be expecting an ACK/NAK, but there is no "proper"
     * way to send an error to the client at this stage of pack negotiation. In practice the client will write the
     * message to stderr and die.
     *
     * @param stdErr the error output from {@code git-http-backend}
     * @return {@code true} if the output suggests the client is asking for a non-existent ref; otherwise, {@code false}
     */
    private boolean handleMissingRef(String stdErr) {
        if (StringUtils.startsWith(stdErr, "fatal: git upload-pack: not our ref")) {
            log.info("{}: {} request from {} failed. A ref was requested that is no longer valid. The ref may " +
                            "have been updated while the request was being processed. Please try again.",
                    repository, getRequestType(), RequestUtils.getRemoteHost(request));
            log.debug("Error stream --> {}", stdErr);
            if (!response.isCommitted()) {
                try {
                    GitHttpUtils.sendErrorAsRawPacket(response,
                            i18nService.getMessage("bitbucket.scm.git.client.request.missing.ref"));
                } catch (IOException e) {
                    log.warn("Failed to write error message to git client", e);
                }
            }

            return true;
        }

        return false;
    }

    /**
     * Examines the {@code Throwable} to check for known failures and log accordingly. If an exception was thrown the
     * command should always be treated as failed.
     *
     * @param stdErr  the error output from {@code git-http-backend}, which may be empty
     * @param thrown  the exception that was thrown while processing the request, which may be {@code null}
     * @return {@code true} if an exception was thrown; otherwise, {@code false}
     * @since 3.4
     */
    private boolean handleThrowable(String stdErr, Throwable thrown) {
        if (thrown == null) {
            return false;
        }

        @SuppressWarnings("ThrowableResultOfMethodCallIgnored")
        Throwable rootCause = Throwables.getRootCause(thrown);
        if (rootCause instanceof SocketTimeoutException) {
            //The remote client went away. Especially for installations used across WAN links
            //this may be a common occurrence, so turn down the logging a bit
            log.info("{}: {} request from {} failed due to a socket timeout",
                    repository, getRequestType(), RequestUtils.getRemoteHost(request));
            return true;
        }
        if (!response.isCommitted()) {
            // use setStatus() instead of sendError() which renders the server's 500 page
            response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
        }

        //If there was any output on stderr, display it on a separate line in the log
        String stdErrMessage = StringUtils.isBlank(stdErr) ? "" : "\nThe following was written to stderr:\n" + stdErr;
        if (log.isDebugEnabled()) {
            //If debug logging is enabled, log the full exception with its stack trace
            log.error("{}: {} request from {} failed{}", repository, getRequestType(),
                    RequestUtils.getRemoteHost(request), stdErrMessage, thrown);
        } else {
            //Otherwise just log its message
            log.error("{}: {} request from {} failed: {}{}", repository, getRequestType(),
                    RequestUtils.getRemoteHost(request), thrown, stdErrMessage);
        }

        return true;
    }

    /**
     * Parses the provided error output to attempt to determine whether it indicates a shallow clone may have occurred.
     * If this is the case, just log the error to debug.
     * <p>
     * Pack negotiation for shallow clones is split over two requests, which means that the server completes the first
     * request and creates a new thread for the second one. The Git client <i>can</i> reuse connections so both requests
     * <i>may</i> be sent in the same TCP connection; however, way Git uses {@code libcurl} in some versions prevents
     * connection reuse, so the requests may be separate.
     * <p>
     * Note: The heuristic applied here is not the most robust, so it's possible that events other than shallow clones
     * could trigger this to return {@code true}. The result of this method is more indicative than indicting.
     *
     * @param stdErr the error output from {@code git-http-backend}
     * @return {@code true} if the output suggests a shallow clone; otherwise {@code false} to indicate some other
     *         error state is more likely
     */
    private boolean handleUnexpectedClientHangup(String stdErr) {
        if ("fatal: The remote end hung up unexpectedly".equals(stdErr)) {
            log.debug("{}: {} request from {} was interrupted: Is this a shallow clone request?",
                    repository, getRequestType(), RequestUtils.getRemoteHost(request));
            return true;
        }

        return false;
    }

    /**
     * Invoked if the process exits normally without error.
     */
    protected abstract void onSuccess();
}
