package com.atlassian.bitbucket.mesh.git.hook;

import com.google.common.annotations.VisibleForTesting;
import org.apache.commons.lang3.StringUtils;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.EOFException;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.ReadableByteChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;

import static com.atlassian.bitbucket.mesh.git.hook.GitHookOutputWriter.SOCKET_OUTPUT_MAX;
import static com.google.common.base.Preconditions.checkState;
import static java.util.Objects.requireNonNull;

/**
 * Reads Git hook input from a channel.
 * <p>
 * Hook data expected as a series of chunks in {@code [ASCII char][unsigned short][data]} format, terminated with a
 * single {@code E} character. The meaning of each part of the chunk is:
 * <dl>
 *     <dt>ASCII char</dt>
 *     <dd>The <em>channel</em> of the data (see {@link ChunkType})</dd>
 *     <dt>unsigned short</dt>
 *     <dd>The length of the data to follow</dd>
 *     <dt>Data</dt>
 *     <dd>Binary data, the contents of which vary from hook to hook</dd>
 * </dl>
 * <b>Note</b>: The terminating {@code E} chunk does not receive any data.
 */
public class GitHookInputParser {

    private final ByteBuffer buffer;
    private final ReadableByteChannel channel;
    private final HookStdinBuilder stdin;

    public GitHookInputParser(@Nonnull ReadableByteChannel channel, @Nullable Path tempDir,
                              long bufferCapacity) {
        this.channel = requireNonNull(channel, "channel");

        //65535 is the maximum unsigned short, so this buffer is sufficient for any single packet.
        buffer = ByteBuffer.allocate(SOCKET_OUTPUT_MAX);
        buffer.flip(); //Present the buffer as ready-to-read and empty

        stdin = new HookStdinBuilder(tempDir, bufferCapacity);
    }

    @Nonnull
    public HookRequest readRequest() throws IOException {
        GitHookRequest.Builder builder = new GitHookRequest.Builder(readChunk(ChunkType.HOOK_TYPE));

        while (true) {
            ChunkType type = readType();
            if (type == ChunkType.END) {
                break;
            }

            if (type == ChunkType.ARG) {
                builder.argument(readString());
            } else if (type == ChunkType.ENVIRONMENT) {
                String[] keyAndValue = StringUtils.splitPreserveAllTokens(readString(), "=", 2);
                builder.environment(keyAndValue[0], keyAndValue[1]);
            } else if (type == ChunkType.STDIN) {
                appendStdin();
            } else {
                throw new IllegalStateException("Unexpected chunk from hook request: " + type.getCharacter());
            }
        }

        return builder.stdin(stdin.build())
                .build();
    }

    @Nonnull
    public HookContextId readContextId() throws IOException {
        ChunkType type = readType();
        if (type == ChunkType.HOOK_REQUEST_ID) {
            return HookContextId.ofHookRequestId(readString());
        }
        if (type == ChunkType.EXECUTION_CONTEXT_ID) {
            return HookContextId.ofExecutionContext(readString(), readString());
        }
        throw new IllegalStateException("Unexpected chunk type: " + type);
    }

    @VisibleForTesting
    String readChunk(ChunkType expectedType) throws IOException {
        ChunkType actualType = readType();
        checkState(expectedType == actualType, "Unexpected chunk type: %s", actualType);

        return readString();
    }

    /**
     * Reads a {@link ChunkType#STDIN stdin} chunk and appends it to the {@link HookStdinBuilder}.
     *
     * @throws IOException if the chunk cannot be read
     */
    private void appendStdin() throws IOException {
        int read = bufferChunk();

        ByteBuffer data;
        if (read < buffer.remaining()) {
            //Prepare a slice containing the correct number of bytes
            data = buffer.slice();
            data.limit(read);
            //Advance the buffer to consume the sliced bytes
            buffer.position(buffer.position() + read);
        } else {
            data = buffer;
        }

        stdin.append(data);
    }

    /**
     * Reads an {@code unsigned short} and then ensures the buffer contains <i>at least</i> that much data. It
     * may contain more, depending on how fast the hook is writing to the socket.
     *
     * @return the number of bytes in the chunk, which may be less than the remaining size in the buffer
     * @throws IOException if the {@code unsigned short} or data cannot be read
     */
    private int bufferChunk() throws IOException {
        maybeReadBytes(2);
        int len = buffer.getShort() & 0xFFFF;

        maybeReadBytes(len);
        return len;
    }

    //If the buffer has less than the required number of bytes, populate it by reading from the channel
    private void maybeReadBytes(int numBytes) throws IOException {
        if (buffer.remaining() >= numBytes) {
            return;
        }

        buffer.compact();
        do {
            if (channel.read(buffer) == -1) {
                throw new IllegalStateException("Hook channel was closed unexpectedly");
            }
        } while (buffer.position() < numBytes);
        buffer.flip();
    }

    private char readAsciiChar() throws IOException {
        maybeReadBytes(1);
        byte b = buffer.get();
        if (b == -1) {
            throw new EOFException();
        }
        int c = b & 0xFF;
        checkState(c <= 127, "Only ASCII characters are supported");
        return (char) c;
    }

    private String readString() throws IOException {
        int len = bufferChunk();

        String result = new String(buffer.array(), buffer.position(), len, StandardCharsets.UTF_8);
        buffer.position(buffer.position() + len);
        return result;
    }

    private ChunkType readType() throws IOException {
        return ChunkType.forChar(readAsciiChar());
    }

    enum ChunkType {

        ARG('A'),
        END('E'),
        ENVIRONMENT('V'),
        EXECUTION_CONTEXT_ID('R'),
        GIT_DIR('D'),
        HOOK_REQUEST_ID('X'),
        HOOK_TYPE('T'),
        STDIN('I');

        private final char character;

        ChunkType(char character) {
            this.character = character;
        }

        @Nonnull
        static ChunkType forChar(char c) {
            for (ChunkType type : values()) {
                if (type.getCharacter() == c) {
                    return type;
                }
            }
            throw new IllegalArgumentException("Unknown chunk type '" + c + "'");
        }

        char getCharacter() {
            return character;
        }
    }
}
