/*
 * Copyright (C) 2012 The Guava 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 com.google.common.io;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.io.TestOption.AVAILABLE_ALWAYS_ZERO;
import static com.google.common.io.TestOption.CLOSE_THROWS;
import static com.google.common.io.TestOption.OPEN_THROWS;
import static com.google.common.io.TestOption.READ_THROWS;
import static com.google.common.io.TestOption.SKIP_THROWS;
import static com.google.common.io.TestOption.WRITE_THROWS;
import static com.google.common.truth.Truth.assertThat;
import static java.lang.Byte.toUnsignedInt;
import static java.lang.Math.max;
import static java.lang.Math.min;
import static java.nio.charset.StandardCharsets.US_ASCII;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertThrows;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.hash.Hashing;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Arrays;
import java.util.EnumSet;
import junit.framework.TestSuite;
import org.jspecify.annotations.NullUnmarked;
import org.jspecify.annotations.Nullable;

/**
 * Tests for the default implementations of {@code ByteSource} methods.
 *
 * @author Colin Decker
 */
@NullUnmarked
public class ByteSourceTest extends IoTestCase {

  @AndroidIncompatible // Android doesn't understand suites whose tests lack default constructors.
  public static TestSuite suite() {
    TestSuite suite = new TestSuite();
    for (boolean asCharSource : new boolean[] {false, true}) {
      suite.addTest(
          ByteSourceTester.tests(
              "ByteSource.wrap[byte[]]",
              SourceSinkFactories.byteArraySourceFactory(),
              asCharSource));
      suite.addTest(
          ByteSourceTester.tests(
              "ByteSource.empty[]", SourceSinkFactories.emptyByteSourceFactory(), asCharSource));
    }
    suite.addTestSuite(ByteSourceTest.class);
    return suite;
  }

  private static final byte[] bytes = newPreFilledByteArray(10000);

  private TestByteSource source;

  @Override
  protected void setUp() throws Exception {
    source = new TestByteSource(bytes);
  }

  public void testOpenBufferedStream() throws IOException {
    InputStream in = source.openBufferedStream();
    assertTrue(source.wasStreamOpened());
    assertFalse(source.wasStreamClosed());

    ByteArrayOutputStream out = new ByteArrayOutputStream();
    ByteStreams.copy(in, out);
    in.close();
    out.close();

    assertTrue(source.wasStreamClosed());
    assertArrayEquals(bytes, out.toByteArray());
  }

  public void testSize() throws IOException {
    assertEquals(bytes.length, source.size());
    assertTrue(source.wasStreamOpened() && source.wasStreamClosed());

    // test that we can get the size even if skip() isn't supported
    assertEquals(bytes.length, new TestByteSource(bytes, SKIP_THROWS).size());

    // test that we can get the size even if available() always returns zero
    assertEquals(bytes.length, new TestByteSource(bytes, AVAILABLE_ALWAYS_ZERO).size());
  }

  public void testCopyTo_outputStream() throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();

    assertEquals(bytes.length, source.copyTo(out));
    assertTrue(source.wasStreamOpened() && source.wasStreamClosed());

    assertArrayEquals(bytes, out.toByteArray());
  }

  public void testCopyTo_byteSink() throws IOException {
    TestByteSink sink = new TestByteSink();

    assertFalse(sink.wasStreamOpened() || sink.wasStreamClosed());

    assertEquals(bytes.length, source.copyTo(sink));
    assertTrue(source.wasStreamOpened() && source.wasStreamClosed());
    assertTrue(sink.wasStreamOpened() && sink.wasStreamClosed());

    assertArrayEquals(bytes, sink.getBytes());
  }

  public void testRead_toArray() throws IOException {
    assertArrayEquals(bytes, source.read());
    assertTrue(source.wasStreamOpened() && source.wasStreamClosed());
  }

  public void testRead_withProcessor() throws IOException {
    byte[] processedBytes = new byte[bytes.length];
    ByteProcessor<byte[]> processor =
        new ByteProcessor<byte[]>() {
          int pos;

          @Override
          public boolean processBytes(byte[] buf, int off, int len) throws IOException {
            System.arraycopy(buf, off, processedBytes, pos, len);
            pos += len;
            return true;
          }

          @Override
          public byte[] getResult() {
            return processedBytes;
          }
        };

    source.read(processor);
    assertTrue(source.wasStreamOpened() && source.wasStreamClosed());

    assertArrayEquals(bytes, processedBytes);
  }

  public void testRead_withProcessor_stopsOnFalse() throws IOException {
    ByteProcessor<@Nullable Void> processor =
        new ByteProcessor<@Nullable Void>() {
          boolean firstCall = true;

          @Override
          public boolean processBytes(byte[] buf, int off, int len) throws IOException {
            assertTrue("consume() called twice", firstCall);
            firstCall = false;
            return false;
          }

          @Override
          public @Nullable Void getResult() {
            return null;
          }
        };

    source.read(processor);
    assertTrue(source.wasStreamOpened() && source.wasStreamClosed());
  }

  public void testHash() throws IOException {
    ByteSource byteSource = new TestByteSource("hamburger\n".getBytes(US_ASCII));

    // Pasted this expected string from `echo hamburger | md5sum`
    assertEquals("cfa0c5002275c90508338a5cdb2a9781", byteSource.hash(Hashing.md5()).toString());
  }

  public void testContentEquals() throws IOException {
    assertTrue(source.contentEquals(source));
    assertTrue(source.wasStreamOpened() && source.wasStreamClosed());

    ByteSource equalSource = new TestByteSource(bytes);
    assertTrue(source.contentEquals(equalSource));
    assertTrue(new TestByteSource(bytes).contentEquals(source));

    ByteSource fewerBytes = new TestByteSource(newPreFilledByteArray(bytes.length / 2));
    assertFalse(source.contentEquals(fewerBytes));

    byte[] copy = bytes.clone();
    copy[9876] = 1;
    ByteSource oneByteOff = new TestByteSource(copy);
    assertFalse(source.contentEquals(oneByteOff));
  }

  public void testSlice() throws IOException {
    // Test preconditions
    assertThrows(IllegalArgumentException.class, () -> source.slice(-1, 10));

    assertThrows(IllegalArgumentException.class, () -> source.slice(0, -1));

    assertCorrectSlice(0, 0, 0, 0);
    assertCorrectSlice(0, 0, 1, 0);
    assertCorrectSlice(100, 0, 10, 10);
    assertCorrectSlice(100, 0, 100, 100);
    assertCorrectSlice(100, 5, 10, 10);
    assertCorrectSlice(100, 5, 100, 95);
    assertCorrectSlice(100, 100, 0, 0);
    assertCorrectSlice(100, 100, 10, 0);
    assertCorrectSlice(100, 101, 10, 0);
  }

  /**
   * Tests that the default slice() behavior is correct when the source is sliced starting at an
   * offset that is greater than the current length of the source, a stream is then opened to that
   * source, and finally additional bytes are appended to the source before the stream is read.
   *
   * <p>Without special handling, it's possible to have reads of the open stream start <i>before</i>
   * the offset at which the slice is supposed to start.
   */
  // TODO(cgdecker): Maybe add a test for this to ByteSourceTester
  public void testSlice_appendingAfterSlicing() throws IOException {
    // Source of length 5
    AppendableByteSource source = new AppendableByteSource(newPreFilledByteArray(5));

    // Slice it starting at offset 10.
    ByteSource slice = source.slice(10, 5);

    // Open a stream to the slice.
    InputStream in = slice.openStream();

    // Append 10 more bytes to the source.
    source.append(newPreFilledByteArray(5, 10));

    // The stream reports no bytes... importantly, it doesn't read the byte at index 5 when it
    // should be reading the byte at index 10.
    // We could use a custom InputStream instead to make the read start at index 10, but since this
    // is a racy situation anyway, this behavior seems reasonable.
    assertEquals(-1, in.read());
  }

  private static class AppendableByteSource extends ByteSource {
    private byte[] bytes;

    AppendableByteSource(byte[] initialBytes) {
      this.bytes = initialBytes.clone();
    }

    @Override
    public InputStream openStream() {
      return new In();
    }

    void append(byte[] b) {
      byte[] newBytes = Arrays.copyOf(bytes, bytes.length + b.length);
      System.arraycopy(b, 0, newBytes, bytes.length, b.length);
      bytes = newBytes;
    }

    private class In extends InputStream {
      private int pos;

      @Override
      public int read() throws IOException {
        byte[] b = new byte[1];
        return read(b) == -1 ? -1 : toUnsignedInt(b[0]);
      }

      @Override
      public int read(byte[] b, int off, int len) {
        if (pos >= bytes.length) {
          return -1;
        }

        int lenToRead = min(len, bytes.length - pos);
        System.arraycopy(bytes, pos, b, off, lenToRead);
        pos += lenToRead;
        return lenToRead;
      }
    }
  }

  /**
   * @param input the size of the input source
   * @param offset the first argument to {@link ByteSource#slice}
   * @param length the second argument to {@link ByteSource#slice}
   * @param expectRead the number of bytes we expect to read
   */
  private static void assertCorrectSlice(int input, int offset, long length, int expectRead)
      throws IOException {
    checkArgument(expectRead == (int) max(0, min(input, offset + length) - offset));

    byte[] expected = newPreFilledByteArray(offset, expectRead);

    ByteSource source = new TestByteSource(newPreFilledByteArray(input));
    ByteSource slice = source.slice(offset, length);

    assertArrayEquals(expected, slice.read());
  }

  public void testCopyToStream_doesNotCloseThatStream() throws IOException {
    TestOutputStream out = new TestOutputStream(ByteStreams.nullOutputStream());
    assertFalse(out.closed());
    source.copyTo(out);
    assertFalse(out.closed());
  }

  public void testClosesOnErrors_copyingToByteSinkThatThrows() {
    for (TestOption option : EnumSet.of(OPEN_THROWS, WRITE_THROWS, CLOSE_THROWS)) {
      TestByteSource okSource = new TestByteSource(bytes);
      assertThrows(IOException.class, () -> okSource.copyTo(new TestByteSink(option)));
      // ensure stream was closed IF it was opened (depends on implementation whether or not it's
      // opened at all if sink.newOutputStream() throws).
      assertTrue(
          "stream not closed when copying to sink with option: " + option,
          !okSource.wasStreamOpened() || okSource.wasStreamClosed());
    }
  }

  public void testClosesOnErrors_whenReadThrows() {
    TestByteSource failSource = new TestByteSource(bytes, READ_THROWS);
    assertThrows(IOException.class, () -> failSource.copyTo(new TestByteSink()));
    assertTrue(failSource.wasStreamClosed());
  }

  public void testClosesOnErrors_copyingToOutputStreamThatThrows() throws IOException {
    TestByteSource okSource = new TestByteSource(bytes);
    OutputStream out = new TestOutputStream(ByteStreams.nullOutputStream(), WRITE_THROWS);
    assertThrows(IOException.class, () -> okSource.copyTo(out));
    assertTrue(okSource.wasStreamClosed());
  }

  public void testConcat() throws IOException {
    ByteSource b1 = ByteSource.wrap(new byte[] {0, 1, 2, 3});
    ByteSource b2 = ByteSource.wrap(new byte[0]);
    ByteSource b3 = ByteSource.wrap(new byte[] {4, 5});

    byte[] expected = {0, 1, 2, 3, 4, 5};

    assertArrayEquals(expected, ByteSource.concat(ImmutableList.of(b1, b2, b3)).read());
    assertArrayEquals(expected, ByteSource.concat(b1, b2, b3).read());
    assertArrayEquals(expected, ByteSource.concat(ImmutableList.of(b1, b2, b3).iterator()).read());
    assertEquals(expected.length, ByteSource.concat(b1, b2, b3).size());
    assertFalse(ByteSource.concat(b1, b2, b3).isEmpty());

    ByteSource emptyConcat = ByteSource.concat(ByteSource.empty(), ByteSource.empty());
    assertTrue(emptyConcat.isEmpty());
    assertEquals(0, emptyConcat.size());
  }

  public void testConcat_infiniteIterable() throws IOException {
    ByteSource source = ByteSource.wrap(new byte[] {0, 1, 2, 3});
    Iterable<ByteSource> cycle = Iterables.cycle(ImmutableList.of(source));
    ByteSource concatenated = ByteSource.concat(cycle);

    byte[] expected = {0, 1, 2, 3, 0, 1, 2, 3};
    assertArrayEquals(expected, concatenated.slice(0, 8).read());
  }

  private static final ByteSource BROKEN_CLOSE_SOURCE =
      new TestByteSource(new byte[10], CLOSE_THROWS);
  private static final ByteSource BROKEN_OPEN_SOURCE =
      new TestByteSource(new byte[10], OPEN_THROWS);
  private static final ByteSource BROKEN_READ_SOURCE =
      new TestByteSource(new byte[10], READ_THROWS);
  private static final ByteSink BROKEN_CLOSE_SINK = new TestByteSink(CLOSE_THROWS);
  private static final ByteSink BROKEN_OPEN_SINK = new TestByteSink(OPEN_THROWS);
  private static final ByteSink BROKEN_WRITE_SINK = new TestByteSink(WRITE_THROWS);

  private static final ImmutableSet<ByteSource> BROKEN_SOURCES =
      ImmutableSet.of(BROKEN_CLOSE_SOURCE, BROKEN_OPEN_SOURCE, BROKEN_READ_SOURCE);
  private static final ImmutableSet<ByteSink> BROKEN_SINKS =
      ImmutableSet.of(BROKEN_CLOSE_SINK, BROKEN_OPEN_SINK, BROKEN_WRITE_SINK);

  public void testCopyExceptions() {
    // test that exceptions are suppressed

    for (ByteSource in : BROKEN_SOURCES) {
      int suppressed = runSuppressionFailureTest(in, newNormalByteSink());
      assertEquals(0, suppressed);

      suppressed = runSuppressionFailureTest(in, BROKEN_CLOSE_SINK);
      assertEquals((in == BROKEN_OPEN_SOURCE) ? 0 : 1, suppressed);
    }

    for (ByteSink out : BROKEN_SINKS) {
      int suppressed = runSuppressionFailureTest(newNormalByteSource(), out);
      assertEquals(0, suppressed);

      suppressed = runSuppressionFailureTest(BROKEN_CLOSE_SOURCE, out);
      assertEquals(1, suppressed);
    }

    for (ByteSource in : BROKEN_SOURCES) {
      for (ByteSink out : BROKEN_SINKS) {
        int suppressed = runSuppressionFailureTest(in, out);
        assertThat(suppressed).isAtMost(1);
      }
    }
  }

  public void testSlice_returnEmptySource() {
    assertEquals(ByteSource.empty(), source.slice(0, 3).slice(4, 3));
  }

  /**
   * @return the number of exceptions that were suppressed on the expected thrown exception
   */
  private static int runSuppressionFailureTest(ByteSource in, ByteSink out) {
    try {
      in.copyTo(out);
      fail();
    } catch (IOException expected) {
      return expected.getSuppressed().length;
    }
    throw new AssertionError(); // can't happen
  }

  private static ByteSource newNormalByteSource() {
    return ByteSource.wrap(new byte[10]);
  }

  private static ByteSink newNormalByteSink() {
    return new ByteSink() {
      @Override
      public OutputStream openStream() {
        return new ByteArrayOutputStream();
      }
    };
  }
}
