/*
 * JBoss, Home of Professional Open Source
 * Copyright 2012, Red Hat Middleware LLC, and individual contributors
 * by the @authors tag. See the copyright.txt in the distribution for a
 * full listing of individual contributors.
 *
 * Licensed 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.jboss.shrinkwrap.impl.nio.file;

import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.AccessMode;
import java.nio.file.CopyOption;
import java.nio.file.DirectoryNotEmptyException;
import java.nio.file.DirectoryStream;
import java.nio.file.DirectoryStream.Filter;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.FileStore;
import java.nio.file.FileSystem;
import java.nio.file.FileSystemAlreadyExistsException;
import java.nio.file.FileSystemNotFoundException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.NoSuchFileException;
import java.nio.file.OpenOption;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileAttribute;
import java.nio.file.attribute.FileAttributeView;
import java.nio.file.spi.FileSystemProvider;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.locks.ReentrantLock;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.jboss.shrinkwrap.api.Archive;
import org.jboss.shrinkwrap.api.ArchivePath;
import org.jboss.shrinkwrap.api.ArchivePaths;
import org.jboss.shrinkwrap.api.Node;
import org.jboss.shrinkwrap.api.asset.Asset;
import org.jboss.shrinkwrap.api.nio.file.MemoryNamedAsset;
import org.jboss.shrinkwrap.api.nio.file.SeekableInMemoryByteChannel;

/**
 * {@link FileSystemProvider} implementation for ShrinkWrap {@link Archive}s.
 *
 * @author <a href="mailto:alr@jboss.org">Andrew Lee Rubinger</a>
 */
public class ShrinkWrapFileSystemProvider extends FileSystemProvider {

    /**
     * Logger
     */
    private static final Logger log = Logger.getLogger(ShrinkWrapFileSystemProvider.class.getName());

    /**
     * Scheme
     */
    private static final String SCHEME = "shrinkwrap";

    /**
     * Environment key for creating a new {@link FileSystem} denoting the archive
     */
    private static final String ENV_KEY_ARCHIVE = "archive";

    /**
     * Open file systems, keyed by the {@link Archive#getId()}
     */
    private final ConcurrentMap<String, ShrinkWrapFileSystem> createdFileSystems = new ConcurrentHashMap<>();

    /**
     * Lock for creation of a new filesystem and other tasks which should block until this op has completed
     */
    private final ReentrantLock createNewFsLock = new ReentrantLock();

    /**
     * {@inheritDoc}
     *
     * @see java.nio.file.spi.FileSystemProvider#getScheme()
     */
    @Override
    public String getScheme() {
        return SCHEME;
    }

    /**
     * {@inheritDoc}
     *
     * @see java.nio.file.spi.FileSystemProvider#newFileSystem(java.net.URI, java.util.Map)
     */
    @Override
    public FileSystem newFileSystem(final URI uri, final Map<String, ?> env) throws IOException {

        // Precondition checks
        if (uri == null) {
            throw new IllegalArgumentException("URI must be specified");
        }
        if (env == null) {
            throw new IllegalArgumentException("Environment must be specified");
        }

        // Scheme is correct?
        final String scheme = uri.getScheme();
        if (!scheme.equals(SCHEME)) {
            throw new IllegalArgumentException(ShrinkWrapFileSystem.class.getSimpleName()
                + " supports URI with scheme " + SCHEME + " only.");
        }

        // Get ID of the archive
        final String id = uri.getHost();

        // Archive is provided?
        Archive<?> archive = null;
        final Object archiveArg = env.get(ENV_KEY_ARCHIVE);
        if (archiveArg != null) {
            try {
                archive = Archive.class.cast(archiveArg);
                // Ensure the name of the archive matches the host specified in the URI
                if (!archive.getId().equals(id)) {
                    throw new IllegalArgumentException("Specified archive " + archive.toString()
                        + " does not have name matching the host of specified URI: " + uri.toString());
                }
                if (log.isLoggable(Level.FINER)) {
                    log.finer("Found archive supplied by environment: " + archive.toString());
                }
            } catch (final ClassCastException cce) {
                // User specified the wrong type, translate and rethrow
                throw new IllegalArgumentException("Unexpected argument passed into environment under key "
                    + ENV_KEY_ARCHIVE + ": " + archiveArg);
            }
        }
        // If archive could not be found in the environment, IllegalArgumentException
        if (archive == null) {
            throw new IllegalArgumentException("archive must be specified in the environment under key " + ENV_KEY_ARCHIVE);
        }

        // Lock for compound operations on createdFileSystems
        createNewFsLock.lock();

        // Exists?
        final ShrinkWrapFileSystem existsFs = this.createdFileSystems.get(archive.getId());
        if (existsFs != null && existsFs.isOpen()) {
            throw new FileSystemAlreadyExistsException("File System for URI " + uri.toString() + " already exists: "
                + existsFs.toString());
        } else if (existsFs != null && !existsFs.isOpen()) {
            if (log.isLoggable(Level.FINE)) {
                log.fine("Attempting to create a file system for URI " + uri.toString()
                    + ", and one has been made but is closed; it will be replaced by a new one.");
            }
        }

        // Make a new FileSystem
        final ShrinkWrapFileSystem newFs = new ShrinkWrapFileSystem(this, archive);
        if (log.isLoggable(Level.FINE)) {
            log.fine("Created new filesystem: " + newFs.toString() + " for URI " + uri.toString());
        }
        this.createdFileSystems.put(archive.getId(), newFs);

        // Unlock, compound operations done on createdFileSystems
        createNewFsLock.unlock();

        // Return
        return newFs;
    }

    /**
     * {@inheritDoc}
     *
     * @see java.nio.file.spi.FileSystemProvider#getFileSystem(java.net.URI)
     */
    @Override
    public FileSystem getFileSystem(final URI uri) {

        // Get open FS
        final FileSystem fs = this.createdFileSystems.get(uri.getHost());

        // If not already created
        if (fs == null) {
            throw new FileSystemNotFoundException("Could not find an open file system with URI: " + uri.toString()
                + "; try creating a new file system?");
        }

        // Return
        return fs;
    }

    /**
     * {@inheritDoc}
     *
     * @see java.nio.file.spi.FileSystemProvider#getPath(java.net.URI)
     */
    @Override
    public Path getPath(final URI uri) {

        // Precondition checks
        if (uri == null) {
            throw new IllegalArgumentException("URI must be specified");
        }

        // ID exists? We're referencing a previously-opened archive?
        final String id = uri.getHost();
        ShrinkWrapFileSystem fs = null;
        if (id != null && id.length() > 0) {
            fs = this.createdFileSystems.get(id);
        }

        // Check that the file system exists
        if (fs == null) {
            throw new FileSystemNotFoundException("Could not find a previously-created filesystem with URI: "
                + uri.toString());
        }
        // Check FS is open
        if (!fs.isOpen()) {
            throw new FileSystemNotFoundException("File System for URI: " + uri.toString()
                + " is closed; create a new one to re-mount.");
        }

        final String pathComponent = uri.getPath();
        final ArchivePath archivePath = ArchivePaths.create(pathComponent);
        final Path path = new ShrinkWrapPath(archivePath, fs);

        // Return
        return path;
    }

    /**
     * {@inheritDoc}
     *
     * @see java.nio.file.spi.FileSystemProvider#newFileChannel(java.nio.file.Path, java.util.Set,
     *      java.nio.file.attribute.FileAttribute<?>[])
     */
    @Override
    public FileChannel newFileChannel(Path path, Set<? extends OpenOption> options,
            FileAttribute<?>... attrs) throws IOException {
        return new ShrinkWrapFileChannel(newByteChannel(path, options, attrs));
    }

    /**
     * {@inheritDoc}
     *
     * @see java.nio.file.spi.FileSystemProvider#newByteChannel(java.nio.file.Path, java.util.Set,
     *      java.nio.file.attribute.FileAttribute<?>[])
     */
    @Override
    public SeekableByteChannel newByteChannel(final Path path, final Set<? extends OpenOption> options,
        final FileAttribute<?>... attrs) throws IOException {
        // Precondition checks for null path done by NIO2 Files

        // Ignore FileAttributes

        // Get Archive
        final Archive<?> archive = this.getArchive(path);

        // // If overwriting isn't allowed and this path exists
        final ArchivePath archivePath = ArchivePaths.create(path.toString());

        // Open a new Channel (which is also a NamedAsset)
        final MemoryNamedAsset channel = new MemoryNamedAsset(archivePath);

        // Writing?
        if (options.contains(StandardOpenOption.CREATE) || options.contains(StandardOpenOption.CREATE_NEW)
            || options.contains(StandardOpenOption.WRITE)) {

            // Plug channel as Asset into the archive

            if (archive.contains(channel.getName())) {
                // Appending?
                if (options.contains(StandardOpenOption.APPEND)) {
                    // Read in the existing content
                    channel.position(0);
                    final InputStream in = archive.get(archivePath).getAsset().openStream();
                    this.copy(in, channel);
                    in.close();
                    // Delete the existing asset and associate the channel as the new asset
                    archive.delete(archivePath);
                    archive.add(channel);
                } else {
                    // Exception translate
                    throw new FileAlreadyExistsException(archivePath.get());
                }
            } else {
                archive.add(channel);
            }

            // Return the channel
            return channel;
        }

        // Else we're reading...

        // Get the Node
        final Node node = archive.get(archivePath);
        if (node == null) {
            throw new IllegalArgumentException(
                "No open options have been set, and cannot read a file that does not exist");
        }
        // Directory?
        final Asset asset = node.getAsset();
        if (asset == null) {
            throw new IllegalArgumentException("Cannot open a new channel to a path with existing directory: "
                + archivePath.get());
        }

        // Existing asset is read into the channel
        final InputStream in = asset.openStream();
        final SeekableByteChannel outChannel = new SeekableInMemoryByteChannel();
        this.copy(in, outChannel);
        // Set the position to 0 so it can be read from the beginning
        outChannel.position(0);
        return outChannel;

    }

    /**
     * {@inheritDoc}
     *
     * @see java.nio.file.spi.FileSystemProvider#newDirectoryStream(java.nio.file.Path,
     *      java.nio.file.DirectoryStream.Filter)
     */
    @Override
    public DirectoryStream<Path> newDirectoryStream(final Path dir, final Filter<? super Path> filter)
        throws IOException {
        final FileSystem fs = dir.getFileSystem();
        if (!(fs instanceof ShrinkWrapFileSystem)) {
            throw new IllegalArgumentException("Expected ShrinkWrap File System for Path: " + dir.toString());
        }
        return new ShrinkWrapDirectoryStream(dir, (ShrinkWrapFileSystem) fs, filter);
    }

    /**
     * {@inheritDoc}
     *
     * @see java.nio.file.spi.FileSystemProvider#createDirectory(java.nio.file.Path,
     *      java.nio.file.attribute.FileAttribute<?>[])
     */
    @Override
    public void createDirectory(final Path dir, final FileAttribute<?>... attrs) throws IOException {
        final Archive<?> archive = this.getArchive(dir);
        final ArchivePath parent = ArchivePaths.create(dir.toString()).getParent();
        if (parent != null && !archive.contains(parent)) {
            // IOException? Despite being a stupid choice for this, it's what the NIO.2 API uses.
            throw new IOException("Cannot recursively create parent directories for: " + dir
                + "; instead invoke \"createDirectories\"");
        }
        archive.addAsDirectories(dir.toString());
        if (log.isLoggable(Level.FINEST)) {
            log.finest("Created directory " + dir.toString() + " on " + archive.toString());
        }
    }

    /**
     * {@inheritDoc}
     *
     * @see java.nio.file.spi.FileSystemProvider#delete(java.nio.file.Path)
     */
    @Override
    public void delete(final Path path) throws IOException {

        assert path != null : "Path must be specified";
        final Archive<?> archive = this.getArchive(path);
        final String pathString = path.toString();
        if (!archive.contains(pathString)) {
            throw new NoSuchFileException(path + " does not exist in " + archive);
        }
        final boolean isDirectory = archive.get(pathString).getAsset() == null;

        // Directory?
        if (isDirectory) {
            // Check empty?
            if (!archive.getContent(new org.jboss.shrinkwrap.api.Filter<ArchivePath>() {
                @Override
                public boolean include(final ArchivePath path) {
                    final String filterPathString = path.get();
                    return filterPathString.startsWith(pathString) && !filterPathString.equals(pathString);
                }
            }).isEmpty()) {
                throw new DirectoryNotEmptyException(pathString);
            }
        }
        archive.delete(pathString);
        if (log.isLoggable(Level.FINEST)) {
            log.finest("Deleted " + path.toString() + " from " + archive.toString());
        }
    }

    /**
     * Obtains the underlying archive associated with the specified Path
     *
     * @param path
     * @return
     */
    private Archive<?> getArchive(final Path path) {
        assert path != null : "Path must be specified";
        final FileSystem fs = path.getFileSystem();
        assert fs != null : "File system is null";
        // Could be user error in this case, passing in a Path from another provider
        if (!(fs instanceof ShrinkWrapFileSystem)) {
            throw new IllegalArgumentException("This path is not associated with a "
                + ShrinkWrapFileSystem.class.getSimpleName());
        }
        final ShrinkWrapFileSystem swfs = (ShrinkWrapFileSystem) fs;
        final Archive<?> archive = swfs.getArchive();
        assert archive != null : "No archive associated with file system";
        return archive;
    }

    /*
     * (non-Javadoc)
     *
     * @see java.nio.file.spi.FileSystemProvider#copy(java.nio.file.Path, java.nio.file.Path,
     * java.nio.file.CopyOption[])
     */
    @Override
    public void copy(Path source, Path target, CopyOption... options) throws IOException {
        // TODO Auto-generated method stub

    }

    /**
     * {@inheritDoc}
     *
     * @see java.nio.file.spi.FileSystemProvider#move(java.nio.file.Path, java.nio.file.Path,
     *      java.nio.file.CopyOption[])
     */
    @Override
    public void move(final Path source, final Path target, final CopyOption... options) throws IOException {

        // Precondition checks
        if (source == null) {
            throw new IllegalArgumentException("source must be specified");
        }
        if (target == null) {
            throw new IllegalArgumentException("target must be specified");
        }

        // Source exists?
        if (!Files.exists(source, new LinkOption[] {})) {
            throw new IllegalArgumentException("Source file doesn't exist: " + source.toString());
        }

        // If equal, NOOP
        if (source == target) {
            return;
        }

        final Archive<?> archive = this.getArchive(target);
        final Node node = archive.get(target.toString());
        // Copying to something already present?
        if (node != null) {
            final Asset asset = node.getAsset();
            // Directory
            if (asset == null) {
                // Not empty
                if (node.getChildren().size() > 0) {
                    throw new DirectoryNotEmptyException("Cannot move to non-empty directory: " + target.toString());
                }
            }
        }

        // Move
        archive.move(source.toString(), target.toString());
    }

    /**
     * {@inheritDoc}
     *
     * @see java.nio.file.spi.FileSystemProvider#isSameFile(java.nio.file.Path, java.nio.file.Path)
     */
    @Override
    public boolean isSameFile(final Path path1, final Path path2) throws IOException {

        // Only equal if pointing to same FS
        final FileSystem fs1 = path1.getFileSystem();
        final FileSystem fs2 = path2.getFileSystem();
        if (fs1 != fs2) {
            return false;
        }

        // Same if the normalized form points to the same location
        final String normalized1 = path1.normalize().toString();
        final String normalized2 = path2.normalize().toString();
        return normalized1.equals(normalized2);
    }

    /**
     * {@inheritDoc}
     *
     * @see java.nio.file.spi.FileSystemProvider#isHidden(java.nio.file.Path)
     */
    @Override
    public boolean isHidden(final Path path) throws IOException {
        // No paths are hidden
        return false;
    }

    /**
     * {@inheritDoc}
     *
     * @see java.nio.file.spi.FileSystemProvider#getFileStore(java.nio.file.Path)
     */
    @Override
    public FileStore getFileStore(final Path path) throws IOException {
        final FileStore fileStore = path.getFileSystem().getFileStores().iterator().next();
        return fileStore;
    }

    /**
     * {@inheritDoc}
     *
     * @see java.nio.file.spi.FileSystemProvider#checkAccess(java.nio.file.Path, java.nio.file.AccessMode[])
     */
    @Override
    public void checkAccess(final Path path, final AccessMode... modes) throws IOException {
        // We support READ, WRITE, and EXECUTE on everything, so long as a file exists
        final Archive<?> archive = this.getArchive(path);
        final String desired = path.toString();
        if (!archive.contains(desired)) {
            throw new NoSuchFileException(desired);
        }
    }

    /**
     * {@inheritDoc}
     *
     * @see java.nio.file.spi.FileSystemProvider#getFileAttributeView(java.nio.file.Path, java.lang.Class,
     *      java.nio.file.LinkOption[])
     */
    @Override
    public <V extends FileAttributeView> V getFileAttributeView(final Path path, final Class<V> type,
        final LinkOption... options) {
        if (path == null) {
            throw new IllegalArgumentException("path must be specified");
        }
        if (type == null) {
            throw new IllegalArgumentException("type must be specified");
        }
        if (!type.isAssignableFrom(ShrinkWrapFileAttributeView.class)) {
            // Nope, we don't support this view
            return null;
        }
        if (!(path instanceof ShrinkWrapPath)) {
            throw new IllegalArgumentException("Only " + ShrinkWrapPath.class.getSimpleName() + " is supported");
        }
        return type.cast(new ShrinkWrapFileAttributeView(new ShrinkWrapFileAttributes((ShrinkWrapPath) path,
                getArchive(path))));
    }

    /**
     * {@inheritDoc}
     *
     * @see java.nio.file.spi.FileSystemProvider#readAttributes(java.nio.file.Path, java.lang.Class,
     *      java.nio.file.LinkOption[])
     */
    @Override
    public <A extends BasicFileAttributes> A readAttributes(final Path path, final Class<A> type,
        final LinkOption... options) throws IOException {
        if (path == null) {
            throw new IllegalArgumentException("path must be specified");
        }
        if (type == null) {
            throw new IllegalArgumentException("type must be specified");
        }
        if (!type.isAssignableFrom(ShrinkWrapFileAttributes.class)) {
            throw new UnsupportedOperationException("Only supports " + ShrinkWrapFileAttributes.class.getSimpleName()
                + " type");
        }
        if (!(path instanceof ShrinkWrapPath)) {
            throw new IllegalArgumentException("Only " + ShrinkWrapPath.class.getSimpleName() + " is supported");
        }
        final ShrinkWrapPath swPath = (ShrinkWrapPath) path;
        if (!((ShrinkWrapFileSystem) swPath.getFileSystem()).getArchive().contains(path.toString())) {
            throw new NoSuchFileException(path.toString());
        }
        final A attributes = type.cast(new ShrinkWrapFileAttributes((ShrinkWrapPath) path, getArchive(path)));
        return attributes;
    }

    /**
     * {@inheritDoc}
     *
     * @see java.nio.file.spi.FileSystemProvider#readAttributes(java.nio.file.Path, java.lang.String,
     *      java.nio.file.LinkOption[])
     */
    @Override
    public Map<String, Object> readAttributes(final Path path, final String attributes, final LinkOption... options)
        throws IOException {
        throw new UnsupportedOperationException("ShrinkWrap File Systems do not support attributes");
    }

    /**
     * {@inheritDoc}
     *
     * @see java.nio.file.spi.FileSystemProvider#setAttribute(java.nio.file.Path, java.lang.String, java.lang.Object,
     *      java.nio.file.LinkOption[])
     */
    @Override
    public void setAttribute(final Path path, final String attribute, final Object value, final LinkOption... options)
        throws IOException {
        throw new UnsupportedOperationException("ShrinkWrap File Systems do not support attributes");
    }

    /**
     * Writes the contents of the {@link InputStream} to the {@link SeekableByteChannel}
     *
     * @param in
     * @param out
     * @throws IOException
     */
    private void copy(final InputStream in, final SeekableByteChannel out) throws IOException {
        assert in != null : "InStream must be specified";
        assert out != null : "Channel must be specified";

        final byte[] backingBuffer = new byte[1024 * 4];
        final ByteBuffer byteBuffer = ByteBuffer.wrap(backingBuffer);
        int bytesRead = 0;
        while ((bytesRead = in.read(backingBuffer, 0, backingBuffer.length)) > -1) {
            // Limit to the amount we've actually read in, so we don't overflow into old data blocks
            byteBuffer.limit(bytesRead);
            out.write(byteBuffer);
            // Position back to 0
            byteBuffer.clear();
        }
    }

}
