#!/usr/bin/env python
# encoding: utf-8
#
# Copyright (c) 2017 Dean Jackson <deanishe@deanishe.net>
#
# MIT Licence. See http://opensource.org/licenses/MIT
#
# Created on 2017-12-17
#

"""A selection of helper functions useful for building workflows."""

from __future__ import print_function, absolute_import

import atexit
from collections import namedtuple
from contextlib import contextmanager
import errno
import fcntl
import functools
import os
import signal
import subprocess
import sys
from threading import Event
import time

# AppleScript to call an External Trigger in Alfred
AS_TRIGGER = """
tell application "Alfred 3"
run trigger "{name}" in workflow "{bundleid}" {arg}
end tell
"""

# AppleScript to save a variable in info.plist
AS_CONFIG_SET = """
tell application "Alfred 3"
set configuration "{name}" to value "{value}" in workflow "{bundleid}" {export}
end tell
"""

# AppleScript to remove a variable from info.plist
AS_CONFIG_UNSET = """
tell application "Alfred 3"
remove configuration "{name}" in workflow "{bundleid}"
end tell
"""


class AcquisitionError(Exception):
    """Raised if a lock cannot be acquired."""


AppInfo = namedtuple('AppInfo', ['name', 'path', 'bundleid'])
"""Information about an installed application.

Returned by :func:`appinfo`. All attributes are Unicode.

.. py:attribute:: name

    Name of the application, e.g. ``u'Safari'``.

.. py:attribute:: path

    Path to the application bundle, e.g. ``u'/Applications/Safari.app'``.

.. py:attribute:: bundleid

    Application's bundle ID, e.g. ``u'com.apple.Safari'``.

"""


def unicodify(s, encoding='utf-8', norm=None):
    """Ensure string is Unicode.

    .. versionadded:: 1.31

    Decode encoded strings using ``encoding`` and normalise Unicode
    to form ``norm`` if specified.

    Args:
        s (str): String to decode. May also be Unicode.
        encoding (str, optional): Encoding to use on bytestrings.
        norm (None, optional): Normalisation form to apply to Unicode string.

    Returns:
        unicode: Decoded, optionally normalised, Unicode string.

    """
    if not isinstance(s, unicode):
        s = unicode(s, encoding)

    if norm:
        from unicodedata import normalize
        s = normalize(norm, s)

    return s


def utf8ify(s):
    """Ensure string is a bytestring.

    .. versionadded:: 1.31

    Returns `str` objects unchanced, encodes `unicode` objects to
    UTF-8, and calls :func:`str` on anything else.

    Args:
        s (object): A Python object

    Returns:
        str: UTF-8 string or string representation of s.

    """
    if isinstance(s, str):
        return s

    if isinstance(s, unicode):
        return s.encode('utf-8')

    return str(s)


def applescriptify(s):
    """Escape string for insertion into an AppleScript string.

    .. versionadded:: 1.31

    Replaces ``"`` with `"& quote &"`. Use this function if you want

    to insert a string into an AppleScript script:
        >>> script = 'tell application "Alfred 3" to search "{}"'
        >>> query = 'g "python" test'
        >>> script.format(applescriptify(query))
        'tell application "Alfred 3" to search "g " & quote & "python" & quote & "test"'

    Args:
        s (unicode): Unicode string to escape.

    Returns:
        unicode: Escaped string

    """
    return s.replace(u'"', u'" & quote & "')


def run_command(cmd, **kwargs):
    """Run a command and return the output.

    .. versionadded:: 1.31

    A thin wrapper around :func:`subprocess.check_output` that ensures
    all arguments are encoded to UTF-8 first.

    Args:
        cmd (list): Command arguments to pass to ``check_output``.
        **kwargs: Keyword arguments to pass to ``check_output``.

    Returns:
        str: Output returned by ``check_output``.

    """
    cmd = [utf8ify(s) for s in cmd]
    return subprocess.check_output(cmd, **kwargs)


def run_applescript(script, *args, **kwargs):
    """Execute an AppleScript script and return its output.

    .. versionadded:: 1.31

    Run AppleScript either by filepath or code. If ``script`` is a valid
    filepath, that script will be run, otherwise ``script`` is treated
    as code.

    Args:
        script (str, optional): Filepath of script or code to run.
        *args: Optional command-line arguments to pass to the script.
        **kwargs: Pass ``lang`` to run a language other than AppleScript.

    Returns:
        str: Output of run command.

    """
    cmd = ['/usr/bin/osascript', '-l', kwargs.get('lang', 'AppleScript')]

    if os.path.exists(script):
        cmd += [script]
    else:
        cmd += ['-e', script]

    cmd.extend(args)

    return run_command(cmd)


def run_jxa(script, *args):
    """Execute a JXA script and return its output.

    .. versionadded:: 1.31

    Wrapper around :func:`run_applescript` that passes ``lang=JavaScript``.

    Args:
        script (str): Filepath of script or code to run.
        *args: Optional command-line arguments to pass to script.

    Returns:
        str: Output of script.

    """
    return run_applescript(script, *args, lang='JavaScript')


def run_trigger(name, bundleid=None, arg=None):
    """Call an Alfred External Trigger.

    .. versionadded:: 1.31

    If ``bundleid`` is not specified, reads the bundle ID of the current
    workflow from Alfred's environment variables.

    Args:
        name (str): Name of External Trigger to call.
        bundleid (str, optional): Bundle ID of workflow trigger belongs to.
        arg (str, optional): Argument to pass to trigger.

    """
    if not bundleid:
        bundleid = os.getenv('alfred_workflow_bundleid')

    if arg:
        arg = 'with argument "{}"'.format(applescriptify(arg))
    else:
        arg = ''

    script = AS_TRIGGER.format(name=name, bundleid=bundleid,
                               arg=arg)

    run_applescript(script)


def set_config(name, value, bundleid=None, exportable=False):
    """Set a workflow variable in ``info.plist``.

    .. versionadded:: 1.33

    Args:
        name (str): Name of variable to set.
        value (str): Value to set variable to.
        bundleid (str, optional): Bundle ID of workflow variable belongs to.
        exportable (bool, optional): Whether variable should be marked
            as exportable (Don't Export checkbox).

    """
    if not bundleid:
        bundleid = os.getenv('alfred_workflow_bundleid')

    name = applescriptify(name)
    value = applescriptify(value)
    bundleid = applescriptify(bundleid)

    if exportable:
        export = 'exportable true'
    else:
        export = 'exportable false'

    script = AS_CONFIG_SET.format(name=name, bundleid=bundleid,
                                  value=value, export=export)

    run_applescript(script)


def unset_config(name, bundleid=None):
    """Delete a workflow variable from ``info.plist``.

    .. versionadded:: 1.33

    Args:
        name (str): Name of variable to delete.
        bundleid (str, optional): Bundle ID of workflow variable belongs to.

    """
    if not bundleid:
        bundleid = os.getenv('alfred_workflow_bundleid')

    name = applescriptify(name)
    bundleid = applescriptify(bundleid)

    script = AS_CONFIG_UNSET.format(name=name, bundleid=bundleid)

    run_applescript(script)


def appinfo(name):
    """Get information about an installed application.

    .. versionadded:: 1.31

    Args:
        name (str): Name of application to look up.

    Returns:
        AppInfo: :class:`AppInfo` tuple or ``None`` if app isn't found.

    """
    cmd = ['mdfind', '-onlyin', '/Applications',
           '-onlyin', os.path.expanduser('~/Applications'),
           '(kMDItemContentTypeTree == com.apple.application &&'
           '(kMDItemDisplayName == "{0}" || kMDItemFSName == "{0}.app"))'
           .format(name)]

    output = run_command(cmd).strip()
    if not output:
        return None

    path = output.split('\n')[0]

    cmd = ['mdls', '-raw', '-name', 'kMDItemCFBundleIdentifier', path]
    bid = run_command(cmd).strip()
    if not bid:  # pragma: no cover
        return None

    return AppInfo(unicodify(name), unicodify(path), unicodify(bid))


@contextmanager
def atomic_writer(fpath, mode):
    """Atomic file writer.

    .. versionadded:: 1.12

    Context manager that ensures the file is only written if the write
    succeeds. The data is first written to a temporary file.

    :param fpath: path of file to write to.
    :type fpath: ``unicode``
    :param mode: sames as for :func:`open`
    :type mode: string

    """
    suffix = '.{}.tmp'.format(os.getpid())
    temppath = fpath + suffix
    with open(temppath, mode) as fp:
        try:
            yield fp
            os.rename(temppath, fpath)
        finally:
            try:
                os.remove(temppath)
            except (OSError, IOError):
                pass


class LockFile(object):
    """Context manager to protect filepaths with lockfiles.

    .. versionadded:: 1.13

    Creates a lockfile alongside ``protected_path``. Other ``LockFile``
    instances will refuse to lock the same path.

    >>> path = '/path/to/file'
    >>> with LockFile(path):
    >>>     with open(path, 'wb') as fp:
    >>>         fp.write(data)

    Args:
        protected_path (unicode): File to protect with a lockfile
        timeout (float, optional): Raises an :class:`AcquisitionError`
            if lock cannot be acquired within this number of seconds.
            If ``timeout`` is 0 (the default), wait forever.
        delay (float, optional): How often to check (in seconds) if
            lock has been released.

    Attributes:
        delay (float): How often to check (in seconds) whether the lock
            can be acquired.
        lockfile (unicode): Path of the lockfile.
        timeout (float): How long to wait to acquire the lock.

    """

    def __init__(self, protected_path, timeout=0.0, delay=0.05):
        """Create new :class:`LockFile` object."""
        self.lockfile = protected_path + '.lock'
        self._lockfile = None
        self.timeout = timeout
        self.delay = delay
        self._lock = Event()
        atexit.register(self.release)

    @property
    def locked(self):
        """``True`` if file is locked by this instance."""
        return self._lock.is_set()

    def acquire(self, blocking=True):
        """Acquire the lock if possible.

        If the lock is in use and ``blocking`` is ``False``, return
        ``False``.

        Otherwise, check every :attr:`delay` seconds until it acquires
        lock or exceeds attr:`timeout` and raises an :class:`AcquisitionError`.

        """
        if self.locked and not blocking:
            return False

        start = time.time()
        while True:

            # Raise error if we've been waiting too long to acquire the lock
            if self.timeout and (time.time() - start) >= self.timeout:
                    raise AcquisitionError('lock acquisition timed out')

            # If already locked, wait then try again
            if self.locked:
                time.sleep(self.delay)
                continue

            # Create in append mode so we don't lose any contents
            if self._lockfile is None:
                self._lockfile = open(self.lockfile, 'a')

            # Try to acquire the lock
            try:
                fcntl.lockf(self._lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB)
                self._lock.set()
                break
            except IOError as err:  # pragma: no cover
                if err.errno not in (errno.EACCES, errno.EAGAIN):
                    raise

                # Don't try again
                if not blocking:  # pragma: no cover
                    return False

                # Wait, then try again
                time.sleep(self.delay)

        return True

    def release(self):
        """Release the lock by deleting `self.lockfile`."""
        if not self._lock.is_set():
            return False

        try:
            fcntl.lockf(self._lockfile, fcntl.LOCK_UN)
        except IOError:  # pragma: no cover
            pass
        finally:
            self._lock.clear()
            self._lockfile = None
            try:
                os.unlink(self.lockfile)
            except (IOError, OSError):  # pragma: no cover
                pass

            return True

    def __enter__(self):
        """Acquire lock."""
        self.acquire()
        return self

    def __exit__(self, typ, value, traceback):
        """Release lock."""
        self.release()

    def __del__(self):
        """Clear up `self.lockfile`."""
        self.release()  # pragma: no cover


class uninterruptible(object):
    """Decorator that postpones SIGTERM until wrapped function returns.

    .. versionadded:: 1.12

    .. important:: This decorator is NOT thread-safe.

    As of version 2.7, Alfred allows Script Filters to be killed. If
    your workflow is killed in the middle of critical code (e.g.
    writing data to disk), this may corrupt your workflow's data.

    Use this decorator to wrap critical functions that *must* complete.
    If the script is killed while a wrapped function is executing,
    the SIGTERM will be caught and handled after your function has
    finished executing.

    Alfred-Workflow uses this internally to ensure its settings, data
    and cache writes complete.

    """

    def __init__(self, func, class_name=''):
        """Decorate `func`."""
        self.func = func
        functools.update_wrapper(self, func)
        self._caught_signal = None

    def signal_handler(self, signum, frame):
        """Called when process receives SIGTERM."""
        self._caught_signal = (signum, frame)

    def __call__(self, *args, **kwargs):
        """Trap ``SIGTERM`` and call wrapped function."""
        self._caught_signal = None
        # Register handler for SIGTERM, then call `self.func`
        self.old_signal_handler = signal.getsignal(signal.SIGTERM)
        signal.signal(signal.SIGTERM, self.signal_handler)

        self.func(*args, **kwargs)

        # Restore old signal handler
        signal.signal(signal.SIGTERM, self.old_signal_handler)

        # Handle any signal caught during execution
        if self._caught_signal is not None:
            signum, frame = self._caught_signal
            if callable(self.old_signal_handler):
                self.old_signal_handler(signum, frame)
            elif self.old_signal_handler == signal.SIG_DFL:
                sys.exit(0)

    def __get__(self, obj=None, klass=None):
        """Decorator API."""
        return self.__class__(self.func.__get__(obj, klass),
                              klass.__name__)
