package org.dcache.simplenfs;

import com.google.common.primitives.Longs;
import org.cliffc.high_scale_lib.NonBlockingHashMap;
import org.cliffc.high_scale_lib.NonBlockingHashMapLong;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.dcache.nfs.FsExport;
import org.dcache.nfs.status.ExistException;
import org.dcache.nfs.status.NoEntException;
import org.dcache.nfs.status.NotEmptyException;
import org.dcache.nfs.v4.NfsIdMapping;
import org.dcache.nfs.v4.SimpleIdMap;
import org.dcache.nfs.v4.xdr.nfsace4;
import org.dcache.nfs.vfs.AclCheckable;
import org.dcache.nfs.vfs.DirectoryEntry;
import org.dcache.nfs.vfs.FsStat;
import org.dcache.nfs.vfs.Inode;
import org.dcache.nfs.vfs.Stat;
import org.dcache.nfs.vfs.Stat.Type;
import org.dcache.nfs.vfs.VirtualFileSystem;

import javax.security.auth.Subject;

import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.DirectoryNotEmptyException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.FileStore;
import java.nio.file.FileSystems;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.BasicFileAttributeView;
import java.nio.file.attribute.DosFileAttributeView;
import java.nio.file.attribute.DosFileAttributes;
import java.nio.file.attribute.FileTime;
import java.nio.file.attribute.GroupPrincipal;
import java.nio.file.attribute.PosixFileAttributeView;
import java.nio.file.attribute.UserPrincipal;
import java.nio.file.attribute.UserPrincipalLookupService;
import java.security.Principal;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;

import org.dcache.auth.GidPrincipal;
import org.dcache.auth.UidPrincipal;
import org.dcache.nfs.status.NotSuppException;
import org.dcache.nfs.status.PermException;
import org.dcache.nfs.status.ServerFaultException;
import org.dcache.nfs.vfs.DirectoryStream;

import static java.nio.file.LinkOption.NOFOLLOW_LINKS;

/**
 *
 */
public class LocalFileSystem implements VirtualFileSystem {

    private static final Logger LOG = LoggerFactory.getLogger(LocalFileSystem.class);

    private final Path _root;
    private final NonBlockingHashMapLong<Path> inodeToPath = new NonBlockingHashMapLong<>();
    private final NonBlockingHashMap<Path, Long> pathToInode = new NonBlockingHashMap<>();
    private final AtomicLong fileId = new AtomicLong(1); //numbering starts at 1
    private final NfsIdMapping _idMapper = new SimpleIdMap();
    private final UserPrincipalLookupService _lookupService =
            FileSystems.getDefault().getUserPrincipalLookupService();

    private final static boolean IS_UNIX;
    static {
        IS_UNIX = !System.getProperty("os.name").startsWith("Win");
    }

    private Inode toFh(long inodeNumber) {
        return Inode.forFile(Longs.toByteArray(inodeNumber));
    }

    private long getInodeNumber(Inode inode) {
        return Longs.fromByteArray(inode.getFileId());
    }

    private Path resolveInode(long inodeNumber) throws NoEntException {
        Path path = inodeToPath.get(inodeNumber);
        if (path == null) {
            throw new NoEntException("inode #" + inodeNumber);
        }
        return path;
    }

    private long resolvePath(Path path) throws NoEntException {
        Long inodeNumber = pathToInode.get(path);
        if (inodeNumber == null) {
            throw new NoEntException("path " + path);
        }
        return inodeNumber;
    }

    private void map(long inodeNumber, Path path) {
        if (inodeToPath.putIfAbsent(inodeNumber, path) != null) {
            throw new IllegalStateException();
        }
        Long otherInodeNumber = pathToInode.putIfAbsent(path, inodeNumber);
        if (otherInodeNumber != null) {
            //try rollback
            if (inodeToPath.remove(inodeNumber) != path) {
                throw new IllegalStateException("cant map, rollback failed");
            }
            throw new IllegalStateException("path ");
        }
    }

    private void unmap(long inodeNumber, Path path) {
        Path removedPath = inodeToPath.remove(inodeNumber);
        if (!path.equals(removedPath)) {
            throw new IllegalStateException();
        }
        if (pathToInode.remove(path) != inodeNumber) {
            throw new IllegalStateException();
        }
    }

    private void remap(long inodeNumber, Path oldPath, Path newPath) {
        //TODO - attempt rollback?
        unmap(inodeNumber, oldPath);
        map(inodeNumber, newPath);
    }

    public LocalFileSystem(Path root, Iterable<FsExport> exportIterable) throws IOException {
        _root = root;
        assert (Files.exists(_root));
        for (FsExport export : exportIterable) {
            String relativeExportPath = export.getPath().substring(1); // remove the opening '/'
            Path exportRootPath = root.resolve(relativeExportPath);
            if (!Files.exists(exportRootPath)) {
                Files.createDirectories(exportRootPath);
            }
        }
        //map existing structure (if any)
        map(fileId.getAndIncrement(), _root); //so root is always inode #1
        Files.walkFileTree(_root, new SimpleFileVisitor<Path>() {
            @Override
            public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
                FileVisitResult superRes = super.preVisitDirectory(dir, attrs);
                if (superRes != FileVisitResult.CONTINUE) {
                    return superRes;
                }
                if (dir.equals(_root)) {
                    return FileVisitResult.CONTINUE;
                }
                map(fileId.getAndIncrement(), dir);
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                FileVisitResult superRes = super.visitFile(file, attrs);
                if (superRes != FileVisitResult.CONTINUE) {
                    return superRes;
                }
                map(fileId.getAndIncrement(), file);
                return FileVisitResult.CONTINUE;
            }
        });
    }

    @Override
    public Inode create(Inode parent, Type type, String path, Subject subject, int mode) throws IOException {
        long parentInodeNumber = getInodeNumber(parent);
        Path parentPath = resolveInode(parentInodeNumber);
        Path newPath = parentPath.resolve(path);
        try {
            Files.createFile(newPath);
        } catch (FileAlreadyExistsException e) {
            throw new ExistException("path " + newPath);
        }
        long newInodeNumber = fileId.getAndIncrement();
        map(newInodeNumber, newPath);
        setOwnershipAndMode(newPath, subject, mode);
        return toFh(newInodeNumber);
    }

    @Override
    public FsStat getFsStat() throws IOException {
        FileStore store = Files.getFileStore(_root);
        long total = store.getTotalSpace();
        long free = store.getUsableSpace();
        return new FsStat(total, Long.MAX_VALUE, total-free, pathToInode.size());
    }

    @Override
    public Inode getRootInode() throws IOException {
        return toFh(1); //always #1 (see constructor)
    }

    @Override
    public Inode lookup(Inode parent, String path) throws IOException {
        //TODO - several issues
        //2. we might accidentally allow composite paths here ("/dome/dir/down")
        //3. we dont actually check that the parent exists
        long parentInodeNumber = getInodeNumber(parent);
        Path parentPath = resolveInode(parentInodeNumber);
        Path child;
        if(path.equals(".")) {
            child = parentPath;
        } else if(path.equals("..")) {
            child = parentPath.getParent();
        } else {
            child = parentPath.resolve(path);
        }
        long childInodeNumber = resolvePath(child);
        return toFh(childInodeNumber);
    }

    @Override
    public Inode link(Inode parent, Inode existing, String target, Subject subject) throws IOException {
        long parentInodeNumber = getInodeNumber(parent);
        Path parentPath = resolveInode(parentInodeNumber);

        long existingInodeNumber = getInodeNumber(existing);
        Path existingPath = resolveInode(existingInodeNumber);

        Path targetPath = parentPath.resolve(target);

        try {
            Files.createLink(targetPath, existingPath);
        } catch (UnsupportedOperationException e) {
            throw new NotSuppException("Not supported", e);
        } catch (FileAlreadyExistsException e) {
            throw new ExistException("Path exists " + target, e);
        } catch (SecurityException e) {
            throw new PermException("Permission denied: " + e.getMessage(), e);
        } catch (IOException e) {
            throw new ServerFaultException("Failed to create: " + e.getMessage(), e);
        }

        long newInodeNumber = fileId.getAndIncrement();
        map(newInodeNumber, targetPath);
        return toFh(newInodeNumber);
    }

    @Override
    public DirectoryStream list(Inode inode, byte[] bytes, long l) throws IOException {
        long inodeNumber = getInodeNumber(inode);
        Path path = resolveInode(inodeNumber);
        final List<DirectoryEntry> list = new ArrayList<>();
        try (java.nio.file.DirectoryStream<Path> ds = Files.newDirectoryStream(path)) {
            int cookie = 2; // first allowed cookie
            for (Path p : ds) {
                cookie++;
                if (cookie > l) {
                    long ino = resolvePath(p);
                    list.add(new DirectoryEntry(p.getFileName().toString(), toFh(ino), statPath(p, ino), cookie));
                }
            }
        }
        return new DirectoryStream(list);
    }

    @Override
    public byte[] directoryVerifier(Inode inode) throws IOException {
        return DirectoryStream.ZERO_VERIFIER;
    }

    @Override
    public Inode mkdir(Inode parent, String path, Subject subject, int mode) throws IOException {
        long parentInodeNumber = getInodeNumber(parent);
        Path parentPath = resolveInode(parentInodeNumber);
        Path newPath = parentPath.resolve(path);
        try {
            Files.createDirectory(newPath);
        } catch (FileAlreadyExistsException e) {
            throw new ExistException("path " + newPath);
        }
        long newInodeNumber = fileId.getAndIncrement();
        map(newInodeNumber, newPath);
        setOwnershipAndMode(newPath, subject, mode);
        return toFh(newInodeNumber);
    }

    private void setOwnershipAndMode(Path target, Subject subject, int mode)
    {
        if (!IS_UNIX) {
            // FIXME: windows must support some kind of file owhership as well
            return;
        }

        int uid = -1;
        int gid = -1;
        for (Principal principal : subject.getPrincipals()) {
            if (principal instanceof UidPrincipal) {
                uid = (int) ((UidPrincipal)principal).getUid();
            }
            if (principal instanceof GidPrincipal) {
                gid = (int) ((GidPrincipal)principal).getGid();
            }
        }

        if (uid != -1) {
            try {
                Files.setAttribute(target, "unix:uid", uid, NOFOLLOW_LINKS);
            } catch (IOException e) {
                LOG.warn("Unable to chown file {}: {}", target, e.getMessage());
            }
        } else {
            LOG.warn("File created without uid: {}", target);
        }
        if (gid != -1) {
            try {
                Files.setAttribute(target, "unix:gid", gid, NOFOLLOW_LINKS);
            } catch (IOException e) {
                LOG.warn("Unable to chown file {}: {}", target, e.getMessage());
            }
        } else {
            LOG.warn("File created without gid: {}", target);
        }

        try {
            Files.setAttribute(target, "unix:mode", mode, NOFOLLOW_LINKS);
        } catch (IOException e) {
            LOG.warn("Unable to set mode of file {}: {}", target, e.getMessage());
        }
    }

    @Override
    public boolean move(Inode src, String oldName, Inode dest, String newName) throws IOException {
        //TODO - several issues
        //1. we might not deal with "." and ".." properly
        //2. we might accidentally allow composite paths here ("/dome/dir/down")
        //3. we return true (changed) even though in theory a file might be renamed to itself?
        long currentParentInodeNumber = getInodeNumber(src);
        Path currentParentPath = resolveInode(currentParentInodeNumber);
        long destParentInodeNumber = getInodeNumber(dest);
        Path destPath = resolveInode(destParentInodeNumber);
        Path currentPath = currentParentPath.resolve(oldName);
        long targetInodeNumber = resolvePath(currentPath);
        Path newPath = destPath.resolve(newName);
        try {
            Files.move(currentPath, newPath, StandardCopyOption.ATOMIC_MOVE);
        } catch (FileAlreadyExistsException e) {
            throw new ExistException("path " + newPath);
        }
        remap(targetInodeNumber, currentPath, newPath);
        return true;
    }

    @Override
    public Inode parentOf(Inode inode) throws IOException {
        long inodeNumber = getInodeNumber(inode);
        if (inodeNumber == 1) {
            throw new NoEntException("no parent"); //its the root
        }
        Path path = resolveInode(inodeNumber);
        Path parentPath = path.getParent();
        long parentInodeNumber = resolvePath(parentPath);
        return toFh(parentInodeNumber);
    }

    @Override
    public int read(Inode inode, byte[] data, long offset, int count) throws IOException {
        long inodeNumber = getInodeNumber(inode);
        Path path = resolveInode(inodeNumber);
        ByteBuffer destBuffer = ByteBuffer.wrap(data, 0, count);
        try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
            return channel.read(destBuffer, offset);
        }
    }

    @Override
    public String readlink(Inode inode) throws IOException {
        long inodeNumber = getInodeNumber(inode);
        Path path = resolveInode(inodeNumber);
        return Files.readSymbolicLink(path).toString();
    }

    @Override
    public void remove(Inode parent, String path) throws IOException {
        long parentInodeNumber = getInodeNumber(parent);
        Path parentPath = resolveInode(parentInodeNumber);
        Path targetPath = parentPath.resolve(path);
        long targetInodeNumber = resolvePath(targetPath);
        try {
            Files.delete(targetPath);
        } catch (DirectoryNotEmptyException e) {
            throw new NotEmptyException("dir " + targetPath + " is note empty", e);
        }
        unmap(targetInodeNumber, targetPath);
    }

    @Override
    public Inode symlink(Inode parent, String linkName, String targetName, Subject subject, int mode) throws IOException {
        long parentInodeNumber = getInodeNumber(parent);
        Path parentPath = resolveInode(parentInodeNumber);
        Path link = parentPath.resolve(linkName);
        Path target = parentPath.resolve(targetName);
        if (!targetName.startsWith("/")) {
            target = parentPath.relativize(target);
        }
        try {
            Files.createSymbolicLink(link, target);
        } catch (UnsupportedOperationException e) {
            throw new NotSuppException("Not supported", e);
        } catch (FileAlreadyExistsException e) {
            throw new ExistException("Path exists " + linkName, e);
        } catch (SecurityException e) {
            throw new PermException("Permission denied: " + e.getMessage(), e);
        } catch (IOException e) {
            throw new ServerFaultException("Failed to create: " + e.getMessage(), e);
        }

        setOwnershipAndMode(link, subject, mode);

        long newInodeNumber = fileId.getAndIncrement();
        map(newInodeNumber, link);
        return toFh(newInodeNumber);
    }

    @Override
    public WriteResult write(Inode inode, byte[] data, long offset, int count, StabilityLevel stabilityLevel) throws IOException {
        long inodeNumber = getInodeNumber(inode);
        Path path = resolveInode(inodeNumber);
        ByteBuffer srcBuffer = ByteBuffer.wrap(data, 0, count);
        try (FileChannel channel = FileChannel.open(path, StandardOpenOption.WRITE)) {
            int bytesWritten = channel.write(srcBuffer, offset);
            return new WriteResult(StabilityLevel.FILE_SYNC, bytesWritten);
        }
    }

    @Override
    public void commit(Inode inode, long l, int i) throws IOException {
        throw new UnsupportedOperationException("Not supported yet.");
    }

    private Stat statPath(Path p, long inodeNumber) throws IOException {

        Class<? extends  BasicFileAttributeView> attributeClass =
                IS_UNIX ? PosixFileAttributeView.class : DosFileAttributeView.class;

        BasicFileAttributes attrs = Files.getFileAttributeView(p, attributeClass, NOFOLLOW_LINKS).readAttributes();

        Stat stat = new Stat();

        stat.setATime(attrs.lastAccessTime().toMillis());
        stat.setCTime(attrs.creationTime().toMillis());
        stat.setMTime(attrs.lastModifiedTime().toMillis());

        if (IS_UNIX) {
            stat.setGid((Integer) Files.getAttribute(p, "unix:gid", NOFOLLOW_LINKS));
            stat.setUid((Integer) Files.getAttribute(p, "unix:uid", NOFOLLOW_LINKS));
            stat.setMode((Integer) Files.getAttribute(p, "unix:mode", NOFOLLOW_LINKS));
            stat.setNlink((Integer) Files.getAttribute(p, "unix:nlink", NOFOLLOW_LINKS));
        } else {
            DosFileAttributes dosAttrs = (DosFileAttributes)attrs;
            stat.setGid(0);
            stat.setUid(0);
            int type = dosAttrs.isSymbolicLink() ? Stat.S_IFLNK : dosAttrs.isDirectory() ? Stat.S_IFDIR : Stat.S_IFREG;
            stat.setMode( type |(dosAttrs.isReadOnly()? 0400 : 0600));
            stat.setNlink(1);
        }

        stat.setDev(17);
        stat.setIno((int) inodeNumber);
        stat.setRdev(17);
        stat.setSize(attrs.size());
        stat.setFileid((int) inodeNumber);
        stat.setGeneration(attrs.lastModifiedTime().toMillis());

        return stat;
    }

    @Override
    public int access(Inode inode, int mode) throws IOException {
        return mode;
    }

    @Override
    public Stat getattr(Inode inode) throws IOException {
        long inodeNumber = getInodeNumber(inode);
        Path path = resolveInode(inodeNumber);
        return statPath(path, inodeNumber);
    }

    @Override
    public void setattr(Inode inode, Stat stat) throws IOException {
        if (!IS_UNIX) {
            // FIXME: windows must support some kind of attribute update as well
            return;
        }

        long inodeNumber = getInodeNumber(inode);
        Path path = resolveInode(inodeNumber);
        PosixFileAttributeView attributeView = Files.getFileAttributeView(path, PosixFileAttributeView.class, NOFOLLOW_LINKS);
        if (stat.isDefined(Stat.StatAttribute.OWNER)) {
            try {
                String uid = String.valueOf(stat.getUid());
                UserPrincipal user = _lookupService.lookupPrincipalByName(uid);
                attributeView.setOwner(user);
            } catch (IOException e) {
                throw new UnsupportedOperationException("set uid failed: " + e.getMessage(), e);
            }
        }
        if (stat.isDefined(Stat.StatAttribute.GROUP)) {
            try {
                String gid = String.valueOf(stat.getGid());
                GroupPrincipal group = _lookupService.lookupPrincipalByGroupName(gid);
                attributeView.setGroup(group);
            } catch (IOException e) {
                throw new UnsupportedOperationException("set gid failed: " + e.getMessage(), e);
            }
        }
        if (stat.isDefined(Stat.StatAttribute.MODE)) {
            try {
                Files.setAttribute(path, "unix:mode", stat.getMode(), NOFOLLOW_LINKS);
            } catch (IOException e) {
                throw new UnsupportedOperationException("set mode unsupported: " + e.getMessage(), e);
            }
        }
        if (stat.isDefined(Stat.StatAttribute.SIZE)) {
            try (RandomAccessFile raf = new RandomAccessFile(path.toFile(), "rw")) {
                raf.setLength(stat.getSize());
            }
        }
        if (stat.isDefined(Stat.StatAttribute.ATIME)) {
            try {
                FileTime time = FileTime.fromMillis(stat.getCTime());
                Files.setAttribute(path, "unix:lastAccessTime", time, NOFOLLOW_LINKS);
            } catch (IOException e) {
                throw new UnsupportedOperationException("set atime failed: " + e.getMessage(), e);
            }
        }
        if (stat.isDefined(Stat.StatAttribute.MTIME)) {
            try {
                FileTime time = FileTime.fromMillis(stat.getMTime());
                Files.setAttribute(path, "unix:lastModifiedTime", time, NOFOLLOW_LINKS);
            } catch (IOException e) {
                throw new UnsupportedOperationException("set mtime failed: " + e.getMessage(), e);
            }
        }
        if (stat.isDefined(Stat.StatAttribute.CTIME)) {
            try {
                FileTime time = FileTime.fromMillis(stat.getCTime());
                Files.setAttribute(path, "unix:ctime", time, NOFOLLOW_LINKS);
            } catch (IOException e) {
                throw new UnsupportedOperationException("set ctime failed: " + e.getMessage(), e);
            }
        }
    }

    @Override
    public nfsace4[] getAcl(Inode inode) throws IOException {
        return new nfsace4[0];
    }

    @Override
    public void setAcl(Inode inode, nfsace4[] acl) throws IOException {
        // NOP
    }

    @Override
    public boolean hasIOLayout(Inode inode) throws IOException {
        return false;
    }

    @Override
    public AclCheckable getAclCheckable() {
        return AclCheckable.UNDEFINED_ALL;
    }

    @Override
    public NfsIdMapping getIdMapper() {
        return _idMapper;
    }
}