/*
 * Copyright (C) 2020 The Android Open Source Project
 *
 * 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.android.zipflinger;

import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.Locale;
import java.util.zip.CRC32;
import java.util.zip.CheckedInputStream;
import java.util.zip.Deflater;
import java.util.zip.DeflaterOutputStream;

public class LargeFileSource extends Source {

    private final Path transferSrc;
    private final int compressionLevel;

    private static final String TMP_DIR = System.getProperty("java.io.tmpdir");

    // Does not load the whole file in memory. If the entry is not compressed, only read it to
    // compute the CRC32 and zero-copy src when needed. If compression is requested, uses a tmp
    // storage to store the deflated payload then zero-copy it when needed.
    public LargeFileSource(
            @NonNull Path src,
            @Nullable Path tmpStorage,
            @NonNull String name,
            int compressionLevel)
            throws IOException {
        super(name);
        this.compressionLevel = compressionLevel;

        if (tmpStorage == null && compressionLevel != Deflater.NO_COMPRESSION) {
            String msg = "Compression without a provided tmp Path is not supported";
            throw new IllegalStateException(msg);
        }

        try (CheckedInputStream in =
                new CheckedInputStream(Files.newInputStream(src), new CRC32())) {
            if (compressionLevel == Deflater.NO_COMPRESSION) {
                buildStored(in);
                transferSrc = src;
            } else {
                buildCompressed(in, compressionLevel, tmpStorage);
                transferSrc = tmpStorage;
            }
            // At this point the input file has been completely read. We can request the crc32.
            crc = Ints.longToUint(in.getChecksum().getValue());
        }
    }

    public LargeFileSource(@NonNull Path src, @NonNull String name, int compressionLevel)
            throws IOException {
        this(src, getTmpStoragePath(src.getFileName().toString()), name, compressionLevel);
    }

    @NonNull
    public static Path getTmpStoragePath(@NonNull String entryName) {
        StringBuilder filename = new StringBuilder();
        filename.append(Integer.toHexString(entryName.hashCode()));
        filename.append("-");
        filename.append(Thread.currentThread().getId());
        filename.append("-");
        filename.append(System.nanoTime());
        filename.append(".tmp");

        Path tmp = Paths.get(TMP_DIR, filename.toString());
        if (Files.exists(tmp)) {
            String msg = String.format(Locale.US, "Cannot use path '%s' (exists)", tmp);
            throw new IllegalStateException(msg);
        }
        return tmp;
    }

    private void buildStored(@NonNull InputStream in) throws IOException {
        byte[] buffer = new byte[4096];
        long inputSize = 0;
        int read;
        while ((read = in.read(buffer)) != -1) {
            inputSize += read;
        }
        compressedSize = inputSize;
        uncompressedSize = compressedSize;
        compressionFlag = LocalFileHeader.COMPRESSION_NONE;
    }

    private void buildCompressed(@NonNull InputStream in, int compressionLevel, @NonNull Path tmp)
            throws IOException {
        // Make sure we are not going to overwrite another tmp file.
        if (Files.exists(tmp)) {
            String msg = String.format("Tmp storage '%s' already exists", tmp.toAbsolutePath());
            throw new IllegalStateException(msg);
        }

        // Pipe the src into the tmp compressed file.
        Deflater deflater = Compressor.getDeflater(compressionLevel);
        try (DeflaterOutputStream out =
                new DeflaterOutputStream(
                        Files.newOutputStream(tmp, StandardOpenOption.CREATE_NEW), deflater)) {

            // Just in case we crash before writeTo is called, attempt to clean up on VM exit.
            tmp.toFile().deleteOnExit();

            int read;
            byte[] buffer = new byte[4096];
            while ((read = in.read(buffer)) != -1) {
                out.write(buffer, 0, read);
            }
        }
        compressedSize = deflater.getBytesWritten();
        uncompressedSize = deflater.getBytesRead();
        compressionFlag = LocalFileHeader.COMPRESSION_DEFLATE;
    }

    @Override
    public void prepare() throws IOException {}

    @Override
    public long writeTo(@NonNull ZipWriter writer) throws IOException {
        try (FileChannel src = FileChannel.open(transferSrc, StandardOpenOption.READ)) {
            writer.transferFrom(src, 0, this.compressedSize);
            return this.compressedSize;
        } finally {
            if (compressionLevel != Deflater.NO_COMPRESSION) {
                Files.delete(transferSrc);
            }
        }
    }
}
