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

import com.atlassian.bitbucket.mesh.io.EncodingUtils;
import com.atlassian.bitbucket.mesh.process.OsProcess;
import com.google.common.base.MoreObjects;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.charset.UnsupportedCharsetException;
import java.util.function.Supplier;

import static java.util.Objects.requireNonNull;

/**
 * An adapter for NIO handlers which process {@code stdout} and/or {@code stderr} one line at a time.
 * <p>
 * As buffers of byte data are received they are decoded and split into lines, which are then passed to the configured
 * {@link Builder#lineSink lineSink} for processing. If a {@code Charset} is specified during construction, bytes
 * will be decoded in that character set. Otherwise, when the first buffer of {@code stdout} data is received,
 * heuristic-based {@link EncodingUtils#detectEncoding(ByteBuffer) encoding detection} is performed and the bytes
 * are decoded using the detected encoding.
 * <p>
 * A {@link Builder#onBinaryHandler binary disposition} can be configured to determine how binary data should be handled. By
 * default, processing is canceled if binary data is detected on the first {@link #onBytes(ByteBuffer, boolean)}
 * callback.
 */
public class LineOutputAdapter {

    private static final int ENCODING_BUFFER_SIZE = 32768;
    private static final Runnable NO_OP = () -> {
    };

    private final Charset charset;
    private final LineDecoder.Mode lineMode;
    private final LineSink lineSink;
    private final Supplier<BinaryDisposition> onBinaryHandler;
    private final Runnable onClosedHandler;

    private boolean done;
    private LineDecoder lineDecoder;
    private int maxLineLength;
    private OsProcess process;

    private LineOutputAdapter(Builder builder) {
        charset = builder.charset;
        lineMode = requireNonNull(builder.lineMode, "lineMode");
        lineSink = requireNonNull(builder.lineSink, "lineSink");
        maxLineLength = builder.maxLineLength;
        onBinaryHandler = builder.onBinaryHandler;
        onClosedHandler = builder.onClosedHandler;
        process = builder.process;
    }

    @Nonnull
    public static Builder newBuilder() {
        return new Builder();
    }

    /**
     * Attaches the {@link LineOutputAdapter} to the provided process, which allows the adapter to check whether the
     * process has been canceled, and to cancel the process if no more output is required.
     * <p>
     * Note: attaching a process automatically {@link #reset() resets} the {@link LineOutputAdapter}.
     *
     * @param process the {@link OsProcess} to attach the {@link LineOutputAdapter} to.
     */
    public void attach(@Nonnull OsProcess process) {
        this.process = requireNonNull(process, "process");
        reset();
    }

    public int getMaxLineLength() {
        return maxLineLength;
    }

    public void onBytes(@Nonnull ByteBuffer buffer, boolean closed) {
        if (done || (process != null && process.isCanceled())) {
            if (buffer.hasRemaining()) {
                //If we've handled all the output we're going to, move the buffer forward to the end of its
                //bytes without actually consuming any. For DirectByteBuffers (which is what NuProcess uses),
                //this is much more efficient than repeatedly calling get() to consume the buffer
                buffer.position(buffer.position() + buffer.remaining());
            }

            return;
        }

        if (lineDecoder == null) {
            if (closed && !buffer.hasRemaining()) {
                //Don't create a LineDecoder; just signal there will be no more lines and return
                onClosedHandler.run();
                return;
            }
            if (charset == null && 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;
            }

            if (lineMode != LineDecoder.Mode.NULL && EncodingUtils.isBinary(buffer)) {
                BinaryDisposition binaryDisposition = requireNonNull(onBinaryHandler.get(), "binaryDisposition");

                if (binaryDisposition != BinaryDisposition.STREAM) {
                    //Consume the buffer; it's not going to be processed further
                    buffer.position(buffer.position() + buffer.remaining());

                    if (process != null) {
                        process.cancel();
                    }

                    done = true;
                    if (binaryDisposition == BinaryDisposition.ERROR) {
                        throw new IllegalStateException("Unsupported binary data detected");
                    }
                    return;
                }
            }

            lineDecoder = new LineDecoder(charset == null ? detectCharset(buffer) : charset, lineMode, maxLineLength);
        }

        done = lineDecoder.forEach(buffer, closed, lineSink);
        if (done && !closed) {
            //If LineDecoder.forEach returns true, indicating sufficient stdout has been parsed, but stdout has
            //not been closed, cancel the process to (hopefully) stop producing output. Subsequent output will
            //be discarded, but it's better to avoid the overhead as much as possible
            if (process != null) {
                process.cancel();
            }
        }
        if (closed || done) {
            //If stdout has been closed, or we're done, signal the derived class there will be no more lines
            onClosedHandler.run();
        }
    }

    /**
     * Resets the {@link LineOutputAdapter}.
     * <p>
     * <b>Note</b>: This override allows line handling to be reused across multiple <i>sequential</i> commands
     * (use across <i>concurrent</i> commands is not possible).
     */
    public void reset() {
        done = false;
        lineDecoder = null;
    }

    public void setMaxLineLength(int value) {
        maxLineLength = value < 1 ? Integer.MAX_VALUE : value;
        if (lineDecoder != null) {
            //If the decoder has already been created, propagate the new max to it as well
            lineDecoder.setMaxLineLength(maxLineLength);
        }
    }

    private static Charset detectCharset(@Nonnull ByteBuffer buffer) {
        String detected = EncodingUtils.detectEncoding(buffer);
        if (detected == null) {
            return StandardCharsets.UTF_8;
        }

        try {
            return Charset.forName(detected);
        } catch (UnsupportedCharsetException e) {
            return StandardCharsets.UTF_8;
        }
    }

    public static class Builder {

        private Charset charset;
        private LineDecoder.Mode lineMode = LineDecoder.Mode.CRLF_OR_LF;
        private LineSink lineSink;
        private int maxLineLength = Integer.MAX_VALUE;
        private Supplier<BinaryDisposition> onBinaryHandler = () -> BinaryDisposition.CANCEL;
        private Runnable onClosedHandler = NO_OP;
        private OsProcess process;

        @Nonnull
        public LineOutputAdapter build() {
            return new LineOutputAdapter(this);
        }

        /**
         * @param value the {@link Charset} to use when decoding bytes. If not provided, or {@code null}, when the first
         *              buffer of {@code stdout} data is received, heuristic-based
         *              {@link EncodingUtils#detectEncoding(ByteBuffer) encoding detection} is performed and the bytes
         *              are decoded using the detected encoding.
         * @return the builder
         */
        @Nonnull
        public Builder charset(@Nullable Charset value) {
            charset = value;
            return this;
        }

        /**
         * @param value the {@link LineDecoder.Mode line mode} to use for detecting line endings. If not provided, or
         *              {@code null}, {@link LineDecoder.Mode#CRLF_OR_LF} is used.
         * @return the builder
         */
        @Nonnull
        public Builder lineMode(@Nullable LineDecoder.Mode value) {
            lineMode = value == null ? LineDecoder.Mode.CRLF_OR_LF : value;
            return this;
        }

        /**
         * @param value the {@link LineSink sink} where lines are sent to
         * @return the builder
         */
        @Nonnull
        public Builder lineSink(@Nonnull LineSink value) {
            lineSink = requireNonNull(value, "lineSink");
            return this;
        }

        /**
         * @param value the maximum line length. Longer lines are truncated before being passed to
         *              {@link LineSink#accept(String, boolean)}
         * @return the builder
         */
        @Nonnull
        public Builder maxLineLength(int value) {
            maxLineLength = value < 1 ? Integer.MAX_VALUE : value;
            return this;
        }

        /**
         * Configures how to handle binary data. If binary data is detected when the <i>first</i> bytes are received
         * on the {@link #onBytes(ByteBuffer, boolean)} callback, the provided {@code onBinary} callback is invoked.
         * Then, depending on its return value, the system:
         * <ul>
         *     <li>{@link BinaryDisposition#CANCEL}: the process is canceled (the default).</li>
         *     <li>{@link BinaryDisposition#ERROR}: an exception is thrown.</li>
         *     <li>{@link BinaryDisposition#STREAM}: the binary data is streamed to the {@link LineSink}.</li>
         * </ul>
         *
         * @param value the callback to invoke when binary data is detected
         * @return the builder
         */
        @Nonnull
        public Builder onBinary(@Nonnull Supplier<BinaryDisposition> value) {
            onBinaryHandler = requireNonNull(value, "binaryDisposition");
            return this;
        }

        /**
         * @param value the handler to call when the end of the byte stream is detected
         * @return the builder
         */
        @Nonnull
        public Builder onClosed(@Nullable Runnable value) {
            this.onClosedHandler = MoreObjects.firstNonNull(value, NO_OP);
            return this;
        }

        /**
         * @param value called by the adapter to determine whether the process has already been canceled, and to cancel
         *              the process if
         * @return the builder
         */
        @Nonnull
        public Builder process(@Nonnull OsProcess value) {
            process = requireNonNull(value, "cancelState");
            return this;
        }
    }
}
