# -*- coding: utf-8 -*-

# Copyright 2011 OpenStack Foundation.
# 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.

from contextlib import contextmanager
import errno
import functools
import logging
import os
from pathlib import Path
import threading
import time

from ._utils import *
from .process_mechanism import _interprocess_mechanism
from .process_mechanism import _interprocess_reader_writer_mechanism

LOG = logging.getLogger(__name__)


def _ensure_tree(path):
    """Create a directory (and any ancestor directories required).

    :param path: Directory to create
    """
    try:
        os.makedirs(path)
    except OSError as e:
        if e.errno == errno.EEXIST:
            if not os.path.isdir(path):
                raise
            else:
                return False
        elif e.errno == errno.EISDIR:
            return False
        else:
            raise
    else:
        return True


class InterProcessLock:
    """An interprocess lock."""

    MAX_DELAY = 0.1  # For backwards compatibility
    DELAY_INCREMENT = 0.01  # For backwards compatibility

    def __init__(
        self,
        path,
        sleep_func=time.sleep,
        logger=None,
    ):
        """
        args:
            path:
                Path to the file that will be used for locking.
            sleep_func:
                Optional function to use for sleeping.
            logger:
                Optional logger to use for logging.
        """
        self.lockfile = None
        self.path = canonicalize_path(path)
        self.acquired = False
        self.sleep_func = sleep_func
        self.logger = pick_first_not_none(logger, LOG)

    def __del__(self):
        """Ensure the file handle is closed when the object is garbage collected."""
        try:
            if self.lockfile is not None:
                self._do_close()
        except Exception:
            # Suppress errors during object destruction (e.g. interpreter shutdown)
            pass

    def _try_acquire(self, blocking, watch):
        try:
            self.trylock()
        except (IOError, OSError) as e:
            if e.errno in (errno.EACCES, errno.EAGAIN):
                if not blocking or watch.expired():
                    return False
                else:
                    raise RetryAgain()
            else:
                raise threading.ThreadError(
                    "Unable to acquire lock on"
                    " `%(path)s` due to"
                    " %(exception)s"
                    % {
                        "path": self.path,
                        "exception": e,
                    }
                )
        else:
            return True

    def _do_open(self):
        basedir = os.path.dirname(self.path)
        if basedir:
            made_basedir = _ensure_tree(basedir)
            if made_basedir:
                self.logger.log(BLATHER, "Created lock base path `%s`", basedir)
        # Open in append mode so we don't overwrite any potential contents of
        # the target file. This eliminates the possibility of an attacker
        # creating a symlink to an important file in our lock path.
        if self.lockfile is None or self.lockfile.closed:
            self.lockfile = open(self.path, "a")

    def acquire(
        self,
        blocking=True,
        delay=0.01,
        max_delay=0.1,
        timeout=None,
    ):
        """Attempt to acquire the lock.

        Args:
            blocking:
                Whether to wait to try to acquire the lock.
            delay:
                When `blocking`, starting delay as well as the delay increment
                (in seconds).
            max_delay:
                When `blocking` the maximum delay in between attempts to
                acquire (in seconds).
            timeout:
                When `blocking`, maximal waiting time (in seconds).

        Returns:
            whether or not the acquisition succeeded
        """
        if delay < 0:
            raise ValueError("Delay must be greater than or equal to zero")
        if timeout is not None and timeout < 0:
            raise ValueError("Timeout must be greater than or equal to zero")
        if delay >= max_delay:
            max_delay = delay

        try:
            self._do_open()
            watch = StopWatch(duration=timeout)
            r = Retry(delay, max_delay, sleep_func=self.sleep_func, watch=watch)
            with watch:
                gotten = r(self._try_acquire, blocking, watch)

            if not gotten:
                self.acquired = False
                return False
            else:
                self.acquired = True
                self.logger.log(
                    BLATHER,
                    "Acquired file lock `%s` after waiting %0.3fs [%s"
                    " attempts were required]",
                    self.path,
                    watch.elapsed(),
                    r.attempts,
                )
                return True
        finally:
            # Crucial fix: If we failed to acquire (gotten is False, or exception raised),
            # we MUST close the file handle immediately.
            if not self.acquired:
                self._do_close()

    def _do_close(self):
        if self.lockfile is not None:
            try:
                self.lockfile.close()
            except (IOError, OSError):
                pass
            finally:
                self.lockfile = None

    def __enter__(self):
        gotten = self.acquire()
        if not gotten:
            # This shouldn't happen, but just in case...
            raise threading.ThreadError(
                "Unable to acquire a file lock"
                " on `%s` (when used as a"
                " context manager)" % self.path
            )
        return self

    def release(self):
        """Release the previously acquired lock."""
        if not self.acquired:
            raise threading.ThreadError("Unable to release an unacquired lock")
        try:
            self.unlock()
        except Exception as e:
            msg = "Could not unlock the acquired lock opened on `%s`", self.path
            self.logger.exception(msg)
            raise threading.ThreadError(msg) from e
        else:
            self.acquired = False
            try:
                self._do_close()
            except IOError:
                self.logger.exception(
                    "Could not close the file handle opened on `%s`", self.path
                )
            else:
                self.logger.log(
                    BLATHER, "Unlocked and closed file lock open on `%s`", self.path
                )

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.release()

    def exists(self):
        return os.path.exists(self.path)

    def trylock(self):
        _interprocess_mechanism.trylock(self.lockfile)

    def unlock(self):
        _interprocess_mechanism.unlock(self.lockfile)


class InterProcessReaderWriterLock:
    """An interprocess readers writer lock."""

    MAX_DELAY = 0.1  # for backwards compatibility
    DELAY_INCREMENT = 0.01  # for backwards compatibility

    def __init__(
        self,
        path,
        sleep_func=time.sleep,
        logger=None,
    ):
        """
        Args:
            path:
                Path to the file that will be used for locking.
            sleep_func:
                Optional function to use for sleeping.
            logger:
                Optional logger to use for logging.
        """
        self.lockfile = None
        self.path = canonicalize_path(path)
        self.sleep_func = sleep_func
        self.logger = pick_first_not_none(logger, LOG)

    def __del__(self):
        """Ensure the file handle is closed when the object is garbage collected."""
        try:
            if self.lockfile is not None:
                self._do_close()
        except Exception:
            pass

    @contextmanager
    def read_lock(self, delay=0.01, max_delay=0.1):
        """Context manager that grans a read lock"""

        self.acquire_read_lock(
            blocking=True, delay=delay, max_delay=max_delay, timeout=None
        )
        try:
            yield
        finally:
            self.release_read_lock()

    @contextmanager
    def write_lock(self, delay=0.01, max_delay=0.1):
        """Context manager that grans a write lock"""

        gotten = self.acquire_write_lock(
            blocking=True, delay=delay, max_delay=max_delay, timeout=None
        )

        if not gotten:
            # This shouldn't happen, but just in case...
            raise threading.ThreadError(
                "Unable to acquire a file lock"
                " on `%s` (when used as a"
                " context manager)" % self.path
            )
        try:
            yield
        finally:
            self.release_write_lock()

    def _try_acquire(self, blocking, watch, exclusive):
        try:
            gotten = _interprocess_reader_writer_mechanism.trylock(
                self.lockfile, exclusive
            )
        except Exception as e:
            raise threading.ThreadError(
                "Unable to acquire lock on {} due to {}!".format(self.path, e)
            )

        if gotten:
            return True

        if not blocking or watch.expired():
            return False

        raise RetryAgain()

    def _do_open(self):
        basedir = os.path.dirname(self.path)
        if basedir:
            made_basedir = _ensure_tree(basedir)
            if made_basedir:
                self.logger.log(BLATHER, "Created lock base path `%s`", basedir)
        if self.lockfile is None:
            self.lockfile = _interprocess_reader_writer_mechanism.get_handle(self.path)

    def acquire_read_lock(
        self,
        blocking=True,
        delay=0.01,
        max_delay=0.1,
        timeout=None,
    ):
        """Attempt to acquire a reader's lock."""
        return self._acquire(blocking, delay, max_delay, timeout, exclusive=False)

    def acquire_write_lock(
        self,
        blocking=True,
        delay=0.01,
        max_delay=0.1,
        timeout=None,
    ):
        """Attempt to acquire a writer's lock."""
        return self._acquire(blocking, delay, max_delay, timeout, exclusive=True)

    def _acquire(
        self, blocking=True, delay=0.01, max_delay=0.1, timeout=None, exclusive=True
    ):
        if delay < 0:
            raise ValueError("Delay must be greater than or equal to zero")
        if timeout is not None and timeout < 0:
            raise ValueError("Timeout must be greater than or equal to zero")
        if delay >= max_delay:
            max_delay = delay

        success = False
        try:
            self._do_open()
            watch = StopWatch(duration=timeout)
            r = Retry(delay, max_delay, sleep_func=self.sleep_func, watch=watch)
            with watch:
                gotten = r(self._try_acquire, blocking, watch, exclusive)

            if gotten:
                self.logger.log(
                    BLATHER,
                    "Acquired file lock `%s` after waiting %0.3fs [%s"
                    " attempts were required]",
                    self.path,
                    watch.elapsed(),
                    r.attempts,
                )
                success = True
                return True
            else:
                return False
        finally:
            if not success:
                self._do_close()

    def _do_close(self):
        if self.lockfile is not None:
            _interprocess_reader_writer_mechanism.close_handle(self.lockfile)
            self.lockfile = None

    def release_write_lock(self):
        """Release the writer's lock."""
        try:
            _interprocess_reader_writer_mechanism.unlock(self.lockfile)
        except IOError:
            self.logger.exception(
                "Could not unlock the acquired lock opened on `%s`", self.path
            )
        else:
            try:
                self._do_close()
            except IOError:
                self.logger.exception(
                    "Could not close the file handle opened on `%s`", self.path
                )
            else:
                self.logger.log(
                    BLATHER, "Unlocked and closed file lock open on `%s`", self.path
                )

    def release_read_lock(self):
        """Release the reader's lock."""
        try:
            _interprocess_reader_writer_mechanism.unlock(self.lockfile)
        except IOError:
            self.logger.exception(
                "Could not unlock the acquired lock opened on `%s`", self.path
            )
        else:
            try:
                self._do_close()
            except IOError:
                self.logger.exception(
                    "Could not close the file handle opened on `%s`", self.path
                )
            else:
                self.logger.log(
                    BLATHER, "Unlocked and closed file lock open on `%s`", self.path
                )


def interprocess_write_locked(path):
    """Acquires & releases an interprocess  **write** lock around the call into
    the decorated function

    Args:
        path: Path to the file used for locking.
    """
    lock = InterProcessReaderWriterLock(path)

    def decorator(f):
        @functools.wraps(f)
        def wrapper(*args, **kwargs):
            with lock.write_lock():
                return f(*args, **kwargs)

        return wrapper

    return decorator


def interprocess_read_locked(path):
    """Acquires & releases an interprocess **read** lock around the call into
    the decorated function

    Args:
        path: Path to the file used for locking.
    """
    lock = InterProcessReaderWriterLock(path)

    def decorator(f):
        @functools.wraps(f)
        def wrapper(*args, **kwargs):
            with lock.read_lock():
                return f(*args, **kwargs)

        return wrapper

    return decorator


def interprocess_locked(path):
    """Acquires & releases an interprocess lock around the call to the
    decorated function.

    Args:
        path: Path to the file used for locking.
    """
    lock = InterProcessLock(path)

    def decorator(f):
        @functools.wraps(f)
        def wrapper(*args, **kwargs):
            with lock:
                return f(*args, **kwargs)

        return wrapper

    return decorator
