# -*- coding: utf-8 -*-
from __future__ import print_function, absolute_import
__all__ = [
    'Entry',
    'EntryData',
]
import os
import shutil
import errno
import uuid
from scandir import scandir
from fsfs import api, util, lockfile, types, _search
from fsfs.constants import UP
from fsfs.channels import band


class EntryNotFoundError(Exception): pass


def relink_uuid(entry):
    '''Search for entry by uuid and relink the entry and entry_data to the
    newly found path.

    Arguments:
        entry (Entry): the entry to attempt to relink

    Returns:
        None

    Raises:
        Exception: when relink fails
    '''

    data = entry.data
    if os.path.isdir(data.path):
        # uuid changed
        uuid_info = data._get_uuid(data.path)
        if uuid_info:
            data.uuid, data.uuid_file = uuid_info
            return
        # uuid file deleted
        data._set_uuid()
        return

    if os.path.isdir(entry.path):
        # Data directory no longer exists
        exc = EntryNotFoundError(
            'Entry data directory no longer exists: ' + data.path
        )
        entry.missing.send(entry, exc)
        raise exc

    match = None

    # Find parent directory. Quickest way to handle renamed entry
    level = 1
    root = os.path.dirname(entry.path)
    while not os.path.isdir(root):
        root = os.path.dirname(root)
        level += 1
        if level >= 10:
            exc = EntryNotFoundError(
                'File system branch containing Entry no longer exists...'
            )
            entry.missing.send(entry, exc)
            raise exc

    match = _search.one_uuid(root, data.uuid, depth=level + 1)

    # Search top-level parent entry
    if not match:
        try:
            parent = list(entry.parents())[-1]
            match = _search.one_uuid(parent.path, data.uuid)
        except IndexError:
            pass

    if match:
        old_root = entry.path
        new_root, new_data_root, new_uuid_file = match
        entry._set_path(new_root, data.uuid, new_uuid_file)
        entry.relinked.send(entry, old_root, new_root)
    else:
        exc = EntryNotFoundError(
            'Could not locate Entry matching uuid: ' + data.uuid +
            '    Entry: ' + repr(entry)
        )
        entry.missing.send(entry, exc)
        raise exc


class EntryData(object):
    '''Interface to a directory's metadata and tags.'''

    def __init__(self, parent, path):
        self.parent = parent

        # Setup data paths
        self.path = None
        self.blobs_path = None
        self.files_path = None
        self.file = None
        self.uuid = None
        self.uuid_file = None
        self._lock = None
        self._set_path(path)

        self._data = None
        self._data_mtime = None

    def _set_path(self, path, uuid=None, uuid_file=None):
        self.path = path
        self.blobs_path = util.unipath(self.path, 'blobs')
        self.files_path = util.unipath(self.path, 'files')
        self.file = util.unipath(self.path, api.get_data_file())

        if not uuid or not uuid_file:
            self.uuid = None
            self.uuid_file = None
            self._find_uuid()
        else:
            self.uuid = uuid
            self.uuid_file = uuid_file

        if self._lock and self._lock.acquired:
            self._lock.release()

        self._lock = lockfile.LockFile(util.unipath(self.path, '.lock'))

    # Act like a dict

    def __repr__(self):
        return self._read().__repr__()

    def __str__(self):
        return self._read().__str__()

    def __getitem__(self, key):
        return self._read()[key]

    def __delitem__(self, key):
        data = self._read()
        del data[key]
        self.parent.data_changed.send(self.parent, dict(self._data))

    def __setitem__(self, key, value):
        self._write(**{key: value})
        self.parent.data_changed.send(self.parent, dict(self._data))

    def __iter__(self):
        return self._read().__iter__()

    def __contains__(self, key):
        return key in self._read()

    def __len__(self):
        return self._read().__len__()

    def keys(self):
        return self._read().keys()

    def items(self):
        return self._read().items()

    def values(self):
        return self._read().values()

    def get(self, *args):
        return self._read().get(*args)

    def update(self, **kwargs):
        self._write(**kwargs)
        self.parent.data_changed.send(self.parent, dict(self._data))

    def __eq__(self, other):
        self._read().__eq__(other)

    def __ne__(self, other):
        self._read().__ne__(other)

    # Core Methods

    def _make_uuid_path(self, uuid):
        return util.unipath(self.path, 'uuid_' + uuid)

    def _has_uuid(self):
        return bool(self.uuid)

    def _has_valid_uuid_file(self):
        return self.uuid_file and os.path.isfile(self.uuid_file)

    def _requires_relink(self):
        return not self.parent.exists and self.uuid_file

    def _get_uuid(self, path):
        for entry in scandir(path):
            if entry.name.startswith('uuid_'):
                return entry.name.replace('uuid_', ''), entry.name

    def _find_uuid(self):

        if not os.path.isdir(self.path):
            return False

        for entry in scandir(self.path):
            if entry.name.startswith('uuid_'):
                self.uuid = entry.name.replace('uuid_', '')
                self.uuid_file = entry.path
                return True

    def _set_uuid(self, _id=None):
        '''Use this at your own risk. UUID is used for Entry rediscovery.
        If you change it other processes will become out of sync.
        '''

        # If this Entry already has a uuid it means we are
        # creating a new one in which case we will emit the
        # uuid_changed event.
        is_new_uuid = bool(self.uuid)

        with self._lock:

            if self.uuid_file and os.path.isfile(self.uuid_file):
                os.remove(self.uuid_file)

            self.uuid = _id or api.generate_id()
            self.uuid_file = self._make_uuid_path(self.uuid)
            util.touch(self.uuid_file)

        if is_new_uuid:
            self.parent.uuid_changed.send(self.parent)

    def _init_uuid(self):
        if self.uuid:
            return
        if self._find_uuid():
            return
        self._set_uuid()

    def _init(self):
        if self._requires_relink():
            relink_uuid(self.parent)

        is_new = False
        if not os.path.isfile(self.file):
            util.touch(self.file)
            is_new = True

        self._init_uuid()

        if is_new:
            self.parent.created.send(self.parent)

    def _read(self):
        '''Ensure data directory is initialized, then read or update cache'''

        if self.parent.exists or self._requires_relink():
            self._init()
        else:
            raise OSError('Entry data does not exist: %s' % self.parent)

        with self._lock:
            mtime = os.path.getmtime(self.file)
            needs_update = (
                self._data is None or
                self._data_mtime < mtime
            )

            if needs_update:

                with open(self.file, 'r') as f:
                    raw_data = f.read()

                if not raw_data:
                    self._data = {}
                else:
                    self._data = api.decode_data(raw_data)

                self._data_mtime = mtime

            return self._data

    def _write(self, replace=False, **data):
        '''Ensure data directory is initialized, then write updated data'''

        self._init()

        with self._lock:
            if not replace:
                new_data = self._read()
                util.update_dict(new_data, data)
            else:
                new_data = data
            with open(self.file, 'w') as f:
                f.write(api.encode_data(new_data))

            self._data = new_data
            self._data_mtime = os.path.getmtime(self.file)

    def _make_tag_path(self, tag):
        return util.unipath(self.path, 'tag_' + tag)

    @property
    def itags(self):
        for entry in scandir(self.path):
            if entry.name.startswith('tag_'):
                yield entry.name.replace('tag_', '')

    @property
    def tags(self):
        return list(self.itags)

    def tag(self, *tags):
        self._init()
        for tag in tags:
            api.validate_tag(tag)
            tag_path = self._make_tag_path(tag)
            if not os.path.isfile(tag_path):
                util.touch(tag_path)

        self.parent.tagged.send(self.parent, tags)

    def untag(self, *tags):
        for tag in tags:
            api.validate_tag(tag)
            tag_path = self._make_tag_path(tag)
            if os.path.isfile(tag_path):
                os.remove(tag_path)

        self.parent.untagged.send(self.parent, tags)

    def read(self, *keys):
        data = self._read()

        if not keys:
            return data

        if len(keys) == 1:
            return data[keys[0]]

        return dict((k, data[k]) for k in keys)

    def write(self, replace=False, **data):
        self._write(replace, **data)
        self.parent.data_changed.send(self.parent, dict(self._data))

    def remove(self, *keys):
        data = self._read()
        for key in keys:
            data.pop(key, None)
        self._write(replace=True, **data)
        self.parent.data_changed.send(self.parent, dict(self._data))

    def read_blob(self, key):
        data = self._read()
        blobs = data.setdefault('blobs', {})
        blob_path = util.unipath(self.blobs_path, blobs[key])
        return types.File(blob_path, mode='rb')

    def write_blob(self, key, data):
        if not os.path.exists(self.blobs_path):
            os.makedirs(self.blobs_path)

        blob_name = key + '.blob'
        with open(util.unipath(self.blobs_path, blob_name), 'wb') as f:
            f.write(data)

        self._write(**dict(blobs={key: blob_name}))
        self.parent.data_changed.send(self.parent, dict(self._data))

    def read_file(self, key):
        data = self._read()
        files = data.setdefault('files', {})
        file_path = util.unipath(self.files_path, files[key])
        return types.File(file_path, mode='rb')

    def write_file(self, key, file):
        if not os.path.exists(self.files_path):
            os.makedirs(self.files_path)

        file_name = os.path.basename(file)
        file_path = util.unipath(self.files_path, file_name)
        util.copy_file(file, file_path)

        self._write(**dict(files={key: file_name}))
        self.parent.data_changed.send(self.parent, dict(self._data))

    def delete(self):
        try:
            shutil.rmtree(self.path)
        except OSError as e:
            if e.errno != errno.EEXIST:
                raise

        self.parent.data_deleted.send(self.parent)


class Entry(object):
    '''Represents a directory with metadata and tags.'''

    created = band.channel('entry.created')
    moved = band.channel('entry.moved')
    tagged = band.channel('entry.data.tagged')
    untagged = band.channel('entry.data.untagged')
    missing = band.channel('entry.missing')
    relinked = band.channel('entry.relinked')
    deleted = band.channel('entry.deleted')
    data_changed = band.channel('entry.data.changed')
    data_deleted = band.channel('entry.data.deleted')
    uuid_changed = band.channel('entry.uuid.changed')

    def __init__(self, path):
        self.path = path
        self.name = os.path.basename(path)
        self.data = EntryData(
            self,
            util.unipath(path, api.get_data_root())
        )

    def __repr__(self):
        return '<fsfs.Entry>(name={name}, path={path})'.format(**self.__dict__)

    def __str__(self):
        return self.path

    def _set_path(self, path, uuid=None, uuid_file=None):
        '''Sets this Entry's path. Used by relink to update this Entry's
        path, name, and it's associated data path. This shouldn't be necessary
        during normal usage.
        '''

        self.path = path
        self.name = os.path.basename(path)
        data_path = util.unipath(path, api.get_data_root())
        self.data._set_path(data_path, uuid, uuid_file)

    @property
    def uuid(self):
        '''Entry's UUID stored in a filename in the data dir uuid_*'''

        return self.data.uuid

    @uuid.setter
    def uuid(self, _id):
        '''Set this Entry's uuid.

        .. warning:: This will invalidate other users cache. You really only
        want to set this at the moment of Entry creation, and hope that nobody
        holds this Entry in their cache.
        '''

        return self.data._set_uuid(_id)

    @property
    def tags(self):
        '''List of this Entry's tags'''

        return self.data.tags

    def tag(self, *tags):
        '''Add tags to this Entry

        Arguments:
            *tags
        '''
        self.data.tag(*tags)

    def untag(self, *tags):
        '''Remove tags from this Entry

        Arguments:
            *tags
        '''
        self.data.untag(*tags)

    def read(self, *keys):
        '''Read this Entry's data

        Arguments:
            *keys: If specified, the returned dict will only contain these keys
                   If only one key is passed, return just the value for that
                   key and not a dict

        Returns:
            dict or value
        '''
        return self.data.read(*keys)

    def write(self, replace=False, **data):
        '''Write data to this Entry

        Arguments:
            **data: key, value pairs to write to the Entry's data
        '''

        self.data.write(replace, **data)

    def remove(self, *keys):
        '''Remove keys from this Entry's data

        Arguments:
            *keys: Keys to remove from data
        '''

        self.data.remove(*keys)

    def read_blob(self, key):
        '''Get a file object representing a blob stored under the specified key

        Arguments:
            key: blob's key

        Returns:
            `fsfs.File` object
        '''

        return self.data.read_blob(key)

    def write_blob(self, key, data):
        '''Write binary data under the specified key. This will store the data
        in a file under the "blobs" subdirectory. You can then get a handle
        to the blob file via `Entry.read_blob(key)`.

        Arguments:
            key: blob's key
            data: binary data
        '''

        self.data.write_blob(key, data)

    def read_file(self, key):
        '''Get a File stored under the specified key

        Arguments:
            key: file's key

        Returns:
            `fsfs.File` object
        '''

        return self.data.read_file(key)

    def write_file(self, key, file):
        '''Write a file under the specified key. This will copy the file to
        the "files" subdirectory. You can then get a handle to the file via
        `Entry.read_file(key)`.

        Arguments:
            key: files's key
            file: path to file
        '''

        self.data.write_file(key, file)

    def parents(self, **kwargs):
        '''Walks up the directory tree yielding Entry objects.

        Arguments:
            **kwargs: search keyword arguments

        Returns:
            Search generator yielding Entry objects
        '''

        kwargs.setdefault('root', self.path)
        kwargs.setdefault('direction', UP)
        kwargs.setdefault('skip_root', True)
        return api.search(**kwargs)

    def parent(self, **kwargs):
        '''Walks up the directory tree returning the first Entry object found.

        Returns:
            Entry: parent of this Entry
        '''

        kwargs.setdefault('root', self.path)
        kwargs.setdefault('direction', UP)
        kwargs.setdefault('skip_root', True)
        return api.search(**kwargs).one()

    def children(self, **kwargs):
        '''Walks down the directory tree yielding Entry objects.

        Arguments:
            **kwargs: search keyword arguments

        Returns:
            Search generator yielding Entry objects
        '''
        kwargs.setdefault('root', self.path)
        kwargs.setdefault('skip_root', True)
        return api.search(**kwargs)

    @property
    def exists(self):
        '''An Entry exists when it's path exists and it's data path exists'''

        return os.path.isdir(self.path) and os.path.isdir(self.data.path)

    def copy(self, dest, only_data=False):
        '''Copy this Entry and it's children to a new location

        Arguments:
            dest (str): Destination path for new Entry
            only_data (bool): Copy only Entry data, includes no files outside
                the Entry's data directories

        Raises:
            OSError: Raised when dest already exists or copy_tree fails.
                Entry is left in original location, any files partially copied
                will be removed.

        '''

        if os.path.exists(dest):
            raise OSError('Can not copy Entry to existing location...')

        try:
            if not only_data:
                util.copy_tree(self.path, dest, force=True, overwrite=True)
            else:
                hierarchy = [self] + list(self.children())
                for entry in hierarchy:
                    old_data_path = entry.data.path
                    rel_data_path = os.path.relpath(old_data_path, self.path)
                    new_data_path = util.unipath(dest, rel_data_path)
                    util.copy_tree(old_data_path, new_data_path)
        except:
            if os.path.exists(dest):
                shutil.rmtree(dest)
            raise

        # Update uuids and send EntryCreated signals
        new_entry = api.get_entry(dest)
        new_entry.data._set_uuid()
        new_entry.created.send(new_entry)
        for child in new_entry.children():
            child.data._set_uuid()
            child.created.send(child)
        return new_entry

    def move(self, dest):
        '''Move this Entry and it's children to a new location

        Arguments:
            dest (str): Destination path for new Entry

        Raises:
            OSError: Raised when dest already exists or move_tree fails.
                Entry is left in original location, any files partially copied
                will be removed.
        '''

        if os.path.exists(dest):
            raise OSError('Can not move Entry to existing location...')

        # Recurisvely moves the tree
        try:
            util.move_tree(self.path, dest, force=True, overwrite=True)
        except:
            if os.path.exists(dest):
                shutil.rmtree(dest)
            raise

        # Update uuids and send EntryCreated signals
        old_path = self.path
        new_path = dest
        self._set_path(dest)  # Update this Entry's path
        self.moved.send(self, old_path, new_path)
        for child in self.children():
            new_child_path = child.path
            old_child_path = new_child_path.replace(new_path, old_path)
            child.moved.send(child, old_child_path, new_child_path)

    def delete(self, remove_root=False):
        '''Delete an entry

        Arguments:
            remove_root (bool): Remove root directory and all it's contents not
                just the data directory that marks this as an Entry
        '''

        self.data.delete()
        self.data = EntryData(
            self,
            util.unipath(self.path, api.get_data_root())
        )

        if remove_root:
            # Delete children depth-first making sure we send all
            # appropriate signals along the way
            for child in list(self.children())[::-1]:
                child.delete(remove_root=remove_root)

            try:
                shutil.rmtree(self.path)
            except OSError as e:
                if e.errno != errno.EEXIST:
                    raise

        self.deleted.send(self)
