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

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintStream;
import java.io.Reader;
import java.io.StringReader;
import java.io.StringWriter;
import java.io.UnsupportedEncodingException;
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.nio.charset.MalformedInputException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import net.ranides.assira.events.EventDispatcher;
import net.ranides.assira.events.EventRouter;
import net.ranides.assira.functional.CheckedFunction;
import net.ranides.assira.text.FormatHex;
import net.ranides.assira.trace.ExceptionUtils;
import org.junit.Test;
import static net.ranides.assira.junit.NewAssert.*;
import net.ranides.assira.text.Charsets;
import net.ranides.assira.text.IOStrings;
import net.ranides.assira.text.StringUtils;

/**
 *
 * @author Ranides Atterwim <ranides@gmail.com>
 */
public class IOStreamsTest {
    
    private static final String TEXT = "Pchnąć w tę łódź jeża lub ośm skrzyń fig";
    
    @Test
    public void testCopy() throws IOException {
        assertCopy(1);
        assertCopy(7);
        assertCopy(740);
        assertCopy(4096 + 0);
        assertCopy(4096 + 1);
        assertCopy(4096 + 740);
        assertCopy(7* 4096 + 0);
        assertCopy(7* 4096 + 1);
        assertCopy(7* 4096 + 740);
    }
    
    @Test
    public void testEquivalent() throws IOException {
        byte[] data1 = random(7*4096+33, 777L);
        byte[] data2 = random(7*4096+33, 777L);
        byte[] data3 = random(7*4096+33, 778L);
        byte[] data4 = random(7*4096+34, 777L);
        assertTrue(IOStreams.equivalent(new ByteArrayInputStream(data1), new ByteArrayInputStream(data1)));
        assertTrue(IOStreams.equivalent(new ByteArrayInputStream(data1), new ByteArrayInputStream(data2)));
        assertFalse(IOStreams.equivalent(new ByteArrayInputStream(data1), new ByteArrayInputStream(data3)));
        assertFalse(IOStreams.equivalent(new ByteArrayInputStream(data1), new ByteArrayInputStream(data4)));
    }
    
    private static void assertCopy(int size) throws IOException {
        byte[] data = random(size, 777L);
        ByteArrayOutputStream bos1 = new ByteArrayOutputStream();
        
        IOStreams.copy(new ByteArrayInputStream(data), bos1);
        assertArrayEquals(data, bos1.toByteArray());
        
        ByteArrayOutputStream bos2 = new ByteArrayOutputStream();
        List<Long> info = new ArrayList<>();
        IOStreams.copy(new ByteArrayInputStream(data), bos2, info::add);
        assertEquals( (int)Math.ceil(size/4096.0), info.size());
        assertEquals( size, info.get(info.size()-1).longValue() );
    }
    
    @SuppressFBWarnings
    private static byte[] random(int size, long seed) {
        byte[] ret = new byte[size];
        new Random(seed).nextBytes(ret);
        return ret;
    }
    
    private static String str(int size) {
        Random rand = new Random(777);
        char[] ret = new char[size];
        for(int i=0; i<ret.length; i++) {
            ret[i] = (char)rand.nextInt(0x7FFF);
        }
        return new String(ret);
    }
    
    @Test
    public void testStreamWriter() throws IOException {
        assertWrite(Charsets.UTF8, "Hello world", ". Text", ".");
        assertWrite(Charsets.UTF8, "Hello world", ". Text", ".", "ret", ".", "dot");
        assertWrite(Charsets.UTF8, str(256*1024));
        
        assertWrite(Charsets.UTF32, str(256*1024));
    }

    @Test
    public void testStreamWriterClose() throws IOException {
        StringWriter out = new StringWriter();
        try(OutputStream ostream = IOStreams.stream(out, Charsets.UTF8)) {
            ostream.write(TEXT.getBytes(Charsets.UTF8));
            ostream.close();
            assertEquals(TEXT, out.toString());
        }
    }
    
    @Test
    public void testStreamWriterOverflow() throws IOException {
        String text = "Quick brown fox jumps over lazy dog";
        StringWriter out = new StringWriter();
        try(OutputStream ostream = IOStreams.stream(out, EVIL_CHARSET)) {
            ostream.write(text.getBytes(Charsets.US_ASCII));
        }
        assertEquals(text, out.toString());
    }
    
    @Test
    public void testStreamWriterParts() throws IOException {
        StringWriter out = new StringWriter();
        try(OutputStream ostream = IOStreams.stream(out, Charsets.UTF8)) {

            byte[] a1 = new byte[]{80, 99, 104, 110, -60, -123, -60, -121, 32, 119, 32, 116, -60, -103, 32};
            byte[] b1 = new byte[]{-59, -126, -61, -77, 100, -59, -70, 32, 106, 101, -59, -68, 97, 32};
            byte[] c1 = new byte[]{108, 117, 98, 32, 111, -59, -101, 109, 32, 115, 107, 114, 122};
            byte[] d1 = new byte[]{121, -59, -124, 32, 102, 105, 103};
            
            byte[] a2 = new byte[]{80, 99, 104, 110, -60, -123, -60, -121, 32, 119, 32, 116, -60, -103, 32,-59};
            byte[] b2 = new byte[]{-126, -61, -77, 100, -59, -70, 32, 106, 101, -59, -68};
            byte[] c2 = new byte[]{97, 32, 108, 117, 98, 32, 111, -59, -101, 109, 32, 115, 107, 114, 122};
            byte[] d2 = new byte[]{121, -59, -124, 32, 102, 105, 103};
            
            ostream.write(a1);
            ostream.write(b1);
            ostream.write(c1);
            ostream.write(d1);
            ostream.write(" | ".getBytes(Charsets.UTF8));
            
            ostream.write(a2);
            ostream.write(b2);
            ostream.flush();
            assertEquals("Pchnąć w tę łódź jeża lub ośm skrzyń fig | Pchnąć w tę łódź jeż", out.toString());
            
            ostream.write(c2);
            ostream.write(d2);
            ostream.flush();
            assertEquals("Pchnąć w tę łódź jeża lub ośm skrzyń fig | Pchnąć w tę łódź jeża lub ośm skrzyń fig", out.toString());
            
            ostream.write(a2);
            assertThrows(MalformedInputException.class, ()->{
                ostream.flush();
            });

            assertEquals("Pchnąć w tę łódź jeża lub ośm skrzyń fig | Pchnąć w tę łódź jeża lub ośm skrzyń fig", out.toString());
            ostream.write(" :)".getBytes(Charsets.UTF8));
            ostream.close();
            
            assertEquals("Pchnąć w tę łódź jeża lub ośm skrzyń fig | Pchnąć w tę łódź jeża lub ośm skrzyń figPchnąć w tę  :)", out.toString());
        }
    }

    private void assertWrite(Charset cs, String... values) throws IOException {
        String expected = StringUtils.join(values, "");
        StringWriter out = new StringWriter();
        try(OutputStream ostream = IOStreams.stream(out, cs)) {
            for(String value : values) {
                byte[] bytes = value.getBytes(cs);
                if(bytes.length==1) {
                    ostream.write(bytes[0]);
                } else {
                    ostream.write(bytes);
                }
            }
            ostream.flush();
            assertEquals(expected, out.toString());
        }
        
    }
    
    @Test
    public void testStreamReader() throws IOException {
        assertRead("Quick brown fox jumps over lazy dog\n", Charsets.US_ASCII);
        assertRead("Pchnąć w tę\nłódź jeża\nlub ośm\nskrzyń fig\n", Charsets.UTF8);
        assertRead(str(256*1024), Charsets.UTF8);
        
        assertRead("Quick brown fox jumps over lazy dog\n", Charsets.UTF32);
        assertRead("Pchnąć w tę\nłódź jeża\nlub ośm\nskrzyń fig\n", Charsets.UTF32);
        assertRead(str(256*1024), Charsets.UTF32);
    }
    
    @Test
    public void testStreamReaderSkip() throws IOException {
        assertSkip(Charsets.UTF8, "Pchnąć", " w tę\nłódź ", "jeża\nlub ośm\nsk", "rzyń fig\n");
        assertSkip(Charsets.UTF32, "Pchnąć", " w tę\nłódź ", "jeża\nlub ośm\nsk", "rzyń fig\n");
        
        assertSkip(Charsets.UTF8, str(1027), str(312), str(6033), str(49));
        assertSkip(Charsets.UTF32, str(1027), str(312), str(6033), str(49));
        
        assertSkipByteBoundaries(Charsets.UTF8, str(311), str(670), str(679), str(301), str(20));
        assertSkipByteBoundaries(Charsets.UTF32, str(311), str(670), str(679), str(301), str(20));
    }
    
    private void assertSkip(Charset cs, String a,String b,String c,String d) throws IOException {
        Reader reader = new StringReader(a+b+c+d);
        try(InputStream istream = IOStreams.stream(reader, cs)){
            assertRead(istream, a, cs);
            assertReadBytes(istream, b, cs);
            assertSkip(istream, c, cs);
            assertRead(istream, d, cs);
        }
    }
    
    private void assertSkipByteBoundaries(Charset cs, String a,String b,String c,String d, String e) throws IOException {
        int cskip = c.getBytes(cs).length+3;
        int dskip = d.getBytes(cs).length-3;
        
        Reader reader = new StringReader(a+b+c+d+e);
        try(InputStream istream = IOStreams.stream(reader, cs)){
            assertRead(istream, a, cs);
            assertReadBytes(istream, b, cs);
            assertEquals(cskip, istream.skip(cskip));
            assertEquals(dskip, istream.skip(dskip));
            assertRead(istream, e, cs);
        }
    }
    
    private void assertSkip(InputStream istream, String text, Charset cs) throws IOException {
        byte[] bytes = text.getBytes(cs);
        assertEquals(bytes.length, istream.skip(bytes.length));
    }
    
    private void assertRead(InputStream istream, String text, Charset cs) throws IOException {
        byte[] bytes = text.getBytes(cs);
        byte[] content = new byte[bytes.length];
        assertEquals(content.length, istream.read(content));
        assertArrayEquals(bytes, content);
    }
    
    private void assertReadBytes(InputStream istream, String text, Charset cs) throws IOException {
        byte[] bytes = text.getBytes(cs);
        byte[] content = new byte[bytes.length];
        for(int i=0; i<content.length; i++) {
            content[i] = (byte)istream.read();
        }
        assertArrayEquals(bytes, content);
    }
    
    private void assertRead(String text, Charset cs) throws IOException {
        Reader reader = new StringReader(text);
        try(InputStream istream = IOStreams.stream(reader, cs)){
            assertEquals(text, IOStrings.read(istream, cs));
        }
    }
    
    @Test
    public void testInputObserver() throws IOException {
        InputStream input = new CIStream(new byte[]{11,12,13,14,15,16,17,18});
        StreamObserver observer = new StreamObserver();
        InputStream istream = IOStreams.observe(input, observer.listener);
        
        assertEquals(11, istream.read());
        byte[] data = new byte[3];
        assertEquals(3, istream.read(data));
        assertArrayEquals(new byte[]{12,13,14}, data);
        
        istream.reset();
        byte[] data2 = new byte[]{7,7,7,7,7,7};
        assertEquals(3, istream.read(data2, 2, 3));
        assertArrayEquals(new byte[]{7,7,11,12,13,7}, data2);
        assertEquals(2, istream.skip(2));
        assertEquals(16, istream.read());
        assertEquals(2, istream.skip(7));
       
        assertEquals(-1, istream.read(new byte[3]));
        assertEquals(-1, istream.read(new byte[3],1,1));
        
        istream.close();
        
        assertThrows(IOException.class, ()-> istream.read(new byte[3]));
        assertThrows(IOException.class, ()-> istream.read(new byte[3],1,1));
        assertThrows(IOException.class, ()-> istream.read());
        assertThrows(IOException.class, ()-> istream.skip(3));
        assertThrows(IOException.class, ()-> istream.reset());
        assertThrows(IOException.class, ()-> istream.close());
        
//        observer.events.stream().forEach(s -> System.out.printf(" - %s%n", s));
        
        List<String> exp = Arrays.asList(
            "CIStream [read] 0B",
            "CIStream [read] 0C 0D 0E",
            "CIStream [reset]",
            "CIStream [read] 0B 0C 0D",
            "CIStream [skip] 2",
            "CIStream [read] 10",
            "CIStream [skip] 2",
            "CIStream [read] ",
            "CIStream [read] ",
            "CIStream [close]",
            "[failure] IOException",
            "[failure] IOException",
            "[failure] IOException",
            "[failure] IOException",
            "[failure] IOException",
            "[failure] IOException"
        );
        
        assertEquals(exp, observer.events);
    }

    @Test
    public void testOutputObserver() throws IOException {
        COStream output = new COStream();
        StreamObserver observer = new StreamObserver();
        OutputStream ostream = IOStreams.observe(output, observer.listener);
        
        ostream.write(13);
        ostream.write(new byte[]{14,15,16,17});
        ostream.flush();
        ostream.write(new byte[]{7,7,7,18,19,7,7,7}, 3, 2);
        
        ostream.close();
        
        assertThrows(IOException.class, ()-> ostream.write(19));
        assertThrows(IOException.class, ()-> ostream.write(new byte[]{8,8},1,1));
        assertThrows(IOException.class, ()-> ostream.write(new byte[]{88}));
        assertThrows(IOException.class, ()-> ostream.flush());
        assertThrows(IOException.class, ()-> ostream.close());
        
//        observer.events.stream().forEach(s -> System.out.printf(" - %s%n", s));
        
        List<String> exp = Arrays.asList(
            "COStream [write] 0D",
            "COStream [write] 0E 0F 10 11",
            "COStream [flush]",
            "COStream [write] 12 13",
            "COStream [flush]",
            "COStream [close]",
            "[failure] IOException",
            "[failure] IOException",
            "[failure] IOException",
            "[failure] IOException",

            "[failure] IOException", // close: flush
            "[failure] IOException" // close
        );
        
        assertEquals(exp, observer.events);
        
    }
    
    @Test
    public void testNULL() throws IOException {
        OutputStream ostream = IOStreams.NULL;
        ostream.close();
        ostream.close();
        ostream.write(3);
        ostream.write(new byte[]{1,5,7});
        ostream.write(new byte[]{1,5,7}, 7, 10);
        ostream.write(new byte[]{1,5,7}, 1, 1);
        ostream.flush();
        
        assertTrue(true);
    }
    
    @Test
    public void testJoin() throws IOException {
        COStream out1 = new COStream();
        ByteArrayOutputStream out2 = new ByteArrayOutputStream();
        StringOutput out3 = new StringOutput();
        StreamObserver observer = new StreamObserver();
        COStream out4data = new COStream();
        OutputStream out4 = IOStreams.observe(out4data, observer.listener);
        
        OutputStream ostream = IOStreams.join(out1, out2, out3, out4);
        ostream.write(0x12);
        ostream.write(0x13);
        ostream.write(new byte[]{0x14, 0x15, 0x16, 0x17});
        ostream.write(new byte[]{7, 0x18, 0x19, 0x20, 0x21, 7, 7, 7}, 1, 4);
        ostream.flush();
        ostream.write(new byte[]{0x22, 0x23, 0x24, 0x25});
        ostream.close();
        
        List<String> exp1 = Arrays.asList("TEST: CLOSED","TEST: CLOSED");
        
        List<String> exp2 = Arrays.asList("COStream [write] 12",
            "COStream [write] 13",
            "COStream [write] 14 15 16 17",
            "COStream [write] 18 19 20 21",
            "COStream [flush]",
            "COStream [write] 22 23 24 25",
            "COStream [flush]",
            "COStream [close]",
            "[failure] IOException"
        );
        
        assertThrows(matchMessages(exp1), () -> {
            ostream.write(new byte[]{0x22, 0x23, 0x24, 0x25});
        });
        
        assertEquals(exp2, observer.events);
    }
    
    @Test
    @SuppressFBWarnings("VA_FORMAT_STRING_USES_NEWLINE")
    @SuppressWarnings("PMD.SystemPrintln")
    public void testTee() throws UnsupportedEncodingException {
        PrintStream prev = System.out;
        PrintStream ps = new PrintStream(new ByteArrayOutputStream(), true, "UTF-8");
        System.setOut(ps);
        
        StringOutput so = IOStreams.tee();
        System.out.printf("Hello world\n");
        System.out.printf("EOF\n");
        System.out.flush();
        
        System.setOut(prev);
        
        assertEquals("Hello world\nEOF\n", so.toString("UTF-8"));
    }
    
    @Test
    public void testSubStream() throws IOException {
        InputStream istream = new BufferedInputStream(new FileInputStream("src/test/resources/text/substream.txt"));

        try(InputStream ss = IOStreams.substream(istream, 5, 12)) {
            assertEquals(7, ss.available());
            assertRead(" porro ", new byte[10], c -> ss.read(c));
        }
        
        try(InputStream ss = IOStreams.substream(istream, 4, 12)) {

            assertRead("qu", new byte[10], c -> ss.read(c,0,2));
            assertRead("am est", new byte[10], c -> ss.read(c,0,30));

            assertEquals(-1, ss.read(new byte[10]));
            assertEquals(-1, ss.read(new byte[10], 2, 4));
            assertEquals(0, ss.skip(10));
        }
        
        try(InputStream ss = IOStreams.substream(istream, 0, 20)) {
            assertRead(" qui", new byte[4], c -> ss.read(c));
            assertEquals(5, ss.skip(5));
            assertRead("rem ", new byte[4], b -> ss.read(b));
        }
        
        try(InputStream ss = IOStreams.limit(istream, 1, 20)) {
            ss.mark(30);
            assertRead("psum quia", new byte[20], b -> ss.read(b,0,9));
            ss.reset();
            assertRead("psum quia", new byte[20], b -> ss.read(b,0,9));
            assertEquals(' ', ss.read());
            assertEquals(8, ss.skip(8));
            assertEquals('t', ss.read());
            assertEquals(-1, ss.read());
        }
        
        assertThrows(e -> "java.io.IOException: Stream closed".equals(e.toString()), ()->{
            IOStreams.substream(istream, 0, 20);
        });
        
        istream.close();
    }
    
    private void assertRead(String text, byte[] buffer, CheckedFunction<byte[],Integer,IOException> value) throws IOException {
        int exp = text.length();
        int ret = value.apply(buffer);
        assertEquals(exp, ret);
        assertEquals(text, new String(buffer, 0, exp, Charsets.US_ASCII));
    }

    private Predicate<Throwable> matchMessages(List<String> exp) {
        return cause -> Arrays
            .stream(cause.getSuppressed())
            .map(Throwable::getMessage)
            .collect(Collectors.toList())
            .equals(exp);
    }
    
    private static class StreamObserver {
        
        public final EventRouter listener = new EventDispatcher();
        
        public final List<String> events = new ArrayList<>();

        public StreamObserver() {
            listener.addEventListener(IOEvent.Reset.class, event -> {
                events.add(event.source() + " [reset]");
            });
            listener.addEventListener(IOEvent.Flush.class, event -> {
                events.add(event.source() + " [flush]");
            });
            listener.addEventListener(IOEvent.Read.class, event -> {
                events.add(event.source() + " [read] " + FormatHex.format(event.data()));
            });
            listener.addEventListener(IOEvent.Write.class, event -> {
                events.add(event.source() + " [write] " + FormatHex.format(event.data()));
            });
            listener.addEventListener(IOEvent.Close.class, event -> {
                events.add(event.source() + " [close]");
            });
            listener.addEventListener(IOEvent.Skip.class, event -> {
                events.add(event.source() + " [skip] " + event.count());
            });
            listener.addEventListener(IOEvent.Failure.class, event -> {
                events.add("[failure] " + event.cause().getClass().getSimpleName());
            });
        }
        
        public void dump() {
            events.stream().forEach(s -> System.out.printf(" - %s%n", s));
        }
    }
    
    private static class COStream extends ByteArrayOutputStream {
        
        private boolean closed = false;
        
        @Override
        public void close() throws IOException {
            check();
            closed = true;
            super.close();
        }

        @Override
        public void reset() {
            check();
            super.reset();
        }

        @Override
        public void write(byte[] b, int off, int len) {
            check();
            super.write(b, off, len);
        }

        @Override
        public void write(int b) {
            check();
            super.write(b);
        }

        @Override
        public void flush() throws IOException {
            check();
            super.flush();
        }
        
        private void check() {
            if(closed) {
                ExceptionUtils.rethrow(new IOException("TEST: CLOSED"));
            }
        }

        @Override
        public String toString() {
            return "COStream";
        }
        
    }
    
    private static class CIStream extends ByteArrayInputStream {
        
        private boolean closed = false;

        public CIStream(byte[] buf) {
            super(buf);
        }

        @Override
        public void close() throws IOException {
            check();
            closed = true;
            super.close();
        }

        @Override
        public int read(byte[] data) throws IOException {
            check();
            return super.read(data);
        }

        @Override
        public void reset() {
            check();
            super.reset();
        }

        @Override
        public int read(byte[] b, int off, int len) {
            check();
            return super.read(b, off, len);
        }

        @Override
        public int read() {
            check();
            return super.read();
        }

        @Override
        public long skip(long n) {
            check();
            return super.skip(n);
        }
        
        private void check() {
            if(closed) {
                ExceptionUtils.rethrow(new IOException("Hello"));
            }
        }

        @Override
        public String toString() {
            return "CIStream";
        }
        
    }

    // złośliwy charset. mówi, że przypada średnio 0.5 byte na char. Czyli, że
    // mamy kodowanie (2 chars in 1 byte). A potem robi zwykłe mapowanie 1:1.
    //
    // to powoduje, że nasz decoder głupieje podczas alokowania bufora (alokuje za mało).
    // normalnie żaden charset tak nie robi bo wszystkie sensowne kodowania generują więcej 
    // bajtów niż znaków
    private static final Charset EVIL_CHARSET = new Charset("evil", new String[0]) {

        @Override
        public boolean contains(Charset cs) {
            return cs.equals(this);
        }
        
        @Override
        public CharsetEncoder newEncoder() {
            throw new UnsupportedOperationException();
        }

        @Override
        public CharsetDecoder newDecoder() {
            return new VDecoder(this);
        }

    };
    
    private static class VDecoder extends CharsetDecoder {

        public VDecoder(Charset cs) {
            super(cs, 0.5f, 1);
        }

        @Override
        protected CoderResult decodeLoop(ByteBuffer in, CharBuffer out) {
            while (in.hasRemaining()) {
                if(out.remaining() < 1) {
                    return CoderResult.OVERFLOW;
                }
                out.put((char)in.get());
            }
            return CoderResult.UNDERFLOW;
        }

    }

}
