/*
 * The Apache Software License, Version 1.1
 *
 * Copyright (c) 2001-2002 The Apache Software Foundation.  All rights
 * reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 *
 * 1. Redistributions of source code must retain the above copyright
 *    notice, this list of conditions and the following disclaimer.
 *
 * 2. Redistributions in binary form must reproduce the above copyright
 *    notice, this list of conditions and the following disclaimer in
 *    the documentation and/or other materials provided with the
 *    distribution.
 *
 * 3. The end-user documentation included with the redistribution, if
 *    any, must include the following acknowlegement:
 *       "This product includes software developed by the
 *        Apache Software Foundation (http://www.apache.org/)."
 *    Alternately, this acknowlegement may appear in the software itself,
 *    if and wherever such third-party acknowlegements normally appear.
 *
 * 4. The names "The Jakarta Project", "Ant", and "Apache Software
 *    Foundation" must not be used to endorse or promote products derived
 *    from this software without prior written permission. For written
 *    permission, please contact apache@apache.org.
 *
 * 5. Products derived from this software may not be called "Apache"
 *    nor may "Apache" appear in their names without prior written
 *    permission of the Apache Group.
 *
 * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESSED OR IMPLIED
 * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
 * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED.  IN NO EVENT SHALL THE APACHE SOFTWARE FOUNDATION OR
 * ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
 * USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT
 * OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
 * SUCH DAMAGE.
 * ====================================================================
 *
 * This software consists of voluntary contributions made by many
 * individuals on behalf of the Apache Software Foundation.  For more
 * information on the Apache Software Foundation, please see
 * <http://www.apache.org/>.
 */

package org.apache.tools.zip;

import java.io.OutputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.util.Date;
import java.util.Hashtable;
import java.util.Vector;
import java.util.zip.CRC32;
import java.util.zip.Deflater;
import java.util.zip.DeflaterOutputStream;
import java.util.zip.ZipException;

/**
 * Reimplementation of {@link java.util.zip.ZipOutputStream
 * java.util.zip.ZipOutputStream} that does handle the extended
 * functionality of this package, especially internal/external file
 * attributes and extra fields with different layouts for local file
 * data and central directory entries.
 *
 * <p>This implementation will use a Data Descriptor to store size and
 * CRC information for DEFLATED entries, this means, you don't need to
 * calculate them yourself.  Unfortunately this is not possible for
 * the STORED method, here setting the CRC and uncompressed size
 * information is required before {@link #putNextEntry putNextEntry}
 * will be called.</p>
 *
 * @author <a href="stefan.bodewig@epost.de">Stefan Bodewig</a>
 * @version $Revision$
 */
public class ZipOutputStream extends DeflaterOutputStream {

    /**
     * Current entry.
     *
     * @since 1.1
     */
    private ZipEntry entry;

    /**
     * The file comment.
     *
     * @since 1.1
     */
    private String comment = "";

    /**
     * Compression level for next entry.
     *
     * @since 1.1
     */
    private int level = Deflater.DEFAULT_COMPRESSION;

    /**
     * Has the compression level changed when compared to the last
     * entry?
     *
     * @since 1.5
     */
    private boolean hasCompressionLevelChanged = false;

    /**
     * Default compression method for next entry.
     *
     * @since 1.1
     */
    private int method = DEFLATED;

    /**
     * List of ZipEntries written so far.
     *
     * @since 1.1
     */
    private Vector entries = new Vector();

    /**
     * CRC instance to avoid parsing DEFLATED data twice.
     *
     * @since 1.1
     */
    private CRC32 crc = new CRC32();

    /**
     * Count the bytes written to out.
     *
     * @since 1.1
     */
    private long written = 0;

    /**
     * Data for current entry started here.
     *
     * @since 1.1
     */
    private long dataStart = 0;

    /**
     * Start of central directory.
     *
     * @since 1.1
     */
    private ZipLong cdOffset = new ZipLong(0);

    /**
     * Length of central directory.
     *
     * @since 1.1
     */
    private ZipLong cdLength = new ZipLong(0);

    /**
     * Helper, a 0 as ZipShort.
     *
     * @since 1.1
     */
    private final static byte[] ZERO = {0, 0};

    /**
     * Helper, a 0 as ZipLong.
     *
     * @since 1.1
     */
    private final static byte[] LZERO = {0, 0, 0, 0};

    /**
     * Holds the offsets of the LFH starts for each entry
     *
     * @since 1.1
     */
    private Hashtable offsets = new Hashtable();

    /**
     * The encoding to use for filenames and the file comment.
     *
     * <p>For a list of possible values see <a
     * href="http://java.sun.com/products/jdk/1.2/docs/guide/internat/encoding.doc.html">http://java.sun.com/products/jdk/1.2/docs/guide/internat/encoding.doc.html</a>.
     * Defaults to the platform's default character encoding.</p>
     *
     * @since 1.3
     */
    private String encoding = null;

    /**
     * Compression method for deflated entries.
     *
     * @since 1.1
     */
    public final static int DEFLATED = ZipEntry.DEFLATED;

    /**
     * Compression method for deflated entries.
     *
     * @since 1.1
     */
    public final static int STORED = ZipEntry.STORED;

    /**
     * Creates a new ZIP OutputStream filtering the underlying stream.
     *
     * @since 1.1
     */
    public ZipOutputStream(OutputStream out) {
        super(out, new Deflater(Deflater.DEFAULT_COMPRESSION, true));
    }

    /**
     * The encoding to use for filenames and the file comment.
     *
     * <p>For a list of possible values see <a
     * href="http://java.sun.com/products/jdk/1.2/docs/guide/internat/encoding.doc.html">http://java.sun.com/products/jdk/1.2/docs/guide/internat/encoding.doc.html</a>.
     * Defaults to the platform's default character encoding.</p>
     *
     * @since 1.3
     */
    public void setEncoding(String encoding) {
        this.encoding = encoding;
    }

    /**
     * The encoding to use for filenames and the file comment.
     *
     * @return null if using the platform's default character encoding.
     * 
     * @since 1.3
     */
    public String getEncoding() {return encoding;}

    /*
     * Found out by experiment, that DeflaterOutputStream.close()
     * will call finish() - so we don't need to override close
     * ourselves.
     */

    /**
     * Finishs writing the contents and closes this as well as the
     * underlying stream.
     *
     * @since 1.1
     */
    public void finish() throws IOException {
        closeEntry();
        cdOffset = new ZipLong(written);
        for (int i=0; i<entries.size(); i++) {
            writeCentralFileHeader((ZipEntry) entries.elementAt(i));
        }
        cdLength = new ZipLong(written-cdOffset.getValue());
        writeCentralDirectoryEnd();
        offsets.clear();
        entries.removeAllElements();
    }

    /**
     * Writes all necessary data for this entry.
     *
     * @since 1.1
     */
    public void closeEntry() throws IOException {
        if (entry == null) {
            return;
        }

        long realCrc = crc.getValue();
        crc.reset();

        if (entry.getMethod() == DEFLATED) {
            def.finish();
            while (!def.finished()) {
                deflate();
            }

            entry.setSize(def.getTotalIn());
            entry.setComprSize(def.getTotalOut());
            entry.setCrc(realCrc);

            def.reset();

            written += entry.getCompressedSize();
        } else {
            if (entry.getCrc() != realCrc) {
                throw new ZipException("bad CRC checksum for entry "
                                       +entry.getName()+ ": " 
                                       + Long.toHexString(entry.getCrc())
                                       + " instead of " 
                                       + Long.toHexString(realCrc));
            }

            if (entry.getSize() != written - dataStart) {
                throw new ZipException("bad size for entry "
                                       +entry.getName()+ ": " 
                                       + entry.getSize()
                                       + " instead of " 
                                       + (written - dataStart));
            }

        }

        writeDataDescriptor(entry);
        entry = null;
   }

    /**
     * Begin writing next entry.
     *
     * @since 1.1
     */
    public void putNextEntry(ZipEntry ze) throws IOException {
        closeEntry();

        entry = ze;
        entries.addElement(entry);

        if (entry.getMethod() == -1) { // not specified
            entry.setMethod(method);
        }
        
        if (entry.getTime() == -1) { // not specified
            entry.setTime(System.currentTimeMillis());
        }

        if (entry.getMethod() == STORED) {
            if (entry.getSize() == -1) {
                throw new ZipException("uncompressed size is required for STORED method");
            }
            if (entry.getCrc() == -1) {
                throw new ZipException("crc checksum is required for STORED method");
            }
            entry.setComprSize(entry.getSize());
        } else if (hasCompressionLevelChanged) {
            def.setLevel(level);
            hasCompressionLevelChanged = false;
        }        
        writeLocalFileHeader(entry);
    }

    /**
     * Set the file comment.
     *
     * @since 1.1
     */
    public void setComment(String comment) {
        this.comment = comment;
    }

    /**
     * Sets the compression level for subsequent entries.
     *
     * <p>Default is Deflater.DEFAULT_COMPRESSION.</p>
     *
     * @since 1.1
     */
    public void setLevel(int level) {
        hasCompressionLevelChanged = (this.level != level);
        this.level = level;
    }

    /**
     * Sets the default compression method for subsequent entries.
     *
     * <p>Default is DEFLATED.</p>
     *
     * @since 1.1
     */
    public void setMethod(int method) {
        this.method = method;
    }

    /**
     * Writes bytes to ZIP entry.
     *
     * <p>Override is necessary to support STORED entries, as well as
     * calculationg CRC automatically for DEFLATED entries.</p>
     */
    public void write(byte[] b, int offset, int length) throws IOException {
        if (entry.getMethod() == DEFLATED) {
            super.write(b, offset, length);
        } else {
            out.write(b, offset, length);
            written += length;
        }
        crc.update(b, offset, length);
    }

    /*
     * Various ZIP constants
     */
    /**
     * local file header signature
     *
     * @since 1.1
     */
    protected final static ZipLong LFH_SIG = new ZipLong(0X04034B50L);
    /**
     * data descriptor signature
     *
     * @since 1.1
     */
    protected final static ZipLong DD_SIG = new ZipLong(0X08074B50L);
    /**
     * central file header signature
     *
     * @since 1.1
     */
    protected final static ZipLong CFH_SIG = new ZipLong(0X02014B50L);
    /**
     * end of central dir signature
     *
     * @since 1.1
     */
    protected final static ZipLong EOCD_SIG = new ZipLong(0X06054B50L);

    /**
     * Writes the local file header entry
     *
     * @since 1.1
     */
    protected void writeLocalFileHeader(ZipEntry ze) throws IOException {
        offsets.put(ze, new ZipLong(written));

        out.write(LFH_SIG.getBytes());
        written += 4;

        // version needed to extract
        // general purpose bit flag
        if (ze.getMethod() == DEFLATED) {
            // requires version 2 as we are going to store length info
            // in the data descriptor
            out.write((new ZipShort(20)).getBytes());

            // bit3 set to signal, we use a data descriptor
            out.write((new ZipShort(8)).getBytes());
        } else {
            out.write((new ZipShort(10)).getBytes());
            out.write(ZERO);
        }
        written += 4;

        // compression method
        out.write((new ZipShort(ze.getMethod())).getBytes());
        written += 2;

        // last mod. time and date
        out.write(toDosTime(new Date(ze.getTime())).getBytes());
        written += 4;

        // CRC
        // compressed length
        // uncompressed length
        if (ze.getMethod() == DEFLATED) {
            out.write(LZERO);
            out.write(LZERO);
            out.write(LZERO);
        } else {
            out.write((new ZipLong(ze.getCrc())).getBytes());
            out.write((new ZipLong(ze.getSize())).getBytes());
            out.write((new ZipLong(ze.getSize())).getBytes());
        }
        written += 12;
        
        // file name length
        byte[] name = getBytes(ze.getName());
        out.write((new ZipShort(name.length)).getBytes());
        written += 2;
        
        // extra field length
        byte[] extra = ze.getLocalFileDataExtra();
        out.write((new ZipShort(extra.length)).getBytes());
        written += 2;

        // file name
        out.write(name);
        written += name.length;

        // extra field
        out.write(extra);
        written += extra.length;

        dataStart = written;
    }

    /**
     * Writes the data descriptor entry
     *
     * @since 1.1
     */
    protected void writeDataDescriptor(ZipEntry ze) throws IOException {
        if (ze.getMethod() != DEFLATED) {
            return;
        }
        out.write(DD_SIG.getBytes());
        out.write((new ZipLong(entry.getCrc())).getBytes());
        out.write((new ZipLong(entry.getCompressedSize())).getBytes());
        out.write((new ZipLong(entry.getSize())).getBytes());
        written += 16;
    }

    /**
     * Writes the central file header entry
     *
     * @since 1.1
     */
    protected void writeCentralFileHeader(ZipEntry ze) throws IOException {
        out.write(CFH_SIG.getBytes());
        written += 4;

        // version made by
        out.write((new ZipShort(20)).getBytes());
        written += 2;

        // version needed to extract
        // general purpose bit flag
        if (ze.getMethod() == DEFLATED) {
            // requires version 2 as we are going to store length info
            // in the data descriptor
            out.write((new ZipShort(20)).getBytes());

            // bit3 set to signal, we use a data descriptor
            out.write((new ZipShort(8)).getBytes());
        } else {
            out.write((new ZipShort(10)).getBytes());
            out.write(ZERO);
        }
        written += 4;

        // compression method
        out.write((new ZipShort(ze.getMethod())).getBytes());
        written += 2;

        // last mod. time and date
        out.write(toDosTime(new Date(ze.getTime())).getBytes());
        written += 4;

        // CRC
        // compressed length
        // uncompressed length
        out.write((new ZipLong(ze.getCrc())).getBytes());
        out.write((new ZipLong(ze.getCompressedSize())).getBytes());
        out.write((new ZipLong(ze.getSize())).getBytes());
        written += 12;
        
        // file name length
        byte[] name = getBytes(ze.getName());
        out.write((new ZipShort(name.length)).getBytes());
        written += 2;
        
        // extra field length
        byte[] extra = ze.getCentralDirectoryExtra();
        out.write((new ZipShort(extra.length)).getBytes());
        written += 2;

        // file comment length
        String comm = ze.getComment();
        if (comm == null) {
            comm = "";
        }
        byte[] comment = getBytes(comm);
        out.write((new ZipShort(comment.length)).getBytes());
        written += 2;
        
        // disk number start
        out.write(ZERO);
        written += 2;

        // internal file attributes
        out.write((new ZipShort(ze.getInternalAttributes())).getBytes());
        written += 2;

        // external file attributes
        out.write((new ZipLong(ze.getExternalAttributes())).getBytes());
        written += 4;

        // relative offset of LFH
        out.write(((ZipLong) offsets.get(ze)).getBytes());
        written += 4;

        // file name
        out.write(name);
        written += name.length;

        // extra field
        out.write(extra);
        written += extra.length;

        // file comment
        out.write(comment);
        written += comment.length;
    }

    /**
     * Writes the &quot;End of central dir record&quot;
     *
     * @since 1.1
     */
    protected void writeCentralDirectoryEnd() throws IOException {
        out.write(EOCD_SIG.getBytes());
        
        // disk numbers
        out.write(ZERO);
        out.write(ZERO);

        // number of entries
        byte[] num = (new ZipShort(entries.size())).getBytes();
        out.write(num);
        out.write(num);

        // length and location of CD
        out.write(cdLength.getBytes());
        out.write(cdOffset.getBytes());

        // ZIP file comment
        byte[] data = getBytes(comment);
        out.write((new ZipShort(data.length)).getBytes());
        out.write(data);
    }

    /**
     * Smallest date/time ZIP can handle.
     *
     * @since 1.1
     */
    private final static ZipLong DOS_TIME_MIN = new ZipLong(0x00002100L);

    /**
     * Convert a Date object to a DOS date/time field.
     *
     * <p>Stolen from InfoZip's <code>fileio.c</code></p>
     *
     * @since 1.1
     */
    protected static ZipLong toDosTime(Date time) {
        int year = time.getYear() + 1900;
        int month = time.getMonth() + 1;
        if (year < 1980) {
            return DOS_TIME_MIN;
        }
        long value =  ((year - 1980) << 25)
            |         (month << 21)
            |         (time.getDate() << 16)
            |         (time.getHours() << 11)
            |         (time.getMinutes() << 5)
            |         (time.getSeconds() >> 1);
        
        byte[] result = new byte[4];
        result[0] = (byte) ((value & 0xFF));
        result[1] = (byte) ((value & 0xFF00) >> 8);
        result[2] = (byte) ((value & 0xFF0000) >> 16);
        result[3] = (byte) ((value & 0xFF000000l) >> 24);
        return new ZipLong(result);
    }

    /**
     * Retrieve the bytes for the given String in the encoding set for
     * this Stream.
     *
     * @since 1.3
     */
    protected byte[] getBytes(String name) throws ZipException {
        if (encoding == null) {
            return name.getBytes();
        } else {
            try {
                return name.getBytes(encoding);
            } catch (UnsupportedEncodingException uee) {
                throw new ZipException(uee.getMessage());
            }
        }
    }

}
