#!/usr/bin/env python
#
# cli.py - Generate command line arguments for a HasProperties object.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#

"""Generate command line arguments for a :class:`.HasProperties` instance.

This module provides the following functions:

 .. autosummary::
    :nosignatures:

    addParserArguments
    applyArguments
    generateArguments

The ``addParserArguments`` function is used to add arguments to an
``ArgumentParser`` object for the properties of a ``HasProperties`` class. The
simplest way to do so is to allow the ``addParserArguments`` function to
automatically generate short and long arguments from the property names::

    >>> import argparse
    >>> import fsleyes_props as props

    >>> class MyObj(props.HasProperties):
            intProp  = props.Int()
            boolProp = props.Boolean()

    >>> parser = argparse.ArgumentParser('MyObj')
    >>> props.addParserArguments(MyObj, parser)

    >>> parser.print_help()
    usage: MyObj [-h] [-b] [-i INT]

    optional arguments:
        -h, --help            show this help message and exit
        -b, --boolProp
        -i INT, --intProp INT

Now, if we have a ``MyObj`` instance, and some arguments::

    >>> myobj = MyObj()

    >>> args = parser.parse_args(['-b', '--intProp', '52'])

    >>> print myobj
    MyObj
      boolProp = False
       intProp = 0

    >>> props.applyArguments(myobj, args)
    >>> print myobj
    MyObj
      boolProp = True
       intProp = 52

If you want to customise the short and long argument tags, and the help text,
for each property, you can pass them in to the ``addParserArguments``
function::

    >>> shortArgs = {'intProp' : 'r',              'boolProp' : 't'}
    >>> longArgs  = {'intProp' : 'TheInt',         'boolProp' : 'someBool'}
    >>> propHelp  = {'intProp' : 'Sets int value', 'boolProp' : 'Toggles bool'}

    >>> parser = argparse.ArgumentParser('MyObj')
    >>> props.addParserArguments(MyObj,
                                 parser,
                                 shortArgs=shortArgs,
                                 longArgs=longArgs,
                                 propHelp=propHelp)
    >>> parser.print_help()
    usage: MyObj [-h] [-t] [-r INT]

    optional arguments:
      -h, --help            show this help message and exit
      -t, --someBool        Toggles bool
      -r INT, --TheInt INT  Sets int value

Or, you can add the short and long arguments, and the help text, as specially
named class attributes of your ``HasProperties`` class or instance::

    >>> class MyObj(props.HasProperties):
            intProp  = props.Int()
            boolProp = props.Boolean()
            _shortArgs = {
                'intProp'  : 'r',
                'boolProp' : 't'
            }
            _longArgs = {
                'intProp'  : 'TheInt',
                'boolProp' : 'someBool'
            }
            _propHelp = {
                'intProp' : 'Sets int value',
                'boolProp' : 'Toggles bool'
            }

    >>> parser = argparse.ArgumentParser('MyObj')
    >>> props.addParserArguments(MyObj, parser)

    >>> parser.print_help()
    usage: MyObj [-h] [-t] [-r INT]

    optional arguments:
      -h, --help            show this help message and exit
      -t, --someBool        Toggles bool
      -r INT, --TheInt INT  Sets int value

    >>> args = parser.parse_args('--someBool -r 23413'.split())
    >>> myobj = MyObj()
    >>> props.applyArguments(myobj, args)
    >>> print myobj
    MyObj
      boolProp = True
       intProp = 23413

The ``generateArguments`` function, as the name suggests, generates command
line arguments from a ``HasProperties`` instance::

    >>> props.generateArguments(myobj)
    ['--someBool', '--TheInt', '23413']

The ``generateArguments`` and ``applyArguments`` functions optionally accept a
set of *transform* functions which, for ``generateArguments``, take the value
of a property, and return some transformation of that property, suitable to be
used as a command line argument value. The transform functions passed to the
``applyArguments`` function perform the reverse transformation.  Transforms
are useful for properties which cannot easily be converted to/from strings,
and also for properties where the value you wish users to pass in on the
command line does not correspond exactly to the value you wish the property to
be given.

For example::

    >>> class MyObject(props.HasProperties):
            showBlah = props.Boolean(default=True)

    >>> shortArgs = {'showBlah' : 'hb'}
    >>> longArgs  = {'showBlah' : 'hideBlah'}
    >>> xforms    = {'showBlah' : lambda b : not b }

    >>> parser = argparse.ArgumentParser('MyObject')
    >>> props.addParserArguments(MyObject,
                                 parser,
                                 shortArgs=shortArgs,
                                 longArgs=longArgs)

    >>> myobj = MyObject()
    >>> myobj.showBlah = False

    >>> props.generateArguments(myobj,
                                shortArgs=shortArgs,
                                longArgs=longArgs,
                                xformFuncs=xforms)
        ['--hideBlah']

In this example, we can use the same transform function for the reverse
operation::

    >>> myobj2 = MyObject()
    >>> args   = parser.parse_args(['--hideBlah'])
    >>> props.applyArguments(myobj2,
                             args,
                             xformFuncs=xforms,
                             longArgs=longArgs)
    >>> print myobj2
        MyObject
            showBlah = False

The ``cli`` module supports the following property types:

.. autosummary::
   :nosignatures:

   _String
   _Choice
   _Boolean
   _Int
   _Real
   _Percentage
   _Bounds
   _Point
   _Colour
   _ColourMap

"""


import logging

import sys
import argparse

import matplotlib        as mpl
import matplotlib.pyplot as plt

import fsleyes_props.properties       as props
import fsleyes_props.properties_types as ptypes


log = logging.getLogger(__name__)


class SkipArgument(Exception):
    """This exception may be raised by transform functions which are called
    by :func:`applyArguments` and :func:`generateArguments` to indicate
    that the arguemnt should be skipped (i.e. not applied, or not generated).
    """


def _String(parser,
            propObj,
            propCls,
            propName,
            propHelp,
            shortArg,
            longArg,
            atype=str):
    """Adds an argument to the given parser for the given :class:`.String`
    property.

    :param parser:   An ``ArgumentParser`` instance.

    :param propCls:  A ``HasProperties`` class or instance.

    :param propObj:  The ``PropertyBase`` class.

    :param propName: Name of the property.

    :param propHelp: Custom help text for the property.

    :param shortArg: String to use as the short argument.

    :param longArg:  String to use as the long argument.

    :param atype:    Data type to expect (defaults to ``str``). This allows
                     the conversion type to be overridden for some, but not
                     all, property types.
    """
    parser.add_argument(shortArg, longArg, help=propHelp, type=atype)


def _Choice(parser,
            propObj,
            propCls,
            propName,
            propHelp,
            shortArg,
            longArg,
            **kwargs):
    """Adds an argument to the given parser for the given :class:`.Choice`
    property. See the :func:`_String` documentation for details on the
    parameters.

    Only works with ``Choice`` properties with string options (unless
    the ``choices`` argument is provided). See the ``allowStr`` parameter
    for :class:`.Choice` instances.

    All of the described arguments must be speified as keyword arguments.
    Any additional arguments will be passed to the
    ``ArgumentParser.add_argument`` method.

    :arg choices: If not ``None``, assumed to be list of possible
                  choices for the property. If not provided, the possible
                  choices are taken from the :meth:`.Choice.getChoices`
                  method. If explicitly passed in as ``None``, the argument
                  will be configured to accept any value.

    :arg default: If not ``None``, gives the default value. Otherwise,
                  the ``default`` attribute of the :class:`.Choice`
                  object is used.

    :arg useAlts: If ``True`` (the default), alternate values for the
                  choices are added as options (see the :class:`.Choice`
                  class). n.b. This flag will have no effect if  ``choices``
                  is explicitly set to ``None``, as desrcibed above.

    :arg metavar: If ``None`` (the default), the available choices for
                  this property will be shown in the help text. Otherwise,
                  this string will be shown instead.
    """

    choices = kwargs.pop('choices', 'notspecified')
    default = kwargs.pop('default', None)
    useAlts = kwargs.pop('useAlts', True)
    metavar = kwargs.pop('metavar', None)

    if choices == 'notspecified':
        choices = propObj.getChoices()

    if useAlts and choices is not None:
        alternates = propObj.getAlternates()

        for altList in alternates:
            choices += [a for a in altList]

    if default is None:
        default = propObj.getAttribute(None, 'default')

    # Make sure that only unique
    # choices are set as options.
    if choices is not None:

        # We could just convert to a set, but
        # this would not preserve ordering, so
        # we'll uniquify the list the hard way.
        choices = [str(c) for c in choices]
        unique  = []

        for c in choices:
            if c not in unique:
                unique.append(c)

        choices = unique

    parser.add_argument(shortArg,
                        longArg,
                        help=propHelp,
                        choices=choices,
                        metavar=metavar,
                        **kwargs)


def _Boolean(parser, propObj, propCls, propName, propHelp, shortArg, longArg):
    """Adds an argument to the given parser for the given :class:`.Boolean`
    property. See the :func:`_String` documentation for details on the
    parameters.
    """
    # Using store_const instead of store_true,
    # because if the user doesn't set this
    # argument, we don't want to explicitly
    # set the property value to False (if it
    # has a default value of True, we don't
    # want that default value overridden).
    parser.add_argument(shortArg,
                        longArg,
                        help=propHelp,
                        action='store_const',
                        const=True)


def _Int(parser, propObj, propCls, propName, propHelp, shortArg, longArg):
    """Adds an argument to the given parser for the given :class:`.Int`
    property. See the :func:`_String` documentation for details on the
    parameters.
    """
    parser.add_argument(shortArg,
                        longArg,
                        help=propHelp,
                        metavar='INT',
                        type=int)


def _Real(parser, propObj, propCls, propName, propHelp, shortArg, longArg):
    """Adds an argument to the given parser for the given :class:`.Real`
    property. See the :func:`_String` documentation for details on the
    parameters.
    """
    parser.add_argument(shortArg,
                        longArg,
                        help=propHelp,
                        metavar='REAL',
                        type=float)


def _Percentage(
        parser, propObj, propCls, propName, propHelp, shortArg, longArg):
    """Adds an argument to the given parser for the given :class:`.Percentage`
    property. See the :func:`_String` documentation for details on the
    parameters.
    """
    parser.add_argument(shortArg,
                        longArg,
                        help=propHelp,
                        metavar='PERC',
                        type=float)


def _Bounds(parser,
            propObj,
            propCls,
            propName,
            propHelp,
            shortArg,
            longArg,
            atype=None):
    """Adds an argument to the given parser for the given :class:`.Bounds`
    property. See the :func:`_String` documentation for details on the
    parameters.
    """
    ndims = getattr(propCls, propName)._ndims
    real  = getattr(propCls, propName)._real

    metavar = tuple(['LO', 'HI'] * ndims)

    if atype is None:
        if real: atype = float
        else:    atype = int

    parser.add_argument(shortArg,
                        longArg,
                        help=propHelp,
                        metavar=metavar,
                        type=atype,
                        nargs=2 * ndims)


def _Point(parser, propObj, propCls, propName, propHelp, shortArg, longArg):
    """Adds an argument to the given parser for the given :class:`.Point`
    property. See the :func:`_String` documentation for details on the
    parameters.
    """
    ndims = getattr(propCls, propName)._ndims
    real  = getattr(propCls, propName)._real
    if real: pType = float
    else:    pType = int
    parser.add_argument(shortArg,
                        longArg,
                        help=propHelp,
                        metavar='N',
                        type=pType,
                        nargs=ndims)


def _Colour(parser,
            propObj,
            propCls,
            propName,
            propHelp,
            shortArg,
            longArg,
            alpha=True):
    """Adds an argument to the given parser for the given :class:`.Colour`
    property. See the :func:`_String` documentation for details on the
    parameters.
    """
    if alpha:
        metavar = ('R', 'G', 'B', 'A')
        nargs   = 4
    else:
        metavar = ('R', 'G', 'B')
        nargs   = 3
    parser.add_argument(shortArg,
                        longArg,
                        help=propHelp,
                        metavar=metavar,
                        type=float,
                        nargs=nargs)


def _ColourMap(parser,
               propObj,
               propCls,
               propName,
               propHelp,
               shortArg,
               longArg,
               parseStr=False,
               choices=None,
               metavar=None):
    """Adds an argument to the given parser for the given :class:`.ColourMap`
    property.

    :arg parseStr: If ``False`` (the default), the parser will be configured
                   to parse any registered ``matplotlib`` colour map name.
                   Otherwise, the parser will accept any string, and assume
                   that the ``ColourMap`` property is set up to accept it.

    :arg choices:  This parameter can be used to restrict the values that
                   will be accepted.

    :arg metavar:  If ``choices`` is not ``None``, they will be shown in the
                   help text, unless this string is provided.

    See the :func:`_String` documentation for details on the other parameters.
    """

    # Attempt to retrieve a matplotlib.cm.ColourMap
    # instance given its name
    def parse(cmapName):
        try:

            cmapKeys   = plt.colormaps()
            cmapNames  = [mpl.colormaps[cm].name for cm in cmapKeys]

            lCmapNames = [s.lower() for s in cmapNames]
            lCmapKeys  = [s.lower() for s in cmapKeys]
            cmapName   = cmapName.lower()

            try:               idx = lCmapKeys .index(cmapName)
            except ValueError: idx = lCmapNames.index(cmapName)

            cmapName = cmapKeys[idx]

            return mpl.colormaps[cmapName]

        except Exception:
            raise argparse.ArgumentTypeError(
                'Unknown colour map: {}'.format(cmapName))

    if choices is not None:
        choices = [c.lower() for c in choices]

    if parseStr: aType = str.lower
    else:        aType = parse

    parser.add_argument(shortArg,
                        longArg,
                        help=propHelp,
                        type=aType,
                        choices=choices,
                        metavar=metavar)


def _getShortArgs(propCls, propNames, exclude=''):
    """Generates unique single-letter argument names for each of the names in
    the given list of property names. Any letters in the exclude string are
    not used as short arguments.

    :param propCls:   A ``HasProperties`` class.

    :param propNames: List of property names for which short arguments
                      are to be generated.

    :param exclude:   String containing letters which should not be used.
    """

    letters   = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
    shortArgs = {}

    for propName in propNames:

        # if '_shortArgs' is present on the hasProps
        # object, and there is an entry for the
        # current property, use that entry.
        if hasattr(propCls, '_shortArgs'):
            if propName in propCls._shortArgs:

                # throw an error if that entry
                # has already been used, or
                # should be excluded
                if propCls._shortArgs[propName] in list(shortArgs.values()) or\
                   propCls._shortArgs[propName] in exclude:
                    raise RuntimeError(
                        'Duplicate or excluded short argument for property '
                        '{}.{}: {}'.format(
                            propCls.__name__,
                            propName,
                            propCls._shortArgs[propName]))

                shortArgs[propName] = propCls._shortArgs[propName]
                continue

        # use the first unique letter in the
        # property name or, if that doesn't
        # work, in the alphabet
        for c in propName + letters:

            if (c not in list(shortArgs.values())) and (c not in exclude):
                shortArgs[propName] = c
                break

    if any([name not in list(shortArgs) for name in propNames]):
        raise RuntimeError('Could not generate default short arguments '
                           'for HasProperties object {} - please provide '
                           'custom short arguments via a _shortArgs '
                           'attribute'.format(propCls.__name__))

    return shortArgs


def applyArguments(hasProps,
                   arguments,
                   propNames=None,
                   xformFuncs=None,
                   longArgs=None,
                   **kwargs):
    """Apply arguments to a ``HasProperties`` instance.

    Given a ``HasProperties`` instance and an ``argparse.Namespace`` instance,
    sets the property values of the ``HasProperties`` instance from the values
    stored in the ``Namespace`` object.

    :param hasProps:   The ``HasProperties`` instance.

    :param arguments:  The ``Namespace`` instance.

    :param propNames:  List of property names to apply. If ``None``, an attempt
                       is made to set all properties. If not ``None``, the
                       property values are set in the order specified by this
                       list.

    :param xformFuncs: A dictionary of ``{property name -> function}``
                       mappings, which can be used to transform the value given
                       on the command line before it is assigned to the
                       property.

    :param longArgs:   Dict containing ``{property name : longArg}`` mappings.

    All other keyword arguments are passed through to the ``xformFuncs``
    functions.
    """

    if propNames is None:
        propNames, propObjs = hasProps.getAllProperties()
    else:
        propObjs = [hasProps.getProp(name) for name in propNames]

    if longArgs is None:
        if hasattr(hasProps, '_longArgs'): longArgs = hasProps._longArgs
        else:                              longArgs = dict(zip(propNames,
                                                               propNames))

    if xformFuncs is None:
        xformFuncs = {}

    for propName, propObj in zip(propNames, propObjs):

        xform   = xformFuncs.get(propName, lambda v, **kwa : v)
        argName = longArgs.get(propName, propName)
        argVal  = getattr(arguments, argName, None)

        # An argVal of None means that no value
        # was passed in for this property
        if argVal is None:
            continue

        try:
            argVal = xform(argVal, **kwargs)
        except SkipArgument:
            continue

        log.debug('Setting {}.{} = {}'.format(
            type(hasProps).__name__,
            propName,
            argVal))

        setattr(hasProps, propName, argVal)


def addParserArguments(
        propCls,
        parser,
        cliProps=None,
        shortArgs=None,
        longArgs=None,
        propHelp=None,
        extra=None,
        exclude=''):
    """Adds arguments to the given ``argparse.ArgumentParser`` for the
    properties of the given ``HasProperties`` class or instance.

    :param propCls:        A ``HasProperties`` class. An instance may
                           alternately be passed in.

    :param parser:         An ``ArgumentParser`` to add arguments to.

    :param list cliProps:  List containing the names of properties to add
                           arguments for. If ``None``, and an attribute called
                           ``_cliProps``' is present on the ``propCls`` class,
                           the value of that attribute is used. Otherwise an
                           argument is added for all properties.

    :param dict shortArgs: Dict containing ``{propName: shortArg}`` mappings,
                           to be used as the short (typically single letter)
                           argument flag for each property. If ``None``, and
                           an attribute called ``_shortArgs`` is present on
                           the ``propCls`` class, the value of that attribute
                           is used. Otherwise, short arguments are
                           automatically generated for each property.

    :param dict longArgs:  Dict containing ``{propName: longArg}`` mappings,
                           to be used as the long argument flag for each
                           property. If ``None``, and an attribute called
                           ``_longArgs`` is present on the ``propCls`` class,
                           the value of that attribute is used. Otherwise, the
                           name of each property is used as its long argument.

    :param dict propHelp:  Dict containing ``{propName: helpString]``
                           mappings, to be used as the help text for each
                           property. If ``None``, and an attribute called
                           ``_propHelp`` is present on the ``propCls`` class,
                           the value of that attribute is used. Otherwise, no
                           help string is used.

    :param dict extra:     Any property-specific settings to be passed through
                           to the parser configuration function (see e.g. the
                           :func:`_Choice` function). If ``None``, and an
                           attribute called ``_propExtra`` is present on the
                           ``propCls`` class, the value of that attribute is
                           used instead.

    :param str exclude:    String containing letters which should not be used
                           as short arguments.
    """

    if isinstance(propCls, props.HasProperties):
        propCls = propCls.__class__

    if cliProps is None:
        if hasattr(propCls, '_cliProps'):
            cliProps = propCls._cliProps
        else:
            cliProps = propCls.getAllProperties()[0]

    if propHelp is None:
        if hasattr(propCls, '_propHelp'): propHelp = propCls._propHelp
        else:                             propHelp = {}

    if longArgs is None:
        if hasattr(propCls, '_longArgs'): longArgs = propCls._longArgs
        else:                             longArgs = dict(zip(cliProps,
                                                              cliProps))

    if shortArgs is None:
        if hasattr(propCls, '_shortArgs'):
            shortArgs = propCls._shortArgs
        else:
            shortArgs = _getShortArgs(propCls, cliProps, exclude)

    if extra is None:
        if hasattr(propCls, '_propExtra'):
            extra = propCls._propExtra
        else:
            extra = {prop : {} for prop in cliProps}

    for propName in cliProps:

        propObj    = propCls.getProp(propName)
        propType   = propObj.__class__.__name__
        parserFunc = getattr(
            sys.modules[__name__],
            '_{}'.format(propType), None)

        if parserFunc is None:
            log.warning('No CLI parser function for property {} '
                        '(type {})'.format(propName, propType))
            continue

        shortArg  =  '-{}'.format(shortArgs[propName])
        longArg   = '--{}'.format(longArgs[ propName])
        propExtra = extra.get(propName, {})

        parserFunc(parser,
                   propObj,
                   propCls,
                   propName,
                   propHelp.get(propName, None),
                   shortArg,
                   longArg,
                   **propExtra)


def generateArguments(hasProps,
                      useShortArgs=False,
                      xformFuncs=None,
                      cliProps=None,
                      shortArgs=None,
                      longArgs=None,
                      exclude='',
                      **kwargs):
    """Given a ``HasProperties`` instance, generates a list of arguments which
    could be used to configure another instance in the same way.

    :param hasProps:     The ``HasProperties`` instance.

    :param useShortArgs: If ``True`` the short argument version is used instead
                         of the long argument version.

    :param xformFuncs:   A dictionary of ``{property name -> function}``
                         mappings, which can be used to perform some arbitrary
                         transformation of property values.


    All other keyword arguments are passed through to the ``xformFuncs``
    functions.

    See the :func:`addParserArguments` function for descriptions of the other
    parameters.
    """
    args = []

    if cliProps is None:
        if hasattr(hasProps, '_cliProps'):
            cliProps = hasProps._cliProps
        else:
            cliProps = hasProps.getAllProperties()[0]

    if longArgs is None:
        if hasattr(hasProps, '_longArgs'): longArgs = hasProps._longArgs
        else:                              longArgs = dict(zip(cliProps,
                                                               cliProps))

    if shortArgs is None:
        if hasattr(hasProps, '_shortArgs'):
            shortArgs = hasProps._shortArgs
        else:
            shortArgs = _getShortArgs(hasProps, cliProps, exclude)

    if xformFuncs is None:
        xformFuncs = {}

    for propName in cliProps:
        propObj = hasProps.getProp(propName)
        xform   = xformFuncs.get(propName, lambda v, **kwa: v)

        try:
            propVal = xform(getattr(hasProps, propName), **kwargs)
        except SkipArgument:
            continue

        # TODO Should I skip a property
        #      if its value is None?
        if propVal is None:
            continue

        if useShortArgs: argKey =  '-{}'.format(shortArgs[propName])
        else:            argKey = '--{}'.format(longArgs[ propName])

        # TODO I should be using the (new)
        #      serialise module for this.
        #      This would incorporate the
        #      TODO below.

        # TODO This logic could somehow be stored
        #      as default transform functions for
        #      the respective types
        if isinstance(propObj, (ptypes.Bounds, ptypes.Point, ptypes.Colour)):
            values = ['{}'.format(v) for v in propVal]

        elif isinstance(propObj, ptypes.ColourMap):
            values = [propVal.name]

        elif isinstance(propObj, ptypes.String):
            values = ['"{}"'.format(propVal)]

        elif isinstance(propObj, ptypes.Boolean):
            values = None
            if not propVal: argKey = None
        else:
            if propVal is None: values = None
            else:               values = [propVal]

        if argKey is not None: args.append(argKey)
        if values is not None: args.extend(values)

    return [str(a) for a in args]
