# -*- coding: utf-8 -*-
#
##########################################################################
# pyeole.lock - EOLE file locking implementation
# Copyright © 2012 Pôle de compétences EOLE <eole@ac-dijon.fr>
#
# License CeCILL:
#  * in french: http://www.cecill.info/licences/Licence_CeCILL_V2-fr.html
#  * in english http://www.cecill.info/licences/Licence_CeCILL_V2-en.html
##########################################################################

"""EOLE file locking implementation

This module is useful if you need to use a lock action to prevent
another one.

Generated lock file includes a pid number. This prevents from another
action to acquire or release his own one.

Take a look at :eole:`projects/creole/wiki/Lock24` for more
information (in french)

Two levels are available:

- *normal*: all lock file are independant

- *system*: if one lock is set, another system lock is not allowed.

The lock name must be a word of lower case alphanum caracters.

Examples:

Acquire new lock:

    >>> from lock import acquire
    >>> acquire('lockname')

Release lock:

    >>> from lock import release
    >>> release('lockname')

Add and release system level:

    >>> from lock import acquire, release, is_locked
    >>> acquire('lockname', level='system')
    >>> if is_locked('lockname', 'system'):
    >>>     print ("locked!")
    locked!
    >>> release('lockname', 'system')
    >>> if is_locked('lockname', 'system'):
    >>>     print ("locked!")

Get system lock name:

    >>> from lock import acquire, get_system_lock_name
    >>> acquire('lockname', level='system')
    >>> print (get_system_lock_name())
    lockname

"""
import sys
from os.path import join, isfile, isdir
from os import getpid, extsep, unlink, makedirs
from glob import glob
from creole.config import LOCK_PATH, LOCK_SYSTEM_PATH
import re

# from pyeole.log import getLogger
# log = getLogger(__name__)


if sys.version_info[0] >= 3:
    class StandardError(Exception):
        pass

PID = getpid()
VALID_NAME_RE = re.compile(r'[^a-z0-9-_]|^$')

__help__ = {}

class AlreadyLocked(StandardError):
    """Lock is already set for this PID.

    """
    pass

class NotMyLock(StandardError):
    """Lock is already set for another PID.

    """
    pass

class NotLocked(StandardError):
    """No lock is set.

    """
    pass

def _valid_name(name):
    """Valid lock :data:`name`. Only lower alphanum caracters.

    :param name: tested name
    :type name: `str`
    :raise ValueError: for invalid name

    """
    if VALID_NAME_RE.search(name):
        raise ValueError('name must be alphanum and in lower case, not '
                '{}'.format(name))

def _check_level(level):
    """
    Valid level's name.

    :param level: name of lock level (``normal`` or ``system``)
    :type level: `str` ``normal`` or ``system``
    :raise ValueError: if :data:`level` is unknown
    """
    if level not in ['normal', 'system']:
        raise ValueError('level must be normal or system, not {}'.format(level))

def _get_dir_lock(level):
    """Get directory where lock files are stored.

    :param level: name of lock level
    :type level: `str` ``normal`` or ``system``
    :return: dir name
    :rtype: `str`

    """
    if level == 'system':
        return LOCK_SYSTEM_PATH
    else:
        return LOCK_PATH

def _get_lock(name, level, pid=True):
    """Get lock file's name for specified :data:`name` and :data:`level`.

    :param name: lock name
    :type name: `str`
    :param level: name of lock level
    :type level: `str` ``normal`` or ``system``
    :param pid: if ``True``, set pid value has extension, otherwise
                set `*` (useful in conjonction with glob)
    :type pid: `boolean`
    :return: lock file's name
    :rtype: `str`

    """
    path = _get_dir_lock(level)
    name = name+extsep
    if pid:
        name = name+str(PID)
    else:
        name = name+'*'
    return join(path, name)

def _get_all_lock(name=None, level='normal', all=False):
    """Gets a list of available lock files.

    :param name: lock name (must not be set if all is True)
    :type name: `str`
    :param level: lock level
    :type level: `str` ``normal`` or ``system``
    :param all: If level is system and True return all system
                lockfiles
    :type all: `boolean`
    :return: list of files
    :rtype: `list`

    """
    if level == 'normal':
        _valid_name(name)
        return glob(_get_lock(name, level, pid=False))
    else:
        if all:
            if name is not None:
                raise ValueError('you must not set name if level is system all is True')
            return glob(join(_get_dir_lock(level), '*'))
        else:
            return glob(_get_lock(name, level, pid=False))

def _release_not_my_lock(name, level):
    """Remove extra lock file from this PID or other one's.

    :param name: lock name
    :type name: `str`
    :param level: lock level
    :type level: `str` ``normal`` or ``system``

    """
    for remlock in _get_all_lock(name, level):
        unlink(remlock)

def _test_not_locked(name, level):
    """Test if no lock is set.

    :param name: lock name
    :type name: `str`
    :param level: lock level
    :type level: `str` ``normal`` or ``system``

    """
    if level == 'normal':
        _test_not_locked_file(name)
    else:
        _test_not_locked_expert(name)

def _test_not_locked_file(name, level='normal'):
    """Test if no lock file

    :param name: lock name
    :type name: `str`
    :param level: lock level
    :type level: `str` ``normal`` or ``system``
    :raise AlreadyLocked: for own lock's file
    :raise NotMyLock: for other process lock's file

    """
    if name == None:
        raise ValueError('name need to be set')
    lock_name = _get_lock(name, level)
    if isfile(lock_name):
        msg = u'A {0} lock is already set for {1}: {2}'
        raise AlreadyLocked(msg.format(level, name, lock_name))
    lock_name = _get_lock(name, level, pid=False)
    lock_files = glob(lock_name)
    if lock_files:
        if len(lock_files) > 1:
            msg = u'Some {0} locks are already set by some other processes: {1}'
        else:
            msg = u'A {0} lock is already set by another process: {1}'
        raise NotMyLock(msg.format(level, ', '.join(lock_files)))

def _test_not_locked_expert(name=None):
    if name is not None:
        _test_not_locked_file(name, level='system')
    expert_locks = _get_all_lock(level='system', all=True)
    if expert_locks:
        if len(expert_locks) > 1:
            msg = 'Some system locks are already set: {0}'
        else:
            msg = 'A system lock is already set: {0}'
        raise NotMyLock(msg.format(', '.join(expert_locks)))

__help__['acquire'] = {'name': 'le nom du lock',
    'valid': "ne pas faire d'erreur si le fichier existe déjà",
    'level': 'nom du niveau (normal ou system)'}
def acquire(name, valid=True, level='normal'):
    """Create new lock.

    If :data:`level` is:

      - ``normal``: test if this lock is already set

      - ``system``: test if a system lock is already set

    :param name: lock name
    :type name: `str`
    :param valid: if `False`, the :exc:`AlreadyLocked` exception is
                  raised if already exists (set by this process or
                  other one's)
    :type valid: `boolean`
    :param level: name of lock level.
    :type level: `str` ``normal`` or ``system``
    :raise AlreadyLocked: for own lock's file
    :raise NotMyLock: for other process lock's file

    """
    _valid_name(name)
    _check_level(level)
    if valid:
        try:
            _test_not_locked(name=name, level=level)
        except NotMyLock as err:
            #acquire didn't raise with NotMyLock
            raise AlreadyLocked(str(err))
    else:
        _release_not_my_lock(name=name, level=level)
    dirname = _get_dir_lock(level)
    if not isdir(dirname):
        makedirs(dirname)
    open(_get_lock(name, level), "wb").close()

__help__['release'] = {'name': 'le nom du lock',
    'valid': "ne pas faire d'erreur si le fichier existe déjà",
    'force': "supprimer des locks d'autres processus",
    'level': 'nom du niveau (normal ou system)'}
def release(name, valid=True, force=False, level='normal'):
    """Delete lock.

    :param name: lock name
    :type name: `str`
    :param valid: if `False`, the :exc:`AlreadyLocked` exception is raised if
                  already exists (set by this process or other one's)
    :type valid: `boolean`
    :param force: delete old lock create by another thread or process
                  otherwise the NotMyLock exception is raised
    :type force: `boolean`
    :param level: name of lock level.
    :type level: `str` ``normal`` or ``system``
    :raise AlreadyLocked: for own lock's file
    :raise NotMyLock: for other process lock's file

    """
    _valid_name(name)
    _check_level(level)
    try:
        _test_not_locked(name=name, level=level)
        if valid:
            raise NotLocked('{} with level {} is not locked'.format(name, level))
    except AlreadyLocked:
        unlink(_get_lock(name, level))
    except NotMyLock as err:
        if not force:
            raise NotMyLock(str(err))
        _release_not_my_lock(name, level)

__help__['is_locked'] = {'name': 'le nom du lock',
    'level': 'nom du niveau (normal ou system)'}
def is_locked(name='', level='normal'):
    """Get status of the lock.

    :param name: lock name (not need for level ``system``)
    :type name: `str`
    :param level: name of lock level.
    :type level: `str` ``normal`` or ``system``
    :return: if the lock is present
    :rtype: `boolean`

    """
    _check_level(level)
    if name == '':
        name = None
    if name is not None:
        _valid_name(name)
    try:
        _test_not_locked(name, level)
        return False
    except AlreadyLocked:
        return True
    except NotMyLock:
        return True

def is_name_locked(name=None, level='normal'):
    """Get status of a specific lock.

    :param name: lock name (not need for level ``system``)
    :type name: `str`
    :param level: name of lock level.
    :type level: `str` ``normal`` or ``system``
    :return: if the lock is present
    :rtype: `boolean`

    """
    _check_level(level)
    if name is not None:
        _valid_name(name)
    try:
        _test_not_locked(name, level)
        return False
    except AlreadyLocked:
        return True
    except NotMyLock:
        return False

def get_system_lock_name(name=None):
    """Return the name of the system lock.

    :param name: lock name (if level is ``system`` return only other
                 lock file)
    :type name: `str`
    :return: system lock files, empty list if no system lock
    :rtype: `list`

    """
    if name is not None:
        _valid_name(name)
    files = []
    for file_ in _get_all_lock(level='system', all=True):
        splitted = file_.split(extsep)
        #remove name from the returned list
        if name is None or splitted[0] != name:
            files.append(splitted[0])
    return files
