/*
 * 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
 *
 *   https://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.commons.compress.compressors.gzip;

import java.io.IOException;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.Charset;
import java.util.zip.CRC32;
import java.util.zip.Deflater;
import java.util.zip.GZIPOutputStream;

import org.apache.commons.compress.compressors.CompressorOutputStream;

/**
 * Compressed output stream using the gzip format. This implementation improves over the standard {@link GZIPOutputStream} class by allowing the configuration
 * of the compression level and the header metadata (file name, comment, modification time, operating system and extra flags).
 *
 * @see <a href="https://datatracker.ietf.org/doc/html/rfc1952">RFC 1952 GZIP File Format Specification</a>
 */
public class GzipCompressorOutputStream extends CompressorOutputStream<OutputStream> {

    /** Deflater used to compress the data */
    private final Deflater deflater;

    /** The buffer receiving the compressed data from the deflater */
    private final byte[] deflateBuffer;

    /** The checksum of the uncompressed data */
    private final CRC32 crc = new CRC32();

    /**
     * Creates a gzip compressed output stream with the default parameters.
     *
     * @param out the stream to compress to
     * @throws IOException if writing fails
     */
    public GzipCompressorOutputStream(final OutputStream out) throws IOException {
        this(out, new GzipParameters());
    }

    /**
     * Creates a gzip compressed output stream with the specified parameters.
     *
     * @param out        the stream to compress to
     * @param parameters the parameters to use
     * @throws IOException if writing fails
     * @since 1.7
     */
    public GzipCompressorOutputStream(final OutputStream out, final GzipParameters parameters) throws IOException {
        super(out);
        this.deflater = new Deflater(parameters.getCompressionLevel(), true);
        this.deflater.setStrategy(parameters.getDeflateStrategy());
        this.deflateBuffer = new byte[parameters.getBufferSize()];
        writeMemberHeader(parameters);
    }

    @Override
    public void close() throws IOException {
        if (!isClosed()) {
            try {
                finish();
            } finally {
                deflater.end();
                super.close();
            }
        }
    }

    private void deflate() throws IOException {
        final int length = deflater.deflate(deflateBuffer, 0, deflateBuffer.length);
        if (length > 0) {
            out.write(deflateBuffer, 0, length);
        }
    }

    /**
     * Finishes writing compressed data to the underlying stream without closing it.
     *
     * @throws IOException on error
     * @since 1.7
     */
    @Override
    public void finish() throws IOException {
        if (!deflater.finished()) {
            deflater.finish();
            while (!deflater.finished()) {
                deflate();
            }
            writeMemberTrailer();
            deflater.reset();
        }
    }

    /**
     * {@inheritDoc}
     *
     * @since 1.1
     */
    @Override
    public void write(final byte[] buffer) throws IOException {
        write(buffer, 0, buffer.length);
    }

    /**
     * {@inheritDoc}
     *
     * @since 1.1
     */
    @Override
    public void write(final byte[] buffer, final int offset, final int length) throws IOException {
        checkOpen();
        if (deflater.finished()) {
            throw new IOException("Cannot write more data, the end of the compressed data stream has been reached.");
        }
        if (length > 0) {
            deflater.setInput(buffer, offset, length);
            while (!deflater.needsInput()) {
                deflate();
            }
            crc.update(buffer, offset, length);
        }
    }

    @Override
    public void write(final int b) throws IOException {
        write(new byte[] { (byte) (b & 0xff) }, 0, 1);
    }

    /**
     * Writes a C-style string, a NUL-terminated string, encoded with the {@code charset}.
     *
     * @param value The String to write.
     * @param charset Specifies the Charset to use.
     * @throws IOException if an I/O error occurs.
     */
    private void writeC(final String value, final Charset charset) throws IOException {
        if (value != null) {
            final byte[] ba = value.getBytes(charset);
            out.write(ba);
            out.write(0);
            crc.update(ba);
            crc.update(0);
        }
    }

    private void writeMemberHeader(final GzipParameters parameters) throws IOException {
        final String fileName = parameters.getFileName();
        final String comment = parameters.getComment();
        final byte[] extra = parameters.getExtraField() != null ? parameters.getExtraField().toByteArray() : null;
        final ByteBuffer buffer = ByteBuffer.allocate(10);
        buffer.order(ByteOrder.LITTLE_ENDIAN);
        buffer.put((byte) GzipUtils.ID1);
        buffer.put((byte) GzipUtils.ID2);
        buffer.put((byte) Deflater.DEFLATED); // compression method (8: deflate)
        buffer.put((byte) ((extra != null ? GzipUtils.FEXTRA : 0)
                | (fileName != null ? GzipUtils.FNAME : 0)
                | (comment != null ? GzipUtils.FCOMMENT : 0)
                | (parameters.getHeaderCRC() ? GzipUtils.FHCRC : 0)
        )); // flags
        buffer.putInt((int) parameters.getModificationInstant().getEpochSecond());
        // extra flags
        final int compressionLevel = parameters.getCompressionLevel();
        if (compressionLevel == Deflater.BEST_COMPRESSION) {
            buffer.put(GzipUtils.XFL_MAX_COMPRESSION);
        } else if (compressionLevel == Deflater.BEST_SPEED) {
            buffer.put(GzipUtils.XFL_MAX_SPEED);
        } else {
            buffer.put(GzipUtils.XFL_UNKNOWN);
        }
        buffer.put((byte) parameters.getOperatingSystem());
        out.write(buffer.array());
        crc.update(buffer.array());
        if (extra != null) {
            out.write(extra.length & 0xff); // little endian
            out.write(extra.length >>> 8 & 0xff);
            out.write(extra);
            crc.update(extra.length & 0xff);
            crc.update(extra.length >>> 8 & 0xff);
            crc.update(extra);
        }
        writeC(fileName, parameters.getFileNameCharset());
        writeC(comment, parameters.getFileNameCharset());
        if (parameters.getHeaderCRC()) {
            final int v = (int) crc.getValue() & 0xffff;
            out.write(v & 0xff);
            out.write(v >>> 8 & 0xff);
        }
        crc.reset();
    }

    /**
     * Writes the member trailer.
     * <pre>
     *      0   1   2   3   4   5   6   7
     *   +---+---+---+---+---+---+---+---+
     *   |     CRC32     |     ISIZE     |
     *   +---+---+---+---+---+---+---+---+
     * </pre>
     *
     * @throws IOException if an I/O error occurs.
     */
    private void writeMemberTrailer() throws IOException {
        final ByteBuffer buffer = ByteBuffer.allocate(8);
        buffer.order(ByteOrder.LITTLE_ENDIAN);
        buffer.putInt((int) crc.getValue());
        buffer.putInt(deflater.getTotalIn());
        out.write(buffer.array());
    }

}
