/*
 * 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
 *
 * 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 org.apache.sling.feature.cpconverter.handlers.slinginitialcontent;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.sling.feature.cpconverter.ContentPackage2FeatureModelConverter;
import org.apache.sling.jcr.contentloader.PathEntry;
import org.jetbrains.annotations.NotNull;

/**
 * Handles collecting the metadata for each sling initial content entry, to be used for extraction in another loop
 */
class SlingInitialContentBundleEntryMetaDataCollector {

    private static final double THRESHOLD_RATIO = 10;
    private static final int BUFFER = 512;
    private static final long TOOBIG = 0x6400000; // Max size of unzipped data, 100MB
    private static final String ZIP_ENTRY_SEPARATOR = "/";

    private final BundleSlingInitialContentExtractContext context;
    private final String basePath;
    private final ContentPackage2FeatureModelConverter contentPackage2FeatureModelConverter;
    private final Path newBundleFile;
    private final Set<SlingInitialContentBundleEntryMetaData> collectedSlingInitialContentBundleEntries = new HashSet<>();
    private final AtomicLong total = new AtomicLong(0);
    private final JarFile jarFile;

    SlingInitialContentBundleEntryMetaDataCollector(@NotNull BundleSlingInitialContentExtractContext context,
                                                    @NotNull ContentPackage2FeatureModelConverter contentPackage2FeatureModelConverter,
                                                    @NotNull Path newBundleFile) {
        this.context = context;
        this.basePath = contentPackage2FeatureModelConverter.getTempDirectory().getPath();
        this.contentPackage2FeatureModelConverter = contentPackage2FeatureModelConverter;
        this.newBundleFile = newBundleFile;
        this.jarFile = context.getJarFile();
    }

    /**
     * Collects all the MetaData from the context into a set
     *
     * @return
     * @throws IOException
     */
    @SuppressWarnings("java:S5042") // we already addressed this
    @NotNull
    Set<SlingInitialContentBundleEntryMetaData> collectFromContextAndWriteTmpFiles() throws IOException {

        final Manifest manifest = context.getManifest();

        // create JAR file to prevent extracting it twice and for random access
        try (OutputStream fileOutput = Files.newOutputStream(newBundleFile, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);
             JarOutputStream bundleOutput = new JarOutputStream(fileOutput, manifest)) {

            Enumeration<? extends JarEntry> entries = jarFile.entries();

            // first we collect all the entries into a set, collectedSlingInitialContentBundleEntries.
            // we need it up front to be perform various checks in another loop later.
            while (entries.hasMoreElements()) {
                JarEntry jarEntry = entries.nextElement();

                if (jarEntry.getName().equals(JarFile.MANIFEST_NAME)) {
                    continue;
                }

                if (!jarEntry.isDirectory()) {
                    extractFile(jarEntry, bundleOutput);
                }

                if (total.get() + BUFFER > TOOBIG) {
                    throw new IllegalStateException("Sling-Initial-Content: File content being unzipped is too big "
                            + "(>" +  FileUtils.byteCountToDisplaySize(TOOBIG) + "): " + context.getPath());
                }
            }
        }

        return collectedSlingInitialContentBundleEntries;
    }

    private void extractFile(JarEntry jarEntry, JarOutputStream bundleOutput) throws IOException {

        byte[] data = new byte[BUFFER];
        long compressedSize = jarEntry.getCompressedSize();

        try (InputStream input = new BufferedInputStream(jarFile.getInputStream(jarEntry))) {
            if (jarEntryIsSlingInitialContent(context, jarEntry)) {

                File targetFile = new File(contentPackage2FeatureModelConverter.getTempDirectory(), jarEntry.getName().replace('/', File.separatorChar));
                
                // Validate that the file is actually unpacking into the temp directory
                if (!targetFile.toPath().normalize().startsWith(contentPackage2FeatureModelConverter.getTempDirectory().toPath().normalize())) {
                    throw new IOException(String.format("unpacking %s (of %s) would write into the directory %s outside the specified "
                            + "temp path %s, thus terminating the operation",
                            jarEntry.getName(),
                            jarFile.getName(),
                            targetFile.toPath().normalize(),
                            contentPackage2FeatureModelConverter.getTempDirectory().getAbsolutePath()
                            ));
                }
                String canonicalDestinationPath = targetFile.getCanonicalPath();


                if (!checkIfPathStartsWithOrIsEqual(contentPackage2FeatureModelConverter.getTempDirectory().getCanonicalPath(), canonicalDestinationPath, File.separator)) {
                    throw new IOException("Entry is outside of the target directory " + canonicalDestinationPath);
                }

                targetFile.getParentFile().mkdirs();
                if (!targetFile.exists() && !targetFile.createNewFile()) {
                    throw new IOException("Could not create placeholder file!");
                }

                FileOutputStream fos = new FileOutputStream(targetFile);
                safelyWriteOutputStream(compressedSize, data, input, fos, true);

                SlingInitialContentBundleEntryMetaData bundleEntry = createSlingInitialContentBundleEntry(context, targetFile);
                collectedSlingInitialContentBundleEntries.add(bundleEntry);
            } else {
                //write 'normal' content out to the normal bundle output
                bundleOutput.putNextEntry(jarEntry);
                safelyWriteOutputStream(compressedSize, data, input, bundleOutput, false);
                IOUtils.copy(input, bundleOutput);
                bundleOutput.closeEntry();
            }
        }
    }

    private void safelyWriteOutputStream(long compressedSize,
                                         byte[] data,
                                         @NotNull InputStream input,
                                         @NotNull OutputStream fos,
                                         boolean shouldClose) throws IOException {
        int count;
        BufferedOutputStream dest = new BufferedOutputStream(fos, BUFFER);
        while (total.get() + BUFFER <= TOOBIG && (count = input.read(data, 0, BUFFER)) != -1) {
            dest.write(data, 0, count);
            total.addAndGet(count);

            double compressionRatio = (double) count / compressedSize;
            if (compressionRatio > THRESHOLD_RATIO) {
                // ratio between compressed and uncompressed data is highly suspicious, looks like a Zip Bomb Attack
                break;
            }
        }
        dest.flush();

        if (shouldClose) {
            dest.close();
        }

    }

    private boolean jarEntryIsSlingInitialContent(@NotNull BundleSlingInitialContentExtractContext context, @NotNull JarEntry jarEntry) {
        final String entryName = jarEntry.getName();
        return context.getPathEntryList().stream().anyMatch(
                pathEntry -> checkIfPathStartsWithOrIsEqual(pathEntry.getPath(), entryName, ZIP_ENTRY_SEPARATOR)
        );
    }

    @NotNull
    private SlingInitialContentBundleEntryMetaData createSlingInitialContentBundleEntry(@NotNull BundleSlingInitialContentExtractContext context,
                                                                                        @NotNull File sourceFile) throws UnsupportedEncodingException {
        final String entryName = StringUtils.replace(StringUtils.substringAfter(sourceFile.getPath(), basePath + File.separator), File.separator, ZIP_ENTRY_SEPARATOR);
        final PathEntry pathEntryValue = context.getPathEntryList().stream().filter(
                pathEntry -> checkIfPathStartsWithOrIsEqual(pathEntry.getPath(), entryName, ZIP_ENTRY_SEPARATOR)
        ).findFirst().orElseThrow(NullPointerException::new);
        final String target = pathEntryValue.getTarget();
        // https://sling.apache.org/documentation/bundles/content-loading-jcr-contentloader.html#file-name-escaping
        String repositoryPath = (target != null ? target : "/") + URLDecoder.decode(entryName.substring(pathEntryValue.getPath().length()), "UTF-8");
        return new SlingInitialContentBundleEntryMetaData(sourceFile, pathEntryValue, repositoryPath);
    }


    private static boolean checkIfPathStartsWithOrIsEqual(String pathA, String pathB, String fileSeparator) {
        String fixedPath = pathA;
        if (!fixedPath.endsWith(fileSeparator)) {
            fixedPath = pathA + fileSeparator;
        }
        return pathB.startsWith(fixedPath) || pathB.equals(pathA);
    }
}
