/*
 *  Copyright (c) 2009, WSO2 Inc. (http://www.wso2.org) All Rights Reserved.
 *
 *  Licensed under the Apache License, Version 2.0 (the "License");
 *  you may not use this file except in compliance with the License.
 *  You may obtain a copy of the License at
 *
 *        http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the License is distributed on an "AS IS" BASIS,
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *  See the License for the specific language governing permissions and
 *  limitations under the License.
 */

package org.apache.synapse.transport.passthru;

import org.apache.http.MalformedChunkCodingException;
import org.apache.http.nio.ContentDecoder;
import org.apache.http.nio.ContentEncoder;
import org.apache.http.nio.IOControl;
import org.apache.http.nio.NHttpClientConnection;
import org.apache.http.nio.NHttpServerConnection;
import org.apache.http.params.HttpConnectionParams;
import org.apache.synapse.transport.passthru.config.BaseConfiguration;
import org.apache.synapse.transport.passthru.config.PassThroughConfiguration;
import org.apache.synapse.transport.passthru.util.ControlledByteBuffer;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * This is a buffer shared by both producers and consumers.
 */
public class Pipe {

    public static final int DEFAULT_TIME_OUT_VALUE = 180000;

    /** IOControl of the reader */
    private IOControl producerIoControl;

    /** IOControl of the consumer */
    private IOControl consumerIoControl;

    /** Fixed size buffer to read and write data */
    private ControlledByteBuffer buffer;

    private ControlledByteBuffer outputBuffer;

    private boolean producerCompleted = false;

    public boolean isProducerCompleted() {
        return producerCompleted;
    }

    /** Lock to synchronize the producers and consumers */
    private Lock lock = new ReentrantLock();

    private Condition readCondition = lock.newCondition();
    private Condition writeCondition = lock.newCondition();

    /** Name to identify the buffer */
    private String name = "Buffer";

    private boolean consumerError = false;

    private boolean producerError = false;

    /** Flag to indicate that the Pipe will not produce anything */
    private boolean discardable = false;

    /**
     * Socket Time out value specified in the nttp properties file.
     */
    private static long socketTimeOut = PassThroughConfiguration.getInstance()
            .getIntProperty(HttpConnectionParams.SO_TIMEOUT, DEFAULT_TIME_OUT_VALUE);

    /**
     * Boolean state to identify whether write condition await interrupted or timeout exceeded.
     * Normal behaviour should be the interruption of the await. If some thing went wrong and the write condition
     * did not got any notification to allow write to the buffer, the await will time out after socket time out
     * value specified in the nttp properties file.
     * "true" - if interrupted
     * "false"- if time out exceeds
     */
    private boolean awaitInterrupted = true;
    boolean isStale = false;

    private BaseConfiguration baseConfig;

    private boolean serializationComplete = false;

    private boolean rawSerializationComplete = false;

    private boolean hasHttpProducer = true;

    private ByteBufferInputStream inputStream;
    private ByteBufferOutputStream outputStream;

    public Pipe(IOControl producerIoControl, ControlledByteBuffer buffer,
                String name, BaseConfiguration baseConfig) {
        this.producerIoControl = producerIoControl;
        this.buffer = buffer;
        this.name += "_" + name;
        this.baseConfig = baseConfig;
    }

    public Pipe(ControlledByteBuffer buffer, String name, BaseConfiguration baseConfig) {
        this.buffer = buffer;
        this.name += "_" + name;
        this.baseConfig = baseConfig;
        this.hasHttpProducer = false;
    }

    /**
     * Set the consumers IOControl
     * @param consumerIoControl IOControl of the consumer
     */
    public void attachConsumer(IOControl consumerIoControl) {
        this.consumerIoControl = consumerIoControl;
    }

    private ControlledByteBuffer getConsumerBuffer() {
        return (outputBuffer != null ? outputBuffer : buffer);
    }

    /**
     * Consume the data from the buffer. Before calling this method attachConsumer
     * method must be called with a valid IOControl.
     *
     * @param encoder encoder used to write the data means there will not be any data
     *                written in to this buffer
     * @return number of bytes written (consumed)
     * @throws IOException if an error occurred while consuming data
     */
    public int consume(final ContentEncoder encoder) throws IOException {
        if (consumerIoControl == null) {
            throw new IllegalStateException("Consumer cannot be null when calling consume");
        }

        if (hasHttpProducer && producerIoControl == null) {
            throw new IllegalStateException("Producer cannot be null when calling consume");
        }

        lock.lock();
        ControlledByteBuffer consumerBuffer = getConsumerBuffer();
        try {
            // if producer at error we have to stop the encoding and return immediately
            if (producerError) {
                encoder.complete();
                return -1;
            }
            setOutputMode(consumerBuffer);
            int bytesWritten = encoder.write(consumerBuffer.getByteBuffer());
            consumePostActions(consumerBuffer, encoder, bytesWritten);
            return bytesWritten;
        } finally {
            lock.unlock();
        }
    }

    /**
     * Consume the data from the buffer. Same as {@link Pipe#consume(org.apache.http.nio.ContentEncoder)} but this gives
     * a copy of data which has been consumed.
     *
     * @param encoder encoder used to write
     * @return a buffer with data written (consumed)
     * @throws IOException if an error occurred while consuming data
     */
    public ByteBuffer copyAndConsume(final ContentEncoder encoder) throws IOException {

        if (consumerIoControl == null) {
            throw new IllegalStateException("Consumer cannot be null when calling consume");
        }
        if (hasHttpProducer && producerIoControl == null) {
            throw new IllegalStateException("Producer cannot be null when calling consume");
        }
        lock.lock();
        ControlledByteBuffer consumerBuffer = getConsumerBuffer();
        try {
            // if producer at error we have to stop the encoding and return immediately
            if (producerError) {
                encoder.complete();
                return null;
            }
            setOutputMode(consumerBuffer);
            // clone original buffer
            ByteBuffer originalBuffer = consumerBuffer.getByteBuffer();
            int bytesWritten = encoder.write(originalBuffer);
            ByteBuffer duplicate = originalBuffer.duplicate();
            // replicate positions of original buffer in duplicated buffer
            int position = originalBuffer.position();
            duplicate.limit(position);
            if (bytesWritten > 0) {
                duplicate.position(position - bytesWritten);
            }
            consumePostActions(consumerBuffer, encoder, bytesWritten);
            return duplicate;
        } finally {
            lock.unlock();
        }
    }

    private void consumePostActions(ControlledByteBuffer consumerBuffer, ContentEncoder encoder, int bytesWritten)
            throws IOException {

        setInputMode(consumerBuffer);
        if (consumerBuffer.position() == 0) {
            if (outputBuffer == null) {
                if (producerCompleted) {
                    encoder.complete();
                } else {
                    // buffer is empty. Wait until the producer fills up the buffer
                    consumerIoControl.suspendOutput();
                }
            } else if (serializationComplete || rawSerializationComplete) {
                encoder.complete();
            }
        }
        if (bytesWritten > 0 && !encoder.isCompleted() && !producerCompleted && hasHttpProducer) {
            producerIoControl.requestInput();
        }
        writeCondition.signalAll();
    }

    /**
     * Produce data in to the buffer.
     *
     * @param decoder decoder to read bytes from the underlying stream
     * @return bytes read (consumed)
     * @throws IOException if an error occurs while reading data
     */
    public int produce(final ContentDecoder decoder) throws IOException {
        if (producerIoControl == null) {
            throw new IllegalStateException("Producer cannot be null when calling produce");
        }

        lock.lock();
        try {
            setInputMode(buffer);
            int bytesRead;
            try {
                bytesRead = decoder.read(buffer.getByteBuffer());
            } catch (MalformedChunkCodingException ignore) {
                // we assume that this is a truncated chunk, hence simply ignore the exception
                // https://issues.apache.org/jira/browse/HTTPCORE-195
                // we should add the EoF character
                buffer.putInt(-1);
                // now the buffer's position should give us the bytes read.
                bytesRead = buffer.position();
            }
            producePostActions(decoder);
            return bytesRead;
        } finally {
            lock.unlock();
        }
    }

    /**
     * Same as {@link Pipe#produce(org.apache.http.nio.ContentDecoder)} and gives a copy of data produced
     *
     * @param decoder decoder to read bytes from the underlying stream
     * @return bytes of data read (consumed)
     * @throws IOException if an error occurs while reading data
     */
    public ByteBuffer copyAndProduce(final ContentDecoder decoder) throws IOException {

        if (producerIoControl == null) {
            throw new IllegalStateException("Producer cannot be null when calling produce");
        }
        lock.lock();
        try {
            ByteBuffer duplicate = null;
            setInputMode(buffer);
            int bytesRead;
            try {
                // clone original buffer
                ByteBuffer originalBuffer = buffer.getByteBuffer();
                bytesRead = decoder.read(originalBuffer);
                duplicate = originalBuffer.duplicate();
                // replicate positions of original buffer in duplicated buffer
                int position = originalBuffer.position();
                duplicate.limit(position);
                if (bytesRead > 0) {
                    duplicate.position(position - bytesRead);
                }
            } catch (MalformedChunkCodingException ignore) {
                // we assume that this is a truncated chunk, hence simply ignore the exception
                // https://issues.apache.org/jira/browse/HTTPCORE-195
                // we should add the EoF character
                buffer.putInt(-1);
                duplicate.putInt(-1);
            }
            producePostActions(decoder);
            return duplicate;
        } finally {
            lock.unlock();
        }
    }

    private void producePostActions(final ContentDecoder decoder) {

        // if consumer is at error we have to let the producer complete
        if (consumerError) {
            buffer.clear();
        }
        if (!buffer.hasRemaining()) {
            // Input buffer is full. Suspend client input
            // until the origin handler frees up some space in the buffer
            producerIoControl.suspendInput();
        }
        // If there is some content in the input buffer make sure consumer output is active
        if (buffer.position() > 0 || decoder.isCompleted()) {
            if (consumerIoControl != null) {
                consumerIoControl.requestOutput();
            }
            readCondition.signalAll();
        }
        if (decoder.isCompleted()) {
            producerCompleted = true;
        }
    }

    /**
     * Helper method to mark as producer completed. The normal behavior only set the boolean param to true only when
     * decoder is completed. However in some cases it is needed to intentionally close chunk stream and mark as
     * producer completed in order to prevent consumer further waiting on end of stream condition.
     *
     * @param decoder decoder instance to consume input
     */
    public void forceProducerComplete(final ContentDecoder decoder) {
        //no need to mark EoS if decoder is completed
        if (!decoder.isCompleted()) {
            lock.lock();
            try {
                producerCompleted = true;
                //there can be edge cases where consumer already  waiting on read condition ( buffer is empty )
                //in that case marking producer complete is not enough need to clear the read condition
                //consumer is waiting on
                readCondition.signalAll();
                //let consumer complete = clear consumerIoControl.suspendOutput();
                if (consumerIoControl != null) {
                    consumerIoControl.requestOutput();
                }
            } finally {
                lock.unlock();
            }
        }
    }

    @Override
    public String toString() {
        return name;
    }

    public void consumerError() {
        lock.lock();
        try {
            this.consumerError = true;
            writeCondition.signalAll();
        } finally {
            lock.unlock();
        }
    }

    public void producerError() {
        lock.lock();
        try {
            this.producerError = true;
            readCondition.signalAll();
        } finally {
            lock.unlock();
        }
    }

    /**
     * Creates an InputStream object on the underlying ByteBuffer. The returned
     * InputStream can be used to read bytes from the underlying buffer which
     * is being filled by the producer.
     *
     * @return An InputStream object
     */
    public synchronized InputStream getInputStream() {
        if (inputStream == null) {
            inputStream = new ByteBufferInputStream();
        }
        return inputStream;
    }

    /**
     * Creates a separate ByteBuffer for the output data and returns an OutputStream
     * on top of it.
     *
     * @return An OutputStream object
     */
    public synchronized OutputStream getOutputStream() {
        if (outputStream == null) {
            outputBuffer = baseConfig.getBufferFactory().getBuffer();
            outputStream = new ByteBufferOutputStream();
        }
        return outputStream;
    }


    /**
     * Creates a separate ByteBuffer for the output data and returns an OutputStream
     * on top of it.
     *
     * @return An OutputStream object
     */
    public synchronized OutputStream resetOutputStream() {
        outputBuffer = baseConfig.getBufferFactory().getBuffer();
        outputStream = new ByteBufferOutputStream();
        return outputStream;
    }

    public synchronized void setSerializationComplete(boolean serializationComplete) {
        if (!this.serializationComplete) {
            this.serializationComplete = serializationComplete;
            if (consumerIoControl != null) {
                consumerIoControl.requestOutput();
            }
        }
    }

    public void setRawSerializationComplete(boolean rawSerializationComplete) {
        this.rawSerializationComplete = rawSerializationComplete;
    }

    public void forceSetSerializationRest(){
        // If the pipe is reused to send the message out (ex. Failover endpoint retry), need to reset
        consumerError = false;
        if(this.serializationComplete){
            this.serializationComplete = false;
        }
    }




    public boolean isSerializationComplete() {
        return serializationComplete;
    }

    public ControlledByteBuffer getBuffer() {
        return buffer;
    }

    public boolean hasHttpProducer() {
        return hasHttpProducer;
    }

    private void setInputMode(ControlledByteBuffer buffer) {
        if (buffer.setInputMode()) {
            if (buffer.hasRemaining()) {
                buffer.compact();
            } else {
                buffer.clear();
            }
        }
    }

    private void setOutputMode(ControlledByteBuffer buffer) {
        if (buffer.setOutputMode()) {
            buffer.flip();
        }
    }

    private boolean hasData(ControlledByteBuffer buffer) {
        lock.lock();
        try {
            setOutputMode(buffer);
            return buffer.hasRemaining();
        } finally {
            lock.unlock();
        }
    }

    public boolean isDiscardable() {
        return discardable ;
    }

    public void setDiscardable(boolean discardable) {
        this.discardable  = discardable ;
    }

    /**
     * This method returns whether buffer consumption is required or not.
     *
     * @return boolean
     * @throws IOException
     */
    public boolean isConsumeRequired() throws IOException {
        lock.lock();
        boolean isInputMode = buffer.isInputMode();
        try {
            if (isInputMode) {
                setOutputMode(buffer);
            }
            int readRemaining = buffer.remaining();
            int readPosition = buffer.position();
            setInputMode(buffer);
            int writePosition = buffer.position();
            int writeRemaining = buffer.remaining();
            // in this method we will return true when the buffer is full when reading didn't happened : writePosition == buffer.capacity() && readPosition == 0
            // we will return true if we have consumed the message partially and when there is remaining to read
            return (readRemaining == 0 && writeRemaining == readPosition) || (writePosition == buffer.capacity() && readPosition == 0);
        } finally {
            if (isInputMode) {
                setInputMode(buffer);
            }
            lock.unlock();
        }
    }

    /**
     * Checks the state of the producerError property
     * @return boolean
     */
    public boolean isProducerError() {
        return producerError;
    }

    private class ByteBufferInputStream extends InputStream {

        @Override
        public int read() throws IOException {
            lock.lock();
            try {
                if (!hasData(buffer)) {
                    waitForData();
                    if(producerError){
                        return -1;
                    }
                }
                if (isEndOfStream()) {
                    return -1;
                }
                return buffer.get() & 0xff;
            } finally {
                lock.unlock();
            }
        }

        public int read(byte[] b, int off, int len) throws IOException {
            if (b == null) {
                return 0;
            }

            lock.lock();
            try {
                if (!hasData(buffer)) {
                    waitForData();
                    if(producerError){
                        return -1;
                    }
                }
                if (isEndOfStream()) {
                    return -1;
                }
                setOutputMode(buffer);
                int chunk = len;
                if (chunk > buffer.remaining()) {
                    chunk = buffer.remaining();
                }
                buffer.get(b, off, chunk);
                return chunk;
            } finally {
                lock.unlock();
            }
        }

        private void waitForData() throws IOException {
            lock.lock();
            try {
                try {
                    while (!hasData(buffer) && !producerCompleted) {
                        if(producerError){
                            break;
                        }
                        producerIoControl.requestInput();
                        readCondition.await();
                    }
                } catch (InterruptedException e) {
                    throw new IOException("Interrupted while waiting for data");
                }
            } finally {
                lock.unlock();
            }
        }

        private boolean isEndOfStream() {
            return !hasData(buffer) && producerCompleted;
        }
    }

    private class ByteBufferOutputStream extends OutputStream {

        @Override
        public void write(int b) throws IOException {
            lock.lock();
            try {
                setInputMode(outputBuffer);
                if (!outputBuffer.hasRemaining()) {
                    flushContent();
                    // If consume error happens inside flushContent then need to
                    // return from here.
                    if (consumerError || isStale) {
                        buffer.clear();
                        return;
                    }

                    // check whether await time out exceeded and throw an IOException
                    if(!awaitInterrupted) {
                        buffer.clear();
                        throw new IOException("Output buffer write time out exceeded");
                    }

                    setInputMode(outputBuffer);
                }
                outputBuffer.put((byte) b);
            } finally {
                lock.unlock();
            }
        }

        public void write(final byte[] b, int off, int len) throws IOException {
            if (b == null) {
                return;
            }
            lock.lock();
            try {
                setInputMode(outputBuffer);
                int remaining = len;
                // if there is a consumer error or a stale connection, there is no point of trying to write.
                // ex: when client connection is closed while writing back the response
                if (consumerError || isStale) {
                    buffer.clear();
                    isStale = false;
                    throw new IOException("Consumer error or stale connection has occurred.");
                }
                if (consumerIoControl instanceof NHttpServerConnection) {
                    if (((NHttpServerConnection) consumerIoControl).isStale()) {
                        isStale = true;
                        writeCondition.signalAll();
                        buffer.clear();
                        return;
                    } else if (isProducerError()) {
                        writeCondition.signalAll();
                        buffer.clear();
                        return;
                    }
                } else if (consumerIoControl instanceof NHttpClientConnection) {
                    if (((NHttpClientConnection) consumerIoControl).isStale()) {
                        isStale = true;
                        writeCondition.signalAll();
                        buffer.clear();
                        return;
                    }
                }
                while (remaining > 0 && !consumerError && !isStale && awaitInterrupted) {
                    if (!outputBuffer.hasRemaining()) {
                        flushContent();
                        // If consume error happens inside flushContent then
                        // need to return from here.
                        if (consumerError || isStale) {
                            buffer.clear();
                            break;
                        }
                        // check whether await time out exceeded and throw an IOException
                        if (!awaitInterrupted) {
                            buffer.clear();
                            throw new IOException("Output buffer write time out exceeded");
                        }
                        setInputMode(outputBuffer);
                    }
                    int chunk = Math.min(remaining, outputBuffer.remaining());
                    outputBuffer.put(b, off, chunk);
                    remaining -= chunk;
                    off += chunk;
                }
            } finally {
                lock.unlock();
            }
        }

        private void flushContent() throws IOException {

            if(rawSerializationComplete){
                return;
            }

            lock.lock();

            try {
                try {
                    while (hasData(outputBuffer) && !consumerError && !isStale) {
                        if(consumerError){
                            break;
                        }
                        if (consumerIoControl != null && writeCondition != null) {
                            consumerIoControl.requestOutput();

                            if (consumerIoControl instanceof NHttpServerConnection) {
                                if (((NHttpServerConnection) consumerIoControl).isStale()) {
                                    isStale = true;
                                    writeCondition.signalAll();
                                }
                            } else if (consumerIoControl instanceof NHttpClientConnection) {
                                if (((NHttpClientConnection) consumerIoControl).isStale()) {
                                    isStale = true;
                                    writeCondition.signalAll();
                                }
                            }
                            awaitInterrupted = writeCondition.await(socketTimeOut, TimeUnit.MILLISECONDS);
                            if(!awaitInterrupted){
                                break;
                            }
                        }
                    }

                } catch (InterruptedException ex) {
                    throw new IOException("Interrupted while flushing the content buffer");
                }
            } finally {
                lock.unlock();
            }
        }
    }

}

