# Copyright 2014 Google Inc. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Locked file interface that should work on Unix and Windows pythons.

This module first tries to use fcntl locking to ensure serialized access
to a file, then falls back on a lock file if that is unavialable.

Usage::

    f = LockedFile('filename', 'r+b', 'rb')
    f.open_and_lock()
    if f.is_locked():
      print('Acquired filename with r+b mode')
      f.file_handle().write('locked data')
    else:
      print('Acquired filename with rb mode')
    f.unlock_and_close()

"""

from __future__ import print_function

import errno
import logging
import os
import time

from oauth2client import util


__author__ = 'cache@google.com (David T McWherter)'

logger = logging.getLogger(__name__)


class CredentialsFileSymbolicLinkError(Exception):
    """Credentials files must not be symbolic links."""


class AlreadyLockedException(Exception):
    """Trying to lock a file that has already been locked by the LockedFile."""
    pass


def validate_file(filename):
    if os.path.islink(filename):
        raise CredentialsFileSymbolicLinkError(
            'File: %s is a symbolic link.' % filename)


class _Opener(object):
    """Base class for different locking primitives."""

    def __init__(self, filename, mode, fallback_mode):
        """Create an Opener.

        Args:
            filename: string, The pathname of the file.
            mode: string, The preferred mode to access the file with.
            fallback_mode: string, The mode to use if locking fails.
        """
        self._locked = False
        self._filename = filename
        self._mode = mode
        self._fallback_mode = fallback_mode
        self._fh = None
        self._lock_fd = None

    def is_locked(self):
        """Was the file locked."""
        return self._locked

    def file_handle(self):
        """The file handle to the file. Valid only after opened."""
        return self._fh

    def filename(self):
        """The filename that is being locked."""
        return self._filename

    def open_and_lock(self, timeout, delay):
        """Open the file and lock it.

        Args:
            timeout: float, How long to try to lock for.
            delay: float, How long to wait between retries.
        """
        pass

    def unlock_and_close(self):
        """Unlock and close the file."""
        pass


class _PosixOpener(_Opener):
    """Lock files using Posix advisory lock files."""

    def open_and_lock(self, timeout, delay):
        """Open the file and lock it.

        Tries to create a .lock file next to the file we're trying to open.

        Args:
            timeout: float, How long to try to lock for.
            delay: float, How long to wait between retries.

        Raises:
            AlreadyLockedException: if the lock is already acquired.
            IOError: if the open fails.
            CredentialsFileSymbolicLinkError if the file is a symbolic link.
        """
        if self._locked:
            raise AlreadyLockedException('File %s is already locked' %
                                         self._filename)
        self._locked = False

        validate_file(self._filename)
        try:
            self._fh = open(self._filename, self._mode)
        except IOError as e:
            # If we can't access with _mode, try _fallback_mode and don't lock.
            if e.errno == errno.EACCES:
                self._fh = open(self._filename, self._fallback_mode)
                return

        lock_filename = self._posix_lockfile(self._filename)
        start_time = time.time()
        while True:
            try:
                self._lock_fd = os.open(lock_filename,
                                        os.O_CREAT | os.O_EXCL | os.O_RDWR)
                self._locked = True
                break

            except OSError as e:
                if e.errno != errno.EEXIST:
                    raise
                if (time.time() - start_time) >= timeout:
                    logger.warn('Could not acquire lock %s in %s seconds',
                                lock_filename, timeout)
                    # Close the file and open in fallback_mode.
                    if self._fh:
                        self._fh.close()
                    self._fh = open(self._filename, self._fallback_mode)
                    return
                time.sleep(delay)

    def unlock_and_close(self):
        """Unlock a file by removing the .lock file, and close the handle."""
        if self._locked:
            lock_filename = self._posix_lockfile(self._filename)
            os.close(self._lock_fd)
            os.unlink(lock_filename)
            self._locked = False
            self._lock_fd = None
        if self._fh:
            self._fh.close()

    def _posix_lockfile(self, filename):
        """The name of the lock file to use for posix locking."""
        return '%s.lock' % filename


class LockedFile(object):
    """Represent a file that has exclusive access."""

    @util.positional(4)
    def __init__(self, filename, mode, fallback_mode, use_native_locking=True):
        """Construct a LockedFile.

        Args:
            filename: string, The path of the file to open.
            mode: string, The mode to try to open the file with.
            fallback_mode: string, The mode to use if locking fails.
            use_native_locking: bool, Whether or not fcntl/win32 locking is
                                used.
        """
        opener = None
        if not opener and use_native_locking:
            try:
                from oauth2client.contrib._win32_opener import _Win32Opener
                opener = _Win32Opener(filename, mode, fallback_mode)
            except ImportError:
                try:
                    from oauth2client.contrib._fcntl_opener import _FcntlOpener
                    opener = _FcntlOpener(filename, mode, fallback_mode)
                except ImportError:
                    pass

        if not opener:
            opener = _PosixOpener(filename, mode, fallback_mode)

        self._opener = opener

    def filename(self):
        """Return the filename we were constructed with."""
        return self._opener._filename

    def file_handle(self):
        """Return the file_handle to the opened file."""
        return self._opener.file_handle()

    def is_locked(self):
        """Return whether we successfully locked the file."""
        return self._opener.is_locked()

    def open_and_lock(self, timeout=0, delay=0.05):
        """Open the file, trying to lock it.

        Args:
            timeout: float, The number of seconds to try to acquire the lock.
            delay: float, The number of seconds to wait between retry attempts.

        Raises:
            AlreadyLockedException: if the lock is already acquired.
            IOError: if the open fails.
        """
        self._opener.open_and_lock(timeout, delay)

    def unlock_and_close(self):
        """Unlock and close a file."""
        self._opener.unlock_and_close()
