package com.atlassian.bitbucket.mesh.git;

import com.google.common.io.ByteStreams;
import org.apache.commons.codec.binary.Hex;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.apache.commons.lang3.StringUtils.leftPad;

/**
 * Describes a single node in a Git tree, with helper methods to facilitate parsing raw trees using
 * {@code git cat-file --batch}.
 */
public class GitTreeNode {

    private final String contentId;
    private final String mode;
    private final String path;

    private GitTreeNode(String path, String contentId, String mode) {
        this.contentId = contentId;
        this.mode = mode;
        this.path = path;
    }

    /**
     * Parses a {@code GitTreeNode} from the provided buffer, <i>if it contains a full node</i>. If the buffer
     * is empty, or contains a partial node, {@code null} will be returned and the buffer's position will not
     * be advanced.
     *
     * @param buffer the buffer to parse a node from
     * @return the parsed node, or {@code null} if the buffer didn't contain the full bytes for a node; if the
     *         returned node is {@code null}, the buffer's position will not be advanced
     */
    @Nullable
    public static GitTreeNode parse(@Nonnull ByteBuffer buffer) {
        //If we can't find a full node, we need to ensure we don't advance the buffer's position since doing
        //so may result in dropped bytes and corrupted output. We do that by recording the buffer's initial
        //position and then resetting to it explicitly, rather than using mark() and reset(), so this method
        //won't disturb any mark set by the calling code
        int mark = buffer.position();

        String rawMode = readUntil(buffer, (byte) ' ');
        if (rawMode == null) {
            //We didn't find a full mode, so restore the buffer's position and return null
            buffer.position(mark);
            return null;
        }

        String path = readUntil(buffer, (byte) 0x00);
        if (path == null || buffer.remaining() < 20) {
            //We didn't find a full path or, after finding the path, there weren't enough bytes left to be
            //a full SHA, so restore the buffer's position and return null
            buffer.position(mark);
            return null;
        }

        String mode = leftPad(rawMode, 6, '0');
        String contentId = readSha(buffer);

        return new GitTreeNode(path, contentId, mode);
    }

    @Nullable
    public static GitTreeNode parse(@Nonnull InputStream stream) throws IOException {
        String rawMode = readUntil(stream, ' ');
        if (rawMode == null) {
            return null;
        }

        String mode = leftPad(rawMode, 6, '0');
        String path = readUntil(stream, 0x00);
        String contentId = readSha(stream);

        return new GitTreeNode(path, contentId, mode);
    }

    @Nonnull
    public String getContentId() {
        return contentId;
    }

    @Nonnull
    public String getPath() {
        return path;
    }

    public boolean isDirectory() {
        return GitUtils.isDirectory(mode);
    }

    public boolean isFile() {
        return GitUtils.isFile(mode);
    }

    @Nonnull
    private static String readSha(@Nonnull ByteBuffer buffer) {
        byte[] bytes = new byte[20];
        buffer.get(bytes);

        return Hex.encodeHexString(bytes);
    }

    /**
     * Reads 20 bytes from the stream and returns them as a hex-encoded string.
     *
     * @param stream the stream to read
     * @return the 40-character SHA-1
     * @throws IOException if the stream cannot be read
     */
    @Nonnull
    private static String readSha(@Nonnull InputStream stream) throws IOException {
        byte[] buffer = new byte[20];
        ByteStreams.readFully(stream, buffer, 0, buffer.length);

        return Hex.encodeHexString(buffer);
    }

    @Nullable
    private static String readUntil(@Nonnull ByteBuffer buffer, byte separator) {
        //Record where we started in the buffer so we can "reset" back if we find the separator. Note
        //that this doesn't use mark()/reset() because the buffer may already be marked
        int start = buffer.position();
        while (buffer.hasRemaining()) {
            byte b = buffer.get();
            if (b == separator) {
                //Allocate an array large enough to hold all the relevant bytes, including the separator,
                //then reset the buffer back to its starting position and read them all
                byte[] bytes = new byte[buffer.position() - start];
                buffer.position(start); //Uses position(int), not reset(), to not clear any existing mark
                buffer.get(bytes);

                return new String(bytes, 0, bytes.length - 1, UTF_8); //-1 to drop the separator
            }
        }

        //If we make it here, we didn't find the separator before running out of bytes
        return null;
    }

    /**
     * Reads from the provided {@code InputStream} until the specified separator character is found.
     * If the stream is exhausted before the separator is found an exception is thrown.
     *
     * @param stream    the stream to read
     * @param separator the separator to find
     * @return the characters before the separator
     * @throws IOException if the stream cannot be read
     */
    @Nullable
    private static String readUntil(@Nonnull InputStream stream, int separator) throws IOException {
        //It's not safe use a StringBuilder here and (char) cast the characters we read. That approach
        //will work for ASCII characters, but fails for Unicode. Instead, we read everything as bytes
        //and then build a String at the end using UTF-8 encoding explicitly
        ByteArrayOutputStream buffer = new ByteArrayOutputStream(255);
        for (int character = stream.read(); character != separator; character = stream.read()) {
            if (character == -1) {
                if (buffer.size() == 0) {
                    return null;
                }
                throw new IllegalStateException("Unexpected end-of-file reading " +
                        (separator == ' ' ? "mode" : "path"));
            }
            buffer.write(character);
        }

        return new String(buffer.toByteArray(), UTF_8);
    }
}