#!/usr/bin/env python

import os, sys, errno, shutil, pathlib
from fuse import FUSE, FuseOSError, Operations
from collections import namedtuple

Translator = namedtuple('Translator', ['mode', 'output_filename', 'command'])
# todo: rename 'path' to 'get'
Entity = namedtuple('Entity', ['path', 'is_translator', 'peek', 'source', 'size'])

class FilterFS(Operations):
    def __init__(self, source, cache):
        self.source = source
        self.cache = cache
    
    def _mkparents(self, f):
        pathlib.Path(os.path.dirname(f)).mkdir(parents = True, exist_ok = True)
    def _parse(self, s):
        if not s.startswith('/!/'):
            return None
        s = s[3:]
        components = s.split('/')
        return Translator(
            mode = int(components[0], base=8),
            output_filename = components[1], # TODO: forbid ../ attacks
            command = '/'.join(components[2:])
        )

    def _get(self, why, path):
        print('get', why, path)
        p = os.path.join(self.source, path.lstrip('/'))
        if os.path.islink(p):
            rl = os.readlink(p)
            if rl.startswith('/!/'):
                cached_output = os.path.join(self.cache, 'get', path.lstrip('/'))
                peek = os.path.join(self.cache, 'peek', path.lstrip('/'))
                if not os.path.exists(cached_output):
                    translator = self._parse(rl)
                    d = os.path.dirname(p)
                    # begin hack
                    old_cwd = os.getcwd()
                    os.chdir(d)
                    os.system(translator.command)
                    os.chdir(old_cwd)
                    self._mkparents(cached_output)
                    shutil.move(os.path.join(d, translator.output_filename), cached_output)
                    # end hack
                    attr = os.lstat(p)
                    os.utime(cached_output, (attr.st_ctime, attr.st_mtime))
                    os.utime(peek, (attr.st_ctime, attr.st_mtime))
                p = cached_output
        return p
    
    def _get_source(self, why, path):
        print("_get_source", why, path)
        return os.path.join(self.source, path.lstrip('/'))
    
    def _get_entity(self, why, path):
        print("_get_entity", why, path)
        p = os.path.join(self.source, path.lstrip('/'))
        if os.path.islink(p):
            rl = os.readlink(p)
            if rl.startswith('/!/'):
                get = os.path.join(self.cache, 'get', path.lstrip('/'))
                peek = os.path.join(self.cache, 'peek', path.lstrip('/'))
                if os.path.exists(get):
                    return Entity(path = get, is_translator = True, peek = peek, source = p, size = None)
                else:
                    translator = self._parse(rl)
                    self._mkparents(peek)
                    pathlib.Path(peek).touch()
                    attr = os.lstat(p)
                    os.utime(peek, (attr.st_ctime, attr.st_mtime))
                    os.chmod(peek, translator.mode)
                    return Entity(path = peek, is_translator = True, peek = peek, source = p, size = 0)
        return Entity(path = p, is_translator = False, peek = p, source = p, size = None)

    # directory
    def readdir(self, path, fh):
        yield '.'
        yield '..'
        orig = self._get("readdir", path)
        if os.path.isdir(orig):
            for d in os.listdir(orig):
                yield d
    def mkdir(self, path, mode):
        pass
    def rmdir(self, path):
        pass

    # directory, file etc
    def getattr(self, path, file_handle = None):
        entity = self._get_entity("getattr", path)
        attr = os.lstat(entity.path)
        print(entity.path)
        return {
            'st_mode': attr.st_mode, # 0o100775 file, 0o40775 dir
            #'st_ino': 42,
            #'st_dev': 123,
            'st_nlink': attr.st_nlink,
            'st_uid': attr.st_uid,
            'st_gid': attr.st_gid,
            'st_size': attr.st_size if entity.size is None else entity.size,
            'st_atime': attr.st_atime,
            'st_mtime': attr.st_mtime,
            'st_ctime': attr.st_ctime,
        }

    def access(self, path, mode):
        entity = self._get_entity("access", path)
        if not os.access(entity.path, mode):
            raise FuseOSError(errno.EACCES)
    
    def chmod(self, path, mode):
        return os.chmod(self._get_entity('chmod', path).peek, mode)
    def chown(self, path, uid, gid):
        return os.chown(self._get_source('chown', path), uid, gid)
    def rename(self, old_path, new_path):
        old_entity = self._get_entity('rename old', old_path)
        new_entity = self._get_entity('rename new', new_path)
        if os.path.exists(new_entity.source):
            raise FuseOSError(errno.EACCES)
        else:
            if entity.is_translator:
                # TODO: preserve the cache for moved file?
                os.unlink(old_entity.path)
                os.unlink(old_entity.peek)
                return os.rename(old_entity.source, new_entity.source)
            else:
                return os.unlink(entity.path, mode)
    def utimens(self, path, times=None):
        # TODO: the "peek" should have an utime in addition to a chmod
        return os.utime(self._get_entity('utimens', path).peek, times)
    def unlink(self, path):
        entity = self._get_entity('unlink', path)
        if entity.is_translator:
            os.unlink(entity.path)
            os.unlink(entity.peek)
            return os.unlink(entity.source)
        else:
            return os.unlink(entity.path, mode)
    def link(self, original_path, clone_path):
        print('link', original_path, clone_path, 'TODO')
        pass

    # other:
    def mknod(self, path, mode, dev):
        return os.mknod(self._get_entity('mknod', path).source, mode, dev)

    # filesystem
    def statfs(self, path):
        pass
    
    # symlinks
    def readlink(self, path):
        pass
    def symlink(self, destination, symlink_path):
        pass
    
    # file
    def open(self, path, flags):
        print('open', path, flags)
        return os.open(self._get("open", path), flags)
    def read(self, path, length, offset, file_handle):
        print('read', path, length, offset, file_handle)
        self._get("read", path)
        os.lseek(file_handle, offset, os.SEEK_SET)
        r = os.read(file_handle, length)
        return r
    def _assert_is_writable(self, why, path):
        # TODO: might be a bit slow for many repeated writes, but guarantees that
        # if a translator is created in src and a file handle was already obtained
        # for its output, no further writes can mix things up?
        # TODO: write a test for the above test case
        entity = self._get_entity("_assert_is_writable for " + why, path)
        if entity.is_translator:
            raise FuseOSError(errno.EACCES)
        else:
            return entity
    def write(self, path, buffer, offset, file_handle):
        self._assert_is_writable("write", path)
        os.lseek(file_handle, offset, os.SEEK_SET)
        return os.write(file_handle, buffer)
    def create(self, path, mode, file = None):
        entity = self._assert_is_writable("write", path)
        return os.open(entity.path, os.O_CREAT | os.O_WRONLY, mode)
    def truncate(self, path, length, file_handle = None):
        entity = self._assert_is_writable("truncate", path)
        with open(entity.path, 'r+') as f:
            f.truncate(length)
    def flush(self, path, file_handle):
        #self._assert_is_writable("flush", path)
        return os.fsync(file_handle)
    def release(self, path, file_handle):
        print('release', path)
        return os.close(file_handle)
    def fsync(self, path, fdatasync, file_handle):
        self._assert_is_writable("fsync",path)
        return self.flush(path, file_handle)

def main(source, cache, mountpoint):
    # direct_io allows us to return size 0 for non-empty files
    FUSE(FilterFS(source, cache), mountpoint, nothreads=True, foreground=True, direct_io = True)

if __name__ == '__main__':
    main(sys.argv[1], sys.argv[2], sys.argv[3])