import os
import plistlib
# import filelock
import logging
import fcntl
import threading
import time
import xml.parsers.expat

"""
Persistant Configuration
"""

__author__ = 'Sam Forester'
__email__ = 'sam.forester@utah.edu'
__copyright__ = 'Copyright (c) 2019 University of Utah, Marriott Library'
__license__ = 'MIT'
__version__ = "1.3.1"

# suppress "No handlers could be found" message
logging.getLogger(__name__).addHandler(logging.NullHandler())

__all__ = [
    'Manager', 
    'FileLock',
    'TimeoutError',
    'ConfigError'
]

class Error(Exception):
    pass


class ConfigError(Error):
    pass


class Missing(Error):
    pass


class TimeoutError(Error):
    """
    Raised when lock could not be acquired before timeout
    """
    def __init__(self, lockfile):
        self.file = lockfile

    def __str__(self):
        return "{0}: lock could not be acquired".format(self.file)


class ReturnProxy(object):
    """
    Wrap the lock to make sure __enter__ is not called twice
    when entering the with statement.
    
    If we would simply return *self*, the lock would be acquired
    again in the *__enter__* method of the BaseFileLock, 
    but not released again automatically.
    (Not sure if this is pertinant, but it definitely breaks without it)
    """
    def __init__(self, lock):
        self.lock = lock
    def __enter__(self):
        return self.lock
    def __exit__(self, exc_type, exc_value, traceback):
        self.lock.release()


class FileLock(object):
    """
    Unix filelocking 
    Adapted from py-filelock, by Benedikt Schmitt
    https://github.com/benediktschmitt/py-filelock
    """
    def __init__(self, file, timeout=-1):
        self._file = file
        self._fd = None
        self._timeout = timeout
        self._thread_lock = threading.Lock()
        self._counter = 0

    @property
    def file(self):
        """
        :returns: lockfile path
        """
        return self._file

    @property
    def timeout(self):
        """
        :returns: value (in seconds) of the timeout
        """
        return self._timeout

    @timeout.setter
    def timeout(self, value):
        """
        Seconds to wait before raising TimeoutError()
        a negative timeout will disable the timeout
        a timeout of 0 will allow for one attempt acquire the lock
        """        
        self._timeout = float(value)

    @property
    def locked(self):
        """
        :returns: True, if the object holds the file lock, else False
        """
        return self._fd is not None
    
    def _acquire(self):
        """
        Unix based locking using fcntl.flock(LOCK_EX | LOCK_NB)
        """
        flags = os.O_RDWR | os.O_CREAT | os.O_TRUNC
        fd = os.open(self._file, flags, 0644)
        try:
            fcntl.flock(fd, fcntl.LOCK_EX|fcntl.LOCK_NB)
            self._fd = fd
        except (IOError, OSError):
            os.close(fd)

    def _release(self):
        """
        Unix based unlocking using fcntl.flock(LOCK_UN)
        """
        fcntl.flock(self._fd, fcntl.LOCK_UN)
        os.close(self._fd)
        self._fd = None

    def acquire(self, timeout=None, poll_intervall=0.05):
        if not timeout:
            timeout = self.timeout
        with self._thread_lock:
            self._counter += 1

        start = time.time()
        try:
            while True:
                with self._thread_lock:
                    if not self.locked:
                        self._acquire()
                if self.locked:
                    break
                elif timeout >= 0 and (time.time() - start) > timeout:
                    raise TimeoutError(self._file)
                else:
                    time.sleep(poll_intervall)
        except:
            with self._thread_lock:
                self._counter = max(0, self._counter-1)
            raise

        return ReturnProxy(lock=self)

    def release(self, force=False):
        """
        Release the lock.

        Note, that the lock is only completly released, if the 
        lock counter is 0
        
        lockfile is not automatically deleted.

        :arg bool force:
            If true, the lock counter is ignored and the lock is 
            released in every case.
        """
        with self._thread_lock:
            if self.locked:
                self._counter -= 1
                if self._counter == 0 or force:
                    self._release()
                    self._counter = 0

    def __enter__(self):
        self.acquire()

    def __exit__(self, exc_type, exc_value, traceback):
        self.release()

    def __del__(self):
        self.release(force=True)


class Manager(object):
    """
    This class is meant to allow scripts to read and serialize 
    configuration files.

    The configuration files themselves are modified via filelocking to 
    prevent them from being mangled when being accessed by multiple 
    scripts.
    
    :param id: the configuration identifier
    :type id: str

    EXAMPLE:
        conf = config.Manager("foo")  # initializes the config manager
        try:
            settings = conf.read()   # read the config file
        except config.Error:
            settings = {}
    
        settings['foo'] = 'bar'        
        
        conf.write(settings)         # serialize the modified settings
    
    All serialization files will be written to: 
        /user/specified/directory (path specified at instantiation)
        /Library/Management/Configuration
        ~/Library/Management/Configuration
    """
    TMP = '/tmp/config'
    
    def __init__(self, id, path=None, logger=None, **kwargs):
        """
        Setup the configuration manager. Checks to make sure a 
        configuration directory exists (creates directory if not)
        """
        if not logger:
            logger = logging.getLogger(__name__)
            logger.addHandler(logging.NullHandler())
        self.log = logger
        lockdir = self.__class__.TMP
        if not os.path.exists(lockdir):
            os.mkdir(lockdir)
        management = 'Library/Management/Configuration'
        homefolder = os.path.expanduser('~')
        directories = [os.path.join('/', management), 
                       os.path.join(homefolder, management)]
        if path:
            if os.path.isfile(path):
                raise TypeError("not a directory: {0}".format(path))
            try:
                dir = check_and_create_directories([path])
            except ConfigError as e:
                if os.path.isdir(path) and os.access(path, os.R_OK):
                    dir = path
                else:
                    raise e
                    
        else:
            # create the config directory if it doesn't exist
            dir = check_and_create_directories(directories)  
        self.file = os.path.join(dir, "{0}.plist".format(id))
        ## create a lockfile to block race conditions
        self.lockfile = "{0}/{1}.lockfile".format(lockdir, id)
        # self.lock = filelock.FileLock(self.lockfile, **kwargs)
        self.lock = FileLock(self.lockfile, **kwargs)


    def write(self, data):
        """
        Serializes specified settings to file
        """
        with self.lock.acquire():
            plistlib.writePlist(data, self.file)

    def read(self):
        """
        :returns: data structure (list|dict) as read from disk
        :raises: ConfigError if unable to read
        """
        if not os.path.exists(self.file):
            raise Missing("file missing: {0}".format(self.file))

        try:
            with self.lock.acquire():
                return plistlib.readPlist(self.file)
        except xml.parsers.expat.ExpatError:
            raise ConfigError("corrupted plist: {0}".format(self.file))

    # TYPE SPECIFIC FUNCTIONS
    def get(self, key, default=None):
        with self.lock.acquire():
            data = self.read()
            return data.get(key, default)
    
    def update(self, value):
        """
        read data from file, update data, and write back to file
        """
        with self.lock.acquire():        
            data = self.read()
            data.update(value)
            self.write(data)
            return data

    def delete(self, key):
        """
        read data from file, update data, and write back to file
        """
        with self.lock.acquire():        
            data = self.read()
            v = data.pop(key)
            self.write(data)
            return v

    def deletekeys(self, keys):
        """
        remove specified keys from file (if they exist)
        returns old values as dictionary
        """
        with self.lock.acquire():        
            data = self.read()
            _old = {}
            for key in keys:
                try:
                    _old[key] = data.pop(key)
                except KeyError:
                    pass
            self.write(data)
            return _old
            
    # EXPERIMENTAL
    def reset(self, key, value):
        """
        this is poor design, but I'm going to leave it for now
        overwrites existing key with value
        returns previous value
        """
        with self.lock.acquire():        
            data = self.read()
            previous = data[key]
            data[key] = value
            self.write(data)
            return previous

    def append(self, value):
        with self.lock.acquire():        
            data = self.read()
            data.append(value)
            self.write(data)
            return data

    def remove(self, key, value=None):
        with self.lock.acquire():        
            data = self.read()
            if value:                
                if isinstance(data[key], list):
                    data[key].remove(value)
                elif isinstance(data[key], dict):
                    data[key].pop(value)
            elif value is None:
                del(data[key])
            else:
                if isinstance(data, list):
                    data.remove(value)
                elif isinstance(data, dict):
                    data.pop(value)
            
            self.write(data)

    def add(self, key, value):
        with self.lock.acquire():        
            data = self.read()
            try:
                for i in value:
                    if i not in data[key]:
                        data[key].append(i)
            # TO-DO: Is there a reason I'm catching KeyError specifically?
            except:
                data[key].append(value)
            self.write(data)

    def setdefault(self, key, default=None):
        with self.lock.acquire():
            data = self.read()
            try:
                return data[key]
            except KeyError:
                data[key] = default
                if default is not None:
                    self.write(data)
                return default


def check_and_create_directories(dirs, mode=0755):
    """
    checks list of directories to see what would be a suitable place
    to write the configuration file
    """
    for path in dirs:
        try:
            os.makedirs(path, mode)
            return path
        except OSError as e:
            if e.errno == 17 and os.access(path, os.W_OK):
                # directory already exists and is writable
                return path
    ## exhausted all options
    raise ConfigError("no suitable directory was found for config")
    

if __name__ == '__main__':
    pass