// Copyright 2015 The Bazel Authors. All rights reserved.
//
// 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.devtools.build.singlejar;

import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import static java.nio.charset.StandardCharsets.UTF_8;

import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

/**
 * A fake zip file to assert that a given {@link ZipInputStream} contains
 * specified entries in a specified order. Just for unit testing.
 */
public final class FakeZipFile {

  private enum EntryMode {
    DONT_CARE,
    EXPECT_DEFLATE,
    EXPECT_STORED;
  }

  /**
   * Validates an input provided as a byte array.
   */
  public static interface ByteValidator {
    /**
     * Check if {@code object} is the expected input. If {@code object} does not match the expected
     * pattern, an assertion should fails with the necessary message.
     */
    void validate(byte[] object);
  }

  private static void assertSameByteArray(byte[] expected, byte[] actual) {
    if (expected == null) {
      assertThat(actual).isNull();
    } else {
      assertThat(actual).isEqualTo(expected);
    }
  }

  private static byte[] readZipEntryContent(ZipInputStream zipInput) throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    byte[] buffer = new byte[1024];
    int bytesCopied;
    while ((bytesCopied = zipInput.read(buffer)) != -1) {
      out.write(buffer, 0, bytesCopied);
    }
    return out.toByteArray();
  }

  private static final class PlainByteValidator implements ByteValidator {
    private final byte[] expected;

    private PlainByteValidator(String expected) {
      this.expected = expected == null ? new byte[0] : expected.getBytes(UTF_8);
    }

    @Override
    public void validate(byte[] object) {
      assertSameByteArray(expected, object);
    }

  }

  private static final class FakeZipEntry {

    private final String name;
    private final ByteValidator content;
    private final Date date;
    private final byte[] extra;
    private final EntryMode mode;

    private FakeZipEntry(String name, Date date, String content, byte[] extra, EntryMode mode) {
      this.name = name;
      this.date = date;
      this.content = new PlainByteValidator(content);
      this.extra = extra;
      this.mode = mode;
    }

    private FakeZipEntry(String name, Date date, ByteValidator content, byte[] extra,
        EntryMode mode) {
      this.name = name;
      this.date = date;
      this.content = content;
      this.extra = extra;
      this.mode = mode;
    }

    public void assertNext(ZipInputStream zipInput) throws IOException {
      ZipEntry zipEntry = zipInput.getNextEntry();
      assertThat(zipEntry).isNotNull();
      switch (mode) {
        case EXPECT_DEFLATE:
          assertThat(zipEntry.getMethod()).isEqualTo(ZipEntry.DEFLATED);
          break;
        case EXPECT_STORED:
          assertThat(zipEntry.getMethod()).isEqualTo(ZipEntry.STORED);
          break;
        default:
          // we don't care.
          break;
      }
      assertThat(zipEntry.getName()).isEqualTo(name);
      if (date != null) {
        assertThat(zipEntry.getTime()).isEqualTo(date.getTime());
      }
      assertSameByteArray(extra, zipEntry.getExtra());
      content.validate(readZipEntryContent(zipInput));
    }
  }

  private final List<FakeZipEntry> entries = new ArrayList<>();

  @CanIgnoreReturnValue
  public FakeZipFile addEntry(String name, String content) {
    entries.add(new FakeZipEntry(name, null, content, null, EntryMode.DONT_CARE));
    return this;
  }

  @CanIgnoreReturnValue
  public FakeZipFile addEntry(String name, String content, boolean compressed) {
    entries.add(new FakeZipEntry(name, null, content, null,
        compressed ? EntryMode.EXPECT_DEFLATE : EntryMode.EXPECT_STORED));
    return this;
  }

  @CanIgnoreReturnValue
  public FakeZipFile addEntry(String name, Date date, String content) {
    entries.add(new FakeZipEntry(name, date, content, null, EntryMode.DONT_CARE));
    return this;
  }

  @CanIgnoreReturnValue
  public FakeZipFile addEntry(String name, Date date, String content, boolean compressed) {
    entries.add(new FakeZipEntry(name, date, content, null,
        compressed ? EntryMode.EXPECT_DEFLATE : EntryMode.EXPECT_STORED));
    return this;
  }

  @CanIgnoreReturnValue
  public FakeZipFile addEntry(String name, ByteValidator content) {
    entries.add(new FakeZipEntry(name, null, content, null, EntryMode.DONT_CARE));
    return this;
  }

  @CanIgnoreReturnValue
  public FakeZipFile addEntry(String name, ByteValidator content, boolean compressed) {
    entries.add(new FakeZipEntry(name, null, content, null,
        compressed ? EntryMode.EXPECT_DEFLATE : EntryMode.EXPECT_STORED));
    return this;
  }

  @CanIgnoreReturnValue
  public FakeZipFile addEntry(String name, Date date, ByteValidator content) {
    entries.add(new FakeZipEntry(name, date, content, null, EntryMode.DONT_CARE));
    return this;
  }

  @CanIgnoreReturnValue
  public FakeZipFile addEntry(String name, Date date, ByteValidator content, boolean compressed) {
    entries.add(new FakeZipEntry(name, date, content, null,
        compressed ? EntryMode.EXPECT_DEFLATE : EntryMode.EXPECT_STORED));
    return this;
  }

  @CanIgnoreReturnValue
  public FakeZipFile addEntry(String name, byte[] extra) {
    entries.add(new FakeZipEntry(name, null, (String) null, extra, EntryMode.DONT_CARE));
    return this;
  }

  @CanIgnoreReturnValue
  public FakeZipFile addEntry(String name, byte[] extra, boolean compressed) {
    entries.add(new FakeZipEntry(name, null, (String) null, extra,
        compressed ? EntryMode.EXPECT_DEFLATE : EntryMode.EXPECT_STORED));
    return this;
  }

  private byte[] preamble = null;

  @CanIgnoreReturnValue
  public FakeZipFile addPreamble(byte[] contents) {
    preamble = Arrays.copyOf(contents, contents.length);
    return this;
  }

  private int getUnsignedShort(byte[] source, int offset) {
    int a = source[offset + 0] & 0xff;
    int b = source[offset + 1] & 0xff;
    return (b << 8) | a;
  }

  public void assertSame(byte[] data) throws IOException {
    int offset = 0;
    int length = data.length;
    if (preamble != null) {
      offset += preamble.length;
      length -= offset;
      byte[] maybePreamble = Arrays.copyOfRange(data, 0, offset);
      assertThat(maybePreamble).isEqualTo(preamble);
    }
    ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(data, offset, length));
    for (FakeZipEntry entry : entries) {
      entry.assertNext(zipInput);
    }
    assertThat(zipInput.getNextEntry()).isNull();
    // Verify that the end of central directory data is correct.
    // This assumes that the end of directory is at the end of input and that there is no zip file
    // comment.
    int count = getUnsignedShort(data, data.length-14);
    assertThat(count).isEqualTo(entries.size());
    count = getUnsignedShort(data, data.length-12);
    assertThat(count).isEqualTo(entries.size());
  }

  /**
   * Assert that {@code expected} is the same zip file as {@code actual}. It is similar to
   * {@link org.junit.Assert#assertArrayEquals(byte[], byte[])} but should use a more
   * helpful error message.
   */
  public static void assertSame(byte[] expected, byte[] actual) throws IOException {
    // First parse the zip files, then compare to have explicit comparison messages.
    ZipInputStream expectedZip = new ZipInputStream(new ByteArrayInputStream(expected));
    ZipInputStream actualZip = new ZipInputStream(new ByteArrayInputStream(actual));
    StringBuffer actualFileList = new StringBuffer();
    StringBuffer expectedFileList = new StringBuffer();
    Map<String, ZipEntry> actualEntries = new HashMap<String, ZipEntry>();
    Map<String, ZipEntry> expectedEntries = new HashMap<String, ZipEntry>();
    Map<String, byte[]> actualEntryContents = new HashMap<String, byte[]>();
    Map<String, byte[]> expectedEntryContents = new HashMap<String, byte[]>();
    parseZipEntry(expectedZip, expectedFileList, expectedEntries, expectedEntryContents);
    parseZipEntry(actualZip, actualFileList, actualEntries, actualEntryContents);
    // Compare the ordered file list first.
    assertThat(actualFileList.toString()).isEqualTo(expectedFileList.toString());

    // Then compare each entry.
    for (String name : expectedEntries.keySet()) {
      ZipEntry expectedEntry = expectedEntries.get(name);
      ZipEntry actualEntry = actualEntries.get(name);
      assertWithMessage("Time differs for " + name)
          .that(actualEntry.getTime())
          .isEqualTo(expectedEntry.getTime());
      assertWithMessage("Extraneous content differs for " + name)
          .that(actualEntry.getExtra())
          .isEqualTo(expectedEntry.getExtra());
      assertWithMessage("Content differs for " + name)
          .that(actualEntryContents.get(name))
          .isEqualTo(expectedEntryContents.get(name));
    }

    // Finally do a binary array comparison to be sure that test fails if files are different in
    // some way we don't test.
    assertThat(actual).isEqualTo(expected);
  }

  private static void parseZipEntry(ZipInputStream expectedZip, StringBuffer expectedFileList,
      Map<String, ZipEntry> expectedEntries, Map<String, byte[]> expectedEntryContents)
      throws IOException {
    ZipEntry expectedEntry;
    while ((expectedEntry = expectedZip.getNextEntry()) != null) {
      expectedFileList.append(expectedEntry.getName()).append("\n");
      expectedEntries.put(expectedEntry.getName(), expectedEntry);
      expectedEntryContents.put(expectedEntry.getName(), readZipEntryContent(expectedZip));
    }
  }
}
