package com.atlassian.bitbucket.scm.git.protocol;

import com.google.common.collect.Lists;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.ByteArrayOutputStream;
import java.util.List;
import java.util.zip.GZIPInputStream;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;

/**
 * Segments a byte stream into a sequence of git packets. Users of this class can append bytes to the
 * {@code GitPacketStream}. When a complete git packet has been appended, the
 * {@link #addListener(GitPacketListener) listeners} of the stream get a
 * {@link GitPacketListener#onPacket(byte[]) callback}.
 */
public class GitPacketStream {

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

    private static final byte[] FLUSH = GitPackets.getFlush();
    private static final int PACKET_PREFIX_SIZE = 4;
    private static final String PACK_HEADER = "PACK";

    private final ByteArrayOutputStream buffer;
    private final List<GitPacketListener> listeners;

    private boolean firstBuffer;
    private boolean inPackSection;
    private boolean inPrefix;
    private int nextPacketSize;

    public GitPacketStream() {
        buffer = new ByteArrayOutputStream(1024);
        firstBuffer = true;
        inPrefix = true;
        listeners = Lists.newArrayList();
        nextPacketSize = PACKET_PREFIX_SIZE;
    }

    public void addListener(@Nonnull GitPacketListener listener) {
        this.listeners.add(checkNotNull(listener, "listener"));
    }

    public void append(@Nonnull byte[] bytes) {
        append(bytes, 0, bytes.length);
    }

    public void append(@Nonnull byte[] bytes, int off, int len) {
        checkNotNull(bytes, "bytes");
        checkArgument(off >= 0, "illegal offset");
        checkArgument(bytes.length >= off + len, "insufficient bytes");

        if (firstBuffer) {
            firstBuffer = false;

            if (len >= 2 && isGzipped(bytes)) {
                // The data is gzip-encoded. Currently we don't support analyzing gzip-encoded streams,
                // so we set inPackSection to just skip analysis (rather than allowing parsePacketSize
                // to throw an exception because it gets unexpected input)
                inPackSection = true;
            }
        }
        if (inPackSection) {
            return;
        }

        int offset = off;
        int end = off + len;
        while (!inPackSection && offset < end && (nextPacketSize - buffer.size()) <= (end - offset)) {
            if (buffer.size() > 0) {
                // buffer contains a partial git packet. Append to the buffer to complete the packet if possible.
                int remainingPacketBytes = nextPacketSize - buffer.size();
                buffer.write(bytes, offset, remainingPacketBytes);

                // update offset to reflect that the 'bytes' array has been processed up to this point
                offset += remainingPacketBytes;

                if (inPrefix) {
                    readPacketPrefix(buffer.toByteArray(), 0);
                    if (inPackSection) {
                        return;
                    }
                    if (nextPacketSize == 0) {
                        // FLUSH (0000) packet encountered. Flush packets deviate from other git packets in that the
                        // packet prefix does not correspond with the actual length of the packet (0000 = 4 bytes).
                        readPacket(FLUSH);
                        buffer.reset();
                        inPrefix = true;
                    }
                } else {
                    readPacket(buffer.toByteArray());
                    buffer.reset();
                }
            } else {
                // no partial packet in buffer.
                if (inPrefix) {
                    readPacketPrefix(bytes, offset);
                    if (inPackSection) {
                        return;
                    }
                    if (nextPacketSize == 0) {
                        // FLUSH (0000) packet encountered. Flush packets deviate from other git packets in that the
                        // packet prefix does not correspond with the actual length of the packet (0000 = 4 bytes).
                        offset += readPacket(FLUSH);
                        inPrefix = true;
                    }
                } else {
                    byte[] packet = new byte[nextPacketSize];
                    System.arraycopy(bytes, offset, packet, 0, nextPacketSize);

                    offset += readPacket(packet);
                }
            }
        }

        if (offset < end) {
            buffer.write(bytes, offset, end - offset);
        }
    }

    public boolean isInPackSection() {
        return inPackSection;
    }

    /**
     * Inspects the provided {@code byte[]} to decide if it represents gzip-compressed data. If so, the bytes
     * can't be analyzed by the current implementation. This check should only be performed on the first array
     * of bytes read from the stream.
     *
     * @param bytes the bytes to check for gzip compression
     * @return {@code true} if the bytes start with the gzip magic constant; otherwise, {@code false} if the
     *         bytes appear to be uncompressed
     */
    private static boolean isGzipped(byte[] bytes) {
        int first = bytes[0] & 0xFF;  //The & is to convert the signed byte to unsigned
        int second = bytes[1] & 0XFF; //to match the int result of InputStream.read()
        int magic = (second << 8) | first; //| the two together to make an unsigned short

        return magic == GZIPInputStream.GZIP_MAGIC;
    }

    private void notifyListeners(byte[] packet) {
        for (GitPacketListener listener : listeners) {
            try {
                listener.onPacket(packet);
            } catch (Exception e) {
                // log the exception
                log.warn("Error while notifying {} of git packet", listener.getClass().getName(), e);
            }
        }
    }

    private int readPacket(byte[] packet) {
        notifyListeners(packet);

        nextPacketSize = PACKET_PREFIX_SIZE;
        inPrefix = true;

        return packet.length;
    }

    private void readPacketPrefix(byte[] bytes, int offset) {
        nextPacketSize = parsePacketSize(bytes, offset);
        inPrefix = false;
    }

    private int parsePacketSize(byte[] bytes, int offset) {
        String prefix = new String(bytes, offset, 4);
        if (PACK_HEADER.equals(prefix)) {
            inPackSection = true;
            return -1;
        }
        try {
            return Integer.parseInt(prefix, 16);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("'" + prefix + "' is not a valid git packet prefix");
        }
    }
}
