package com.atlassian.bitbucket.mesh.git;

import com.atlassian.bitbucket.mesh.execution.ExecutionLocal;
import com.atlassian.bitbucket.mesh.execution.ExecutionManager;
import com.atlassian.bitbucket.mesh.git.hosting.GitReceivePackFilter;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.atlassian.bitbucket.mesh.rpc.v1.RpcHostingStreamResult;
import com.atlassian.bitbucket.mesh.rpc.v1.RpcStreamChunk;
import com.google.protobuf.UnsafeByteOperations;
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.concurrent.atomic.AtomicBoolean;

public class LoggingGrpcPushTracer implements GrpcPushTracer {

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

    private final int maxBufferSize;
    private final ExecutionLocal<PushTrace> pushTrace;

    public LoggingGrpcPushTracer(ExecutionManager executionManager, int maxBufferSize) {
        this.maxBufferSize = Integer.max(0, maxBufferSize);
        pushTrace = executionManager.newExecutionLocal(LoggingGrpcPushTracer::onRpcEnd);
    }

    @Override
    public GitReceivePackFilter createFilter(@Nonnull Repository repository) {
        if (log.isDebugEnabled() && pushTrace.isActive()) {
            return new TracingGitReceivePackFilter(repository);
        }
        return null;
    }

    @Override
    public void onPreReceive(int exitCode) {
        pushTrace.ifPresent(trace -> trace.onPreReceive(exitCode));
    }

    @Override
    public void onPostReceive(int exitCode) {
        pushTrace.ifPresent(trace -> trace.onPostReceive(exitCode));
    }

    @Override
    public void onResult(@Nonnull RpcHostingStreamResult result) {
        PushTrace trace = pushTrace.remove();
        if (trace != null) {
            trace.wrapUp(result);
        }
    }

    @Override
    public void onStdout(@Nonnull RpcStreamChunk chunk) {
        pushTrace.ifPresent(trace -> trace.onStdout(chunk));
    }

    @Override
    public void onStderr(@Nonnull RpcStreamChunk chunk) {
        pushTrace.ifPresent(trace -> trace.onStderr(chunk));
    }

    private static void onRpcEnd(@Nonnull PushTrace trace) {
        //This will be a no-op if the trace was already wrapped up when the git command finished
        trace.wrapUp(null);
    }

    private static class PushTrace {

        private final Repository repository;
        private final RingBuffer stderr;
        private final RingBuffer stdout;
        private final AtomicBoolean wrappedUp;

        private volatile boolean packReceived;
        private volatile Integer postReceiveExitCode;
        private volatile Integer preReceiveExitCode;
        private volatile String pushCommands;

        PushTrace(Repository repository, int maxBufferSize) {
            this.repository = repository;

            stderr = new RingBuffer(maxBufferSize);
            stdout = new RingBuffer(maxBufferSize);
            wrappedUp = new AtomicBoolean(false);
        }

        void onPostReceive(int exitCode) {
            postReceiveExitCode = exitCode;
        }

        void onPreReceive(int exitCode) {
            preReceiveExitCode = exitCode;
        }

        void onStderr(RpcStreamChunk chunk) {
            chunk.getData().asReadOnlyByteBufferList().forEach(stderr::write);
        }

        void onStdout(RpcStreamChunk chunk) {
            chunk.getData().asReadOnlyByteBufferList().forEach(stdout::write);
        }

        void setPackReceived(boolean value) {
            packReceived = value;
        }

        void setPushCommands(String value) {
            pushCommands = value;
        }

        void wrapUp(RpcHostingStreamResult result) {
            if (wrappedUp.compareAndSet(false, true)) {
                if (postReceiveExitCode != null || //post-receive was called
                        (preReceiveExitCode != null && preReceiveExitCode != 0) || //pre-receive vetoed
                        !packReceived || // client never even sent a pack; there was nothing to push
                        (result != null && result.getExitCode() != 0)) { // command failed with exception logged
                    //Exit conditions that do not need to be logged
                    return;
                }

                log.debug("[{}] Push did not complete as expected; {\nrequest: \"{}\",\npre-receive: {},\n" +
                                "post-receive: {}, \nstdout: \"{}\",\nstderr: \"{}\"}",
                        repository, pushCommands,
                        preReceiveExitCode == null ? "not called" : "{exit: " + preReceiveExitCode + "}",
                        postReceiveExitCode == null ? "not called" : "{exit: " + postReceiveExitCode + "}",
                        getStdout(), getStderr());
            }
        }

        private String getStdout() {
            return stdout.toString();
        }

        private String getStderr() {
            return stderr.toString();
        }
    }

    private class TracingGitReceivePackFilter implements GitReceivePackFilter {

        private final StringBuilder pushCommands;
        private final Repository repository;

        private volatile boolean packDetected;

        private TracingGitReceivePackFilter(Repository repository) {
            this.repository = repository;

            pushCommands = new StringBuilder(256);
        }

        @Nonnull
        @Override
        public RpcStreamChunk onPack(@Nonnull RpcStreamChunk chunk) {
            packDetected = true;
            PushTrace trace = pushTrace.computeIfAbsent(() -> new PushTrace(repository, maxBufferSize));
            if (trace != null) {
                trace.setPackReceived(true);
                trace.setPushCommands(pushCommands.toString());
            }
            return chunk;
        }

        @Nonnull
        @Override
        public RpcStreamChunk onPacketLine(@Nonnull RpcStreamChunk chunk) {
            //Only capture the first MAX_PUSH_COMMAND_TRACE_LENGTH characters provided by the client
            if (pushCommands.length() < maxBufferSize) {
                if (pushCommands.length() > 0) {
                    pushCommands.append('\n');
                }
                pushCommands.append(StringUtils.substring(chunk.getData().toStringUtf8(), 0,
                        Math.max(0, maxBufferSize - pushCommands.length())));
                if (pushCommands.length() >= maxBufferSize) {
                    pushCommands.append("...");
                }
            }
            return chunk;
        }

        @Override
        public boolean wantsPack() {
            return !packDetected;
        }

        @Override
        public boolean wantsPacketLines() {
            return !packDetected && pushCommands.length() < maxBufferSize;
        }
    }

    private static class RingBuffer {

        private final Object lock = new Object();
        private final int size;

        private int bytesRead;
        private byte[] data;
        private int writeIndex;

        RingBuffer(int size) {
            this.size = size;
        }

        void write(ByteBuffer buffer) {
            if (!buffer.hasRemaining()) {
                return;
            }

            synchronized (lock) {
                if (data == null) {
                    data = new byte[size];
                }

                int remaining = buffer.remaining();
                if (buffer.remaining() > data.length) {
                    //Skip to the last buffer of data and completely replace the buffer
                    buffer.position(buffer.position() + buffer.remaining() - data.length);
                    buffer.get(data);
                    writeIndex = data.length;
                } else {
                    //Copy to bytes[writeIndex..end]
                    int n = Math.min(data.length - writeIndex, buffer.remaining());
                    if (n > 0) {
                        buffer.get(data, writeIndex, n);
                    }
                    writeIndex = (writeIndex + n) % data.length;
                    if (buffer.hasRemaining()) {
                        n = buffer.remaining();
                        //Copy to bytes[0..newWriteIndex]
                        buffer.get(data, writeIndex, n);
                        writeIndex += n;
                    }
                }
                bytesRead += remaining;
            }
        }

        public String toString() {
            synchronized (lock) {
                if (data == null) {
                    return "";
                }
                if (bytesRead > data.length) {
                    return "..." + UnsafeByteOperations.unsafeWrap(data, writeIndex, data.length - writeIndex)
                            .concat(UnsafeByteOperations.unsafeWrap(data, 0, writeIndex)).toStringUtf8();
                }
                return new String(data, 0, bytesRead, StandardCharsets.UTF_8);
            }
        }
    }
}
