/*
 * SFTPFileSystemProvider.java
 * Copyright 2016 Rob Spoor
 *
 * 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 com.github.robtimus.filesystems.sftp;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.AccessMode;
import java.nio.file.CopyOption;
import java.nio.file.DirectoryStream;
import java.nio.file.DirectoryStream.Filter;
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.OpenOption;
import java.nio.file.Path;
import java.nio.file.ProviderMismatchException;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributeView;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileAttribute;
import java.nio.file.attribute.FileAttributeView;
import java.nio.file.attribute.FileOwnerAttributeView;
import java.nio.file.attribute.FileTime;
import java.nio.file.attribute.GroupPrincipal;
import java.nio.file.attribute.PosixFileAttributeView;
import java.nio.file.attribute.PosixFileAttributes;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.UserPrincipal;
import java.nio.file.spi.FileSystemProvider;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import com.github.robtimus.filesystems.Messages;
import com.github.robtimus.filesystems.URISupport;

/**
 * A provider for SFTP file systems.
 *
 * @author Rob Spoor
 */
public class SFTPFileSystemProvider extends FileSystemProvider {

    private final Map<URI, SFTPFileSystem> fileSystems = new HashMap<>();

    /**
     * Returns the URI scheme that identifies this provider: {@code sftp}.
     */
    @Override
    public String getScheme() {
        return "sftp"; //$NON-NLS-1$
    }

    /**
     * Constructs a new {@code FileSystem} object identified by a URI.
     * <p>
     * The URI must have a {@link URI#getScheme() scheme} equal to {@link #getScheme()}, and no {@link URI#getUserInfo() user information},
     * {@link URI#getPath() path}, {@link URI#getQuery() query} or {@link URI#getFragment() fragment}. Authentication credentials must be set through
     * the given environment map, preferably through {@link SFTPEnvironment}.
     * <p>
     * This provider allows multiple file systems per host, but only one file system per user on a host.
     * Once a file system is {@link FileSystem#close() closed}, this provider allows a new file system to be created with the same URI and credentials
     * as the closed file system.
     */
    @Override
    @SuppressWarnings("resource")
    public FileSystem newFileSystem(URI uri, Map<String, ?> env) throws IOException {
        // user info must come from the environment map
        checkURI(uri, false, false);

        SFTPEnvironment environment = wrapEnvironment(env);

        String username = environment.getUsername();
        URI normalizedURI = normalizeWithUsername(uri, username);
        synchronized (fileSystems) {
            if (fileSystems.containsKey(normalizedURI)) {
                throw new FileSystemAlreadyExistsException(normalizedURI.toString());
            }

            SFTPFileSystem fs = new SFTPFileSystem(this, normalizedURI, environment);
            fileSystems.put(normalizedURI, fs);
            return fs;
        }
    }

    SFTPEnvironment wrapEnvironment(Map<String, ?> env) {
        return SFTPEnvironment.wrap(env);
    }

    /**
     * Returns an existing {@code FileSystem} created by this provider.
     * <p>
     * The URI must have a {@link URI#getScheme() scheme} equal to {@link #getScheme()}, and no {@link URI#getPath() path},
     * {@link URI#getQuery() query} or {@link URI#getFragment() fragment}. Because the original credentials were provided through an environment map,
     * the URI can contain {@link URI#getUserInfo() user information}, although this should not contain a password for security reasons.
     * <p>
     * Once a file system is {@link FileSystem#close() closed}, this provided will throw a {@link FileSystemNotFoundException}.
     */
    @Override
    public FileSystem getFileSystem(URI uri) {
        checkURI(uri, true, false);

        return getExistingFileSystem(uri);
    }

    /**
     * Return a {@code Path} object by converting the given {@link URI}. The resulting {@code Path} is associated with a {@link FileSystem} that
     * already exists. This method does not support constructing {@code FileSystem}s automatically.
     * <p>
     * The URI must have a {@link URI#getScheme() scheme} equal to {@link #getScheme()}, and no {@link URI#getQuery() query} or
     * {@link URI#getFragment() fragment}. Because the original credentials were provided through an environment map,
     * the URI can contain {@link URI#getUserInfo() user information}, although this should not contain a password for security reasons.
     */
    @Override
    @SuppressWarnings("resource")
    public Path getPath(URI uri) {
        checkURI(uri, true, true);

        SFTPFileSystem fs = getExistingFileSystem(uri);
        return fs.getPath(uri.getPath());
    }

    private SFTPFileSystem getExistingFileSystem(URI uri) {
        URI normalizedURI = normalizeWithoutPassword(uri);
        synchronized (fileSystems) {
            SFTPFileSystem fs = fileSystems.get(normalizedURI);
            if (fs == null) {
                throw new FileSystemNotFoundException(uri.toString());
            }
            return fs;
        }
    }

    private void checkURI(URI uri, boolean allowUserInfo, boolean allowPath) {
        if (!uri.isAbsolute()) {
            throw Messages.uri().notAbsolute(uri);
        }
        if (!getScheme().equalsIgnoreCase(uri.getScheme())) {
            throw Messages.uri().invalidScheme(uri, getScheme());
        }
        if (!allowUserInfo && uri.getUserInfo() != null && !uri.getUserInfo().isEmpty()) {
            throw Messages.uri().hasUserInfo(uri);
        }
        if (uri.isOpaque()) {
            throw Messages.uri().notHierarchical(uri);
        }
        if (!allowPath && uri.getPath() != null && !uri.getPath().isEmpty()) {
            throw Messages.uri().hasPath(uri);
        }
        if (uri.getQuery() != null && !uri.getQuery().isEmpty()) {
            throw Messages.uri().hasQuery(uri);
        }
        if (uri.getFragment() != null && !uri.getFragment().isEmpty()) {
            throw Messages.uri().hasFragment(uri);
        }
    }

    @SuppressWarnings("resource")
    void removeFileSystem(URI uri) {
        URI normalizedURI = normalizeWithoutPassword(uri);
        synchronized (fileSystems) {
            fileSystems.remove(normalizedURI);
        }
    }

    private URI normalizeWithoutPassword(URI uri) {
        String userInfo = uri.getUserInfo();
        if (userInfo == null && uri.getPath() == null && uri.getQuery() == null && uri.getFragment() == null) {
            // nothing to normalize, return the URI
            return uri;
        }
        String username = null;
        if (userInfo != null) {
            int index = userInfo.indexOf(':');
            username = index == -1 ? userInfo : userInfo.substring(0, index);
        }
        // no path, query or fragment
        return URISupport.create(uri.getScheme(), username, uri.getHost(), uri.getPort(), null, null, null);
    }

    private URI normalizeWithUsername(URI uri, String username) {
        if (username == null && uri.getUserInfo() == null && uri.getPath() == null && uri.getQuery() == null && uri.getFragment() == null) {
            // nothing to normalize or add, return the URI
            return uri;
        }
        // no path, query or fragment
        return URISupport.create(uri.getScheme(), username, uri.getHost(), uri.getPort(), null, null, null);
    }

    /**
     * Opens a file, returning an input stream to read from the file.
     * This method works in exactly the manner specified by the {@link Files#newInputStream(Path, OpenOption...)} method.
     * <p>
     * Note: while the returned input stream is not closed, the path's file system will have one available connection fewer.
     * It is therefore essential that the input stream is closed as soon as possible.
     */
    @Override
    public InputStream newInputStream(Path path, OpenOption... options) throws IOException {
        return toSFTPPath(path).newInputStream(options);
    }

    /**
     * Opens or creates a file, returning an output stream that may be used to write bytes to the file.
     * This method works in exactly the manner specified by the {@link Files#newOutputStream(Path, OpenOption...)} method.
     * <p>
     * Note: while the returned output stream is not closed, the path's file system will have one available connection fewer.
     * It is therefore essential that the output stream is closed as soon as possible.
     */
    @Override
    public OutputStream newOutputStream(Path path, OpenOption... options) throws IOException {
        return toSFTPPath(path).newOutputStream(options);
    }

    /**
     * Opens or creates a file, returning a seekable byte channel to access the file.
     * This method works in exactly the manner specified by the {@link Files#newByteChannel(Path, Set, FileAttribute...)} method.
     * <p>
     * This method does not support any file attributes to be set. If any file attributes are given, an {@link UnsupportedOperationException} will be
     * thrown.
     * <p>
     * Note: while the returned channel is not closed, the path's file system will have one available connection fewer.
     * It is therefore essential that the channel is closed as soon as possible.
     */
    @Override
    public SeekableByteChannel newByteChannel(Path path, Set<? extends OpenOption> options, FileAttribute<?>... attrs) throws IOException {
        return toSFTPPath(path).newByteChannel(options, attrs);
    }

    @Override
    public DirectoryStream<Path> newDirectoryStream(Path dir, Filter<? super Path> filter) throws IOException {
        return toSFTPPath(dir).newDirectoryStream(filter);
    }

    /**
     * Creates a new directory.
     * This method works in exactly the manner specified by the {@link Files#createDirectory(Path, FileAttribute...)} method.
     * <p>
     * This method does not support any file attributes to be set. If any file attributes are given, an {@link UnsupportedOperationException} will be
     * thrown.
     */
    @Override
    public void createDirectory(Path dir, FileAttribute<?>... attrs) throws IOException {
        toSFTPPath(dir).createDirectory(attrs);
    }

    @Override
    public void delete(Path path) throws IOException {
        toSFTPPath(path).delete();
    }

    @Override
    public Path readSymbolicLink(Path link) throws IOException {
        return toSFTPPath(link).readSymbolicLink();
    }

    /**
     * Copy a file to a target file.
     * This method works in exactly the manner specified by the {@link Files#copy(Path, Path, CopyOption...)} method except that both the source and
     * target paths must be associated with this provider.
     * <p>
     * Most of the standard copy options are supported. {@link StandardCopyOption#COPY_ATTRIBUTES} and {@link StandardCopyOption#ATOMIC_MOVE} are not
     * supported though.
     */
    @Override
    public void copy(Path source, Path target, CopyOption... options) throws IOException {
        toSFTPPath(source).copy(toSFTPPath(target), options);
    }

    /**
     * Move or rename a file to a target file.
     * This method works in exactly the manner specified by the {@link Files#move(Path, Path, CopyOption...)} method except that both the source and
     * target paths must be associated with this provider.
     * <p>
     * Most of the standard copy options are supported. {@link StandardCopyOption#COPY_ATTRIBUTES} is not supported though.
     * {@link StandardCopyOption#ATOMIC_MOVE} is only supported if the paths have the same file system.
     */
    @Override
    public void move(Path source, Path target, CopyOption... options) throws IOException {
        toSFTPPath(source).move(toSFTPPath(target), options);
    }

    @Override
    public boolean isSameFile(Path path, Path path2) throws IOException {
        return toSFTPPath(path).isSameFile(path2);
    }

    @Override
    public boolean isHidden(Path path) throws IOException {
        return toSFTPPath(path).isHidden();
    }

    @Override
    public FileStore getFileStore(Path path) throws IOException {
        return toSFTPPath(path).getFileStore();
    }

    @Override
    public void checkAccess(Path path, AccessMode... modes) throws IOException {
        toSFTPPath(path).checkAccess(modes);
    }

    /**
     * Returns a file attribute view of a given type.
     * This method works in exactly the manner specified by the {@link Files#getFileAttributeView(Path, Class, LinkOption...)} method.
     * <p>
     * This provider supports {@link BasicFileAttributeView}, {@link FileOwnerAttributeView} and {@link PosixFileAttributeView}.
     * All other classes will result in a {@code null} return value.
     * <p>
     * Note: if the type is {@link BasicFileAttributeView} or a sub type, the last access time and creation time must be {@code null} when calling
     * {@link BasicFileAttributeView#setTimes(FileTime, FileTime, FileTime)}, otherwise an exception will be thrown.
     * When setting the owner or group for the path, the name must be the UID/GID of the owner/group.
     */
    @Override
    public <V extends FileAttributeView> V getFileAttributeView(Path path, Class<V> type, LinkOption... options) {
        Objects.requireNonNull(type);
        if (type == BasicFileAttributeView.class) {
            return type.cast(new AttributeView("basic", toSFTPPath(path))); //$NON-NLS-1$
        }
        if (type == FileOwnerAttributeView.class) {
            return type.cast(new AttributeView("owner", toSFTPPath(path))); //$NON-NLS-1$
        }
        if (type == PosixFileAttributeView.class) {
            return type.cast(new AttributeView("posix", toSFTPPath(path))); //$NON-NLS-1$
        }
        return null;
    }

    private static final class AttributeView implements PosixFileAttributeView {

        private final String name;
        private final SFTPPath path;

        private AttributeView(String name, SFTPPath path) {
            this.name = Objects.requireNonNull(name);
            this.path = Objects.requireNonNull(path);
        }

        @Override
        public String name() {
            return name;
        }

        @Override
        public UserPrincipal getOwner() throws IOException {
            return readAttributes().owner();
        }

        @Override
        public PosixFileAttributes readAttributes() throws IOException {
            return path.readAttributes();
        }

        @Override
        public void setTimes(FileTime lastModifiedTime, FileTime lastAccessTime, FileTime createTime) throws IOException {
            path.setTimes(lastModifiedTime, lastAccessTime, createTime);
        }

        @Override
        public void setOwner(UserPrincipal owner) throws IOException {
            path.setOwner(owner);
        }

        @Override
        public void setGroup(GroupPrincipal group) throws IOException {
            path.setGroup(group);
        }

        @Override
        public void setPermissions(Set<PosixFilePermission> perms) throws IOException {
            path.setPermissions(perms);
        }
    }

    /**
     * Reads a file's attributes as a bulk operation.
     * This method works in exactly the manner specified by the {@link Files#readAttributes(Path, Class, LinkOption...)} method.
     * <p>
     * This provider supports {@link BasicFileAttributes} and {@link PosixFileAttributes} (there is no {@code FileOwnerFileAttributes}).
     * All other classes will result in an {@link UnsupportedOperationException} to be thrown.
     */
    @Override
    public <A extends BasicFileAttributes> A readAttributes(Path path, Class<A> type, LinkOption... options) throws IOException {
        if (type == BasicFileAttributes.class || type == PosixFileAttributes.class) {
            return type.cast(toSFTPPath(path).readAttributes(options));
        }
        throw Messages.fileSystemProvider().unsupportedFileAttributesType(type);
    }

    /**
     * Reads a set of file attributes as a bulk operation.
     * This method works in exactly the manner specified by the {@link Files#readAttributes(Path, String, LinkOption...)} method.
     * <p>
     * This provider supports views {@code basic}, {@code owner} and {@code posix}, where {@code basic} will be used if no view is given.
     * All other views will result in an {@link UnsupportedOperationException} to be thrown.
     */
    @Override
    public Map<String, Object> readAttributes(Path path, String attributes, LinkOption... options) throws IOException {
        return toSFTPPath(path).readAttributes(attributes, options);
    }

    /**
     * Sets the value of a file attribute.
     * This method works in exactly the manner specified by the {@link Files#setAttribute(Path, String, Object, LinkOption...)} method.
     * <p>
     * This provider supports views {@code basic}, {@code owner} and {@code posix}, where {@code basic} will be used if no view is given.
     * All other views will result in an {@link UnsupportedOperationException} to be thrown.
     * <p>
     * Note: updating the last access time or creation time is not supported.
     * When setting the owner or group for the path, the name must be the UID/GID of the owner/group.
     */
    @Override
    public void setAttribute(Path path, String attribute, Object value, LinkOption... options) throws IOException {
        toSFTPPath(path).setAttribute(attribute, value, options);
    }

    private static SFTPPath toSFTPPath(Path path) {
        Objects.requireNonNull(path);
        if (path instanceof SFTPPath) {
            return (SFTPPath) path;
        }
        throw new ProviderMismatchException();
    }

    /**
     * Send a keep-alive signal for an SFTP file system.
     *
     * @param fs The SFTP file system to send a keep-alive signal for.
     * @throws ProviderMismatchException If the given file system is not an SFTP file system (not created by an {@code SFTPFileSystemProvider}).
     * @throws IOException If an I/O error occurred.
     */
    public static void keepAlive(FileSystem fs) throws IOException {
        if (fs instanceof SFTPFileSystem) {
            ((SFTPFileSystem) fs).keepAlive();
            return;
        }
        throw new ProviderMismatchException();
    }
}