# encoding: utf-8
#
# Copyright (c) 2016 Dean Jackson <deanishe@deanishe.net>
#
# MIT Licence. See http://opensource.org/licenses/MIT
#
# Created on 2016-06-25
#

"""
:class:`Workflow3` supports Alfred 3's new features.

It is an Alfred 3-only version of :class:`~workflow.workflow.Workflow`.

It supports setting :ref:`workflow-variables` and
:class:`the more advanced modifiers <Modifier>` supported by Alfred 3.

In order for the feedback mechanism to work correctly, it's important
to create :class:`Item3` and :class:`Modifier` objects via the
:meth:`Workflow3.add_item()` and :meth:`Item3.add_modifier()` methods
respectively. If you instantiate :class:`Item3` or :class:`Modifier`
objects directly, the current :class:`~workflow.workflow3.Workflow3`
object won't be aware of them, and they won't be sent to Alfred when
you call :meth:`~workflow.workflow3.Workflow3.send_feedback()`.
"""

from __future__ import print_function, unicode_literals, absolute_import

import json
import os
import sys

from .workflow import Workflow


class Variables(dict):
    """Workflow variables for Run Script actions.

    .. versionadded: 1.26

    This class allows you to set workflow variables from
    Run Script actions.

    It is a subclass of `dict`.

    >>> v = Variables(username='deanishe', password='hunter2')
    >>> v.arg = u'output value'
    >>> print(v)

    Attributes:
        arg (unicode): Output value (``{query}``).
        config (dict): Configuration for downstream workflow element.

    """

    def __init__(self, arg=None, **variables):
        """Create a new `Variables` object.

        Args:
            arg (unicode, optional): Main output/``{query}``.
            **variables: Workflow variables to set.

        """
        self.arg = arg
        self.config = {}
        super(Variables, self).__init__(**variables)

    @property
    def obj(self):
        """Return ``alfredworkflow`` `dict`."""
        o = {}
        if self:
            d2 = {}
            for k, v in self.items():
                d2[k] = v
            o['variables'] = d2

        if self.config:
            o['config'] = self.config

        if self.arg is not None:
            o['arg'] = self.arg

        return {'alfredworkflow': o}

    def __unicode__(self):
        """Convert to ``alfredworkflow`` JSON object.

        Returns:
            unicode: ``alfredworkflow`` JSON object
        """
        if not self and not self.config:
            if self.arg:
                return self.arg
            else:
                return u''

        return json.dumps(self.obj)

    def __str__(self):
        """Convert to ``alfredworkflow`` JSON object.

        Returns:
            str: UTF-8 encoded ``alfredworkflow`` JSON object
        """
        return unicode(self).encode('utf-8')


class Modifier(object):
    """Modify ``Item3`` values for when specified modifier keys are pressed.

    Valid modifiers (i.e. values for ``key``) are:

     * cmd
     * alt
     * shift
     * ctrl
     * fn

    Attributes:
        arg (unicode): Arg to pass to following action.
        key (unicode): Modifier key (see above).
        subtitle (unicode): Override item subtitle.
        valid (bool): Override item validity.
        variables (dict): Workflow variables set by this modifier.
    """

    def __init__(self, key, subtitle=None, arg=None, valid=None):
        """Create a new :class:`Modifier`.

        You probably don't want to use this class directly, but rather
        use :meth:`Item3.add_modifier()` to add modifiers to results.

        Args:
            key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc.
            subtitle (unicode, optional): Override default subtitle.
            arg (unicode, optional): Argument to pass for this modifier.
            valid (bool, optional): Override item's validity.
        """
        self.key = key
        self.subtitle = subtitle
        self.arg = arg
        self.valid = valid

        self.config = {}
        self.variables = {}

    def setvar(self, name, value):
        """Set a workflow variable for this Item.

        Args:
            name (unicode): Name of variable.
            value (unicode): Value of variable.
        """
        self.variables[name] = value

    def getvar(self, name, default=None):
        """Return value of workflow variable for ``name`` or ``default``.

        Args:
            name (unicode): Variable name.
            default (None, optional): Value to return if variable is unset.

        Returns:
            unicode or ``default``: Value of variable if set or ``default``.
        """
        return self.variables.get(name, default)

    @property
    def obj(self):
        """Modifier formatted for JSON serialization for Alfred 3.

        Returns:
            dict: Modifier for serializing to JSON.
        """
        o = {}

        if self.subtitle is not None:
            o['subtitle'] = self.subtitle

        if self.arg is not None:
            o['arg'] = self.arg

        if self.valid is not None:
            o['valid'] = self.valid

        # Variables and config
        if self.variables or self.config:
            d = {}
            if self.variables:
                d['variables'] = self.variables

            if self.config:
                d['config'] = self.config

            if self.arg is not None:
                d['arg'] = self.arg

            o['arg'] = json.dumps({'alfredworkflow': d})

        return o


class Item3(object):
    """Represents a feedback item for Alfred 3.

    Generates Alfred-compliant JSON for a single item.

    You probably shouldn't use this class directly, but via
    :meth:`Workflow3.add_item`. See :meth:`~Workflow3.add_item`
    for details of arguments.
    """

    def __init__(self, title, subtitle='', arg=None, autocomplete=None,
                 valid=False, uid=None, icon=None, icontype=None,
                 type=None, largetext=None, copytext=None, quicklookurl=None):
        """Use same arguments as for :meth:`Workflow.add_item`.

        Argument ``subtitle_modifiers`` is not supported.
        """
        self.title = title
        self.subtitle = subtitle
        self.arg = arg
        self.autocomplete = autocomplete
        self.valid = valid
        self.uid = uid
        self.icon = icon
        self.icontype = icontype
        self.type = type
        self.quicklookurl = quicklookurl
        self.largetext = largetext
        self.copytext = copytext

        self.modifiers = {}

        self.config = {}
        self.variables = {}

    def setvar(self, name, value):
        """Set a workflow variable for this Item.

        Args:
            name (unicode): Name of variable.
            value (unicode): Value of variable.

        """
        self.variables[name] = value

    def getvar(self, name, default=None):
        """Return value of workflow variable for ``name`` or ``default``.

        Args:
            name (unicode): Variable name.
            default (None, optional): Value to return if variable is unset.

        Returns:
            unicode or ``default``: Value of variable if set or ``default``.
        """
        return self.variables.get(name, default)

    def add_modifier(self, key, subtitle=None, arg=None, valid=None):
        """Add alternative values for a modifier key.

        Args:
            key (unicode): Modifier key, e.g. ``"cmd"`` or ``"alt"``
            subtitle (unicode, optional): Override item subtitle.
            arg (unicode, optional): Input for following action.
            valid (bool, optional): Override item validity.

        Returns:
            Modifier: Configured :class:`Modifier`.
        """
        mod = Modifier(key, subtitle, arg, valid)

        for k in self.variables:
            mod.setvar(k, self.variables[k])

        self.modifiers[key] = mod

        return mod

    @property
    def obj(self):
        """Item formatted for JSON serialization.

        Returns:
            dict: Data suitable for Alfred 3 feedback.
        """
        # Required values
        o = {'title': self.title,
             'subtitle': self.subtitle,
             'valid': self.valid}

        icon = {}

        # Optional values

        # arg & variables
        v = Variables(self.arg, **self.variables)
        v.config = self.config
        arg = unicode(v)
        if arg:
            o['arg'] = arg

        if self.autocomplete is not None:
            o['autocomplete'] = self.autocomplete

        if self.uid is not None:
            o['uid'] = self.uid

        if self.type is not None:
            o['type'] = self.type

        if self.quicklookurl is not None:
            o['quicklookurl'] = self.quicklookurl

        # Largetype and copytext
        text = self._text()
        if text:
            o['text'] = text

        icon = self._icon()
        if icon:
            o['icon'] = icon

        # Modifiers
        mods = self._modifiers()
        if mods:
            o['mods'] = mods

        return o

    def _icon(self):
        """Return `icon` object for item.

        Returns:
            dict: Mapping for item `icon` (may be empty).
        """
        icon = {}
        if self.icon is not None:
            icon['path'] = self.icon

        if self.icontype is not None:
            icon['type'] = self.icontype

        return icon

    def _text(self):
        """Return `largetext` and `copytext` object for item.

        Returns:
            dict: `text` mapping (may be empty)
        """
        text = {}
        if self.largetext is not None:
            text['largetype'] = self.largetext

        if self.copytext is not None:
            text['copy'] = self.copytext

        return text

    def _modifiers(self):
        """Build `mods` dictionary for JSON feedback.

        Returns:
            dict: Modifier mapping or `None`.
        """
        if self.modifiers:
            mods = {}
            for k, mod in self.modifiers.items():
                mods[k] = mod.obj

            return mods

        return None


class Workflow3(Workflow):
    """Workflow class that generates Alfred 3 feedback.

    Attributes:
        item_class (class): Class used to generate feedback items.
        variables (dict): Top level workflow variables.
    """

    item_class = Item3

    def __init__(self, **kwargs):
        """Create a new :class:`Workflow3` object.

        See :class:`~workflow.workflow.Workflow` for documentation.
        """
        Workflow.__init__(self, **kwargs)
        self.variables = {}
        self._rerun = 0
        self._session_id = None

    @property
    def _default_cachedir(self):
        """Alfred 3's default cache directory."""
        return os.path.join(
            os.path.expanduser(
                '~/Library/Caches/com.runningwithcrayons.Alfred-3/'
                'Workflow Data/'),
            self.bundleid)

    @property
    def _default_datadir(self):
        """Alfred 3's default data directory."""
        return os.path.join(os.path.expanduser(
            '~/Library/Application Support/Alfred 3/Workflow Data/'),
            self.bundleid)

    @property
    def rerun(self):
        """How often (in seconds) Alfred should re-run the Script Filter."""
        return self._rerun

    @rerun.setter
    def rerun(self, seconds):
        """Interval at which Alfred should re-run the Script Filter.

        Args:
            seconds (int): Interval between runs.
        """
        self._rerun = seconds

    @property
    def session_id(self):
        """A unique session ID every time the user uses the workflow.

        .. versionadded:: 1.25

        The session ID persists while the user is using this workflow.
        It expires when the user runs a different workflow or closes
        Alfred.

        """
        if not self._session_id:
            sid = os.getenv('_WF_SESSION_ID')
            if not sid:
                from uuid import uuid4
                sid = uuid4().hex
                self.setvar('_WF_SESSION_ID', sid)

            self._session_id = sid

        return self._session_id

    def setvar(self, name, value):
        """Set a "global" workflow variable.

        These variables are always passed to downstream workflow objects.

        If you have set :attr:`rerun`, these variables are also passed
        back to the script when Alfred runs it again.

        Args:
            name (unicode): Name of variable.
            value (unicode): Value of variable.
        """
        self.variables[name] = value

    def getvar(self, name, default=None):
        """Return value of workflow variable for ``name`` or ``default``.

        Args:
            name (unicode): Variable name.
            default (None, optional): Value to return if variable is unset.

        Returns:
            unicode or ``default``: Value of variable if set or ``default``.
        """
        return self.variables.get(name, default)

    def add_item(self, title, subtitle='', arg=None, autocomplete=None,
                 valid=False, uid=None, icon=None, icontype=None,
                 type=None, largetext=None, copytext=None, quicklookurl=None):
        """Add an item to be output to Alfred.

        See :meth:`~workflow.workflow.Workflow.add_item` for the main
        documentation.

        The key difference is that this method does not support the
        ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()`
        method instead on the returned item instead.

        Returns:
            Item3: Alfred feedback item.
        """
        item = self.item_class(title, subtitle, arg,
                               autocomplete, valid, uid, icon, icontype, type,
                               largetext, copytext, quicklookurl)

        self._items.append(item)
        return item

    def _mk_session_name(self, name):
        """New cache name/key based on session ID."""
        return '_wfsess-{0}-{1}'.format(self.session_id, name)

    def cache_data(self, name, data, session=False):
        """Cache API with session-scoped expiry.

        .. versionadded:: 1.25

        Args:
            name (str): Cache key
            data (object): Data to cache
            session (bool, optional): Whether to scope the cache
                to the current session.

        ``name`` and ``data`` are as for the
        :meth:`~workflow.workflow.Workflow.cache_data` on
        :class:`~workflow.workflow.Workflow`.

        If ``session`` is ``True``, the ``name`` variable is prefixed
        with :attr:`session_id`.

        """
        if session:
            name = self._mk_session_name(name)

        return super(Workflow3, self).cache_data(name, data)

    def cached_data(self, name, data_func=None, max_age=60, session=False):
        """Cache API with session-scoped expiry.

        .. versionadded:: 1.25

        Args:
            name (str): Cache key
            data_func (callable): Callable that returns fresh data. It
                is called if the cache has expired or doesn't exist.
            max_age (int): Maximum allowable age of cache in seconds.
            session (bool, optional): Whether to scope the cache
                to the current session.

        ``name``, ``data_func`` and ``max_age`` are as for the
        :meth:`~workflow.workflow.Workflow.cached_data` on
        :class:`~workflow.workflow.Workflow`.

        If ``session`` is ``True``, the ``name`` variable is prefixed
        with :attr:`session_id`.

        """
        if session:
            name = self._mk_session_name(name)

        return super(Workflow3, self).cached_data(name, data_func, max_age)

    def clear_session_cache(self):
        """Remove *all* session data from the cache.

        .. versionadded:: 1.25
        """
        def _is_session_file(filename):
            return filename.startswith('_wfsess-')

        self.clear_cache(_is_session_file)

    @property
    def obj(self):
        """Feedback formatted for JSON serialization.

        Returns:
            dict: Data suitable for Alfred 3 feedback.
        """
        items = []
        for item in self._items:
            items.append(item.obj)

        o = {'items': items}
        if self.variables:
            o['variables'] = self.variables
        if self.rerun:
            o['rerun'] = self.rerun
        return o

    def send_feedback(self):
        """Print stored items to console/Alfred as JSON."""
        json.dump(self.obj, sys.stdout)
        sys.stdout.flush()
