/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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.maven.surefire.api.stream;

import javax.annotation.Nonnull;

import java.io.EOFException;
import java.io.File;
import java.math.BigInteger;
import java.nio.Buffer;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.channels.ReadableByteChannel;
import java.nio.charset.CharsetDecoder;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

import org.apache.maven.plugin.surefire.log.api.ConsoleLogger;
import org.apache.maven.surefire.api.booter.Constants;
import org.apache.maven.surefire.api.booter.ForkedProcessEventType;
import org.apache.maven.surefire.api.event.Event;
import org.apache.maven.surefire.api.fork.ForkNodeArguments;
import org.apache.maven.surefire.api.stream.AbstractStreamDecoder.MalformedFrameException;
import org.apache.maven.surefire.api.stream.AbstractStreamDecoder.Memento;
import org.apache.maven.surefire.api.stream.AbstractStreamDecoder.Segment;
import org.junit.BeforeClass;
import org.junit.Test;

import static java.lang.Math.min;
import static java.lang.System.arraycopy;
import static java.nio.charset.CodingErrorAction.REPLACE;
import static java.nio.charset.StandardCharsets.ISO_8859_1;
import static java.nio.charset.StandardCharsets.US_ASCII;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Collections.emptyMap;
import static java.util.Collections.singletonMap;
import static org.apache.maven.surefire.api.booter.Constants.DEFAULT_STREAM_ENCODING;
import static org.apache.maven.surefire.api.booter.ForkedProcessEventType.BOOTERCODE_STDOUT;
import static org.apache.maven.surefire.api.stream.SegmentType.END_OF_FRAME;
import static org.assertj.core.api.Assertions.assertThat;
import static org.powermock.reflect.Whitebox.invokeMethod;

/**
 * The performance of "get( Integer )" is 13.5 nano seconds on i5/2.6GHz:
 * <pre>
 *     {@code
 *     TreeMap<Integer, ForkedProcessEventType> map = new TreeMap<>();
 *     map.get( hash );
 *     }
 * </pre>
 *
 * <br> The performance of getting event type by Segment is 33.7 nano seconds:
 * <pre>
 *     {@code
 *     Map<Segment, ForkedProcessEventType> map = new HashMap<>();
 *     byte[] array = ForkedProcessEventType.BOOTERCODE_STDOUT.getOpcode().getBytes( UTF_8 );
 *     map.get( new Segment( array, 0, array.length ) );
 *     }
 * </pre>
 *
 * <br> The performance of decoder:
 * <pre>
 *     {@code
 *     CharsetDecoder decoder = STREAM_ENCODING.newDecoder()
 *             .onMalformedInput( REPLACE )
 *             .onUnmappableCharacter( REPLACE );
 *     ByteBuffer buffer = ByteBuffer.wrap( ForkedProcessEventType.BOOTERCODE_STDOUT.getOpcode().getBytes( UTF_8 ) );
 *     CharBuffer chars = CharBuffer.allocate( 100 );
 *     decoder.reset().decode( buffer, chars, true );
 *
 *     String s = chars.flip().toString(); // 37 nanos = CharsetDecoder + toString
 *
 *     buffer.clear();
 *     chars.clear();
 *
 *     ForkedProcessEventType.byOpcode( s ); // 65 nanos = CharsetDecoder + toString + byOpcode
 *     }
 * </pre>
 *
 * <br> The performance of decoding 100 bytes via CharacterDecoder - 71 nano seconds:
 * <pre>
 *     {@code
 *     decoder.reset()
 *         .decode( buffer, chars, true ); // CharsetDecoder 71 nanos
 *     chars.flip().toString(); // CharsetDecoder + toString = 91 nanos
 *     }
 * </pre>
 *
 * <br> The performance of a pure string creation (instead of decoder) - 31.5 nano seconds:
 * <pre>
 *     {@code
 *     byte[] b = {};
 *     new String( b, UTF_8 );
 *     }
 * </pre>
 *
 * <br> The performance of CharsetDecoder with empty ByteBuffer:
 * <pre>
 *     {@code
 *     CharsetDecoder + ByteBuffer.allocate( 0 ) makes 11.5 nanos
 *     CharsetDecoder + ByteBuffer.allocate( 0 ) + toString() makes 16.1 nanos
 *     }
 * </pre>
 */
@SuppressWarnings("checkstyle:magicnumber")
public class AbstractStreamDecoderTest {
    private static final Map<Segment, ForkedProcessEventType> EVENTS = new HashMap<>();

    private static final String PATTERN1 =
            "0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789";

    private static final String PATTERN2 = "€ab©c";

    private static final byte[] PATTERN2_BYTES =
            new byte[] {(byte) -30, (byte) -126, (byte) -84, 'a', 'b', (byte) 0xc2, (byte) 0xa9, 'c'};

    @BeforeClass
    public static void setup() {
        for (ForkedProcessEventType event : ForkedProcessEventType.values()) {
            byte[] array = event.getOpcodeBinary();
            EVENTS.put(new Segment(array, 0, array.length), event);
        }
    }

    @Test
    public void shouldDecodeHappyCase() throws Exception {
        CharsetDecoder decoder = UTF_8.newDecoder().onMalformedInput(REPLACE).onUnmappableCharacter(REPLACE);
        ByteBuffer input = ByteBuffer.allocate(1024);
        ((Buffer) input.put(PATTERN2_BYTES)).flip();
        int bytesToDecode = PATTERN2_BYTES.length;
        Buffer output = CharBuffer.allocate(1024);
        int readBytes = invokeMethod(
                AbstractStreamDecoder.class, "decodeString", decoder, input, output, bytesToDecode, true, 0);

        assertThat(readBytes).isEqualTo(bytesToDecode);

        assertThat(output.flip().toString()).isEqualTo(PATTERN2);
    }

    @Test
    public void shouldDecodeShifted() throws Exception {
        CharsetDecoder decoder = UTF_8.newDecoder().onMalformedInput(REPLACE).onUnmappableCharacter(REPLACE);
        ByteBuffer input = ByteBuffer.allocate(1024);
        ((Buffer) input.put(PATTERN1.getBytes(UTF_8))
                        .put(90, (byte) 'A')
                        .put(91, (byte) 'B')
                        .put(92, (byte) 'C'))
                .position(90);
        Buffer output = CharBuffer.allocate(1024);
        int readBytes = invokeMethod(AbstractStreamDecoder.class, "decodeString", decoder, input, output, 2, true, 0);

        assertThat(readBytes).isEqualTo(2);

        assertThat(output.flip().toString()).isEqualTo("AB");
    }

    @Test(expected = IllegalArgumentException.class)
    public void shouldNotDecode() throws Exception {
        CharsetDecoder decoder = UTF_8.newDecoder();
        ByteBuffer input = ByteBuffer.allocate(100);
        int bytesToDecode = 101;
        CharBuffer output = CharBuffer.allocate(1000);
        invokeMethod(AbstractStreamDecoder.class, "decodeString", decoder, input, output, bytesToDecode, true, 0);
    }

    @Test
    public void shouldReadInt() throws Exception {
        Channel channel = new Channel(new byte[] {0x01, 0x02, 0x03, 0x04, ':'}, 1);

        Mock thread = new Mock(channel, new MockForkNodeArguments(), emptyMap());

        Memento memento = thread.new Memento();

        assertThat(thread.readInt(memento)).isEqualTo(new BigInteger(new byte[] {0x01, 0x02, 0x03, 0x04}).intValue());
    }

    @Test
    public void shouldReadInteger() throws Exception {
        Channel channel = new Channel(new byte[] {(byte) 0xff, 0x01, 0x02, 0x03, 0x04, ':'}, 1);

        Mock thread = new Mock(channel, new MockForkNodeArguments(), emptyMap());

        Memento memento = thread.new Memento();
        assertThat(thread.readInteger(memento))
                .isEqualTo(new BigInteger(new byte[] {0x01, 0x02, 0x03, 0x04}).intValue());
    }

    @Test
    public void shouldReadNullInteger() throws Exception {
        Channel channel = new Channel(new byte[] {(byte) 0x00, ':'}, 1);

        Mock thread = new Mock(channel, new MockForkNodeArguments(), emptyMap());

        Memento memento = thread.new Memento();
        assertThat(thread.readInteger(memento)).isNull();
    }

    @Test(expected = EOFException.class)
    public void shouldNotReadString() throws Exception {
        Channel channel = new Channel(PATTERN1.getBytes(), PATTERN1.length());
        channel.read(ByteBuffer.allocate(100));

        Mock thread = new Mock(channel, new MockForkNodeArguments(), emptyMap());

        Memento memento = thread.new Memento();
        invokeMethod(thread, "readString", memento, 10);
    }

    @Test
    public void shouldReadString() throws Exception {
        Channel channel = new Channel(PATTERN1.getBytes(), PATTERN1.length());

        Mock thread = new Mock(channel, new MockForkNodeArguments(), emptyMap());

        Memento memento = thread.new Memento();
        String s = invokeMethod(thread, "readString", memento, 10);
        assertThat(s).isEqualTo("0123456789");
    }

    @Test
    public void shouldReadStringOverflowOnNewLine() throws Exception {
        StringBuilder s = new StringBuilder(1025);
        for (int i = 0; i < 10; i++) {
            s.append(PATTERN1);
        }
        s.append(PATTERN1, 0, 23);
        s.append("\u00FA\n"); // 2-bytes encoded character + LF

        Channel channel = new Channel(s.toString().getBytes(UTF_8), s.length());

        Mock thread = new Mock(channel, new MockForkNodeArguments(), emptyMap());

        Memento memento = thread.new Memento();

        assertThat((String) invokeMethod(thread, "readString", memento, 1026)).isEqualTo(s.toString());

        assertThat(memento.getByteBuffer().remaining()).isEqualTo(0);
    }

    @Test
    public void shouldReadStringOverflowOn4BytesEncodedSymbol() throws Exception {
        StringBuilder s = new StringBuilder(1025);
        for (int i = 0; i < 10; i++) {
            s.append(PATTERN1);
        }
        s.append(PATTERN1, 0, 23);
        s.append("\uD83D\uDE35"); // 4-bytes encoded character

        Channel channel = new Channel(s.toString().getBytes(UTF_8), s.length());

        Mock thread = new Mock(channel, new MockForkNodeArguments(), emptyMap());

        Memento memento = thread.new Memento();

        assertThat((String) invokeMethod(thread, "readString", memento, 1027)).isEqualTo(s.toString());

        assertThat(memento.getByteBuffer().remaining()).isEqualTo(0);
    }

    @Test
    public void shouldReadStringShiftedBuffer() throws Exception {
        StringBuilder s = new StringBuilder(1100);
        for (int i = 0; i < 11; i++) {
            s.append(PATTERN1);
        }

        Channel channel = new Channel(s.toString().getBytes(UTF_8), s.length());

        Mock thread = new Mock(channel, new MockForkNodeArguments(), emptyMap());

        Memento memento = thread.new Memento();
        // whatever position will be compacted to 0
        ((Buffer) ((Buffer) memento.getByteBuffer()).limit(974)).position(974);
        assertThat((String) invokeMethod(thread, "readString", memento, PATTERN1.length() + 3))
                .isEqualTo(PATTERN1 + "012");
    }

    @Test
    public void shouldReadStringShiftedInput() throws Exception {
        StringBuilder s = new StringBuilder(1100);
        for (int i = 0; i < 11; i++) {
            s.append(PATTERN1);
        }

        Channel channel = new Channel(s.toString().getBytes(UTF_8), s.length());
        channel.read(ByteBuffer.allocate(997));

        Mock thread = new Mock(channel, new MockForkNodeArguments(), emptyMap());

        Memento memento = thread.new Memento();
        assertThat((String) invokeMethod(thread, "readString", memento, PATTERN1.length()))
                .isEqualTo("789" + PATTERN1.substring(0, 97));
    }

    @Test
    public void shouldReadMultipleStringsAndShiftedInput() throws Exception {
        StringBuilder s = new StringBuilder(5000);

        for (int i = 0; i < 50; i++) {
            s.append(PATTERN1);
        }

        Channel channel = new Channel(s.toString().getBytes(UTF_8), s.length());
        channel.read(ByteBuffer.allocate(1997));

        Mock thread = new Mock(channel, new MockForkNodeArguments(), emptyMap());

        Memento memento = thread.new Memento();
        // whatever position will be compacted to 0
        ((Buffer) memento.getByteBuffer()).limit(974).position(974);

        StringBuilder expected = new StringBuilder("789");
        for (int i = 0; i < 11; i++) {
            expected.append(PATTERN1);
        }
        expected.setLength(1100);
        assertThat((String) invokeMethod(thread, "readString", memento, 1100)).isEqualTo(expected.toString());
    }

    @Test
    public void shouldDecode3BytesEncodedSymbol() throws Exception {
        byte[] encodedSymbol = new byte[] {(byte) -30, (byte) -126, (byte) -84};
        int countSymbols = 1024;
        byte[] input = new byte[encodedSymbol.length * countSymbols];
        for (int i = 0; i < countSymbols; i++) {
            arraycopy(encodedSymbol, 0, input, encodedSymbol.length * i, encodedSymbol.length);
        }

        Channel channel = new Channel(input, 64 * 1024);
        Mock thread = new Mock(channel, new MockForkNodeArguments(), emptyMap());
        Memento memento = thread.new Memento();
        String decodedOutput = invokeMethod(thread, "readString", memento, input.length);

        assertThat(decodedOutput).isEqualTo(new String(input, 0, input.length, UTF_8));
    }

    @Test
    public void shouldDecode100Bytes() throws Exception {
        CharsetDecoder decoder =
                DEFAULT_STREAM_ENCODING.newDecoder().onMalformedInput(REPLACE).onUnmappableCharacter(REPLACE);
        // empty stream: CharsetDecoder + ByteBuffer.allocate( 0 ) makes 11.5 nanos
        // empty stream: CharsetDecoder + ByteBuffer.allocate( 0 ) + toString() makes 16.1 nanos
        ByteBuffer buffer = ByteBuffer.wrap(PATTERN1.getBytes(UTF_8));
        CharBuffer chars = CharBuffer.allocate(100);
        // uncomment this section for a proper measurement of the exec time
        TimeUnit.SECONDS.sleep(2);
        System.gc();
        TimeUnit.SECONDS.sleep(5);
        String s = null;
        long l1 = System.currentTimeMillis();
        for (int i = 0; i < 10_000_000; i++) {
            decoder.reset().decode(buffer, chars, true); // CharsetDecoder 71 nanos
            s = ((Buffer) chars).flip().toString(); // CharsetDecoder + toString = 91 nanos
            ((Buffer) buffer).clear();
            ((Buffer) chars).clear();
        }
        long l2 = System.currentTimeMillis();
        System.out.println("decoded 100 bytes within " + (l2 - l1) + " ms (10 million cycles)");
        assertThat(s).isEqualTo(PATTERN1);
    }

    @Test
    public void shouldReadEventType() throws Exception {
        byte[] array = BOOTERCODE_STDOUT.getOpcodeBinary();
        Map<Segment, ForkedProcessEventType> messageType =
                singletonMap(new Segment(array, 0, array.length), BOOTERCODE_STDOUT);

        byte[] stream = ":maven-surefire-event:\u000E:std-out-stream:".getBytes(UTF_8);
        Channel channel = new Channel(stream, 1);
        Mock thread = new Mock(channel, new MockForkNodeArguments(), messageType);

        Memento memento = thread.new Memento();
        memento.setCharset(UTF_8);

        ForkedProcessEventType eventType = thread.readMessageType(memento);
        assertThat(eventType).isEqualTo(BOOTERCODE_STDOUT);
    }

    @Test(expected = EOFException.class)
    public void shouldEventTypeReachedEndOfStream() throws Exception {
        byte[] stream = ":maven-surefire-event:\u000E:xxx".getBytes(UTF_8);
        Channel channel = new Channel(stream, 1);
        Mock thread = new Mock(channel, new MockForkNodeArguments(), EVENTS);

        Memento memento = thread.new Memento();
        memento.setCharset(UTF_8);
        thread.readMessageType(memento);
    }

    @Test(expected = MalformedFrameException.class)
    public void shouldEventTypeReachedMalformedHeader() throws Exception {
        byte[] stream = ":xxxxx-xxxxxxxx-xxxxx:\u000E:xxx".getBytes(UTF_8);
        Channel channel = new Channel(stream, 1);
        Mock thread = new Mock(channel, new MockForkNodeArguments(), emptyMap());

        Memento memento = thread.new Memento();
        memento.setCharset(UTF_8);
        thread.readMessageType(memento);
    }

    @Test
    public void shouldReadEmptyString() throws Exception {
        byte[] stream = "\u0000\u0000\u0000\u0000::".getBytes(UTF_8);
        Channel channel = new Channel(stream, 1);
        Mock thread = new Mock(channel, new MockForkNodeArguments(), emptyMap());

        Memento memento = thread.new Memento();
        memento.setCharset(UTF_8);

        assertThat(thread.readString(memento)).isEmpty();
    }

    @Test
    public void shouldReadNullString() throws Exception {
        byte[] stream = "\u0000\u0000\u0000\u0001:\u0000:".getBytes(UTF_8);
        Channel channel = new Channel(stream, 1);
        Mock thread = new Mock(channel, new MockForkNodeArguments(), emptyMap());

        Memento memento = thread.new Memento();
        memento.setCharset(UTF_8);

        assertThat(thread.readString(memento)).isNull();
    }

    @Test
    public void shouldReadSingleCharString() throws Exception {
        byte[] stream = "\u0000\u0000\u0000\u0001:A:".getBytes(UTF_8);
        Channel channel = new Channel(stream, 1);
        Mock thread = new Mock(channel, new MockForkNodeArguments(), emptyMap());

        Memento memento = thread.new Memento();
        memento.setCharset(UTF_8);

        assertThat(thread.readString(memento)).isEqualTo("A");
    }

    @Test
    public void shouldReadThreeCharactersString() throws Exception {
        byte[] stream = "\u0000\u0000\u0000\u0003:ABC:".getBytes(UTF_8);
        Channel channel = new Channel(stream, 1);
        Mock thread = new Mock(channel, new MockForkNodeArguments(), emptyMap());

        Memento memento = thread.new Memento();
        memento.setCharset(UTF_8);

        assertThat(thread.readString(memento)).isEqualTo("ABC");
    }

    @Test
    public void shouldReadDefaultCharset() throws Exception {
        byte[] stream = "\u0005:UTF-8:".getBytes(US_ASCII);
        Channel channel = new Channel(stream, 1);
        Mock thread = new Mock(channel, new MockForkNodeArguments(), emptyMap());

        Memento memento = thread.new Memento();
        memento.setCharset(UTF_8);

        assertThat(thread.readCharset(memento)).isNotNull().isEqualTo(UTF_8);
    }

    @Test
    public void shouldReadNonDefaultCharset() throws Exception {
        byte[] stream = ((char) 10 + ":ISO_8859_1:").getBytes(US_ASCII);
        Channel channel = new Channel(stream, 1);
        Mock thread = new Mock(channel, new MockForkNodeArguments(), emptyMap());

        Memento memento = thread.new Memento();
        memento.setCharset(UTF_8);

        assertThat(thread.readCharset(memento)).isNotNull().isEqualTo(ISO_8859_1);
    }

    @Test
    public void shouldSetNonDefaultCharset() {
        byte[] stream = {};
        Channel channel = new Channel(stream, 1);
        Mock thread = new Mock(channel, new MockForkNodeArguments(), emptyMap());

        Memento memento = thread.new Memento();
        memento.setCharset(ISO_8859_1);
        assertThat(memento.getDecoder().charset()).isEqualTo(ISO_8859_1);

        memento.setCharset(UTF_8);
        assertThat(memento.getDecoder().charset()).isEqualTo(UTF_8);

        memento.reset();
        assertThat(memento.getDecoder()).isNotNull();
        assertThat(memento.getDecoder().charset()).isEqualTo(UTF_8);
    }

    @Test(expected = MalformedFrameException.class)
    public void malformedCharset() throws Exception {
        byte[] stream = ((char) 8 + ":ISO_8859:").getBytes(US_ASCII);
        Channel channel = new Channel(stream, 1);
        Mock thread = new Mock(channel, new MockForkNodeArguments(), emptyMap());

        Memento memento = thread.new Memento();
        memento.setCharset(UTF_8);

        thread.readCharset(memento);
    }

    private static class Channel implements ReadableByteChannel {
        private final byte[] bytes;
        private final int chunkSize;
        protected int i;

        Channel(byte[] bytes, int chunkSize) {
            this.bytes = bytes;
            this.chunkSize = chunkSize;
        }

        @Override
        public int read(ByteBuffer dst) {
            if (i == bytes.length) {
                return -1;
            } else if (dst.hasRemaining()) {
                int length = min(min(chunkSize, bytes.length - i), dst.remaining());
                dst.put(bytes, i, length);
                i += length;
                return length;
            } else {
                return 0;
            }
        }

        @Override
        public boolean isOpen() {
            return false;
        }

        @Override
        public void close() {}
    }

    private static class MockForkNodeArguments implements ForkNodeArguments {
        @Nonnull
        @Override
        public String getSessionId() {
            return null;
        }

        @Override
        public int getForkChannelId() {
            return 0;
        }

        @Nonnull
        @Override
        public File dumpStreamText(@Nonnull String text) {
            return null;
        }

        @Nonnull
        @Override
        public File dumpStreamException(@Nonnull Throwable t) {
            return null;
        }

        @Override
        public void logWarningAtEnd(@Nonnull String text) {}

        @Nonnull
        @Override
        public ConsoleLogger getConsoleLogger() {
            return null;
        }

        @Nonnull
        @Override
        public Object getConsoleLock() {
            return new Object();
        }

        @Override
        public File getEventStreamBinaryFile() {
            return null;
        }

        @Override
        public File getCommandStreamBinaryFile() {
            return null;
        }
    }

    private static class Mock extends AbstractStreamDecoder<Event, ForkedProcessEventType, SegmentType> {
        protected Mock(
                @Nonnull ReadableByteChannel channel,
                @Nonnull ForkNodeArguments arguments,
                @Nonnull Map<Segment, ForkedProcessEventType> messageTypes) {
            super(channel, arguments, messageTypes);
        }

        @Override
        public Event decode(@Nonnull Memento memento) throws MalformedChannelException {
            throw new MalformedChannelException();
        }

        @Nonnull
        @Override
        protected byte[] getEncodedMagicNumber() {
            return Constants.MAGIC_NUMBER_FOR_EVENTS_BYTES;
        }

        @Nonnull
        @Override
        protected SegmentType[] nextSegmentType(@Nonnull ForkedProcessEventType messageType) {
            return new SegmentType[] {END_OF_FRAME};
        }

        @Nonnull
        @Override
        protected Event toMessage(@Nonnull ForkedProcessEventType messageType, @Nonnull Memento memento) {
            return null;
        }

        @Override
        public void close() throws Exception {}
    }
}
