/*
 * Copyright 2000-2013 JetBrains s.r.o.
 *
 * 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.
 */

/*
 * @author max
 */
package com.intellij.util.io.zip;

import com.intellij.openapi.util.io.FileUtil;
import com.intellij.util.ArrayUtil;
import org.jetbrains.annotations.NotNull;

import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.util.zip.Inflater;
import java.util.zip.InflaterInputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipException;

/**
 * Extension that adds better handling of extra fields and provides
 * access to the internal and external file attributes.
 */
@SuppressWarnings({"OctalInteger"})
public class JBZipEntry implements Cloneable {
  private static final int PLATFORM_UNIX = 3;
  private static final int PLATFORM_FAT = 0;
  private static final int SHORT_MASK = 0xFFFF;
  private static final int SHORT_SHIFT = 16;

  private long time = -1;     // modification time (in DOS time)
  private long crc = -1;      // crc-32 of entry data
  private long size = -1;     // uncompressed size of entry data
  private long csize = -1;    // compressed size of entry data
  private int method = -1;    // compression method
  private byte[] extra = ArrayUtil.EMPTY_BYTE_ARRAY;   // optional extra field data for entry
  private String comment;     // optional comment string for entry

  private int internalAttributes = 0;
  private int platform = PLATFORM_FAT;
  private long externalAttributes = 0;
  private String name = null;

  private long headerOffset = -1;
  private final JBZipFile myFile;


  /**
   * Creates a new zip entry with the specified name.
   *
   * @param name the name of the entry
   * @param file
   * @since 1.1
   */
  protected JBZipEntry(String name, JBZipFile file) {
    this.name = name;
    myFile = file;
  }

  /**
   * @param file
   * @since 1.9
   */
  protected JBZipEntry(JBZipFile file) {
    name = "";
    myFile = file;
  }

  /**
   * Retrieves the internal file attributes.
   *
   * @return the internal file attributes
   * @since 1.1
   */
  public int getInternalAttributes() {
    return internalAttributes;
  }

  /**
   * Sets the internal file attributes.
   *
   * @param value an <code>int</code> value
   * @since 1.1
   */
  public void setInternalAttributes(int value) {
    internalAttributes = value;
  }

  /**
   * Retrieves the external file attributes.
   *
   * @return the external file attributes
   * @since 1.1
   */
  public long getExternalAttributes() {
    return externalAttributes;
  }

  /**
   * Sets the external file attributes.
   *
   * @param value an <code>long</code> value
   * @since 1.1
   */
  public void setExternalAttributes(long value) {
    externalAttributes = value;
  }

  public long getHeaderOffset() {
    return headerOffset;
  }

  public void setHeaderOffset(long headerOffset) {
    this.headerOffset = headerOffset;
  }

  /**
   * Sets Unix permissions in a way that is understood by Info-Zip's
   * unzip command.
   *
   * @param mode an <code>int</code> value
   * @since Ant 1.5.2
   */
  public void setUnixMode(int mode) {
    setExternalAttributes((mode << 16)
                          // MS-DOS read-only attribute
                          | ((mode & 0200) == 0 ? 1 : 0)
                          // MS-DOS directory flag
                          | (isDirectory() ? 0x10 : 0));
    platform = PLATFORM_UNIX;
  }

  /**
   * Unix permission.
   *
   * @return the unix permissions
   * @since Ant 1.6
   */
  public int getUnixMode() {
    return (int)((getExternalAttributes() >> SHORT_SHIFT) & SHORT_MASK);
  }

  /**
   * Platform specification to put into the &quot;version made
   * by&quot; part of the central file header.
   *
   * @return 0 (MS-DOS FAT) unless {@link #setUnixMode setUnixMode}
   *         has been called, in which case 3 (Unix) will be returned.
   * @since Ant 1.5.2
   */
  public int getPlatform() {
    return platform;
  }

  /**
   * Set the platform (UNIX or FAT).
   *
   * @param platform an <code>int</code> value - 0 is FAT, 3 is UNIX
   * @since 1.9
   */
  protected void setPlatform(int platform) {
    this.platform = platform;
  }

  /**
   * Sets the optional extra field data for the entry.
   * @param extra the extra field data bytes
   * @exception IllegalArgumentException if the length of the specified
   *		  extra field data is greater than 0xFFFF bytes
   * @see #getExtra()
   */
  public void setExtra(byte[] extra) {
      if (extra != null && extra.length > 0xFFFF) {
          throw new IllegalArgumentException("invalid extra field length");
      }
      this.extra = extra;
  }

  /**
   * Retrieves the extra data for the local file data.
   *
   * @return the extra data for local file
   * @since 1.1
   */
  public byte[] getLocalFileDataExtra() {
    byte[] e = getExtra();
    return e != null ? e : new byte[0];
  }

  /**
   * Sets the modification time of the entry.
   *
   * @param time the entry modification time in number of milliseconds
   *             since the epoch
   * @see #getTime()
   */
  public void setTime(long time) {
    this.time = time;
  }

  /**
   * Returns the modification time of the entry, or -1 if not specified.
   *
   * @return the modification time of the entry, or -1 if not specified
   * @see #setTime(long)
   */
  public long getTime() {
    return time;
  }

  /**
   * Sets the uncompressed size of the entry data.
   *
   * @param size the uncompressed size in bytes
   * @throws IllegalArgumentException if the specified size is less
   *                                  than 0 or greater than 0xFFFFFFFF bytes
   * @see #getSize()
   */
  public void setSize(long size) {
    if (size < 0 || size > 0xFFFFFFFFL) {
      throw new IllegalArgumentException("invalid entry size");
    }
    this.size = size;
  }

  /**
   * Returns the uncompressed size of the entry data, or -1 if not known.
   *
   * @return the uncompressed size of the entry data, or -1 if not known
   * @see #setSize(long)
   */
  public long getSize() {
    return size;
  }

  /**
   * Get the name of the entry.
   *
   * @return the entry name
   * @since 1.9
   */
  public String getName() {
    return name;
  }

  /**
   * Returns the size of the compressed entry data, or -1 if not known.
   * In the case of a stored entry, the compressed size will be the same
   * as the uncompressed size of the entry.
   *
   * @return the size of the compressed entry data, or -1 if not known
   * @see #setCompressedSize(long)
   */
  public long getCompressedSize() {
    return csize;
  }

  /**
   * Sets the size of the compressed entry data.
   *
   * @param csize the compressed size to set to
   * @see #getCompressedSize()
   */
  public void setCompressedSize(long csize) {
    this.csize = csize;
  }

  /**
   * Sets the CRC-32 checksum of the uncompressed entry data.
   *
   * @param crc the CRC-32 value
   * @throws IllegalArgumentException if the specified CRC-32 value is
   *                                  less than 0 or greater than 0xFFFFFFFF
   * @see #getCrc()
   */
  public void setCrc(long crc) {
    if (crc < 0 || crc > 0xFFFFFFFFL) {
      throw new IllegalArgumentException("invalid entry crc-32");
    }
    this.crc = crc;
  }

  /**
   * Returns the CRC-32 checksum of the uncompressed entry data, or -1 if
   * not known.
   *
   * @return the CRC-32 checksum of the uncompressed entry data, or -1 if
   *         not known
   * @see #setCrc(long)
   */
  public long getCrc() {
    return crc;
  }

  /**
   * Sets the compression method for the entry.
   *
   * @param method the compression method, either STORED or DEFLATED
   * @throws IllegalArgumentException if the specified compression
   *                                  method is invalid
   * @see #getMethod()
   */
  public void setMethod(int method) {
    if (method != ZipEntry.STORED && method != ZipEntry.DEFLATED) {
      throw new IllegalArgumentException("invalid compression method");
    }
    this.method = method;
  }

  /**
   * Returns the compression method of the entry, or -1 if not specified.
   *
   * @return the compression method of the entry, or -1 if not specified
   * @see #setMethod(int)
   */
  public int getMethod() {
    return method;
  }

  /**
   * Is this entry a directory?
   *
   * @return true if the entry is a directory
   * @since 1.10
   */
  public boolean isDirectory() {
    return getName().endsWith("/");
  }

  /**
   * Set the name of the entry.
   *
   * @param name the name to use
   */
  protected void setName(String name) {
    this.name = name;
  }

  /**
   * Get the hashCode of the entry.
   * This uses the name as the hashcode.
   *
   * @return a hashcode.
   * @since Ant 1.7
   */
  public int hashCode() {
    // this method has severe consequences on performance. We cannot rely
    // on the super.hashCode() method since super.getName() always return
    // the empty string in the current implemention (there's no setter)
    // so it is basically draining the performance of a hashmap lookup
    return getName().hashCode();
  }

  public void erase() throws IOException {
    myFile.eraseEntry(this);
  }

  private InputStream getInputStream() throws IOException {
    long start = calcDataOffset();

    BoundedInputStream bis = new BoundedInputStream(start, getCompressedSize());
    switch (getMethod()) {
      case ZipEntry.STORED:
        return bis;
      case ZipEntry.DEFLATED:
        bis.addDummy();
        return new InflaterInputStream(bis, new Inflater(true));
      default:
        throw new ZipException("Found unsupported compression method " + getMethod());
    }
  }

  /**
   * Returns the extra field data for the entry, or null if none.
   *
   * @return the extra field data for the entry, or null if none
   * @see #setExtra(byte[])
   */
  public byte[] getExtra() {
    return extra;
  }

  /**
   * Sets the optional comment string for the entry.
   *
   * @param comment the comment string
   * @throws IllegalArgumentException if the length of the specified
   *                                  comment string is greater than 0xFFFF bytes
   * @see #getComment()
   */
  public void setComment(String comment) {
    if (comment != null && comment.length() > 0xffff / 3 && getUTF8Length(comment) > 0xffff) {
      throw new IllegalArgumentException("invalid entry comment length");
    }
    this.comment = comment;
  }

  /*
  * Returns the length of String's UTF8 encoding.
  */
  private static int getUTF8Length(String s) {
    int count = 0;
    for (int i = 0; i < s.length(); i++) {
      char ch = s.charAt(i);
      if (ch <= 0x7f) {
        count++;
      }
      else if (ch <= 0x7ff) {
        count += 2;
      }
      else {
        count += 3;
      }
    }
    return count;
  }


  /**
   * Returns the comment string for the entry, or null if none.
   *
   * @return the comment string for the entry, or null if none
   * @see #setComment(String)
   */
  public String getComment() {
    return comment;
  }

  public void setData(byte[] bytes, long timestamp) throws IOException {
    time = timestamp;
    JBZipOutputStream stream = myFile.getOutputStream();
    stream.putNextEntryBytes(this, bytes);
  }

  public void setData(byte[] bytes) throws IOException {
    setData(bytes, time);
  }

  public byte[] getData() throws IOException {
    if (size == -1) throw new IOException("no data");

    final InputStream stream = getInputStream();
    try {
      return FileUtil.loadBytes(stream, (int)size);
    }
    finally {
      stream.close();
    }
  }

  private long calcDataOffset() throws IOException {
    long offset = getHeaderOffset();
    myFile.archive.seek(offset + JBZipFile.LFH_OFFSET_FOR_FILENAME_LENGTH);
    byte[] b = new byte[JBZipFile.WORD];
    myFile.archive.readFully(b);
    int fileNameLen = ZipShort.getValue(b, 0);
    int extraFieldLen = ZipShort.getValue(b, JBZipFile.SHORT);
    return offset + JBZipFile.LFH_OFFSET_FOR_FILENAME_LENGTH + JBZipFile.WORD + fileNameLen + extraFieldLen;
  }

  /**
   * InputStream that delegates requests to the underlying
   * RandomAccessFile, making sure that only bytes from a certain
   * range can be read.
   */
  private class BoundedInputStream extends InputStream {
    private long remaining;
    private long loc;
    private boolean addDummyByte = false;

    BoundedInputStream(long start, long remaining) {
      this.remaining = remaining;
      loc = start;
    }

    @Override
    public int read(@NotNull byte[] b, int off, int len) throws IOException {
      if (remaining <= 0) {
        if (addDummyByte) {
          addDummyByte = false;
          b[off] = 0;
          return 1;
        }
        return -1;
      }

      if (len <= 0) {
        return 0;
      }

      if (len > remaining) {
        len = (int)remaining;
      }

      final int ret;
      RandomAccessFile archive = myFile.archive;
      archive.seek(loc);
      ret = archive.read(b, off, len);

      if (ret > 0) {
        loc += ret;
        remaining -= ret;
      }
      return ret;
    }

    @Override
    public int read() throws IOException {
      if (remaining-- <= 0) {
        if (addDummyByte) {
          addDummyByte = false;
          return 0;
        }
        return -1;
      }

      RandomAccessFile archive = myFile.archive;
      archive.seek(loc++);
      return archive.read();
    }

    /**
     * Inflater needs an extra dummy byte for nowrap - see
     * Inflater's javadocs.
     */
    void addDummy() {
      addDummyByte = true;
    }
  }

  @Override
  public String toString() {
    return name;
  }
}
