#!/usr/bin/env python

from contextlib import contextmanager
from functools import wraps
import errno
from itertools import imap, chain
import logging
import os
import re
import stat
import time
import threading

from dulwich.repo import Repo
from dulwich.errors import NotGitRepository
import fuse


log = logging.getLogger('legitfs')

fuse.fuse_python_api = (0, 2)

GIT_REL_REF = re.compile(r'(.*?)((?:\^|\~\d+)+)$')


## {{{ http://code.activestate.com/recipes/502283/ (r1)

# Read write lock
# ---------------

class ReadWriteLock(object):
    """Read-Write lock class. A read-write lock differs from a standard
    threading.RLock() by allowing multiple threads to simultaneously hold a
    read lock, while allowing only a single thread to hold a write lock at the
    same point of time.

    When a read lock is requested while a write lock is held, the reader
    is blocked; when a write lock is requested while another write lock is
    held or there are read locks, the writer is blocked.

    Writers are always preferred by this implementation: if there are blocked
    threads waiting for a write lock, current readers may request more read
    locks (which they eventually should free, as they starve the waiting
    writers otherwise), but a new thread requesting a read lock will not
    be granted one, and block. This might mean starvation for readers if
    two writer threads interweave their calls to acquireWrite() without
    leaving a window only for readers.

    In case a current reader requests a write lock, this can and will be
    satisfied without giving up the read locks first, but, only one thread
    may perform this kind of lock upgrade, as a deadlock would otherwise
    occur. After the write lock has been granted, the thread will hold a
    full write lock, and not be downgraded after the upgrading call to
    acquireWrite() has been match by a corresponding release().
    """

    def __init__(self):
        """Initialize this read-write lock."""

        # Condition variable, used to signal waiters of a change in object
        # state.
        self.__condition = threading.Condition(threading.Lock())

        # Initialize with no writers.
        self.__writer = None
        self.__upgradewritercount = 0
        self.__pendingwriters = []

        # Initialize with no readers.
        self.__readers = {}

    def acquireRead(self, timeout=None):
        """Acquire a read lock for the current thread, waiting at most
        timeout seconds or doing a non-blocking check in case timeout is <= 0.

        In case timeout is None, the call to acquireRead blocks until the
        lock request can be serviced.

        In case the timeout expires before the lock could be serviced, a
        RuntimeError is thrown."""

        if timeout is not None:
            endtime = time.time() + timeout
        me = threading.currentThread()
        self.__condition.acquire()
        try:
            if self.__writer is me:
                # If we are the writer, grant a new read lock, always.
                self.__writercount += 1
                return
            while True:
                if self.__writer is None:
                    # Only test anything if there is no current writer.
                    if self.__upgradewritercount or self.__pendingwriters:
                        if me in self.__readers:
                            # Only grant a read lock if we already have one
                            # in case writers are waiting for their turn.
                            # This means that writers can't easily get starved
                            # (but see below, readers can).
                            self.__readers[me] += 1
                            return
                        # No, we aren't a reader (yet), wait for our turn.
                    else:
                        # Grant a new read lock, always, in case there are
                        # no pending writers (and no writer).
                        self.__readers[me] = self.__readers.get(me, 0) + 1
                        return
                if timeout is not None:
                    remaining = endtime - time.time()
                    if remaining <= 0:
                        # Timeout has expired, signal caller of this.
                        raise RuntimeError("Acquiring read lock timed out")
                    self.__condition.wait(remaining)
                else:
                    self.__condition.wait()
        finally:
            self.__condition.release()

    def acquireWrite(self, timeout=None):
        """Acquire a write lock for the current thread, waiting at most
        timeout seconds or doing a non-blocking check in case timeout is <= 0.

        In case the write lock cannot be serviced due to the deadlock
        condition mentioned above, a ValueError is raised.

        In case timeout is None, the call to acquireWrite blocks until the
        lock request can be serviced.

        In case the timeout expires before the lock could be serviced, a
        RuntimeError is thrown."""

        if timeout is not None:
            endtime = time() + timeout
        me, upgradewriter = threading.currentThread(), False
        self.__condition.acquire()
        try:
            if self.__writer is me:
                # If we are the writer, grant a new write lock, always.
                self.__writercount += 1
                return
            elif me in self.__readers:
                # If we are a reader, no need to add us to pendingwriters,
                # we get the upgradewriter slot.
                if self.__upgradewritercount:
                    # If we are a reader and want to upgrade, and someone
                    # else also wants to upgrade, there is no way we can do
                    # this except if one of us releases all his read locks.
                    # Signal this to user.
                    raise ValueError(
                        "Inevitable dead lock, denying write lock"
                        )
                upgradewriter = True
                self.__upgradewritercount = self.__readers.pop(me)
            else:
                # We aren't a reader, so add us to the pending writers queue
                # for synchronization with the readers.
                self.__pendingwriters.append(me)
            while True:
                if not self.__readers and self.__writer is None:
                    # Only test anything if there are no readers and writers.
                    if self.__upgradewritercount:
                        if upgradewriter:
                            # There is a writer to upgrade, and it's us. Take
                            # the write lock.
                            self.__writer = me
                            self.__writercount = self.__upgradewritercount + 1
                            self.__upgradewritercount = 0
                            return
                        # There is a writer to upgrade, but it's not us.
                        # Always leave the upgrade writer the advance slot,
                        # because he presumes he'll get a write lock directly
                        # from a previously held read lock.
                    elif self.__pendingwriters[0] is me:
                        # If there are no readers and writers, it's always
                        # fine for us to take the writer slot, removing us
                        # from the pending writers queue.
                        # This might mean starvation for readers, though.
                        self.__writer = me
                        self.__writercount = 1
                        self.__pendingwriters = self.__pendingwriters[1:]
                        return
                if timeout is not None:
                    remaining = endtime - time()
                    if remaining <= 0:
                        # Timeout has expired, signal caller of this.
                        if upgradewriter:
                            # Put us back on the reader queue. No need to
                            # signal anyone of this change, because no other
                            # writer could've taken our spot before we got
                            # here (because of remaining readers), as the test
                            # for proper conditions is at the start of the
                            # loop, not at the end.
                            self.__readers[me] = self.__upgradewritercount
                            self.__upgradewritercount = 0
                        else:
                            # We were a simple pending writer, just remove us
                            # from the FIFO list.
                            self.__pendingwriters.remove(me)
                        raise RuntimeError("Acquiring write lock timed out")
                    self.__condition.wait(remaining)
                else:
                    self.__condition.wait()
        finally:
            self.__condition.release()

    def release(self):
        """Release the currently held lock.

        In case the current thread holds no lock, a ValueError is thrown."""

        me = threading.currentThread()
        self.__condition.acquire()
        try:
            if self.__writer is me:
                # We are the writer, take one nesting depth away.
                self.__writercount -= 1
                if not self.__writercount:
                    # No more write locks; take our writer position away and
                    # notify waiters of the new circumstances.
                    self.__writer = None
                    self.__condition.notifyAll()
            elif me in self.__readers:
                # We are a reader currently, take one nesting depth away.
                self.__readers[me] -= 1
                if not self.__readers[me]:
                    # No more read locks, take our reader position away.
                    del self.__readers[me]
                    if not self.__readers:
                        # No more readers, notify waiters of the new
                        # circumstances.
                        self.__condition.notifyAll()
            else:
                raise ValueError("Trying to release unheld lock")
        finally:
            self.__condition.release()
## end of http://code.activestate.com/recipes/502283/ }}}


class VNode(object):
    primary = False

    def __init__(self, name):
        self.name = name
        self.children = {}
        self.parent = None

    def add_child(self, child):
        self.children[child.name] = child
        child.parent = self

    def attach_child(self, child, path):
        cur = self
        while path:
            sub_name = path.pop(0)
            if not sub_name in cur.children:
                cur.add_child(DirNode(sub_name))

            cur = cur.children[sub_name]

        cur.add_child(child)

    def ancestors(self):
        node = self.parent
        while node:
            yield node
            node = node.parent

    def clone_position_from(self, target):
        self.parent = target.parent
        self.children = target.children.copy()

    def dfs_iter(self):
        stack = [self]

        while stack:
            item = stack.pop()
            yield item
            stack.extend(reversed(item.children.values()))

    def dumps(self):
        parts = [str(self)]

        for child in self.children.itervalues():
            parts.append(child.dumps())

        return '\n'.join(parts)

    def find_handler(self, path):
        components = path.split(os.sep)
        if components[-1] == '':
            components = components[:-1]

        return self._find_handler(components)

    def remove_child(self, child):
        del self.children[child.name]
        child.parent = None
        return self

    @property
    def debug_name(self):
        return '<%s(%s)>' % (self.__class__.__name__, self.name)

    @property
    def leaf(self):
        return not bool(self.children)

    @property
    def path(self):
        if not self.parent:
            return os.sep + self.name

        names = [a.name for a in reversed(list(self.ancestors()))]
        names.append(self.name)
        return os.sep.join(names)

    def _find_handler(self, path_comp):
        me = path_comp.pop(0)

        assert me == self.name

        if not path_comp:
            # we've reached what we were looking for!
            return self

        if not path_comp[0] in self.children:
            return None

        return self.children[path_comp[0]]._find_handler(path_comp)

    def __str__(self):
        a_names = [a.debug_name for a in reversed(list(self.ancestors()))]
        a_names.append(self.debug_name)
        return '/'.join(a_names)


class DirNode(VNode):
    def fuse_getattr(self, path):
        return fuse.Stat(
            st_mode=stat.S_IFDIR | 0755,  # FIXME
            st_ino=0,
            st_dev=0,
            st_nlink=2,
            st_uid=0,  # FIXME
            st_gid=0,  # FIXME,
            st_size=4096,
            st_atime=0,  # FIXME
            st_mtime=0,  # FIXME,
            st_ctime=0,  # FIXME
        )

    def fuse_opendir(self, path):
        return 0  # opening allowed

    def fuse_readdir(self, path, offset):
        for e in '.', '..':
            yield fuse.Direntry(e)

        for child in self.children:
            yield fuse.Direntry(child)

    def fuse_releasedir(self, path):
        return 0


class FileNode(VNode):
    pass


class VirtualDirNode(DirNode):
    def _find_handler(self, path_comp):
        me = path_comp.pop(0)
        assert me == self.name
        return self  # children are virtual

    def _get_rel_path(self, path):
        return os.path.relpath(path, self.path)


class RepoNode(DirNode):
    primary = True

    def __init__(self, name, repo_path):
        super(RepoNode, self).__init__(name)
        self.repo = Repo(repo_path)

        # also add refs
        for ref_name in self.repo.refs.allkeys():
            components = ref_name.split('/')
            try:
                ref_sha = self.repo.refs[ref_name]
            except KeyError:
                log.warn('%r not found in %r' % (ref_name, self.repo))
            else:
                ref_node = GitRefNode(components[-1], self, ref_sha)
                self.attach_child(ref_node, components[:-1])

        commits = DirNode('commits')
        for commit in  filter(lambda obj: obj.type_name == 'commit',
                             (self.repo[sha] for sha in
                                             self.repo.object_store)):
            commits.add_child(GitCommitNode(commit.id, self.repo, commit.id))

        self.add_child(commits)

    def _find_handler(self, path_comp):
        path_copy = path_comp[:]  # make a copy of path_comp
        rv = super(RepoNode, self)._find_handler(path_comp)

        if not rv:
            m = GIT_REL_REF.match(path_copy[-1])
            if m:
                path_copy[-1] = m.group(1)
                offset = m.group(2)

                handler = super(RepoNode, self)._find_handler(path_copy)

                if not handler:
                    return None  # does not exist

                return handler.create_offset_node(offset)

        return rv

    def get_commit_path(self, commit_sha):
        return os.path.join(self.path, 'commits', commit_sha)


class GitCommitNode(VirtualDirNode):
    primary = True

    def __init__(self, name, repo, commit):
        super(GitCommitNode, self).__init__(name)
        self.repo = repo
        self.commit_sha = commit

    def fuse_getattr(self, path):
        if self.path == path:
            return super(GitCommitNode, self).fuse_getattr(path)

        try:
            mode, blob = self._get_object(path)
        except KeyError:
            return -errno.ENOENT

        s = fuse.Stat(
            st_mode=mode,
            st_ino=0,
            st_dev=0,
            st_nlink=1,
            st_uid=0,  # FIXME
            st_gid=0,  # FIXME,
            st_size=blob.raw_length(),
            st_atime=0,  # FIXME
            st_mtime=0,  # FIXME,
            st_ctime=0,  # FIXME
        )
        return s
    fuse_fgetattr = fuse_getattr

    def fuse_open(self, path, flags):
        if flags & os.O_APPEND or\
            flags & os.O_CREAT or\
            flags & os.O_DIRECTORY or\
            flags & os.O_EXCL or\
            flags & os.O_LARGEFILE or\
            flags & os.O_NONBLOCK or\
            flags & os.O_TRUNC or\
            flags & os.O_WRONLY or\
            flags & os.O_RDWR:
            return -errno.ENOSYS

        return 0  # always succeed

    def fuse_read(self, path, size, offset):
        mode, blob = self._get_object(path)
        return blob.data[offset:offset + size]  # FIXME: this is horrible

    def fuse_readdir(self, path, offset):
        for de in super(GitCommitNode, self).fuse_readdir(path, offset):
            yield de

        mode, tree = self._get_object(path)

        for mode, path, sha in tree.entries():
            yield fuse.Direntry(path)

        # "supported": O_NOATIME, O_NOCTTY, O_NOFOLLOW, O_SYNC, O_ASYNC

    def fuse_readlink(self, path):
        mode, blob = self._get_object(path)
        return blob.data

    def fuse_release(self, path, flags):
        return 0  # always succeed

    @property
    def commit(self):
        if not hasattr(self, '_commit'):
            self._load()

        return self._commit

    @property
    def tree(self):
        if not hasattr(self, '_tree'):
            self._load()

        return self._tree

    def _load(self):
        if not hasattr(self, 'commit'):
            self._commit = self.repo[self.commit_sha]
            self._tree = self.repo[self.commit.tree]

    def _get_object(self, path):
        rel_path = self._get_rel_path(path)
        if '.' == rel_path:
            return stat.S_IFDIR | 0755, self.tree  # FIXME: mode?
        mode, sha = self.tree.lookup_path(
            self.repo.__getitem__, rel_path
        )
        return mode, self.repo[sha]


class GitRefNode(FileNode):
    primary = True

    def __init__(self, name, repo_node, sha):
        super(GitRefNode, self).__init__(name)
        self.repo_node = repo_node
        self.sha = sha

    def fuse_getattr(self, path):
        s = fuse.Stat(
            st_mode=stat.S_IFLNK | 0777,
            st_ino=0,
            st_dev=0,
            st_nlink=1,
            st_uid=0,  # FIXME
            st_gid=0,  # FIXME,
            st_size=4096,
            st_atime=0,  # FIXME
            st_mtime=0,  # FIXME,
            st_ctime=0,  # FIXME
        )
        return s

    def fuse_readlink(self, path):
        return os.path.relpath(self.repo_node.get_commit_path(self.sha),
                               self.parent.path)

    def create_offset_node(self, offset):
        offset_node = self.__class__(self.name, self.repo_node, self.sha)
        offset_node.clone_position_from(self)

        commit = self.repo_node.repo[self.sha]

        cs = iter(offset)

        try:
            while True:
                c = cs.next()

                if '^' == c:
                    val = 1
                elif '~' == c:
                    val = int(cs.next())

                for i in xrange(val):
                    commit = self.repo_node.repo[commit.parents[0]]
        except StopIteration:
            pass
        except IndexError:
            return None  # we've reached the root

        offset_node.sha = commit.id

        return offset_node


class LegitFS(fuse.Fuse):
    def __init__(self, *args, **kwargs):
        super(LegitFS, self).__init__(*args, **kwargs)

        self.clean = False

        # add parser options
        self.parser.add_option(mountopt='root', metavar='ROOT', default='./',
                               help='Top-level dir to search for git '\
                                    'repositories')
        self.parser.add_option(mountopt='verbose', help='Output more stuff',
                               default=False, action='store_true')
        self._lock = ReadWriteLock()

    @contextmanager
    def read_lock(self):
        self._lock.acquireRead()
        yield
        self._lock.release()

    @contextmanager
    def write_lock(self):
        self._lock.acquireWrite()
        yield
        self._lock.release()

    def __getattr__(self, name):
        def _(path, *args, **kwargs):
            with self.read_lock():
                self.update_tree()  # ensure tree is up-to-date
                # acquired lock on tree
                endpoint = self.root.find_handler(path)
                log.debug("called %s(%r, %r, %r), endpoint %s" % (
                    name, path, args, kwargs, endpoint))
                if not endpoint:
                    return -errno.ENOENT
                func = getattr(endpoint, 'fuse_' + name, None)

                if not func:
                    return -errno.ENOSYS

                return func(path, *args, **kwargs)
        return _

    def fsdestroy(self):
        log.debug('fsdestroy')

    def main(self):
        log.setLevel(logging.DEBUG if self.cmdline[0].verbose else
        logging.WARNING)
        self.root_path = os.path.abspath(os.path.expanduser(self.cmdline[0].root))

        if not os.path.exists(self.root_path):
            log.critical('%s does not exist' % self.root_path)
            return None

        if not os.path.isdir(self.root_path):
            log.critical('%s is not a directory' % self.root_path)
            return None

        log.debug('root_path = %r' % self.root_path)

        try:
            import watchdog
            from watchdog.events import FileSystemEventHandler
            from watchdog.observers import Observer
        except ImportError:
            log.warning('watchdog not available, won\'t auto-update')
        else:
            class TreeUpdater(FileSystemEventHandler):
                # FIXME: be smart about what when we need to rebuild
                def __init__(self, fs):
                    self.fs = fs

                def on_any_event(self, event):
                    if event.is_directory:
                        if event.src_path.endswith('.git'):
                            log.info(event)
                            # potential new repository
                            self.fs.rebuild_tree()
                    else:
                        # it's a file
                        if event.src_path.contains('.git'):
                            log.info(event)
                            self.fs.rebuild_tree()


            event_handler = TreeUpdater(self)

            self.fs_observer = Observer()
            self.fs_observer.schedule(
                event_handler,
                path=self.root_path,
                recursive=True
            )
            self.fs_observer.start()  # fixme: might need to join thread()

        log.info('Running')
        return super(LegitFS, self).main()

    def rebuild_tree(self):
        log.debug('Tree now dirty')
        with self.write_lock():
            self.clean = False

    def stop_background_threads(self):
        if hasattr(self, 'fs_observer'):
            self.fs_observer.stop()

    def update_tree(self):
        with self.read_lock():
            if not self.clean:
                log.debug('Rebuilding tree')
                with self.write_lock():
                    self.root = rebuild_tree(self.root_path)
                    self.clean = True
                log.debug('Done rebuilding tree')


def make_node(path, relpath):
    try:
        dnode = RepoNode(relpath, path)
        return dnode
    except NotGitRepository:
        return DirNode(relpath)


def rebuild_tree(build_path):
    log.info('Collecting underpants...')

    def walk_subtree(path, name):
        root = make_node(path, name)

        for sub in os.listdir(path):
            subpath = os.path.join(path, sub)
            if os.path.isdir(subpath):
                root.add_child(walk_subtree(subpath, sub))

        return root

    root_node = walk_subtree(build_path, '')

    # prune tree
    queue = [node for node in root_node.dfs_iter() if node.leaf]
    while queue:
        node = queue.pop(0)
        if not node.primary:
            parent = node.parent.remove_child(node)
            if parent.leaf:
                queue.append(parent)

    log.debug(root_node.dumps())

    return root_node


if __name__ == '__main__':
    logging.basicConfig()

    fs = LegitFS(dash_s_do='setsingle')
    fs.parse(errex=1)
    fs.main()
    log.debug('main exited')
    fs.stop_background_threads()
