#
##
##  This file is part of pyFormex 1.0.5  (Sat Feb 16 10:40:32 CET 2019)
##  pyFormex is a tool for generating, manipulating and transforming 3D
##  geometrical models by sequences of mathematical operations.
##  Home page: http://pyformex.org
##  Project page:  http://savannah.nongnu.org/projects/pyformex/
##  Copyright 2004-2018 (C) Benedict Verhegghe (benedict.verhegghe@ugent.be)
##  Distributed under the GNU General Public License version 3 or later.
##
##  This program is free software: you can redistribute it and/or modify
##  it under the terms of the GNU General Public License as published by
##  the Free Software Foundation, either version 3 of the License, or
##  (at your option) any later version.
##
##  This program is distributed in the hope that it will be useful,
##  but WITHOUT ANY WARRANTY; without even the implied warranty of
##  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
##  GNU General Public License for more details.
##
##  You should have received a copy of the GNU General Public License
##  along with this program.  If not, see http://www.gnu.org/licenses/.
##
"""software.py

A module to help with detecting required software and helper software,
and to check the versions of it.

This module is currently experimental. It contains some old functions
moved here from utils.py.
"""
from __future__ import absolute_import, division, print_function

import os
import sys
import re
from distutils.version import LooseVersion as SaneVersion

import pyformex as pf
from pyformex.odict import OrderedDict

# Python modules we know how to use
# Do not include pyformex or python here: they are predefined
# and could be erased by the detection
# The value is a sequence of:
#   - module name
#   - module name to load the version
#   - a sequence of attributes to get the version
# If empty, the attribute is supposed to be '__version__'
# If module name is an empty string, it is supposed to be equal to our alias
known_modules = {
    'calpy': (),
    'dicom': (),
    'docutils': (),
    'gdcm': ('', '', 'GDCM_VERSION'),
    'gl2ps': ('', '', 'GL2PS_VERSION'),
    'gnuplot': ('Gnuplot',),
#    'ipython': ('IPython',),
#    'ipython-qt': ('IPython.frontend.qt',),
    'matplotlib': (),
    'numpy': (),
    'pil': ('PIL', 'PIL', 'VERSION'),
    'pyopengl': ('OpenGL',),
    'pyqt4': ('PyQt4.QtCore', 'PyQt4', 'QtCore', 'QT_VERSION_STR'),
    'pyqt4gl': ('PyQt4.QtOpenGL', 'PyQt4', 'QtCore', 'QT_VERSION_STR'),
    'pyside': ('PySide',),
    'pyside2': ('PySide2',),
    'scipy': (),
    'vtk': ('pyformex.vtk_light', 'pyformex', 'vtk_light', 'VTK_VERSION'),
     }

known_externals = {
# BEWARE ! Because utils.system now does not use a new shell by default,
# commands that require a shell should spawn the shell with the command
# as a -c parameter (see e.g. tetgen)

#  NOTE: abaqus command may hang longtime on checking the license server
#    'abaqus': ('abaqus info=sys|head -n2|tail -n1', 'Abaqus (\S+)'),
    'admesh': ('admesh --version', r'ADMesh - version (\S+)'),
    'calculix': ('ccx -v', r'.*version (\S+)'),
    'calix': ('calix --version', r'CALIX-(\S+)'),
    'calpy': ('calpy --version', r'Calpy (\S+)'),
    'dxfparser': ('pyformex-dxfparser --version', r'dxfparser (\S+)'),
    'ffmpeg': ('ffmpeg -version', r'[fF][fF]mpeg version (\S+)'),
    'freetype': ('freetype-config --ftversion', r'(\S+)'),
    'gts': ('gtsset -h', r'Usage(:) '),
    'gts-bin': ('gts2stl -h', r'Usage(:) '),
    'gts-extra': ('gtsinside -h', r'Usage(:) '),
    'imagemagick': ('import -version', r'Version: ImageMagick (\S+)'),
    'postabq': ('pyformex-postabq -V', r'postabq (\S+).*'),
    'python': ('python --version', r'Python (\\S+)'),
    'recordmydesktop': ('recordmydesktop --version', r'recordMyDesktop v(\S+)'),
    'tetgen': ("bash -c 'type -p tetgen'", r'(\S+)'),
    'units': ('units --version', r'GNU Units version (\S+)'),
    'vmtk': ("bash -c 'type -p vmtk'", r'(\S+)'),
    }


if pf.PY3:
    # Some detect commands fail on Python3
    del known_externals['calculix']
    del known_externals['postabq']
    del known_externals['recordmydesktop']


# versions of detected modules
the_version = {
    'pyformex': pf.__version__.split()[0],
    'python': sys.version.split()[0],
    }
# versions of detected external commands
the_external = {}

def checkVersion(name,version,external=False):
    """Checks a version of a program/module.

    name is either a module or an external program whose availability has
    been registered.
    Default is to treat name as a module. Add external=True for a program.

    Return value is -1, 0 or 1, depending on a version found that is
    <, == or > than the requested values.
    This should normally understand version numbers in the format 2.10.1
    Returns -2 if no version found.
    """
    if external:
        ver = hasExternal(name)
    else:
        ver = hasModule(name)
    if not ver:
        return -2
    if SaneVersion(ver) > SaneVersion(version):
        return 1
    elif SaneVersion(ver) == SaneVersion(version):
        return 0
    else:
        return -1


def hasModule(name,check=False):
    """Test if we have the named module available.

    Returns a nonzero (version) string if the module is available,
    or an empty string if it is not.

    By default, the module is only checked on the first call.
    The result is remembered in the the_version dict.
    The optional argument check==True forces a new detection.
    """
    if name in the_version and not check:
        return the_version[name]
    else:
        return checkModule(name)


def requireModule(name,version=None,comp='ge'):
    """Ensure that the named Python module/version is available.

    Checks that the specified module is available, and that its version
    number is not lower than the specified version.
    If no version is specified, any version is ok.

    The default comparison operator 'ge' can be replaced with one of:
    'eq', 'ge', 'gt', 'le', 'lt', 'ne'.

    Returns if the required module/version could be loaded, else an
    error is raised.
    """
    if pf.sphinx:
        # Do not check when building docs
        return

    import operator
    ver = hasModule(name)
    if not ver:
        if name in known_modules:
            # Get the correct name, if different from our alias
            try:
                realname = known_modules[name][0]
                if realname:
                    name = realname
            except:
                pass
            attr = 'required'
        else:
            attr = 'unknown'
        errmsg = "Could not load %s module '%s'" % (attr, name)
        errmsg = """..

**Module %s not found!**

You activated some functionality requiring
the Python module '%s'.
However, the module '%s' could not be loaded.
Probably it is not installed on your system.
""" % (name, name, name)
        pf.error(errmsg)
        raise ValueError(errmsg)

    else:
        if version is not None:
            op = getattr(operator,comp)
            if not op(checkVersion(name, version),0):
                errmsg = """..

**Version of module %s (%s) does not meet requirements (%s %s)!**

You activated some functionality using the Python module '%s'.
However, the required version for that module could not be loaded.
""" % (name, ver, comp, version, name)
                pf.error(errmsg)
                raise ValueError(errmsg)


def checkAllModules():
    """Check the existence of all known modules.

    """
    [ checkModule(n, quiet=True) for n in known_modules ]


def checkModule(name,ver=(),fatal=False,quiet=False):
    """Check if the named Python module is available, and record its version.

    ver is a tuple of:

    - modname: name of the module to test import
    - vername: name of the module holding the version string
    - more fields are consecutive attributes leading to the version string

    The obtained version string is returned, empty if the module could not
    be loaded.
    The (name,version) pair is also inserted into the the_version dict.

    If fatal=True, pyFormex will abort if the module can not be loaded.
    """
    if len(ver) == 0 and name in known_modules:
        ver = known_modules[name]

    modname = name
    if len(ver) > 0  and len(ver[0]) > 0:
        modname = ver[0]


    try:
        if not quiet:
            pf.debug(modname, pf.DEBUG.DETECT)
        m = __import__(modname)
        if not quiet:
            pf.debug(m, pf.DEBUG.DETECT)
        if len(ver) > 1 and len(ver[1]) > 0:
            modname = ver[1]
            m = __import__(modname)
            if not quiet:
                pf.debug(m, pf.DEBUG.DETECT)
        ver = ver[2:]
        if len(ver) == 0:
            ver = ('__version__',)
        for a in ver:
            m = getattr(m, a)
            if not quiet:
                pf.debug(m, pf.DEBUG.DETECT)

    except:
        # failure: unexisting or unregistered modules
        if fatal:
            raise
        m = ''

    # If the attribute is a callable, call it
    if callable(m):
        m = m()
        # if a tuple is returned, turned it into a string
        if isinstance(m,tuple):
            m = '.'.join(map(str,m))
    # make sure version is a string (e.g. gl2ps uses a float!)
    m = str(m)
    _congratulations(name, m, 'module', fatal, quiet=quiet)
    the_version[name] = m
    return m


def hasExternal(name,force=False):
    """Test if we have the external command 'name' available.

    Returns a nonzero string if the command is available,
    or an empty string if it is not.

    The external command is only checked on the first call.
    The result is remembered in the the_external dict.
    """
    if name in the_external and not force:
        return the_external[name]
    else:
        return checkExternal(name)


def requireExternal(name):
    """Ensure that the named external program is available.

    If the module is not available, an error is raised.
    """
    if pf.sphinx:
        # Do not require externals when building docs
        return

    if not hasExternal(name):
        if name in known_externals:
            # Get the correct name, if different from our alias
            try:
                realname = known_externals[name][0]
                if realname:
                    name = realname
            except:
                pass
            attr = 'required'
        else:
            attr = 'unknown'
        errmsg = """..

**Program %s not found!**

You activated some functionality requiring
the external program '%s'.
However, '%s' was not found on your system.
""" % (name, name, name)
        pf.error(errmsg)
        raise ValueError(errmsg)


def checkAllExternals():
    """Check the existence of all known externals.

    Returns a dict with all the known externals, detected or not.
    The detected ones have a non-zero value, usually the version number.
    """
    [ checkExternal(n, quiet=True) for n in known_externals ]
    return the_external


def checkExternal(name,command=None,answer=None,quiet=False):
    """Check if the named external command is available on the system.

    name is the generic command name,
    command is the command as it will be executed to check its operation,
    answer is a regular expression to match positive answers from the command.
    answer should contain at least one group. In case of a match, the
    contents of the match will be stored in the the_external dict
    with name as the key. If the result does not match the specified answer,
    an empty value is inserted.

    Usually, command will contain an option to display the version, and
    the answer re contains a group to select the version string from
    the result.

    As a convenience, we provide a list of predeclared external commands,
    that can be checked by their name alone.
    """
    from pyformex import utils

    if command is None or answer is None:
        cmd, ans = known_externals.get(name, (name, r'(.+)\n'))
        if command is None:
            command = cmd
        if answer is None:
            answer = ans

    pf.debug("Check %s\n%s" % (name, command), pf.DEBUG.DETECT)
    P = utils.system(command)
    pf.debug("Status:\n%s\nStdout:\n%s\nStderr:\n%s" % (P.sta, P.out, P.err), pf.DEBUG.DETECT)
    version = ''
    # Beware: some programs write their version to stderr, others to stdout
    m = None
    if P.out:
        m = re.match(answer, P.out)
    if m is None and P.err:
        m = re.match(answer, P.err)
    if m:
        version = str(m.group(1))
    _congratulations(name, version, 'program', quiet=quiet)
    the_external[name] = version
    return str(version)


def _congratulations(name,version,typ='module',fatal=False,quiet=False,severity=2):
    """Report a detected module/program."""
    if version:
        if not quiet:
            pf.debug("Congratulations! You have %s (%s)" % (name, version), pf.DEBUG.DETECT)
    else:
        if not quiet or fatal:
            pf.debug("ALAS! I could not find %s '%s' on your system" % (typ, name), pf.DEBUG.DETECT)
        if fatal:
            pf.error("Sorry, I'm getting out of here....")
            sys.exit()


def Libraries():
    from pyformex.lib import accelerated
    return [ m.__name__ for m in accelerated ]


def Shaders():
    """Return a list of the available GPU shader programs.

    Shader programs are in the pyformex/glsl directory and consist
    at least of two files:
    'vertex_shader_SHADER.c' and 'fragment_shader_SHADER.c'.
    This function will return a list of all the SHADER filename parts
    currently available. The default shader programs do not have the
    '_SHADER' part and will not be contained in this list.
    """
    from pyformex import utils
    files = utils.listTree(pf.cfg['shaderdir'],listdirs=False, sorted=True,
        includefiles=['vertex_shader_.*\.c$', 'fragment_shader_.*\.c$'])
    files = [ os.path.basename(f) for f in files ]
    vshaders = [ f[14:-2] for f in files if f.startswith('v') ]
    fshaders = [ f[16:-2] for f in files if f.startswith('f') ]
    shaders = set(vshaders) & set(fshaders)
    return sorted(shaders)


def detectedSoftware(all=True):
    """Return a dict with all detected helper software"""
    if all:
        checkAllModules()
        checkAllExternals()

    system, host, release, version, arch = os.uname()
    soft = {
        'System': OrderedDict([
            ('pyFormex_version', the_version['pyformex']),
            ('pyFormex_installtype', pf.installtype),
            ('pyFormex_fullversion', pf.fullVersion()),
            ('pyFormex_libraries', ', '.join(Libraries())),
            ('pyFormex_shaders', ', '.join(Shaders())),
            ('Python_version', the_version['python']),
            ('Python_fullversion', sys.version.replace('\n', ' ')),
            ('System', system),
            ('Host', host),
            ('Release', release),
            ('Version', version),
            ('Arch', arch),
            ]),
        'Modules': the_version,
        'Externals': the_external,
        }
    return soft


def reportSoftware(soft=None,header=None):
    from pyformex import utils
    notfound = '** Not Found **'
    def format_dict(d,sort=True):
        keys = sorted(d.keys()) if sort else d
        items = [ "  {} ({})".format(k, d[k] if d[k] else notfound) for k in keys ]
        return '\n'.join(items)

    if soft is None:
        soft = detectedSoftware()
    s = ""
    if header:
        header = str(header)
        s += utils.underlineHeader(header)
    for key, desc, sort in [
        ('System', 'Installed System', False),
        ('Modules', 'Detected Python Modules', True),
        ('Externals', 'Detected External Programs', True)
        ]:
        s += "\n%s:\n" % desc
        s += format_dict(soft[key], sort=sort)
        s += '\n'
    return s


def fmtDict(d,name=None):
    s = str(d)
    out = ''
    if name:
        out += name
        out += ' = '
    i = j = ind = 0
    while j < len(s):
        print(i,j,s[i:j+1])
        if s[j] == '{':
            out += s[i:j+1]
            ind += 4
            out += '\n' + ' '*ind
            i = j+1
        j = j + 1
    return out


def formatDict(d,indent=4):
    """Format a dict in nicely formatted Python source representation.

    Each (key,value) pair is formatted on a line of the form::

       key = value

    If all the keys are strings containing only characters that are
    allowed in Python variable names, the resulting text is a legal
    Python script to define the items in the dict. It can be stored
    on a file and executed.

    This format is the storage format of the Config class.
    """
    s = "{\n"
    for k, v in d.items():
        s += "%s'%s' : " % (' '*indent,k)
        if isinstance(v, dict):
            s += formatDict(v,indent+4)
        else:
            s += "%r" % v
        s += ",\n"
    s += "%s}" % (' '*indent)
    return s


import operator
comparators = {
    '==': operator.__eq__,
    '!=': operator.__ne__,
    '>' : operator.__gt__,
    '>=': operator.__ge__,
    '<' : operator.__lt__,
    '<=': operator.__le__,
    }

re_Required = re.compile('(?P<cmp>(==|!=|([<>]=?)))? *(?P<require>.*)')

def compareVersion(has,want):
    """Check whether a detected version matches the requirements.

    has is the version string detected.
    want is the required version string, possibly preceded by one
    of the doubly underscored comparison operators: __gt__, etc.
    If no comparison operator is specified, '__eq__' is assumed.

    Note that any tail behind x.y.z version is considered to be later
    version than x.y.z.

    Returns the result of the comparison: True or False
    Examples:

      >>> compareVersion('2.7','2.4.3')
      False
      >>> compareVersion('2.7','>2.4.3')
      True
      >>> compareVersion('2.7','>= 2.4.3')
      True
      >>> compareVersion('2.7','>= 2.7-rc3')
      False
      >>> compareVersion('2.7-rc4','>= 2.7-rc3')
      True

    """
    if not has:
        return False
    m = re_Required.match(want)
    if not m:
        return False

    d = m.groupdict()
    want = d['require']
    comp = d['cmp']
    if comp is None:
        comp = '=='
    has = SaneVersion(has)
    want = SaneVersion(want)
    return comparators[comp](has,want)


def checkItem(has, want):
    if has == want:
        return 'Matching'
    if not has:
        return 'Missing'
    if has and not want:
        return 'Unwanted'
    has = SaneVersion(has)
    want = SaneVersion(want)
    print(("HAS %s; WANT %s" % (has, want)))
    if has == want:
        return 'Matching'
    if has < want:
        return 'Too Old'
    if has > want:
        return 'Too Recent'


def checkItem2(has,want):
    if compareVersion(has,want):
        return 'OK'
    else:
        return 'FAIL'


def checkDict(has, want):
    """Check that software dict has has the versions required in want"""
    return [ (k, has[k], want[k], checkItem2(has[k], want[k])) for k in want ]


def checkSoftware(req,report=False):
    """Check that we have the matching components

    Returns True or False.
    If report=True, also returns a string with a full report.
    """
    from pyformex import utils
    soft = detectedSoftware()
    comp = []
    for k in req:
        comp.extend(checkDict(soft[k], req[k]))
    print(comp)
    result = all([ s[3] == 'OK' for s in comp ])
    if report:
        s = utils.underlineHeader("%30s %15s %15s %10s\n" % ("Item", "Found", "Required", "OK?"))
        for item in comp:
            s += "%30s %15s %15s %10s\n" % item
        s += "RESULT=%s\n" % ('OK' if result else 'FAIL')
        return result,s
    else:
        return result


def registerSoftware(req):
    """Register the current values of required software"""
    from pyformex import utils
    soft = detectedSoftware()
    reg = {}
    for k in req:
        reg[k] = utils.selectDict(soft[k], list(req[k].keys()))
    return reg


def soft2config(soft):
    """Convert software collection to config"""
    from pyformex import utils
    from pyformex import config
    conf = config.Config()
    for k in soft:
        conf.update(utils.prefixDict(soft[k], k+'/'))
    return conf


def config2soft(conf):
    """Convert software collection from config"""
    from pyformex import utils
    soft = {}
    for k in ['System', 'Modules', 'Externals']:
        soft[k] = utils.subDict(conf, prefix=k+'/')
    return soft


def storeSoftware(soft,fn,mode='pretty'):
    """Store the software collection on file."""
    if mode == 'pretty':
        with open(fn, 'w') as fil:
            fil.write("soft = "+formatDict(soft)+'\n')
    elif mode == 'python':
        with open(fn, 'w') as fil:
            fil.write("soft=%r\n" % soft)
    elif mode == 'config':
        conf = soft2config(soft)
        conf.write(fn)
    elif mode == 'pickle':
        from pyformex import pickle
        print(("PICKLING", soft))
        pickle.dump(soft, open(fn, 'w'))


def readSoftware(fn,mode='python'):
    """Read the software collection from file.

    - `mode` = 'pretty': readable, editable
    - `mode` = 'python': readable, editable
    - `mode` = 'config': readable, editable
    - `mode` = 'pickle': binary
    """
    from pyformex import config
    if mode == 'python':
        print((os.path.abspath(fn)))
        with open(fn, 'r') as fil:
            exec(fil.read())
    elif mode == 'config':
        conf = config.Config(fn)
        soft = config2soft(conf)
    elif mode == 'pickle':
        from pyformex import pickle
        soft = pickle.load(open(fn, 'r'))
    return soft


#### execute as pyFormex script for testing ########

if __name__ in ["draw","script"] :

    Required = {
        'System': {
            'pyFormex_installtype': 'R',
            },
        'Modules': {
            'pyformex': '>= 0.9.1',
            'python': '>= 2.7.3',
            'matplotlib': '1.1.1',
            },
        'Externals': {
            'admesh': '>= 0.95',
            },
        }

    soft = detectedSoftware()
    print((reportSoftware(header="Detected Software")))
    print('\n ')
    print((reportSoftware(Required, header="Required Software")))
    print('\n ')

    print('CHECK')
    ok,report = checkSoftware(Required,True)
    print(report)

    reg = registerSoftware(Required)
    print("REGISTER")
    print(formatDict(reg))

    storeSoftware(reg, 'checksoft.py')
    req = readSoftware('checksoft.py')
    print('CHECK REGISTERED')
    print(formatDict(req))

    ok,report = checkSoftware(req,True)
    print(report)


# End
