/*
 * 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.commons.compress.changes;

import java.io.IOException;
import java.io.InputStream;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.Set;

import org.apache.commons.compress.archivers.ArchiveEntry;
import org.apache.commons.compress.archivers.ArchiveInputStream;
import org.apache.commons.compress.archivers.ArchiveOutputStream;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipFile;
import org.apache.commons.compress.changes.Change.ChangeType;

/**
 * Performs ChangeSet operations on a stream. This class is thread safe and can be used multiple times. It operates on a copy of the ChangeSet. If the ChangeSet
 * changes, a new Performer must be created.
 *
 * @param <I> The {@link ArchiveInputStream} type.
 * @param <O> The {@link ArchiveOutputStream} type.
 * @param <E> The {@link ArchiveEntry} type, must be compatible between the input {@code I} and output {@code O} stream types.
 * @ThreadSafe
 * @Immutable
 */
public class ChangeSetPerformer<I extends ArchiveInputStream<E>, O extends ArchiveOutputStream<E>, E extends ArchiveEntry> {

    /**
     * Abstracts getting entries and streams for archive entries.
     *
     * <p>
     * Iterator#hasNext is not allowed to throw exceptions that's why we can't use Iterator&lt;ArchiveEntry&gt; directly - otherwise we'd need to convert
     * exceptions thrown in ArchiveInputStream#getNextEntry.
     * </p>
     */
    private interface ArchiveEntryIterator<E extends ArchiveEntry> {

        InputStream getInputStream() throws IOException;

        boolean hasNext() throws IOException;

        E next();
    }

    private static final class ArchiveInputStreamIterator<E extends ArchiveEntry> implements ArchiveEntryIterator<E> {

        private final ArchiveInputStream<E> inputStream;
        private E next;

        ArchiveInputStreamIterator(final ArchiveInputStream<E> inputStream) {
            this.inputStream = inputStream;
        }

        @Override
        public InputStream getInputStream() {
            return inputStream;
        }

        @Override
        public boolean hasNext() throws IOException {
            return (next = inputStream.getNextEntry()) != null;
        }

        @Override
        public E next() {
            return next;
        }
    }

    private static final class ZipFileIterator implements ArchiveEntryIterator<ZipArchiveEntry> {

        private final ZipFile zipFile;
        private final Enumeration<ZipArchiveEntry> nestedEnumeration;
        private ZipArchiveEntry currentEntry;

        ZipFileIterator(final ZipFile zipFile) {
            this.zipFile = zipFile;
            this.nestedEnumeration = zipFile.getEntriesInPhysicalOrder();
        }

        @Override
        public InputStream getInputStream() throws IOException {
            return zipFile.getInputStream(currentEntry);
        }

        @Override
        public boolean hasNext() {
            return nestedEnumeration.hasMoreElements();
        }

        @Override
        public ZipArchiveEntry next() {
            return currentEntry = nestedEnumeration.nextElement();
        }
    }

    private final Set<Change<E>> changes;

    /**
     * Constructs a ChangeSetPerformer with the changes from this ChangeSet
     *
     * @param changeSet the ChangeSet which operations are used for performing
     */
    public ChangeSetPerformer(final ChangeSet<E> changeSet) {
        this.changes = changeSet.getChanges();
    }

    /**
     * Copies the ArchiveEntry to the Output stream
     *
     * @param inputStream  the stream to read the data from
     * @param outputStream the stream to write the data to
     * @param archiveEntry the entry to write
     * @throws IOException if data cannot be read or written
     */
    private void copyStream(final InputStream inputStream, final O outputStream, final E archiveEntry) throws IOException {
        outputStream.putArchiveEntry(archiveEntry);
        org.apache.commons.io.IOUtils.copy(inputStream, outputStream);
        outputStream.closeArchiveEntry();
    }

    /**
     * Checks if an ArchiveEntry is deleted later in the ChangeSet. This is necessary if a file is added with this ChangeSet, but later became deleted in the
     * same set.
     *
     * @param entry the entry to check
     * @return true, if this entry has a deletion change later, false otherwise
     */
    private boolean isDeletedLater(final Set<Change<E>> workingSet, final E entry) {
        final String source = entry.getName();

        if (!workingSet.isEmpty()) {
            for (final Change<E> change : workingSet) {
                final ChangeType type = change.getType();
                final String target = change.getTargetFileName();
                if (type == ChangeType.DELETE && source.equals(target)) {
                    return true;
                }

                if (type == ChangeType.DELETE_DIR && source.startsWith(target + "/")) {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * Performs all changes collected in this ChangeSet on the input entries and streams the result to the output stream.
     *
     * This method finishes the stream, no other entries should be added after that.
     *
     * @param entryIterator the entries to perform the changes on
     * @param outputStream  the resulting OutputStream with all modifications
     * @throws IOException if a read/write error occurs
     * @return the results of this operation
     */
    private ChangeSetResults perform(final ArchiveEntryIterator<E> entryIterator, final O outputStream) throws IOException {
        final ChangeSetResults results = new ChangeSetResults();

        final Set<Change<E>> workingSet = new LinkedHashSet<>(changes);

        for (final Iterator<Change<E>> it = workingSet.iterator(); it.hasNext();) {
            final Change<E> change = it.next();

            if (change.getType() == ChangeType.ADD && change.isReplaceMode()) {
                @SuppressWarnings("resource") // InputStream not allocated here
                final InputStream inputStream = change.getInputStream();
                copyStream(inputStream, outputStream, change.getEntry());
                it.remove();
                results.addedFromChangeSet(change.getEntry().getName());
            }
        }

        while (entryIterator.hasNext()) {
            final E entry = entryIterator.next();
            boolean copy = true;

            for (final Iterator<Change<E>> it = workingSet.iterator(); it.hasNext();) {
                final Change<E> change = it.next();

                final ChangeType type = change.getType();
                final String name = entry.getName();
                if (type == ChangeType.DELETE && name != null) {
                    if (name.equals(change.getTargetFileName())) {
                        copy = false;
                        it.remove();
                        results.deleted(name);
                        break;
                    }
                } else if (type == ChangeType.DELETE_DIR && name != null) {
                    // don't combine ifs to make future extensions more easy
                    if (name.startsWith(change.getTargetFileName() + "/")) { // NOPMD NOSONAR
                        copy = false;
                        results.deleted(name);
                        break;
                    }
                }
            }

            if (copy && !isDeletedLater(workingSet, entry) && !results.hasBeenAdded(entry.getName())) {
                @SuppressWarnings("resource") // InputStream not allocated here
                final InputStream inputStream = entryIterator.getInputStream();
                copyStream(inputStream, outputStream, entry);
                results.addedFromStream(entry.getName());
            }
        }

        // Adds files which hasn't been added from the original and do not have replace mode on
        for (final Iterator<Change<E>> it = workingSet.iterator(); it.hasNext();) {
            final Change<E> change = it.next();

            if (change.getType() == ChangeType.ADD && !change.isReplaceMode() && !results.hasBeenAdded(change.getEntry().getName())) {
                @SuppressWarnings("resource")
                final InputStream input = change.getInputStream();
                copyStream(input, outputStream, change.getEntry());
                it.remove();
                results.addedFromChangeSet(change.getEntry().getName());
            }
        }
        outputStream.finish();
        return results;
    }

    /**
     * Performs all changes collected in this ChangeSet on the input stream and streams the result to the output stream. Perform may be called more than once.
     *
     * This method finishes the stream, no other entries should be added after that.
     *
     * @param inputStream  the InputStream to perform the changes on
     * @param outputStream the resulting OutputStream with all modifications
     * @throws IOException if a read/write error occurs
     * @return the results of this operation
     */
    public ChangeSetResults perform(final I inputStream, final O outputStream) throws IOException {
        return perform(new ArchiveInputStreamIterator<>(inputStream), outputStream);
    }

    /**
     * Performs all changes collected in this ChangeSet on the ZipFile and streams the result to the output stream. Perform may be called more than once.
     *
     * This method finishes the stream, no other entries should be added after that.
     *
     * @param zipFile      the ZipFile to perform the changes on
     * @param outputStream the resulting OutputStream with all modifications
     * @throws IOException if a read/write error occurs
     * @return the results of this operation
     * @since 1.5
     */
    public ChangeSetResults perform(final ZipFile zipFile, final O outputStream) throws IOException {
        @SuppressWarnings("unchecked")
        final ArchiveEntryIterator<E> entryIterator = (ArchiveEntryIterator<E>) new ZipFileIterator(zipFile);
        return perform(entryIterator, outputStream);
    }
}
