package org.jolokia.server.core.util;

import java.io.IOException;
import java.io.OutputStream;
import java.io.Writer;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.*;

/**
 * Created by gnufied on 2/7/16.
 * Implement chunked writing of data. Part of chunking is actually already
 * done by OutputStream and doing so here again will result in double chunking.
 * We just ensure that, we are flushing and closing the stream properly.
 *
 * This code is very closely yanked from java.nio.StreamEncoder class.
 * The reason we couldn't simply extend StreamEncoder class is, that class marks certain
 * attributes private which are very important for overriding close and flush methods.
 */
public class ChunkedWriter extends Writer {

    private final OutputStream out;
    private final CharsetEncoder encoder;
    private final ByteBuffer bb;
    // Leftover first char in a surrogate pair
    private boolean haveLeftoverChar = false;
    private char leftoverChar;
    private CharBuffer lcb = null;

    private static final byte[] EMPTY = {};

    public ChunkedWriter(OutputStream stream, String charset) {
        super(stream);
        this.out = stream;
        if (Charset.isSupported(charset)) {
            Charset cs = Charset.forName(charset);
            this.encoder = cs.newEncoder().onMalformedInput(CodingErrorAction.REPLACE)
                    .onUnmappableCharacter(CodingErrorAction.REPLACE);
        } else {
            throw new UnsupportedCharsetException(charset);
        }
        bb = ByteBuffer.allocate(DEFAULT_BYTE_BUFFER_SIZE);
    }

    private static final int DEFAULT_BYTE_BUFFER_SIZE = 4096;

    private volatile boolean isOpen = true;

    private void ensureOpen() throws IOException {
        if (!isOpen)
            throw new IOException("Stream closed");
    }

    public boolean isOpen() { return isOpen; }

    @Override
    public void write(char[] cbuf, int off, int len) throws IOException {
        synchronized (lock) {
            ensureOpen();
            if ((off < 0) || (off > cbuf.length) || (len < 0) ||
                    ((off + len) > cbuf.length) || ((off + len) < 0)) {
                throw new IndexOutOfBoundsException();
            } else if (len == 0) {
                return;
            }
            implWrite(cbuf, off, len);
        }
    }

    public void write(int c) throws IOException {
        char[] cbuf = new char[1];
        cbuf[0] = (char) c;
        write(cbuf, 0, 1);
    }

    public void write(String str, int off, int len) throws IOException {
        /* Check the len before creating a char buffer */
        if (len < 0)
            throw new IndexOutOfBoundsException();
        char[] cbuf = new char[len];
        str.getChars(off, off + len, cbuf, 0);
        write(cbuf, 0, len);
    }

    @Override
    public void flush() throws IOException {
        synchronized (lock) {
            ensureOpen();
            implFlush();
        }
    }

    void implFlushBuffer() throws IOException {
        if (bb.position() > 0)
            writeBytes();
        flushLeftOverChar(null, true);
        try {
            for (;;) {
                CoderResult cr = encoder.flush(bb);
                if (cr.isUnderflow())
                    break;
                if (cr.isOverflow()) {
                    assert bb.position() > 0;
                    writeBytes();
                    continue;
                }
                cr.throwException();
            }

            if (bb.position() > 0)
                writeBytes();
        } catch (IOException x) {
            encoder.reset();
            throw x;
        }
        out.write(EMPTY);
    }

    void implFlush() throws IOException {
        implFlushBuffer();
        if (out != null)
            out.flush();
    }

    @Override
    public void close() throws IOException {
        synchronized (lock) {
            if (!isOpen)
                return;
            out.close();
            isOpen = false;
        }
    }

    void implWrite(char[] cbuf, int off, int len) throws IOException {
        CharBuffer cb = CharBuffer.wrap(cbuf, off, len);

        if(haveLeftoverChar)
            flushLeftOverChar(cb, false);

        while (cb.hasRemaining()) {
            CoderResult cr = encoder.encode(cb, bb, false);

            if (cr.isUnderflow()) {
                assert (cb.remaining() <= 1) : cb.remaining();

                if(cb.remaining() == 1) {
                    haveLeftoverChar = true;
                    leftoverChar = cb.get();
                }
                break;
            }

            if (cr.isOverflow()) {
                assert bb.position() > 0;
                writeBytes();
                continue;
            }
            cr.throwException();
        }

    }

    private void flushLeftOverChar(CharBuffer cb, boolean endOfInput) throws IOException {
        if (!haveLeftoverChar && !endOfInput)
            return;

        if (lcb == null)
            lcb = CharBuffer.allocate(2);
        else
            lcb.clear();

        if (haveLeftoverChar)
            lcb.put(leftoverChar);

        if ((cb != null) && cb.hasRemaining())
            lcb.put(cb.get());
        lcb.flip();

        while (lcb.hasRemaining() || endOfInput) {
            CoderResult cr = encoder.encode(lcb, bb, endOfInput);

            if(cr.isUnderflow()) {
                if (lcb.hasRemaining()) {
                    leftoverChar = lcb.get();
                    if (cb != null && cb.hasRemaining())
                        flushLeftOverChar(cb,endOfInput);
                    return;
                }
                break;
            }

            if(cr.isOverflow()) {
                assert bb.position() > 0;
                writeBytes();
                continue;
            }
            cr.throwException();
        }
        haveLeftoverChar = false;
    }

    private void writeBytes() throws IOException {
        bb.flip();
        int lim = bb.limit();
        int pos = bb.position();
        assert (pos <= lim);

        int rem = lim - pos;

        if (rem > 0) {
            out.write(bb.array(), bb.arrayOffset() + pos, rem);
        }
        bb.clear();
    }
}
