/*
 * Copyright 2004-2023 H2 Group. Multiple-Licensed under the MPL 2.0,
 * and the EPL 1.0 (https://h2database.com/html/license.html).
 * Initial Developer: H2 Group
 */
package org.h2.tools;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.zip.DeflaterOutputStream;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
import java.util.zip.InflaterInputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;

import org.h2.api.ErrorCode;
import org.h2.compress.CompressDeflate;
import org.h2.compress.CompressLZF;
import org.h2.compress.CompressNo;
import org.h2.compress.Compressor;
import org.h2.compress.LZFInputStream;
import org.h2.compress.LZFOutputStream;
import org.h2.engine.Constants;
import org.h2.message.DbException;
import org.h2.util.Bits;
import org.h2.util.StringUtils;
import org.h2.util.Utils;

/**
 * A tool to losslessly compress data, and expand the compressed data again.
 */
public class CompressTool {

    private static final int MAX_BUFFER_SIZE = 3 * Constants.IO_BUFFER_SIZE_COMPRESS;

    private byte[] buffer;

    private CompressTool() {
        // don't allow construction
    }

    private byte[] getBuffer(int min) {
        if (min > MAX_BUFFER_SIZE) {
            return Utils.newBytes(min);
        }
        if (buffer == null || buffer.length < min) {
            buffer = Utils.newBytes(min);
        }
        return buffer;
    }

    /**
     * Get a new instance. Each instance uses a separate buffer, so multiple
     * instances can be used concurrently. However each instance alone is not
     * multithreading safe.
     *
     * @return a new instance
     */
    public static CompressTool getInstance() {
        return new CompressTool();
    }

    /**
     * Compressed the data using the specified algorithm. If no algorithm is
     * supplied, LZF is used
     *
     * @param in the byte array with the original data
     * @param algorithm the algorithm (LZF, DEFLATE)
     * @return the compressed data
     */
    public byte[] compress(byte[] in, String algorithm) {
        int len = in.length;
        if (in.length < 5) {
            algorithm = "NO";
        }
        Compressor compress = getCompressor(algorithm);
        byte[] buff = getBuffer((len < 100 ? len + 100 : len) * 2);
        int newLen = compress(in, in.length, compress, buff);
        return Utils.copyBytes(buff, newLen);
    }

    private static int compress(byte[] in, int len, Compressor compress,
            byte[] out) {
        out[0] = (byte) compress.getAlgorithm();
        int start = 1 + writeVariableInt(out, 1, len);
        int newLen = compress.compress(in, 0, len, out, start);
        if (newLen > len + start || newLen <= 0) {
            out[0] = Compressor.NO;
            System.arraycopy(in, 0, out, start, len);
            newLen = len + start;
        }
        return newLen;
    }

    /**
     * Expands the compressed  data.
     *
     * @param in the byte array with the compressed data
     * @return the uncompressed data
     */
    public byte[] expand(byte[] in) {
        if (in.length == 0) {
            throw DbException.get(ErrorCode.COMPRESSION_ERROR);
        }
        int algorithm = in[0];
        Compressor compress = getCompressor(algorithm);
        try {
            int len = readVariableInt(in, 1);
            int start = 1 + getVariableIntLength(len);
            byte[] buff = Utils.newBytes(len);
            compress.expand(in, start, in.length - start, buff, 0, len);
            return buff;
        } catch (Exception e) {
            throw DbException.get(ErrorCode.COMPRESSION_ERROR, e);
        }
    }

    /**
     * INTERNAL
     * @param in compressed data
     * @param out uncompressed result
     * @param outPos the offset at the output array
     */
    public static void expand(byte[] in, byte[] out, int outPos) {
        int algorithm = in[0];
        Compressor compress = getCompressor(algorithm);
        try {
            int len = readVariableInt(in, 1);
            int start = 1 + getVariableIntLength(len);
            compress.expand(in, start, in.length - start, out, outPos, len);
        } catch (Exception e) {
            throw DbException.get(ErrorCode.COMPRESSION_ERROR, e);
        }
    }

    /**
     * Read a variable size integer using Rice coding.
     *
     * @param buff the buffer
     * @param pos the position
     * @return the integer
     */
    public static int readVariableInt(byte[] buff, int pos) {
        int x = buff[pos++] & 0xff;
        if (x < 0x80) {
            return x;
        }
        if (x < 0xc0) {
            return ((x & 0x3f) << 8) + (buff[pos] & 0xff);
        }
        if (x < 0xe0) {
            return ((x & 0x1f) << 16) +
                    ((buff[pos++] & 0xff) << 8) +
                    (buff[pos] & 0xff);
        }
        if (x < 0xf0) {
            return ((x & 0xf) << 24) +
                    ((buff[pos++] & 0xff) << 16) +
                    ((buff[pos++] & 0xff) << 8) +
                    (buff[pos] & 0xff);
        }
        return Bits.readInt(buff, pos);
    }

    /**
     * Write a variable size integer using Rice coding.
     * Negative values need 5 bytes.
     *
     * @param buff the buffer
     * @param pos the position
     * @param x the value
     * @return the number of bytes written (0-5)
     */
    public static int writeVariableInt(byte[] buff, int pos, int x) {
        if (x < 0) {
            buff[pos++] = (byte) 0xf0;
            Bits.writeInt(buff, pos, x);
            return 5;
        } else if (x < 0x80) {
            buff[pos] = (byte) x;
            return 1;
        } else if (x < 0x4000) {
            buff[pos++] = (byte) (0x80 | (x >> 8));
            buff[pos] = (byte) x;
            return 2;
        } else if (x < 0x20_0000) {
            buff[pos++] = (byte) (0xc0 | (x >> 16));
            buff[pos++] = (byte) (x >> 8);
            buff[pos] = (byte) x;
            return 3;
        } else if (x < 0x1000_0000) {
            Bits.writeInt(buff, pos, x | 0xe000_0000);
            return 4;
        } else {
            buff[pos++] = (byte) 0xf0;
            Bits.writeInt(buff, pos, x);
            return 5;
        }
    }

    /**
     * Get a variable size integer length using Rice coding.
     * Negative values need 5 bytes.
     *
     * @param x the value
     * @return the number of bytes needed (0-5)
     */
    public static int getVariableIntLength(int x) {
        if (x < 0) {
            return 5;
        } else if (x < 0x80) {
            return 1;
        } else if (x < 0x4000) {
            return 2;
        } else if (x < 0x20_0000) {
            return 3;
        } else if (x < 0x1000_0000) {
            return 4;
        } else {
            return 5;
        }
    }

    private static Compressor getCompressor(String algorithm) {
        if (algorithm == null) {
            algorithm = "LZF";
        }
        int idx = algorithm.indexOf(' ');
        String options = null;
        if (idx > 0) {
            options = algorithm.substring(idx + 1);
            algorithm = algorithm.substring(0, idx);
        }
        int a = getCompressAlgorithm(algorithm);
        Compressor compress = getCompressor(a);
        compress.setOptions(options);
        return compress;
    }

    /**
     * INTERNAL
     * @param algorithm to translate into index
     * @return index of the specified algorithm
     */
    private static int getCompressAlgorithm(String algorithm) {
        algorithm = StringUtils.toUpperEnglish(algorithm);
        if ("NO".equals(algorithm)) {
            return Compressor.NO;
        } else if ("LZF".equals(algorithm)) {
            return Compressor.LZF;
        } else if ("DEFLATE".equals(algorithm)) {
            return Compressor.DEFLATE;
        } else {
            throw DbException.get(
                    ErrorCode.UNSUPPORTED_COMPRESSION_ALGORITHM_1,
                    algorithm);
        }
    }

    private static Compressor getCompressor(int algorithm) {
        switch (algorithm) {
        case Compressor.NO:
            return new CompressNo();
        case Compressor.LZF:
            return new CompressLZF();
        case Compressor.DEFLATE:
            return new CompressDeflate();
        default:
            throw DbException.get(
                    ErrorCode.UNSUPPORTED_COMPRESSION_ALGORITHM_1,
                    Integer.toString(algorithm));
        }
    }

    /**
     * INTERNAL
     * @param out stream
     * @param compressionAlgorithm to be used
     * @param entryName in a zip file
     * @return compressed stream
     */
    public static OutputStream wrapOutputStream(OutputStream out,
            String compressionAlgorithm, String entryName) {
        try {
            if ("GZIP".equals(compressionAlgorithm)) {
                out = new GZIPOutputStream(out);
            } else if ("ZIP".equals(compressionAlgorithm)) {
                ZipOutputStream z = new ZipOutputStream(out);
                z.putNextEntry(new ZipEntry(entryName));
                out = z;
            } else if ("DEFLATE".equals(compressionAlgorithm)) {
                out = new DeflaterOutputStream(out);
            } else if ("LZF".equals(compressionAlgorithm)) {
                out = new LZFOutputStream(out);
            } else if (compressionAlgorithm != null) {
                throw DbException.get(
                        ErrorCode.UNSUPPORTED_COMPRESSION_ALGORITHM_1,
                        compressionAlgorithm);
            }
            return out;
        } catch (IOException e) {
            throw DbException.convertIOException(e, null);
        }
    }

    /**
     * INTERNAL
     * @param in stream
     * @param compressionAlgorithm to be used
     * @param entryName in a zip file
     * @return in stream or null if there is no such entry
     */
    public static InputStream wrapInputStream(InputStream in,
            String compressionAlgorithm, String entryName) {
        try {
            if ("GZIP".equals(compressionAlgorithm)) {
                in = new GZIPInputStream(in);
            } else if ("ZIP".equals(compressionAlgorithm)) {
                ZipInputStream z = new ZipInputStream(in);
                while (true) {
                    ZipEntry entry = z.getNextEntry();
                    if (entry == null) {
                        return null;
                    }
                    if (entryName.equals(entry.getName())) {
                        break;
                    }
                }
                in = z;
            } else if ("DEFLATE".equals(compressionAlgorithm)) {
                in = new InflaterInputStream(in);
            } else if ("LZF".equals(compressionAlgorithm)) {
                in = new LZFInputStream(in);
            } else if (compressionAlgorithm != null) {
                throw DbException.get(
                        ErrorCode.UNSUPPORTED_COMPRESSION_ALGORITHM_1,
                        compressionAlgorithm);
            }
            return in;
        } catch (IOException e) {
            throw DbException.convertIOException(e, null);
        }
    }

}

