/*
 * Copyright 2021 the original author or authors.
 *
 * 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.gradle.util.internal;

import org.gradle.api.Action;
import org.gradle.internal.UncheckedException;

import java.io.IOException;
import java.io.InputStream;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * An {@code InputStream} which reads from the source {@code InputStream}. In addition, when the {@code InputStream} is
 * closed, all threads blocked reading from the stream will receive an end-of-stream.
 */
public class DisconnectableInputStream extends BulkReadInputStream {
    private final Lock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();
    private final byte[] buffer;
    private int readPos;
    private int writePos;
    private boolean closed;
    private boolean inputFinished;

    /*
        The song and dance with Action<Runnable> is to ease testing.
        See DisconnectableInputStreamTest
     */

    static class ThreadExecuter implements Action<Runnable> {
        @Override
        public void execute(Runnable runnable) {
            Thread thread = new Thread(runnable);
            thread.setName("DisconnectableInputStream source reader");
            thread.setDaemon(true);
            thread.start();
        }
    }

    public DisconnectableInputStream(InputStream source) {
        this(source, 1024);
    }

    public DisconnectableInputStream(final InputStream source, int bufferLength) {
        this(source, new ThreadExecuter(), bufferLength);
    }

    DisconnectableInputStream(InputStream source, Action<Runnable> executer) {
        this(source, executer, 1024);
    }

    DisconnectableInputStream(final InputStream source, Action<Runnable> executer, int bufferLength) {
        buffer = new byte[bufferLength];
        Runnable consume = new Runnable() {
            @Override
            public void run() {
                try {
                    while (true) {
                        int pos;
                        lock.lock();
                        try {
                            while (!closed && writePos == buffer.length && writePos != readPos) {
                                // buffer is full, wait until it has been read
                                condition.await();
                            }
                            assert writePos >= readPos;
                            if (closed) {
                                // stream has been closed, don't bother reading anything else
                                inputFinished = true;
                                condition.signalAll();
                                return;
                            }
                            if (readPos == writePos) {
                                // buffer has been fully read, start at the beginning
                                readPos = 0;
                                writePos = 0;
                            }
                            pos = writePos;
                        } finally {
                            lock.unlock();
                        }

                        int nread = source.read(buffer, pos, buffer.length - pos);

                        lock.lock();
                        try {
                            if (nread > 0) {
                                // Have read some data - let readers know
                                assert writePos >= readPos;
                                writePos += nread;
                                assert buffer.length >= writePos;
                                condition.signalAll();
                            }
                            if (nread < 0) {
                                // End of the stream
                                inputFinished = true;
                                condition.signalAll();
                                return;
                            }
                        } finally {
                            lock.unlock();
                        }
                    }
                } catch (Throwable throwable) {
                    lock.lock();
                    try {
                        inputFinished = true;
                        condition.signalAll();
                    } finally {
                        lock.unlock();
                    }
                    throw UncheckedException.throwAsUncheckedException(throwable);
                }
            }
        };

        executer.execute(consume);
    }

    @Override
    public int read(byte[] bytes, int pos, int count) throws IOException {
        lock.lock();
        try {
            while (!inputFinished && !closed && readPos == writePos) {
                condition.await();
            }
            if (closed) {
                return -1;
            }

            // Drain the buffer before returning end-of-stream
            if (writePos > readPos) {
                int nread = Math.min(count, writePos - readPos);
                System.arraycopy(buffer, readPos, bytes, pos, nread);
                readPos += nread;
                assert writePos >= readPos;
                condition.signalAll();
                return nread;
            }

            assert inputFinished;
            return -1;
        } catch (InterruptedException e) {
            throw UncheckedException.throwAsUncheckedException(e);
        } finally {
            lock.unlock();
        }
    }

    /**
     * Closes this {@code InputStream} for reading. Any threads blocked in read() will receive an end-of-stream. Also requests that the
     * reader thread stop reading, if possible, but does not block waiting for this to happen.
     *
     * <p>NOTE: this method does not close the source input stream.</p>
     */
    @Override
    public void close() throws IOException {
        lock.lock();
        try {
            closed = true;
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }
}
