package com.iqser.red.persistence.service.v1.external.api.impl.controller;

import static com.iqser.red.service.persistence.management.v1.processor.service.FeignExceptionHandler.processFeignException;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;

import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipFile;
import org.apache.commons.io.IOUtils;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import com.knecon.fforesight.keycloakcommons.security.KeycloakSecurity;
import com.iqser.red.service.pdftron.redaction.v1.api.model.ByteContentDocument;
import com.iqser.red.service.persistence.management.v1.processor.exception.BadRequestException;
import com.iqser.red.service.persistence.management.v1.processor.service.AccessControlService;
import com.iqser.red.service.persistence.management.v1.processor.service.ReanalysisService;
import com.iqser.red.service.persistence.management.v1.processor.service.UploadService;
import com.iqser.red.service.persistence.management.v1.processor.service.persistence.AuditPersistenceService;
import com.iqser.red.service.persistence.management.v1.processor.utils.FileUtils;
import com.iqser.red.service.persistence.service.v1.api.external.resource.UploadResource;
import com.iqser.red.service.persistence.service.v1.api.shared.model.AuditCategory;
import com.iqser.red.service.persistence.service.v1.api.shared.model.FileUploadResult;
import com.iqser.red.service.persistence.service.v1.api.shared.model.audit.AuditRequest;

import feign.FeignException;
import io.micrometer.core.annotation.Timed;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import lombok.experimental.FieldDefaults;
import lombok.extern.slf4j.Slf4j;

@RestController
@RequiredArgsConstructor
@Slf4j
public class UploadController implements UploadResource {

    private static final int THRESHOLD_ENTRIES = 10000;
    private static final int THRESHOLD_SIZE = 1000000000; // 1 GB
    private static final double THRESHOLD_RATIO = 10;

    private static final List<String> VALID_FILE_EXTENSIONS = List.of("pdf", "docx", "doc", "xls", "xlsx", "ppt", "pptx");

    private final UploadService uploadService;
    private final ReanalysisService reanalysisService;
    private final AccessControlService accessControlService;
    private final AuditPersistenceService auditPersistenceService;


    @Timed
    @Override
    public FileUploadResult upload(@RequestPart(name = "file") MultipartFile file,
                                   @PathVariable(DOSSIER_ID) String dossierId,
                                   @RequestParam(value = "keepManualRedactions", required = false, defaultValue = "false") boolean keepManualRedactions) {

        if (file.getOriginalFilename() == null) {
            throw new BadRequestException("Could not upload file, no filename provided.");
        }

        var extension = getExtension(file.getOriginalFilename());

        try {
            switch (extension) {
                case "zip":
                    return handleZip(dossierId, file.getBytes(), keepManualRedactions);
                case "csv":
                    return uploadService.importCsv(dossierId, file.getBytes());
                default:
                    if (VALID_FILE_EXTENSIONS.contains(extension)) {
                        return uploadService.processSingleFile(dossierId, file.getOriginalFilename(), file.getBytes(), keepManualRedactions);
                    } else {
                        throw new BadRequestException("Invalid file uploaded");
                    }
            }
        } catch (IOException e) {
            throw new BadRequestException(e.getMessage(), e);
        }

    }


    public void importRedactions(@RequestPart(name = "file") MultipartFile file,
                                 @PathVariable(DOSSIER_ID) String dossierId,
                                 @PathVariable(FILE_ID) String fileId,
                                 @RequestParam(value = "pageInclusionRequest", required = false) Set<Integer> pageInclusionRequest) {

        accessControlService.verifyFileIsNotApproved(dossierId, fileId);
        accessControlService.verifyUserIsReviewerOrApprover(dossierId, fileId);

        try {

            reanalysisService.importRedactions(ByteContentDocument.builder().dossierId(dossierId).fileId(fileId).document(file.getBytes()).pages(pageInclusionRequest).build());

            auditPersistenceService.audit(AuditRequest.builder()
                    .userId(KeycloakSecurity.getUserId())
                    .objectId(fileId)
                    .category(AuditCategory.DOCUMENT.name())
                    .message("Redactions were imported")
                    .details(Map.of("dossierId", dossierId))
                    .build());
        } catch (IOException e) {
            throw new BadRequestException(e.getMessage(), e);
        } catch (FeignException e) {
            throw processFeignException(e);
        }
    }


    private String getExtension(String fileName) {

        return fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase();
    }


    private FileUploadResult handleZip(String dossierId, byte[] fileContent, boolean keepManualRedactions) throws IOException {

        File tempFile = FileUtils.createTempFile(UUID.randomUUID().toString(), ".zip");
        try (var fileOutputStream = new FileOutputStream(tempFile)) {
            IOUtils.write(fileContent, fileOutputStream);
        }

        try {
            checkForSymlinks(tempFile);

            var zipData = unzip(tempFile, dossierId, keepManualRedactions);

            if (zipData.csvBytes != null) {
                try {
                    var importResult = uploadService.importCsv(dossierId, zipData.csvBytes);
                    zipData.fileUploadResult.getProcessedAttributes().addAll(importResult.getProcessedAttributes());
                    zipData.fileUploadResult.getProcessedFileIds().addAll(importResult.getProcessedFileIds());
                } catch (Exception e) {
                    log.debug("CSV file inside ZIP failed", e);
                    // TODO return un-processed files to client
                }
            }

            return zipData.fileUploadResult;

        } finally {
            boolean isDeleted = tempFile.delete();
            if (!isDeleted) {
                log.warn("tempFile could not be deleted");
            }
        }
    }


    private void checkForSymlinks(File tempFile) throws IOException {

        try (var fis = new FileInputStream(tempFile); var zipFile = new ZipFile(fis.getChannel())) {
            for (var entryEnum = zipFile.getEntries(); entryEnum.hasMoreElements(); ) {
                var ze = entryEnum.nextElement();
                if (ze.isUnixSymlink()) {
                    throw new BadRequestException("ZIP-files with symlinks are not allowed");
                }
            }
        }
    }


    private ZipData unzip(File tempFile, String dossierId, boolean keepManualRedactions) throws IOException {

        var zipData = new ZipData();

        try (var fis = new FileInputStream(tempFile); var zipFile = new ZipFile(fis.getChannel())) {

            for (var entryEnum = zipFile.getEntries(); entryEnum.hasMoreElements(); ) {
                var ze = entryEnum.nextElement();
                zipData.totalEntryArchive++;

                if (!ze.isDirectory()) {
                    processFileZipEntry(ze, zipFile, dossierId, keepManualRedactions, zipData);
                }
            }
        }
        return zipData;
    }


    private void processFileZipEntry(ZipArchiveEntry ze, ZipFile zipFile, String dossierId, boolean keepManualRedactions, ZipData zipData) throws IOException {

        var extension = getExtension(ze.getName());
        final String fileName;
        if (ze.getName().lastIndexOf("/") >= 0) {
            fileName = ze.getName().substring(ze.getName().lastIndexOf("/") + 1);
        } else {
            fileName = ze.getName();
        }

        if (fileName.startsWith(".")) {
            return;
        }

        var entryAsBytes = readCurrentZipEntry(ze, zipFile);
        zipData.totalSizeArchive += entryAsBytes.length;

        // 1. the uncompressed data size is too much for the application resource capacity
        // 2. too many entries in the archive can lead to inode exhaustion of the file-system
        if (zipData.totalSizeArchive > THRESHOLD_SIZE || zipData.totalEntryArchive > THRESHOLD_ENTRIES) {
            throw new BadRequestException("ZIP-Bomb detected.");
        }

        if ("csv".equals(extension)) {
            zipData.csvBytes = entryAsBytes;
        } else if (VALID_FILE_EXTENSIONS.contains(extension)) {
            try {
                var result = uploadService.processSingleFile(dossierId, fileName, entryAsBytes, keepManualRedactions);
                zipData.fileUploadResult.getFileIds().addAll(result.getFileIds());
            } catch (Exception e) {
                log.debug("PDF File inside ZIP failed", e);
                // TODO return un-processed files to client
            }
        }
    }


    private byte[] readCurrentZipEntry(ZipArchiveEntry ze, ZipFile zipFile) throws IOException {

        var bos = new ByteArrayOutputStream();

        try (var entryStream = zipFile.getInputStream(ze)) {
            var buffer = new byte[2048];
            var nBytes = 0;
            int totalSizeEntry = 0;

            while ((nBytes = entryStream.read(buffer)) > 0) {
                bos.write(buffer, 0, nBytes);
                totalSizeEntry += nBytes;

                double compressionRatio = (float) totalSizeEntry / ze.getCompressedSize();
                if (compressionRatio > THRESHOLD_RATIO) {
                    // ratio between compressed and uncompressed data is highly suspicious, looks like a Zip Bomb Attack
                    throw new BadRequestException("ZIP-Bomb detected.");
                }
            }
        }

        return bos.toByteArray();
    }


    @FieldDefaults(level = AccessLevel.PUBLIC)
    private static final class ZipData {

        byte[] csvBytes;
        int totalSizeArchive;
        int totalEntryArchive;
        FileUploadResult fileUploadResult = new FileUploadResult();

    }

}
