#!/usr/bin/python
#
# Copyright (c) 2018  Peter Pentchev
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright
#    notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
# SUCH DAMAGE.


from __future__ import print_function


import argparse
import re
import subprocess


try:
    import simplejson as js
except ImportError:
    import json as js


default_option = '--features'
default_prefix = 'Features: '
default_output_fmt = 'tsv'
version_string = '0.1.1'


rex = {
    'var': '[A-Za-z0-9_-]+',
    'value': '[A-Za-z0-9.]+',
    'op': '(?: < | <= | = | >= | > | lt | le | eq | ge | gt )',
    'num_alpha': '(?P<num> [0-9]* ) (?P<alpha> .*)',
}


rex_comp = {}
for (name, expr) in rex.items():
    rex_comp[name] = re.compile(expr + '$', re.X)


def version_split_num_alpha(v):
    """
    Split a version component into a numeric and an alphanumeric part,
    e.g. "2a" is split into ('2', 'a').
    """
    d = rex_comp['num_alpha'].match(v).groupdict()
    return d['num'], d['alpha']


def version_compare_split(sa, sb):
    """
    Compare two version numbers already split into lists of version
    number components.
    Returns -1, 0, or 1 for the first version being less than, equal to,
    or greater than the second one.
    """
    if not sa:
        if not sb:
            return 0
        elif version_split_num_alpha(sb[0])[0] == '':
            return 1
        else:
            return -1
    elif not sb:
        if version_split_num_alpha(sa[0])[0] == '':
            return -1
        else:
            return 1

    (fa, fb) = (sa.pop(0), sb.pop(0))
    (na, ra) = version_split_num_alpha(fa)
    if na == '' and ra == '':
        exit('Internal error: could not split {v}'.format(v=fa))
    (nb, rb) = version_split_num_alpha(fb)
    if nb == '' and rb == '':
        exit('Internal error: could not split {v}'.format(v=fb))

    if na != '':
        if nb != '':
            if int(na) < int(nb):
                return -1
            elif int(na) > int(nb):
                return 1
        else:
            return 1
    elif nb != '':
        return -1

    if ra != '':
        if rb != '':
            if ra < rb:
                return 1
            elif ra > rb:
                return -1
        else:
            return 1
    elif rb != '':
        return -1

    return version_compare_split(sa, sb)


def version_compare(va, vb):
    """
    Compare two version numbers as strings.
    Returns -1, 0, or 1 for the first version being less than, equal to,
    or greater than the second one.
    """
    return version_compare_split(va.split('.'), vb.split('.'))


class Result(object):
    """
    The base class for an expression result.
    """
    def __init__(self):
        """
        Initialize a Result object... do nothing.
        """
        pass


class ResultBool(Result):
    """
    A boolean result of an expression; the "value" member is boolean.
    """
    def __init__(self, value):
        """
        Initialize a ResultBool object with the specified value.
        """
        self.value = value


class ResultVersion(Result):
    """
    A version number as a result of an expression; the "value" member is
    the version number string.
    """
    def __init__(self, value):
        """
        Initialize a ResultVersion object with the specified value.
        """
        self.value = value


class Expr(object):
    """
    The (pretty much abstract) base class for an expression.
    """
    def __init__(self):
        """
        Initialize an expression object... do nothing.
        """
        pass

    def evaluate(self, cfg, features):
        """
        Overridden in actual expression classes to evaluate
        the expression and return a Result object.
        """
        raise Exception('{t}.evaluate() must be overrridden'
                        .format(t=type(self).__name__))


class ExprFeature(Expr):
    """
    An expression that returns a program feature name as a string.
    """
    def __init__(self, name):
        """
        Initialize the expression with the specified feature name.
        """
        self.name = name

    def evaluate(self, cfg, data):
        """
        Look up the feature and return a ResultVersion object with the result.
        """
        return ResultVersion(value=data[self.name])


class ExprVersion(Expr):
    """
    An expression that returns a version number for a feature.
    """
    def __init__(self, value):
        """
        Initialize the expression with the specified version string.
        """
        self.value = value

    def evaluate(self, cfg, data):
        """
        Return the version number as a ResultVersion object.
        """
        return ResultVersion(value=self.value)


class ExprOp(Expr):
    """
    A two-argument operation expression.
    """

    OPS = {
        'lt': {
            'args': [ResultVersion, ResultVersion],
            'do': lambda a: version_compare(a[0].value, a[1].value) < 0,
        },
        'le': {
            'args': [ResultVersion, ResultVersion],
            'do': lambda a: version_compare(a[0].value, a[1].value) <= 0,
        },
        'eq': {
            'args': [ResultVersion, ResultVersion],
            'do': lambda a: version_compare(a[0].value, a[1].value) == 0,
        },
        'ge': {
            'args': [ResultVersion, ResultVersion],
            'do': lambda a: version_compare(a[0].value, a[1].value) >= 0,
        },
        'gt': {
            'args': [ResultVersion, ResultVersion],
            'do': lambda a: version_compare(a[0].value, a[1].value) > 0,
        },
    }

    SYNONYMS = {
        '<':  'lt',
        '<=': 'le',
        '=':  'eq',
        '>=': 'ge',
        '>':  'gt',
    }

    for (k, v) in SYNONYMS.items():
        OPS[k] = OPS[v]

    def __init__(self, op, args):
        """
        Initialize an expression with the specified operation and
        arguments (Expr objects in their own right).
        """
        super(ExprOp, self).__init__()
        if op not in self.OPS:
            raise ValueError('op')

        # TODO: handle all, any
        if len(args) != len(self.OPS[op]['args']):
            raise ValueError('args')
        if list(filter(lambda a: not isinstance(a, Expr), args)):
            raise ValueError('args')

        self.op = op
        self.args = args

    def evaluate(self, cfg, data):
        """
        Evaluate the expression over the specified data.
        """
        op = self.OPS[self.op]
        args = list(map(lambda a: a.evaluate(cfg, data), self.args))

        # TODO: handle all, any
        for (idx, value) in enumerate(args):
            if not isinstance(value, op['args'][idx]):
                raise ValueError('{op} argument {idx}'.format(op=self.op,
                                                              idx=idx))

        return ResultBool(value=op['do'](args))


def version():
    '''
    Display program version information.
    '''
    print('feature-check {ver}'.format(ver=version_string))


def features():
    '''
    Display program features information.
    '''
    print('{prefix}feature-check={ver} single=1.0 list=1.0 simple=1.0'
          .format(prefix=default_prefix, ver=version_string))


def obtain_features(cfg):
    '''
    Execute the specified program and get its list of features.
    '''
    try:
        proc = subprocess.Popen([cfg['program'], cfg['args'].option_name],
                                stdin=None,
                                stdout=subprocess.PIPE,
                                stderr=subprocess.PIPE)
        res = proc.communicate()
        if proc.returncode != 0 or res[1].decode() != '':
            # It does not support '--features', does it?
            exit(1)
        data = res[0].decode().split('\n')
    except Exception:
        # Something went wrong in the --features processing
        exit(1)

    prefix = cfg['args'].features_prefix
    matching = list(filter(lambda s: s.startswith(prefix), data))
    if len(matching) != 1:
        exit(2)

    feature_list = matching[0][len(prefix):].split()
    data = {}
    for f in feature_list:
        kv = f.split('=', 1)
        if len(kv) == 1:
            data[kv[0]] = '1.0'
        else:
            data[kv[0]] = kv[1]
    return data


def output_tsv(data):
    for feature in sorted(data.keys()):
        print('{feature}\t{version}'
              .format(feature=feature, version=data[feature]))


def output_json(data):
    print(js.dumps(data, sort_keys=True, indent='  '))


output = {
    'tsv': output_tsv,
    'json': output_json,
}


def process_list(cfg, data):
    output[cfg['args'].output_format](data)


def process_single(cfg, data):
    if cfg['feature'] in data:
        if cfg['args'].display_version:
            print(data[cfg['feature']])
        exit(0)
    else:
        exit(1)


def process_expr(cfg, data):
    res = cfg['ast'].evaluate(cfg, data)
    if isinstance(res, ResultBool):
        exit(0 if res.value else 1)
    else:
        exit('FIXME: how to handle a {t} object?'.format(t=type(res).__name__))


process = {
    'list': process_list,
    'single': process_single,
    'expr': process_expr,
}


def main():
    '''
    The main routine: parse command-line arguments, do things.
    '''
    parser = argparse.ArgumentParser(
        prog='feature-check',
        usage='''
    feature-check [-v] [-O optname] [-P prefix] program feature
    feature-check [-O optname] [-P prefix] program feature op version
    feature-check [-O optname] [-o json|tsv] [-P prefix] -l program
    feature-check -V | -h''')
    parser.add_argument('-V', '--version', action='store_true',
                        help='display program version information and exit')
    parser.add_argument('--features', action='store_true',
                        help='display supported features and exit')
    parser.add_argument('-l', '--list', action='store_true',
                        help='list the features supported by a program')
    parser.add_argument('-O', '--option-name', type=str,
                        default=default_option,
                        help='the query-features option to pass')
    parser.add_argument('-o', '--output-format',
                        default=default_output_fmt,
                        choices=sorted(output.keys()),
                        help='specify the output format for the list')
    parser.add_argument('-P', '--features-prefix', type=str,
                        default=default_prefix,
                        help='the features prefix in the program output')
    parser.add_argument('-v', '--display-version', action='store_true',
                        help='display the feature version')
    parser.add_argument('args', nargs='*',
                        help='the program and features to test')

    args = parser.parse_args()
    if args.version:
        version()
        exit(0)
    if args.features:
        features()
        exit(0)

    cfg = {'args': args}

    if args.list:
        if len(args.args) != 1:
            parser.error('No program specified')
        cfg['program'] = args.args.pop(0)
        cfg['mode'] = 'list'
    else:
        if len(args.args) < 2:
            parser.error('No program or feature specified')
        cfg['program'] = args.args.pop(0)
        cfg['feature'] = ' '.join(args.args)
        m_single = re.match(rex['var'] + '$', cfg['feature'])
        m_simple = re.match('(?P<var> {var} ) \s* '
                            '(?P<op> {op} ) \s* '
                            '(?P<value> {value} ) '
                            '$'.format(var=rex['var'],
                                       op=rex['op'],
                                       value=rex['value']),
                            cfg['feature'], re.X)
        if m_single:
            cfg['mode'] = 'single'
        elif m_simple:
            d = m_simple.groupdict()
            cfg['mode'] = 'expr'
            cfg['ast'] = ExprOp(op=d['op'], args=[
                ExprFeature(name=d['var']),
                ExprVersion(value=d['value']),
                ])
        else:
            parser.error('Only querying a single feature supported so far')

    data = obtain_features(cfg)
    process[cfg['mode']](cfg, data)


if __name__ == '__main__':
    main()
