package com.atlassian.bitbucket.mesh.process.handler;

import com.atlassian.bitbucket.mesh.io.EncodingUtils;
import com.atlassian.bitbucket.mesh.process.OsProcess;
import org.apache.commons.lang3.StringUtils;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.*;

/**
 * A base class for handlers which buffer {@code stdout} and produce a result based on the <i>chomped</i> output.
 * <p>
 * Handlers derived from this should be used with caution, as they buffer the full output in memory. They're
 * mostly appropriate for commands that produce a <i>known, minimal</i> amount of output.
 *
 * @param <T> the type returned by {@link #getResult()}
 */
public abstract class AbstractStringStdioHandler<T> extends AbstractProcessStdioHandler<T> {

    private static final int ENCODING_BUFFER_SIZE = 32768;

    private final StringBuilder builder;
    private final CharBuffer chars;
    private final Charset charset;

    private CharsetDecoder decoder;

    protected AbstractStringStdioHandler() {
        this(null);
    }

    protected AbstractStringStdioHandler(@Nullable Charset charset) {
        this.charset = charset;

        builder = new StringBuilder();
        chars = CharBuffer.allocate(128);
    }

    @Nonnull
    public String getStdout() {
        return StringUtils.chomp(builder.toString());
    }

    /**
     * Prepares the output buffer.
     * <p>
     * <b>Note</b>: This override allows output buffering to be reused across multiple <i>sequential</i> commands
     * (use across <i>concurrent</i> commands is not possible). Subclasses may add their own state on top of this
     * buffering, however, and it is up to such subclasses to manage their own state to allow for reuse.
     *
     * @param process the process about to be started
     */
    @Override
    public void onPreStart(@Nonnull OsProcess process) {
        super.onPreStart(process);

        builder.setLength(0);
        decoder = null;
    }

    /**
     * Decodes the provided {@link ByteBuffer} and appends it to the buffered output. The buffer <i>may not be
     * fully consumed</i> when this method returns--for example, in the case where a multi-byte character ends
     * up split between two buffers/calls.
     *
     * @param buffer a buffer of output to append
     * @param closed {@code true} if this is the final buffer; otherwise, {@code false} to indicate additional
     *               buffers should still be provided
     */
    @Override
    public void onStdout(@Nonnull ByteBuffer buffer, boolean closed) {
        if (decoder == null) {
            if (closed && !buffer.hasRemaining()) {
                //Don't bother; there's no stdout to process
                return;
            }
            if (buffer.remaining() < ENCODING_BUFFER_SIZE && !closed) {
                //If we have a buffer with a small amount of content and stdout hasn't been closed,
                //return and wait for another callback to give encoding detection more to work with
                return;
            }

            decoder = (charset == null ? detectCharset(buffer) : charset).newDecoder()
                    //Using REPLACE for these actions matches what the StreamDecoder for appendAll does,
                    //which keeps BIO and NIO handling consistent
                    .onMalformedInput(CodingErrorAction.REPLACE)
                    .onUnmappableCharacter(CodingErrorAction.REPLACE);
        }

        CoderResult result;
        do {
            result = decoder.decode(buffer, chars, closed);
            chars.flip();

            if (chars.hasRemaining()) {
                //Passing the array is more efficient than passing the CharBuffer, which would be copied
                //over character by character rather than in one shot
                builder.append(chars.array(), chars.arrayOffset(), chars.remaining());
            }
            chars.clear();
        } while (result.isOverflow());
    }

    private static Charset toCharset(String encoding) {
        return encoding == null ? StandardCharsets.UTF_8 : Charset.forName(encoding);
    }

    private Charset detectCharset(ByteBuffer buffer) {
        return toCharset(EncodingUtils.detectEncoding(buffer));
    }
}