/**
 * 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 Lesser 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 Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */


package com.mucommander.commons.file.archive;

import com.mucommander.commons.file.AbstractFile;
import com.mucommander.commons.file.FileFactory;
import com.mucommander.commons.file.FileOperation;
import com.mucommander.commons.file.FileURL;
import com.mucommander.commons.file.ModificationDateBasedMonitoredFile;
import com.mucommander.commons.file.MonitoredFile;
import com.mucommander.commons.file.ProxyFile;
import com.mucommander.commons.file.UnsupportedFileOperationException;
import com.mucommander.commons.file.filter.FileFilter;
import com.mucommander.commons.file.filter.FilenameFilter;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.swing.tree.DefaultMutableTreeNode;
import java.io.IOException;
import java.io.InputStream;
import java.util.Vector;
import java.util.WeakHashMap;

/**
 * <code>AbstractArchiveFile</code> is the superclass of all archive files. It allows archive file to be browsed as if
 * they were regular directories, independently of the underlying protocol used to access the actual file.
 * <p>
 * <code>AbstractArchiveFile</code> extends {@link ProxyFile} to delegate the <code>AbstractFile</code>
 * implementation to the actual archive file and overrides some methods to provide the added functionality.<br>
 * There are two kinds of <code>AbstractArchiveFile</code>, both of which extend this class:
 * <ul>
 *  <li>{@link AbstractROArchiveFile}: read-only archives, these are only able to perform read operations such as
 * listing the archive's contents or retrieving a particular entry's contents.
 *  <li>{@link AbstractRWArchiveFile}: read-write archives, these are also able to modify the archive by adding or
 * deleting an entry from the archive. These operations usually require random access to the underlying file,
 * so write operations may not be available on all underlying file types. The {@link #isWritable()} method allows
 * to determine whether the archive file is able to carry out write operations or not.
 * </ul>
 * When implementing a new archive file/format, either <code>AbstractROArchiveFile</code> or <code>AbstractRWArchiveFile</code>
 * should be subclassed, but not this class.
 * </p>
 *
 * <p>The first time one of the <code>ls()</code> methods is called to list the archive's contents,
 * {@link #getEntryIterator()} is called to retrieve a list of *all* the entries contained by the archive, not only the
 * ones at the top level but also the ones nested one of several levels below. Using this list of entries, it creates
 * a tree to map the structure of the archive and list the content of any particular directory within the archive.
 * This tree is recreated (<code>getEntryIterator()</code> is called again) only if the archive file has changed, i.e.
 * if its date has changed since the tree was created.</p>
 *
 * <p>Files returned by the <code>ls()</code> are {@link AbstractArchiveEntryFile} instances which use an {@link ArchiveEntry}
 * object to retrieve the entry's attributes. In turn, these <code>AbstractArchiveEntryFile</code> instances query the
 * associated <code>AbstractArchiveFile</code> to list their content.
 * <br>From an implementation perspective, one only needs to deal with {@link ArchiveEntry} instances, all the nuts
 * and bolts are taken care of by this class.</p>
 *
 * <p>Note that an instance of <code>AbstractArchiveFile</code> may or may not actually be an archive:
 * {@link #isArchive()} returns <code>true</code> only if the file currently exists and is not a directory. The value
 * returned by {@link #isArchive()} may change over time as the file is modified. When an
 * <code>AbstractArchiveFile</code> is not currently an archive, it acts just as a 'normal' file and delegates
 * <code>ls()</code> methods to the underlying {@link AbstractFile}</p>
 *
 * @see com.mucommander.commons.file.FileFactory
 * @see com.mucommander.commons.file.archive.ArchiveFormatProvider
 * @see com.mucommander.commons.file.archive.ArchiveEntry
 * @see AbstractArchiveEntryFile
 * @see com.mucommander.commons.file.archiver.Archiver
 * @author Maxence Bernard
 */
public abstract class AbstractArchiveFile extends ProxyFile {
    private static final Logger LOGGER = LoggerFactory.getLogger(AbstractArchiveFile.class);

    /** Archive entries tree */
    protected ArchiveEntryTree entryTreeRoot;

    /** Date this file had when the entries tree was created. Used to detect if the archive file has changed and entries
     * need to be reloaded */
    protected long entryTreeDate;

    /** Caches {@link AbstractArchiveEntryFile} instances so that there is only one AbstractArchiveEntryFile
     * corresponding to the same entry at any given time, to avoid attribute inconsistencies. The key is the
     * corresponding ArchiveEntry. */
    protected WeakHashMap<ArchiveEntry, AbstractArchiveEntryFile> archiveEntryFiles;

    /**
     * Creates an AbstractArchiveFile on top of the given file.
     *
     * @param file the file on top of which to create the archive
     */
    protected AbstractArchiveFile(AbstractFile file) {
        super(file);
    }

    /**
     * Creates the entries tree, used by {@link #ls(AbstractArchiveEntryFile , com.mucommander.commons.file.filter.FilenameFilter, com.mucommander.commons.file.filter.FileFilter)}
     * to quickly list the contents of an archive's subfolder.
     *
     * @throws IOException if an error occured while retrieving this archive's entries
     * @throws UnsupportedFileOperationException if {@link FileOperation#READ_FILE} operations are not supported by the
     * underlying file protocol.
     */
    protected void createEntriesTree() throws IOException, UnsupportedFileOperationException {
        // TODO: this method is not thread-safe and needs to be synchronized
        ArchiveEntryTree treeRoot = new ArchiveEntryTree();
        archiveEntryFiles = new WeakHashMap<ArchiveEntry, AbstractArchiveEntryFile>();

        long start = System.currentTimeMillis();
        try(ArchiveEntryIterator entries = getEntryIterator()) {
            ArchiveEntry entry;
            while((entry=entries.nextEntry())!=null)
                treeRoot.addArchiveEntry(entry);

            LOGGER.info("entries tree created in "+(System.currentTimeMillis()-start)+" ms");

            this.entryTreeRoot = treeRoot;
            declareEntriesTreeUpToDate();
        }
    }

    /**
     * Checks if the entries tree exists and if this file hasn't been modified since the tree was last created.
     * If any of those 2 conditions isn't met, the entries tree is (re)created.
     *
     * @throws IOException if an error occurred while creating the tree
     * @throws UnsupportedFileOperationException if {@link FileOperation#READ_FILE} operations are not supported by the
     * underlying file protocol.
     */
    protected void checkEntriesTree() throws IOException, UnsupportedFileOperationException {
        if(this.entryTreeRoot==null || getDate()!=this.entryTreeDate)
            createEntriesTree();
    }

    /**
     * Declares the entries tree up-to-date by setting the current tree date to the archive file's.
     * This method should be called by {@link AbstractRWArchiveFile} implementations when the archive file has been
     * modified and the entries propagated in the tree, to avoid the tree from being automatically re-created when
     * {@link #checkEntriesTree()} is called.
     */
    protected void declareEntriesTreeUpToDate() {
        this.entryTreeDate = getDate();
    }

    /**
     * Adds the given {@link ArchiveEntry} to the entries tree. This method will create the tree if it doesn't already
     * exist, or re-create it if the archive file has changed since it was last created.
     *
     * @param entry the ArchiveEntry to add to the tree
     * @throws IOException if an error occurred while creating the entries tree
     * @throws UnsupportedFileOperationException if {@link FileOperation#READ_FILE} operations are not supported by the
     * underlying file protocol.
     */
    protected void addToEntriesTree(ArchiveEntry entry) throws IOException, UnsupportedFileOperationException {
        checkEntriesTree();
        entryTreeRoot.addArchiveEntry(entry);
    }

    /**
     * Removes the given {@link ArchiveEntry} from the entries tree. This method will create the tree if it doesn't
     * already exist, or re-create it if the archive file has changed since it was last created.
     *
     * @param entry the ArchiveEntry to remove from the tree
     * @throws IOException if an error occurred while creating the entries tree
     * @throws UnsupportedFileOperationException if {@link FileOperation#READ_FILE} operations are not supported by the
     * underlying file protocol.
     */
    protected void removeFromEntriesTree(ArchiveEntry entry) throws IOException, UnsupportedFileOperationException {
        checkEntriesTree();
        DefaultMutableTreeNode entryNode = entryTreeRoot.findEntryNode(entry.getPath());

        if(entryNode!=null) {
            DefaultMutableTreeNode parentNode = (DefaultMutableTreeNode)entryNode.getParent();
            parentNode.remove(entryNode);
        }
    }

    /**
     * Returns the {@link ArchiveEntryTree} instance corresponding to the root of the archive entry tree.
     * The returned value can be <code>null</code> if the tree hasn't been intialized yet.
     *
     * @return the ArchiveEntryTree instance corresponding to the root of the archive entry tree
     */
    ArchiveEntryTree getArchiveEntryTree() {
        return entryTreeRoot;
    }

    /**
     * Returns the contents of the specified folder entry.
     *
     * @throws UnsupportedFileOperationException if {@link FileOperation#READ_FILE} operations are not supported by the
     * underlying file protocol.
     */
    protected AbstractFile[] ls(AbstractArchiveEntryFile entryFile, FilenameFilter filenameFilter, FileFilter fileFilter) throws IOException, UnsupportedFileOperationException {
        // Make sure the entries tree is created and up-to-date
        checkEntriesTree();        

        if(!entryFile.isBrowsable())
            throw new IOException();

        DefaultMutableTreeNode matchNode = entryTreeRoot.findEntryNode(entryFile.getEntry().getPath());
        if(matchNode==null)
            throw new IOException();

        return ls(matchNode, entryFile, filenameFilter, fileFilter);
    }

    /**
     * Returns the contents (direct children) of the specified tree node.
     *
     * @throws UnsupportedFileOperationException if {@link FileOperation#READ_FILE} operations are not supported by the
     * underlying file protocol.
     */
    private AbstractFile[] ls(DefaultMutableTreeNode treeNode, AbstractFile parentFile, FilenameFilter filenameFilter, FileFilter fileFilter) throws IOException, UnsupportedFileOperationException {
        AbstractFile files[];
        int nbChildren = treeNode.getChildCount();

        // No FilenameFilter, create entry files and store them directly into an array
        if(filenameFilter==null) {
            files = new AbstractFile[nbChildren];

            for(int c=0; c<nbChildren; c++) {
                files[c] = getArchiveEntryFile((ArchiveEntry)(((DefaultMutableTreeNode)treeNode.getChildAt(c)).getUserObject()), parentFile);
            }
        }
        // Use provided FilenameFilter and temporarily store created entry files that match the filter in a Vector
        else {
            Vector<AbstractFile> filesV = new Vector<AbstractFile>();
            for(int c=0; c<nbChildren; c++) {
                ArchiveEntry entry = (ArchiveEntry)(((DefaultMutableTreeNode)treeNode.getChildAt(c)).getUserObject());
                if(!filenameFilter.accept(entry.getName()))
                    continue;

                filesV.add(getArchiveEntryFile(entry, parentFile));
            }

            files = new AbstractFile[filesV.size()];
            filesV.toArray(files);
        }

        return fileFilter==null?files:fileFilter.filter(files);
    }

    /**
     * Creates and returns an AbstractFile using the provided entry and parent file. This method takes care of
     * creating the proper AbstractArchiveFile instance if the entry is itself an archive.
     * The entry file's path will use the separator of the underlying file, as returned by {@link #getSeparator()}.
     * That means entries paths of archives located on Windows local filesystems will use '\' as a separator, and
     * '/' for Unix local archives.
     */
    protected AbstractFile getArchiveEntryFile(ArchiveEntry entry, AbstractFile parentFile) throws IOException {

        String entryPath = entry.getPath();

        // If the parent file's separator is not '/' (the default entry separator), replace '/' occurrences by
        // the parent file's separator. For local files Under Windows, this allows entries' path to have '\' separators.
        String fileSeparator = getSeparator();
        if(!fileSeparator.equals("/"))
            entryPath = entryPath.replace("/", fileSeparator);

        // Cache AbstractArchiveEntryFile instances so that there is only one AbstractArchiveEntryFile corresponding to 
        // the same entry at any given time, to avoid attribute inconsistencies.

        AbstractArchiveEntryFile entryFile = archiveEntryFiles.get(entry);
        if(entryFile==null) {
            FileURL archiveURL = getURL();
            FileURL entryURL = (FileURL)archiveURL.clone();
            entryURL.setPath(addTrailingSeparator(archiveURL.getPath()) + entryPath);

            // Create an RO and RW entry file, depending on whether this archive file is RO or RW
            entryFile = this instanceof AbstractRWArchiveFile
                ?new RWArchiveEntryFile(
                  entryURL,
                  this,
                  entry
                )
                :new ROArchiveEntryFile(
                      entryURL,
                      this,
                      entry
                );

            entryFile.setParent(parentFile);

            archiveEntryFiles.put(entry, entryFile);
        }
        return FileFactory.wrapArchive(entryFile);
    }


    /**
     * Shorthand for {@link #getArchiveEntryFile(String)} called with the given entry's path.
     *
     * @param entry an entry contained by this archive
     * @return an AbstractFile that corresponds to the given entry
     * @throws IOException if neither the entry nor its parent exist within the archive
     * @throws UnsupportedFileOperationException if {@link FileOperation#READ_FILE} operations are not supported by the
     * underlying file protocol.
     */
    public AbstractFile getArchiveEntryFile(ArchiveEntry entry) throws IOException, UnsupportedFileOperationException {
        return getArchiveEntryFile(entry.getPath());
    }

    /**
     * Creates and returns an AbstractFile that corresponds to the given entry path within the archive.
     * The requested entry may or may not exist in the archive, the {@link #exists()} method of the returned entry file
     * can be used to find this out. However, if the requested entry does not exist in the archive and is
     * not located at the top level (i.e. is located in a subfolder), its parent folder must exist in the archive or
     * else an <code>IOException</code> will be thrown.
     *
     * <p>Important note: the given path's separator character must be '/' and the path must be relative to the
     * archive's root, i.e. not start with a leading '/', otherwise the entry will not be found.</p>
     *
     * @param entryPath path to an entry within this archive
     * @return an AbstractFile that corresponds to the given entry path
     * @throws IOException if neither the entry nor its parent exist within the archive
     * @throws UnsupportedFileOperationException if {@link FileOperation#READ_FILE} operations are not supported by the
     * underlying file protocol.
     */
    public AbstractFile getArchiveEntryFile(String entryPath) throws IOException, UnsupportedFileOperationException {
        DefaultMutableTreeNode entryNode = getArchiveEntryNode(entryPath);

        if (entryNode==null) {
            int depth = ArchiveEntry.getDepth(entryPath);

            AbstractFile parentFile;
            if(depth==1)
                parentFile = this;
            else {
                String parentPath = entryPath;
                if(parentPath.endsWith("/"))
                    parentPath = parentPath.substring(0, parentPath.length()-1);

                parentPath = parentPath.substring(0, parentPath.lastIndexOf('/'));

                parentFile = getArchiveEntryFile(parentPath);
                if(parentFile==null)    // neither the entry nor the parent exist
                    throw new IOException();
            }

            return getArchiveEntryFile(new ArchiveEntry(entryPath, false, 0, 0, false), parentFile);
        }

        return getArchiveEntryFile(entryNode);
    }

    /**
     * Returns the entry node that corresponds to the given path within the archive file.
     *
     * @param entryPath path to an entry within this archive
     * @return the entry node corresponding to the given path
     * @throws IOException if neither the entry nor its parent exist within the archive
     * @throws UnsupportedFileOperationException if {@link FileOperation#READ_FILE} operations are not supported by the
     * underlying file protocol.
     */
    public DefaultMutableTreeNode getArchiveEntryNode(String entryPath) throws IOException, UnsupportedFileOperationException {
        // Make sure the entries tree is created and up-to-date
        checkEntriesTree();

        // TODO: check if that's really necessary / if there is a way to remove this
        entryPath = entryPath.replace('\\', '/');

        // Find the entry node corresponding to the given path
        return entryTreeRoot.findEntryNode(entryPath);
    }

    /**
     * Creates and returns an {@link AbstractFile} instance corresponding to the given entry node.
     * This method recurses to resolve the entry's parent file.
     *
     * @param entryNode tree node corresponding to the entry for which to return a file
     * @return an {@link AbstractFile} instance corresponding to the given entry node
     */
    protected AbstractFile getArchiveEntryFile(DefaultMutableTreeNode entryNode) throws IOException {
        DefaultMutableTreeNode parentNode = (DefaultMutableTreeNode)entryNode.getParent();
        return getArchiveEntryFile(
                (ArchiveEntry)entryNode.getUserObject(),
                parentNode==entryTreeRoot
                    ?this
                    :getArchiveEntryFile(parentNode)
        );
    }

    
    //////////////////////
    // Abstract methods //
    //////////////////////
	
    /**
     * Returns an iterator of {@link ArchiveEntry} that iterates through all the entries of this archive.
     * Implementations of this method should as much as possible return entries in their "natural order", i.e. the order
     * in which they are stored in the archive.
     * <p>
     * This method is called the first time one of the <code>ls()</code> is called. It will not be called anymore,
     * unless the file's date has changed since the last time one of the <code>ls()</code> methods was called.
     * </p>
     *
     * @return an iterator of {@link ArchiveEntry} that iterates through all the entries of this archive
     * @throws IOException if an error occurred while reading the archive, either because the archive is corrupt or
     * because of an I/O error
     * @throws UnsupportedFileOperationException if {@link FileOperation#READ_FILE} operations are not supported by the
     * underlying file protocol.
     */
    public abstract ArchiveEntryIterator getEntryIterator() throws IOException, UnsupportedFileOperationException;

    /**
     * Returns an <code>InputStream</code> to read from the given archive entry. The specified {@link ArchiveEntry}
     * instance must be one of the entries that were returned by the {@link ArchiveEntryIterator} returned by
     * {@link #getEntryIterator()}.
     *
     * @param entry the archive entry to read
     * @param entryIterator the iterator that is used to iterate through entries by the caller (if any). This parameter
     * may be <code>null</code>, but when it is known, specifying may improve the performance of this method
     * by an order of magnitude.
     * @return an <code>InputStream</code> to read from the given archive entry
     * @throws IOException if an error occurred while reading the archive, either because the archive is corrupt or
     * because of an I/O error, or if the given entry wasn't found in the archive
     * @throws UnsupportedFileOperationException if {@link FileOperation#READ_FILE} operations are not supported by the 
     * underlying file protocol.
     */
    public abstract InputStream getEntryInputStream(ArchiveEntry entry, ArchiveEntryIterator entryIterator) throws IOException, UnsupportedFileOperationException;

    /**
     * Returns <code>true</code> if this archive file is writable, i.e. is capable of adding and deleting entries from
     * the underlying archive file.
     *
     * <p>
     * This method is implemented by {@link com.mucommander.commons.file.archive.AbstractROArchiveFile} and
     * {@link com.mucommander.commons.file.archive.AbstractRWArchiveFile} to respectively return <code>false</code> and
     * <code>true</code>. This method may be overridden by <code>AbstractRWArchiveFile</code> implementations if write
     * access is only available under certain conditions, for example if it requires random write access to the
     * proxied archive file (which may not always be available).
     * Therefore, this method should be used to test if an <code>AbstractArchiveFile</code> is writable, rather than
     * testing if it is an instance of <code>AbstractRWArchiveFile</code>.
     * </p>
     *
     * @return <code>true</code> if this archive is writable, i.e. is capable of adding and deleting entries from
     * the underlying archive file.
     */
    public abstract boolean isWritable();


    /////////////////////////////////////////
    // Partial AbstractFile implementation //
    /////////////////////////////////////////

    @Override
    public boolean isArchive() {
        return exists() && !isDirectory();
    }


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

    /**
     * This method is overridden to list and return the topmost entries contained by this archive.
     * The returned files are {@link AbstractArchiveEntryFile} instances.
     *
     * @return the topmost entries contained by this archive
     * @throws IOException if the archive entries could not be listed
     * @throws UnsupportedFileOperationException if {@link FileOperation#READ_FILE} operations are not supported by the
     * underlying file protocol.
     */
    @Override
    public AbstractFile[] ls() throws IOException, UnsupportedFileOperationException {
        // Delegate to the ancestor if this file isn't actually an archive
        if(!isArchive())
            return super.ls();

        // Make sure the entries tree is created and up-to-date
        checkEntriesTree();

        return ls(entryTreeRoot, this, null, null);
    }

    /**
     * This method is overridden to list and return the topmost entries contained by this archive, filtering out
     * the ones that do not match the specified {@link FilenameFilter}. The returned files are {@link AbstractArchiveEntryFile}
     * instances.
     *
     * @param filter the FilenameFilter to be used to filter files out from the list, may be <code>null</code>
     * @return the topmost entries contained by this archive
     * @throws IOException if the archive entries could not be listed
     * @throws UnsupportedFileOperationException if {@link FileOperation#READ_FILE} operations are not supported by the
     * underlying file protocol.
     */
    @Override
    public AbstractFile[] ls(FilenameFilter filter) throws IOException, UnsupportedFileOperationException {
        // Delegate to the ancestor if this file isn't actually an archive
        if(!isArchive())
            return super.ls(filter);

        // Make sure the entries tree is created and up-to-date
        checkEntriesTree();

        return ls(entryTreeRoot, this, filter, null);
    }

    /**
     * This method is overridden to list and return the topmost entries contained by this archive, filtering out
     * the ones that do not match the specified {@link FileFilter}. The returned files are {@link AbstractArchiveEntryFile} instances.
     *
     * @param filter the FilenameFilter to be used to filter files out from the list, may be <code>null</code>
     * @return the topmost entries contained by this archive
     * @throws IOException if the archive entries could not be listed
     * @throws UnsupportedFileOperationException if {@link FileOperation#READ_FILE} operations are not supported by the
     * underlying file protocol.
     */
    @Override
    public AbstractFile[] ls(FileFilter filter) throws IOException, UnsupportedFileOperationException {
        // Delegate to the ancestor if this file isn't actually an archive
        if(!isArchive())
            return super.ls(filter);

        // Make sure the entries tree is created and up-to-date
        checkEntriesTree();

        return ls(entryTreeRoot, this, null, filter);
    }

    @Override
    public MonitoredFile toMonitoredFile() {
        return new ModificationDateBasedMonitoredFile(this);
    }

    // Note: do not override #isDirectory() to always return true, as AbstractArchiveFile instances may be created when
    // the file does not exist yet, and then be mkdir(): in that case, the file will be a directory and not an archive.
}
