/*
 * This file is part of muCommander, http://www.mucommander.com
 *
 * muCommander is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 3 of the License, or
 * (at your option) any later version.
 *
 * muCommander is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */


package com.mucommander.job.impl;

import com.mucommander.commons.file.*;
import com.mucommander.commons.file.archive.AbstractArchiveFile;
import com.mucommander.commons.file.archive.AbstractRWArchiveFile;
import com.mucommander.commons.file.archive.ArchiveEntry;
import com.mucommander.commons.file.archive.ArchiveEntryIterator;
import com.mucommander.commons.file.util.FileSet;
import com.mucommander.commons.file.util.PathUtils;
import com.mucommander.desktop.ActionType;
import com.mucommander.job.FileJobAction;
import com.mucommander.job.FileJobState;
import com.mucommander.text.Translator;
import com.mucommander.ui.action.ActionManager;
import com.mucommander.ui.dialog.DialogAction;
import com.mucommander.ui.dialog.file.FileCollisionDialog;
import com.mucommander.ui.dialog.file.ProgressDialog;
import com.mucommander.ui.main.MainFrame;

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.util.List;

import javax.swing.tree.DefaultMutableTreeNode;


/**
 * This job unpacks a set of archive files to a base destination folder. Archive entries are extracted in their natural
 * order using {@link com.mucommander.commons.file.archive.AbstractArchiveFile#getEntryIterator()}, to traverse the archive only once
 * and achieve optimal performance.
 *
 * @author Maxence Bernard
 */
public class UnpackJob extends AbstractCopyJob {

    /**
     * Archive entries to be unpacked
     */
    protected List<ArchiveEntry> selectedEntries;

    /**
     * Depth of the folder in which the top entries are located. 0 is the highest depth (archive's root folder)
     */
    protected int baseArchiveDepth;


    /**
     * Creates a new UnpackJob without starting it.
     * <p>
     * The base destination folder will be created if it doesn't exist.
     * </p>
     *
     * @param progressDialog   dialog which shows this job's progress
     * @param mainFrame        mainFrame this job has been triggered by
     * @param files            files which are going to be unpacked
     * @param destFolder       destination folder where the files will be copied
     * @param fileExistsAction default action to be performed when a file already exists in the destination, see {@link com.mucommander.ui.dialog.file.FileCollisionDialog} for allowed values
     */
    public UnpackJob(ProgressDialog progressDialog, MainFrame mainFrame, FileSet files, AbstractFile destFolder, FileCollisionDialog.FileCollisionAction fileExistsAction) {
        super(progressDialog, mainFrame, files, destFolder, null, fileExistsAction);

        this.errorDialogTitle = Translator.get("unpack_dialog.error_title");
        this.baseArchiveDepth = 0;
    }

    /**
     * Creates a new UnpackJob without starting it.
     *
     * @param progressDialog   dialog which shows this job's progress
     * @param mainFrame        mainFrame this job has been triggered by
     * @param archiveFile      the archive file which is going to be unpacked
     * @param destFolder       destination folder where the files will be copied
     * @param newName          the new filename in the destination folder, if <code>null</code> the original filename will be used
     * @param fileExistsAction default action to be performed when a file already exists in the destination, see {@link com.mucommander.ui.dialog.file.FileCollisionDialog} for allowed values
     * @param selectedEntries  entries to be unpacked
     * @param baseArchiveDepth depth of the folder in which the top entries are located. 0 is the highest depth (archive's root folder)
     */
    public UnpackJob(ProgressDialog progressDialog, MainFrame mainFrame, AbstractArchiveFile archiveFile, int baseArchiveDepth, AbstractFile destFolder, String newName, FileCollisionDialog.FileCollisionAction fileExistsAction, List<ArchiveEntry> selectedEntries) {
        super(progressDialog, mainFrame, new FileSet(archiveFile.getParent(), archiveFile), destFolder, newName, fileExistsAction);

        this.errorDialogTitle = Translator.get("unpack_dialog.error_title");
        this.baseArchiveDepth = baseArchiveDepth;
        this.selectedEntries = selectedEntries;
    }


    ////////////////////////////////////
    // TransferFileJob implementation //
    ////////////////////////////////////

    @Override
    protected void jobStarted() {
        super.jobStarted();

        // Create the base destination folder if it doesn't exist yet
        if (!baseDestFolder.exists()) {
            // Loop for retry
            do {
                try {
                    baseDestFolder.mkdir();
                } catch (IOException e) {
                    // Unable to create folder
                    DialogAction ret = showErrorDialog(errorDialogTitle, Translator.get("cannot_create_folder", baseDestFolder.getName()));
                    // Retry loops
                    if (ret == FileJobAction.RETRY)
                        continue;
                    // Cancel or close dialog interrupts the job
                    interrupt();
                    // Skip continues
                }
                break;
            } while (true);
        }
    }

    /**
     * Unpacks the given archive file. If the file is a directory, its children will be processed recursively.
     * If the file is not an archive file nor a directory, it is not processed and <code>false</code> is returned.
     *
     * @param file          the file to unpack
     * @param recurseParams unused
     * @return <code>true</code> if the file has been processed successfully
     */
    @Override
    protected boolean processFile(AbstractFile file, Object recurseParams) {
        // Stop if interrupted
        if (getState() == FileJobState.INTERRUPTED)
            return false;

        // Destination folder
        AbstractFile destFolder = baseDestFolder;

        // If the file is a directory, process its children recursively
        if (file.isDirectory()) {
            do {    // Loop for retries
                try {
                    // List files inside archive file (can throw an IOException)
                    AbstractFile[] archiveFiles = getCurrentFile().ls();

                    // Recurse on zip's contents
                    for (int j = 0; j < archiveFiles.length && getState() != FileJobState.INTERRUPTED; j++) {
                        // Notify job that we're starting to process this file (needed for recursive calls to processFile)
                        nextFile(archiveFiles[j]);
                        // Recurse
                        processFile(archiveFiles[j], destFolder);
                    }
                    // Return true when complete
                    return true;
                } catch (IOException e) {
                    // File could not be uncompressed properly
                    DialogAction ret = showErrorDialog(errorDialogTitle, Translator.get("cannot_read_file", getCurrentFilename()));
                    // Retry loops
                    if (ret == FileJobAction.RETRY)
                        continue;
                    // cancel, skip or close dialog will simply return false
                    return false;
                }
            } while (true);
        }

        // Abort if the file is neither an archive file nor a directory
        if (!file.isArchive())
            return false;

        // 'Cast' the file as an archive file
        AbstractArchiveFile archiveFile = file.getAncestor(AbstractArchiveFile.class);

        String destSeparator = destFolder.getSeparator();

        // Unpack the archive, copying entries one by one, in the iterator's order
        try (ArchiveEntryIterator iterator = archiveFile.getEntryIterator()) {
            ArchiveEntry entry;
            while ((entry = iterator.nextEntry()) != null && getState() != FileJobState.INTERRUPTED) {
                String entryPath = entry.getPath();

                boolean processEntry = false;
                if (selectedEntries == null) {    // Entries are processed
                    processEntry = true;
                } else {                          // We need to determine if the entry should be processed or not
                    // Process this entry if the selectedEntries set contains this entry, or a parent of this entry
                    int nbSelectedEntries = selectedEntries.size();
                    for (int i = 0; i < nbSelectedEntries; i++) {
                        ArchiveEntry selectedEntry = selectedEntries.get(i);
                        // Note: paths of directory entries must end with '/', so this compares whether
                        // selectedEntry is a parent of the current entry.
                        if (selectedEntry.isDirectory()) {
                            if (entryPath.startsWith(selectedEntry.getPath())) {
                                processEntry = true;
                                break;
                                // Note: we can't remove selectedEntryPath from the set, we still need it
                            }
                        } else if (entryPath.equals(selectedEntry.getPath())) {
                            // If the (regular file) entry is in the set, remove it as we no longer need it (will speed up
                            // subsequent searches)
                            processEntry = true;
                            selectedEntries.remove(i);
                            break;
                        }
                    }
                }

                if (!processEntry)
                    continue;

                // Resolve the entry file
                AbstractFile entryFile = archiveFile.getArchiveEntryFile(entryPath);

                // Notify the job that we're starting to process this file
                nextFile(entryFile);

                // Figure out the destination file's path, relatively to the base destination folder
                String relDestPath = baseArchiveDepth == 0
                        ? entry.getPath()
                        : PathUtils.removeLeadingFragments(entry.getPath(), "/", baseArchiveDepth);

                if (newName != null)
                    relDestPath = newName + (PathUtils.getDepth(relDestPath, "/") <= 1 ? "" : "/" + PathUtils.removeLeadingFragments(relDestPath, "/", 1));

                if (!"/".equals(destSeparator))
                    relDestPath = relDestPath.replace("/", destSeparator);

                // Create destination AbstractFile instance
                AbstractFile destFile = destFolder.getChild(relDestPath);

                // Check for ZipSlip (see https://snyk.io/research/zip-slip-vulnerability)
                do {
                    if (destFolder.isParentOf(destFile))
                        break;

                    DialogAction ret = showErrorDialog(errorDialogTitle, Translator.get("unpack.entry_out_of_target_dir", destFile.getName()));
                    // Retry loops
                    if (ret == FileJobAction.RETRY)
                        continue;
                    // Cancel, skip or close dialog returns false
                    return false;
                } while (true);

                // Check if the file does not already exist in the destination
                destFile = checkForCollision(entryFile, destFolder, destFile, false);
                if (destFile == null) {
                    // A collision occurred and either the file was skipped, or the user cancelled the job
                    continue;
                }

                // It is noteworthy that the iterator returns entries in no particular order (consider it random).
                // For that reason, we cannot assume that the parent directory of an entry will be processed
                // before the entry itself.

                // If the entry is a directory ...
                if (entryFile.isDirectory()) {
                    // Create the directory in the destination, if it doesn't already exist
                    if (!(destFile.exists() && destFile.isDirectory())) {
                        // Loop for retry
                        do {
                            try {
                                // Use mkdirs() instead of mkdir() to create any parent folder that doesn't exist yet
                                destFile.mkdirs();
                            } catch (IOException e) {
                                // Unable to create folder
                                DialogAction ret = showErrorDialog(errorDialogTitle, Translator.get("cannot_create_folder", entryFile.getName()));
                                // Retry loops
                                if (ret == FileJobAction.RETRY)
                                    continue;
                                // Cancel or close dialog return false
                                return false;
                                // Skip continues
                            }
                            break;
                        } while (true);
                    }
                }
                // The entry is a regular file, copy it
                else {
                    // Create the file's parent directory(s) if it doesn't already exist
                    AbstractFile destParentFile = destFile.getParent();
                    if (!destParentFile.exists()) {
                        // Use mkdirs() instead of mkdir() to create any parent folder that doesn't exist yet
                        destParentFile.mkdirs();
                    }

                    if (entry.isSymlink()) {
                        Files.createSymbolicLink(
                                FileSystems.getDefault().getPath(destFile.getAbsolutePath()),
                                FileSystems.getDefault().getPath(entry.getLinkTarget()));
                        continue;
                    }

                    // The entry is wrapped in a ProxyFile to override #getInputStream() and delegate it to
                    // ArchiveFile#getEntryInputStream in order to take advantage of the ArchiveEntryIterator, which for
                    // some archive file implementations (such as TAR) can speed things by an order of magnitude.
                    if (!tryCopyFile(new ProxiedEntryFile(entryFile, entry, archiveFile, iterator), destFile, append, errorDialogTitle))
                        return false;
                }
            }

            return true;
        } catch (IOException e) {
            showErrorDialog(errorDialogTitle, Translator.get("cannot_read_file", archiveFile.getName()));
        }

        return false;
    }

    // This job modifies the base destination folder and its subfolders
    @Override
    protected boolean hasFolderChangedImpl(AbstractFile folder) {
        return baseDestFolder.isParentOf(folder);
    }


    ////////////////////////
    // Overridden methods //
    ////////////////////////

    @Override
    protected void jobCompleted() {
        super.jobCompleted();

        // If the destination files are located inside an archive, optimize the archive file
        AbstractArchiveFile archiveFile = baseDestFolder.getParentArchive();
        if (archiveFile != null && archiveFile.isArchive() && archiveFile.isWritable())
            optimizeArchive((AbstractRWArchiveFile) archiveFile);

        // Unselect all files in the active table upon successful completion
        if (selectedEntries != null) {
            ActionManager.performAction(ActionType.UnmarkAll, getMainFrame());
        }
    }

    @Override
    public String getStatusString() {
        if (isCheckingIntegrity())
            return super.getStatusString();

        if (isOptimizingArchive)
            return Translator.get("optimizing_archive", archiveToOptimize.getName());

        return Translator.get("unpack_dialog.unpacking_file", getCurrentFilename());
    }


    ///////////////////
    // Inner classes //
    ///////////////////

    private static class ProxiedEntryFile extends ProxyFile {

        private ArchiveEntry entry;
        private AbstractArchiveFile archiveFile;
        private ArchiveEntryIterator iterator;

        public ProxiedEntryFile(AbstractFile entryFile, ArchiveEntry entry, AbstractArchiveFile archiveFile, ArchiveEntryIterator iterator) {
            super(entryFile);

            this.entry = entry;
            this.archiveFile = archiveFile;
            this.iterator = iterator;
        }

        @Override
        public InputStream getInputStream() throws IOException {
            return archiveFile.getEntryInputStream(entry, iterator);
        }
    }
}
