/*
 * @author Ranides Atterwim <ranides@gmail.com>
 * @copyright Ranides Atterwim
 * @license WTFPL
 * @url http://ranides.net/projects/assira
 */

package net.ranides.assira.io;

import java.io.FilterInputStream;
import java.io.FilterOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintStream;
import java.io.Reader;
import java.io.UnsupportedEncodingException;
import java.io.Writer;
import java.nio.Buffer;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CharsetEncoder;
import java.nio.charset.CoderResult;
import java.util.Arrays;
import java.util.function.Consumer;
import net.ranides.assira.events.EventListener;
import net.ranides.assira.generic.CompareUtils;
import net.ranides.assira.trace.ExceptionUtils;
import net.ranides.assira.trace.LoggerUtils;

/**
 *
 * @author Ranides Atterwim <ranides@gmail.com>
 */
public final class IOStreams {
    
    public static final OutputStream NULL = new NULLStream();
    
    public static final InputStream EMPTY = new EmptyStream();
    
    private static final int COPY_BUFFER = 4096;
    
    private static final int READER_BUFFER = 512;
    
    private static final int WRITER_BUFFER = 512;
    
    private static final org.slf4j.Logger LOGGER = LoggerUtils.getLogger();
    
    private IOStreams() {
        /* utility class */
    }
    
    
    public static long copy(InputStream input, OutputStream output) throws IOException {
        return copy(input, output, null);
    }
    
    public static long copy(InputStream input, OutputStream output, Consumer<Long> callback) throws IOException {
        byte[] buffer = new byte[COPY_BUFFER];
        int n;
        long s = 0;
        while( (n = input.read(buffer)) > 0 ) {
            s += n;
            output.write(buffer, 0, n);
            if(null != callback) {
                callback.accept(s);
            }
        }
        return s;
    }
    
    public static boolean equivalent(InputStream stream1, InputStream stream2) throws IOException {
        return equivalent(stream1, stream2, COPY_BUFFER);
    }
    
    static boolean equivalent(InputStream stream1, InputStream stream2, int bufsize) throws IOException {
        byte[] bleft  = new byte[bufsize];
        byte[] bright = new byte[bufsize];
        while (true) {
            int ileft  = stream1.read(bleft, 0, bufsize);
            int iright = stream2.read(bright, 0, bufsize);
            if (ileft != iright) {
                return false;
            }
            if (ileft <= 0) {
                return true;
            }
            if( !Arrays.equals(bleft, bright) ) {
                return false;
            }
        }
    }

    public static InputStream observe(InputStream is, EventListener<? super IOEvent> listener) {
        return new InputObserver(is, listener);
    }
    
    public static OutputStream observe(OutputStream os, EventListener<? super IOEvent> listener) {
        return new OutputObserver(os, listener);
    }
    
    public static OutputStream join(OutputStream... streams) {
        return new OutputJoiner(streams);
    }
    
    public static <S extends OutputStream> S tee(S ostream) {
        try {
            System.setOut(new PrintStream(join(System.out, ostream), false, Charset.defaultCharset().name()));
            return ostream;
        } catch (UnsupportedEncodingException cause) {
            throw ExceptionUtils.rethrow(cause);
        }
    }
    
    public static StringOutput tee() {
        return tee(new StringOutput());
    }
    
    public static OutputStream stream(Writer writer, Charset cs) {
        return new WriterStream(writer, cs);
    }
    
    public static InputStream stream(Reader reader, Charset cs) {
        CharsetEncoder ce = cs.newEncoder();
        float bpc = ce.maxBytesPerChar();
        
        if(CompareUtils.equals(bpc, 1, 0.1)) {
            return new ReaderStreamFBP(reader, ce, 1);
        }
        switch(cs.name()) {
            case "UTF-32":
            case "UTF-32BE":
            case "UTF-32LE":
            case "X-UTF-32BE-BOM":
            case "X-UTF-32LE-BOM":
                return new ReaderStreamFBP(reader, ce, 4);
            default:
                return new ReaderStream(reader, ce);
        }
    }
    
    public static Thread pipe(InputStream input, OutputStream output) {
        return pipe(input, output, (Exception e) -> LOGGER.error("Exception ignored in #pipe", e));
    }
    
    public static Thread pipe(InputStream input, OutputStream output, Consumer<? super Exception> handler) {
        Thread thread = new Thread(() -> {
            try {
                copy(input, output);
            } catch(Exception cause) {
                handler.accept(cause);
            } finally {
                IOUtils.close(input, handler);
                IOUtils.close(output, handler);
            }
        });
        thread.start();
        return thread;
    }
    
    public static Thread pipe(InputStream input, OutputStream output, EventListener<? super IOEvent> listener) {
        if(null == listener) {
            return pipe(input, output);
        }
        Thread thread = new Thread(() -> {
            try {
                copy(input, output, p -> listener.handleEvent(IOEvent.move(input, p)));
                listener.handleEvent(IOEvent.flush(output));
            } catch(IOException cause) {
                listener.handleEvent(IOEvent.failure(cause));
            } finally {
                IOUtils.close(input, listener);
                IOUtils.close(output, listener);
                listener.handleEvent(IOEvent.close(input));
                listener.handleEvent(IOEvent.close(output));
            }
        });
        thread.start();
        return thread;
    }
    
    /**
     * Wraps stream (wrapper does not close stream after use).
     * @param istream
     * @param begin
     * @param end
     * @return
     * @throws IOException 
     */
    public static InputStream substream(InputStream istream, long begin, long end) throws IOException {
        if(0 == begin && end == Long.MAX_VALUE) {
            return istream;
        }
        return new SubInput(istream, begin, end, false);
    }
    
    /**
     * Wraps stream (wrapper closes stream after use).
     * @param istream
     * @param begin
     * @param end
     * @return
     * @throws IOException 
     */
    public static InputStream limit(InputStream istream, long begin, long end) throws IOException {
        if(0 == begin && end == Long.MAX_VALUE) {
            return istream;
        }
        return new SubInput(istream, begin, end, true);
    }
    
    
    private static final class SubInput extends FilterInputStream {
        
        private final boolean close;
        private final long max;
        private long pos;
        private long mark;
        
        public SubInput(InputStream in, long begin, long end, boolean close) throws IOException {
            super(in);
            in.skip(begin);
            this.max = end-begin;
            this.close = close;
        }

        @Override
        public void reset() throws IOException {
            super.reset();
            pos = mark;
        }

        @Override
        public void mark(int readlimit) {
            super.mark(readlimit);
            mark = pos;
        }

        @Override
        public void close() throws IOException {
            if(close) {
                super.close();
            }
        }

        @Override
        public int available() throws IOException {
            return Math.max(0, Math.min((int)(max - pos), super.available()));
        }

        @Override
        public long skip(long n) throws IOException {
            if (pos>=max) {
                return 0;
            }
            long r = super.skip( Math.min(n, max-pos) );
            pos += r;
            return r;
        }

        @Override
        public int read(byte[] b, int off, int len) throws IOException {
            if (pos>=max) {
                return -1;
            }
            int br = super.read(b, off, (int)Math.min(len, max-pos));
            if(-1 != br) {
                pos += br;
            }
            return br;
        }

        @Override
        public int read() throws IOException {
            if (pos>=max) {
                return -1;
            }
            int cr = super.read();
            pos += 1;
            return cr;
        }
        
    }
    
    private static final class InputObserver extends FilterInputStream {
        
        private final EventListener<? super IOEvent> listener;

        public InputObserver(InputStream in, EventListener<? super IOEvent> listener) {
            super(in);
            this.listener = listener;
        }

        @Override
        public void reset() throws IOException {
            try {
                super.reset();
                listener.handleEvent(IOEvent.reset(in));
            } catch(IOException cause) {
                throw rethrow(cause);
            }
        }

        @Override
        public void close() throws IOException {
            try {
                super.close();
                listener.handleEvent(IOEvent.close(in));
            } catch(IOException cause) {
                throw rethrow(cause);
            }
        }

        @Override
        public long skip(long n) throws IOException {
            try {
                long ret = super.skip(n);
                listener.handleEvent(IOEvent.skip(in, ret));
                return ret;
            } catch(IOException cause) {
                throw rethrow(cause);
            }
        }

        @Override
        public int read(byte[] b, int off, int len) throws IOException {
            try {
                int ret = super.read(b, off, len);
                if(-1==ret) {
                    listener.handleEvent(IOEvent.read(in, -1));
                } else {
                    listener.handleEvent(IOEvent.read(in, b, off, ret));
                }
                
                return ret;
            } catch(IOException cause) {
                throw rethrow(cause);
            }
        }

        @Override
        public int read(byte[] b) throws IOException {
            try {
                int ret = in.read(b);
                if(-1==ret) {
                    listener.handleEvent(IOEvent.read(in, -1));
                } else {
                    listener.handleEvent(IOEvent.read(in, b, 0, ret));
                }
                return ret;
            } catch(IOException cause) {
                throw rethrow(cause);
            }
        }

        @Override
        public int read() throws IOException {
            try {
                int ret = super.read();
                listener.handleEvent(IOEvent.read(in, ret));
                return ret;
            } catch(IOException cause) {
                throw rethrow(cause);
            }
        }
        
        private IOException rethrow(IOException cause) throws RuntimeException {
            listener.handleEvent(IOEvent.failure(cause));
            throw ExceptionUtils.rethrow(cause);
        }
        
    }
    
    private static final class OutputObserver extends FilterOutputStream {
        
        private final EventListener<? super IOEvent> listener;
        
        public OutputObserver(OutputStream out, EventListener<? super IOEvent> listener) {
            super(out);
            this.listener = listener;
        }

        @Override
        public void close() throws IOException {
            try {
                super.close();
                listener.handleEvent(IOEvent.close(out));
            } catch(IOException cause) {
                throw rethrow(cause);
            }
        }

        @Override
        public void flush() throws IOException {
            try {
                super.flush();
                listener.handleEvent(IOEvent.flush(out));
            } catch(IOException cause) {
                throw rethrow(cause);
            }
        }

        @Override
        public void write(byte[] b, int off, int len) throws IOException {
            try {
                out.write(b, off, len);
                listener.handleEvent(IOEvent.write(out, b, off, len));
            } catch(IOException cause) {
                throw rethrow(cause);
            }
        }

        @Override
        public void write(byte[] b) throws IOException {
            try {
                out.write(b);
                listener.handleEvent(IOEvent.write(out, b));
            } catch(IOException cause) {
                throw rethrow(cause);
            }
        }

        @Override
        public void write(int b) throws IOException {
            try {
                out.write(b);
                listener.handleEvent(IOEvent.write(out, b));
            } catch(IOException cause) {
                throw rethrow(cause);
            }
        }
        
        private IOException rethrow(IOException cause) throws RuntimeException {
            listener.handleEvent(IOEvent.failure(cause));
            throw ExceptionUtils.rethrow(cause);
        }
        
    }
    
    private static final class EmptyStream extends InputStream {

        @Override
        public boolean markSupported() {
            return true;
        }

        @Override
        public void reset() throws IOException {
            // NOP
        }

        @Override
        public void close() throws IOException {
            // NOP
        }

        @Override
        public int available() throws IOException {
            return 0;
        }

        @Override
        public long skip(long n) throws IOException {
            return 0;
        }

        @Override
        public int read(byte[] b, int off, int len) throws IOException {
            return -1;
        }
        
        @Override
        public int read() throws IOException {
            return -1;
        }
        
    }
    
    private static final class NULLStream extends OutputStream {
    
        @Override
        public void close() {
            // do nothing
        }

        @Override
        public void write(int data){
            // do nothing
        }

        @Override
        public void write(byte[] data, int offset, int length) {
            // do nothing
        }

    }
    
    private static class OutputJoiner extends OutputStream {

        private final OutputStream[] streams;

        public OutputJoiner(OutputStream... streams) {
            this.streams = streams;
        }

        @Override
        public void write(int data) throws IOException {
            run(os -> os.write(data));
        }
        
        @Override
        public void write(byte[] data) throws IOException {
            run(os -> os.write(data));
        }

        @Override
        public void write(byte[] data, int offset, int length) throws IOException {
            run(os -> os.write(data, offset, length));
        }

        @Override
        public void close() throws IOException {
            run(os -> os.close());
        }

        @Override
        public void flush() throws IOException {
            run(os -> os.flush());
        }

        private void run(IOHandler handler) throws IOException {
            IOException out = null;
            for(OutputStream stream : streams) {
                try {
                    handler.run(stream);
                } catch(IOException | RuntimeException cause) {
                    out = add(out, cause);
                } catch(Exception cause) {
                    out = add(out, cause);
                }
            }
            if(null != out) {
                throw out;
            }
        }
        
        private IOException add(IOException out, Exception cause) {
            if(null == out) {
                out = new IOException("Exception caused by one or more internal stream. Check suppressed exceptions.");
            }
            out.addSuppressed(cause);
            return out;
        }

    }
    
    private interface IOHandler {

        void run(OutputStream os) throws IOException;

    }
    
    private static final class WriterStream extends OutputStream {
        
        private final CharsetDecoder cd;
        private final Writer writer;
        private final ByteBuffer buffer;

        public WriterStream(Writer writer, Charset cs) {
            assert writer != null : "writer is NULL";
            this.cd = cs.newDecoder();
            this.writer = writer;
            this.buffer = ByteBuffer.allocate(WRITER_BUFFER);
        }
        
        @Override
        public void write(int b) throws IOException {
            buffer.put((byte)b);
            if(buffer.remaining()==0) {
                flush();
            }
        }

        @Override
        public void write(byte[] b, int off, int len) throws IOException {
            if(buffer.remaining() < len) {
                flush();
                write(ByteBuffer.wrap(b, off, len), false);
                return;
            }
            buffer.put(b, off, len);
            if(buffer.remaining()==0) {
                flush();
            }
        }

        @Override
        public void flush() throws IOException {
            buffer.flip();
            write(buffer, false);
            writer.flush();
        }
        
        
        @Override
        public void close() throws IOException {
            buffer.flip();
            write(buffer, true);
            writer.flush();
            writer.close();
            super.close();
        }
        
        private void write(ByteBuffer src, boolean end) throws IOException {
            if (0 == src.remaining()) {
                return;
            }
            
            int n = (int)(src.remaining() * cd.averageCharsPerByte());
            CharBuffer out = CharBuffer.allocate(n);

            while(true) {
                CoderResult cr = src.hasRemaining() ?
                    cd.decode(src, out, true) : CoderResult.UNDERFLOW;
                if (end && cr.isUnderflow()) {
                    cr = cd.flush(out);
                }
                if (cr.isUnderflow()) {
                    writer.write(out.array(), 0, out.position());
                    break;
                }
                if (cr.isOverflow()) {
                    writer.write(out.array(), 0, out.position());
                    out.clear();
                    continue;
                }
                cr.throwException();
            }
            src.clear();
        }
        
    }
    
    private static class ReaderStreamFBP extends ReaderStream {
        
        private final int bytesize;

        public ReaderStreamFBP(Reader reader, CharsetEncoder ce, int bytesize) {
            super(reader, ce);
            this.bytesize = bytesize;
        }

        @Override
        public long skip(long len) throws IOException {
            long ret = 0;
            
            ret += skip(bytes, Math.min(len-ret, bytes.remaining()));
            if(ret == len) {
                return ret;
            }
            
            long charc = Math.min((len-ret), chars.remaining());
            ret += skip(chars, charc/bytesize)*bytesize;
            if(ret == len) {
                return ret;
            }
            
            ret += reader.skip((len-ret)/bytesize)*bytesize;
            if(ret == len || !readnext()) {
                return ret;
            }
            
            ret += skip(bytes, Math.min(len-ret, bytes.remaining()));
            return ret;
        }

        private long skip(Buffer buffer, long n) {
            buffer.position(buffer.position()+(int)n);
            return n;
        }

        
    }
    
    private static class ReaderStream extends InputStream {
        
        protected final CharsetEncoder ce;
        protected final Reader reader;
        protected final CharBuffer chars;
        protected final ByteBuffer bytes;
        
        protected boolean eof;

        public ReaderStream(Reader reader, CharsetEncoder ce) {
            assert reader != null : "reader is NULL";
            this.ce = ce;
            this.reader = reader;
            this.chars = CharBuffer.allocate((int)(READER_BUFFER / ce.averageBytesPerChar()));
            this.bytes = ByteBuffer.allocate(READER_BUFFER);
            this.chars.limit(0);
            this.bytes.limit(0);
        }

        @Override
        public int read() throws IOException {
            if(!bytes.hasRemaining() && !readnext()) {
                return -1;
            }
            return bytes.get();
        }
        
        @Override
        public int read(byte[] target, int off, int len) throws IOException {
            if(!bytes.hasRemaining() && !readnext()) {
                return -1;
            }
            int out = 0;
            while(true) {
                int c = Math.min(len-out, bytes.remaining());
                bytes.get(target, off+out, c);
                out += c;
                if(out == len || !readnext()) {
                    return out;
                }
            }
        }
        
        @Override
        public long skip(long len) throws IOException {
            int out = 0;
            while(true) {
                int c = (int)Math.min(len-out, bytes.remaining());
                bytes.position(bytes.position()+c);
                out += c;
                if(out == len || !readnext()) {
                    return out;
                }
            }
        }
        
        @Override
        public int available() throws IOException {
            return bytes.remaining();
        }

        @Override
        public void close() throws IOException {
            reader.close();
            super.close();
        }
        
        protected boolean readnext() throws IOException {
            if(!chars.hasRemaining() && (eof || !readchars())) {
                return false;
            }
            readbytes();
            return true;
        }

        protected void readbytes() throws IOException {
            bytes.clear();
            CoderResult cr = ce.encode(chars, bytes, eof);
            bytes.flip();
            
            if( cr.isError()) {
                cr.throwException();
            }
        }

        protected boolean readchars() throws IOException {
            chars.clear();
            int count = reader.read(chars);
            chars.flip();
            
            if(count < chars.capacity()) {
                eof = true;
            }
            return count > 0;
        }

    }

}
