# $Id$
##
##  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/.
##
"""Menu with pyFormex apps.

"""
from __future__ import absolute_import, division, print_function

import os, random
from gettext import gettext as _

import pyformex as pf
from pyformex import zip
from pyformex import apps
from pyformex import utils
from pyformex import olist
from pyformex import script
from pyformex.gui import menu
from pyformex.gui.menus import Settings, Actions


catname = 'apps.cat'


def sortSets(d):
    """Turn the set values in d into sorted lists.

    - `d`: a Python dictionary

    All the values in the dictionary are checked. Those that are of type
    `set` are converted to a sorted list.
    """
    for k in d:
        if isinstance(d[k], set):
            d[k] = sorted(d[k])


def classify(appdir,pkg,nmax=0):
    """Classify the files in submenus according to keywords.

    """
    class failed(object):
        """A class to allow failing examples in the catalog"""
        _status = 'failed'

    all_apps = sorted(apps.detect(appdir))
    kat = ['all', 'status', 'level', 'topics', 'techniques']
    cat = dict([ (k, set()) for k in kat])
    cat['status'] = [ 'failed', 'checked', 'unchecked' ]
    cat['level'] = [ 'beginner', 'normal', 'advanced' ]
    print(cat)
    col = {}

    if nmax > 9: # Do not exagerate!
        # split the full collection in alphabetical groups of length nmax
        lbl, grp = splitAlpha(all_apps, nmax)
        cat['all'] = lbl
        for l, g in zip(lbl, grp):
            col['all/'+l] = g

    for i, appname in enumerate(all_apps):

        #col['all'].update([appname])
        try:
            fullname = str(pkg)+'.'+appname
            app = apps.load(fullname,strict=True)
            if app is None:
                raise RuntimeError("App '%s' has no run method" % fullname)
        except:
            app = failed
            print("Failed to load app '%s'" % fullname)

        for k in kat:
            if hasattr(app, '_'+k):
                v = getattr(app, '_'+k)
                if isinstance(v, list):
                    v = [ vi.lower() for vi in v ]
                else:
                    v = v.lower()
                if k in ['status', 'level']:
                    v = [v]
                else:
                    cat[k].update(v)
                for i in v:
                    ki = '%s/%s' % (k, i)
                    if ki not in col:
                        col[ki] = set()
                    col[ki].update([appname])

    sortSets(cat)
    sortSets(col)

    return all_apps, kat, cat, col


def splitAlpha(strings,n,ignorecase=True):
    """Split a series of strings in alphabetic collections.

    The strings are split over a series of bins in alphabetical order.
    Each bin can contain strings starting with multiple successive
    characters, but not more than n items. Items starting with the same
    character are always in the same bin. If any starting character
    occurs more than n times, the maximum will be exceeded.

    - `files`: a list of strings start with an upper case letter ('A'-'Z')
    - `n`: the desired maximum number of items in a bin.

    Returns: a tuple of

    - `labels`: a list of strings specifying the range of start characters
      (or the single start character) for the bins
    - `groups`: a list with the contents of the bins. Each item is a list
      of sorted strings starting with one of the characters in the
      corresponding label
    """
    from pyformex.arraytools import multiplicity
    if ignorecase:
        key = key=str.upper
    else:
        key = str
    strings = sorted(strings, key=key)
    mult, bins = multiplicity([ord(key(f[0])) for f in strings ])
    count = dict(zip(bins, mult))
    cat = []
    grp = []

    def accept(i, j, mtot, skip):
        if not skip:
            if i == j:
                cat.append(chr(i))
            else:
                cat.append('%c-%c' % (chr(i), chr(j)))
            grp.append(strings[:mtot])
        del strings[:mtot]

    def group(fromchar,tochar,skip=False):
        """Take the group from fromchar to tochar, inclusive

        fromchar and tochar are characters
        """
        j = i = ord(fromchar)
        mtot = count.get(i, 0)
        while j < ord(tochar):
            if mtot > n:
                accept(i, j, mtot, skip)
                j = i = i+1
                mtot = count.get(i, 0)
            else:
                mj = count.get(j+1, 0)
                if mtot+mj > n:
                    accept(i, j, mtot, skip)
                    j = i = j+1
                    mtot = mj
                else:
                    j += 1
                    mtot += mj
        if mtot > 0:
            accept(i, j, mtot, skip)

    # The grouping has to start from char(0) and loop over all values!
    group(chr(0), chr(31), skip=True)
    group(' ', '@')
    group('A', 'Z')
    if not ignorecase:
        # we should skip the group between
        group('a', 'z')

    return cat, grp


class AppMenu(menu.Menu):
    """A menu of pyFormex applications in a directory or list.

    This class creates a menu of pyFormex applications or scripts
    collected from a directory or specified as a list of modules.
    It is used in the pyFormex GUI to create
    the examples menu, and for the apps history. The pyFormex apps
    can then be run from the menu or from the button toolbar.
    The user may use this class to add his own apps/scripts
    into the pyFormex GUI.

    Apps are simply Python modules that have a 'run' function.
    Only these modules will be added to the menu.
    Only files that are recognized by :func:`utils.is_pyFormex()` as being
    pyFormex scripts will be added to the menu.

    The constructor takes the following arguments:

    - `title`: the top level label for the menu
    - `dir`: an optional directory path. If specified, and no `files` argument
      is specified, all Python files in `dir` that do not start with either
      '.' or '_', will be considered for inclusion in the menu.
      If mode=='app', they will only be included if
      they can be loaded as a module. If mode=='script', they will only be
      included if they are considered a pyFormex script by utils.is_pyFormex.
      If `files` is specified, `dir` will just be prepended to each file in
      the list.
    - `files`: an explicit list of file names of pyFormex scripts.
      If no `dir` nor `ext` arguments are given, these should be the full path
      names to the script files. Otherwise, `dir` is prepended and `ext` is
      appended to each filename.
    - `ext`: an extension to be added to each filename. If `dir` was specified,
      the default extension is '.py'. If no `dir` was specified, the default
      extension is an empty string.
    - `recursive`: if True, a cascading menu of all pyFormex scripts in the
      directory and below will be constructed. If only `dir` and no `files`
      are specified, the default is True
    - `max`: if specified, the list of files will be truncated to this number
      of items. Adding more files to the menu will then be done at the top and
      the surplus number of files will be dropped from the bottom of the list.

    The defaults were thus chosen to be convenient for the three most frequent
    uses of this class::

      AppMenu('My Apps',dir="/path/to/my/appsdir")

    creates a menu with all pyFormex apps in the specified path and its
    subdirectories.

    ::

      ApptMenu('My Scripts',dir="/path/to/my/sciptsdir",mode='scripts')

    creates a menu with all pyFormex scripts in the specified path and its
    subdirectories.

    ::

      AppMenu('History',files=["/my/script1.py","/some/other/script.pye"],\
          mode='script',recursive=False)

    is typically used to create a history menu of previously visited script
    files.

    With the resulting file list, a menu is created. Selecting a menu item
    will make the corresponding file the current script and unless the
    `autoplay` configuration variable was set to False, the script is executed.

    Furthermore, if the menu is a toplevel one, it will have the following
    extra options:

    - :menuselection:`Classify scripts`
    - :menuselection:`Remove catalog`
    - :menuselection:`Reload scripts`

    The first option uses the keyword specifications in the scripts docstring
    to make a classification of the scripts according to keywords.
    See the :func:`scriptKeywords()` function for more info. The second option
    removes the classification. Both options are especially useful for the
    pyFormex examples.

    The last option reloads a ScriptMenu. This can be used to update the menu
    when you created a new script file.
    """

    def __init__(self,title,dir=None,files=None,mode='app',ext=None,recursive=None,max=0,autoplay=False,toplevel=True,parent=None,before=None,runall=True):
        """Create a menu with pyFormex apps/scripts to play."""
        menu.Menu.__init__(self, title, parent=parent, before=before)
        self.dir = dir
        self.files = files
        if self.dir is None and self.files is None:
            raise ValueError("At least one of 'dir' or 'files' must be set.")
        self.mode = mode
        if ext is None and self.mode != 'app':
            if self.dir is None:
                ext = ''
            else:
                ext = '.py'
        self.ext = ext
        if recursive is None:
            recursive = True
        self.recursive = recursive and self.dir is not None
        self.toplevel = toplevel
        self.max = max
        self.autoplay = autoplay
        if self.dir and self.mode == 'app':
            self.pkg = os.path.basename(self.dir)
        else:
            self.pkg = None
        self.runall = runall
        self.load()
        #self.addRunAllMenu()



    def loadCatalog(self):
        catfile = os.path.join(self.dir, catname)
        if os.path.exists(catfile):
            pf.execFile(catfile, globals())
            for k in kat:
                if k == 'all_apps' and self.mode != 'app':
                    files = col[k]
                else:
                    files = []
                mk = AppMenu(k.capitalize(), dir=self.dir, files=files, mode=self.mode, recursive=False, toplevel=False, autoplay=self.autoplay, parent=self, runall=True)
                for i in cat[k]:
                    if '-' in i:
                        # alpha label like A-B
                        lbl = i
                    else:
                        # string from catalog file
                        lbl = i.capitalize()
                    ki = '%s/%s' % (k, i)
                    mi = AppMenu(lbl, dir=self.dir, files=col.get(ki, []), mode=self.mode, recursive=False, toplevel=False, autoplay=self.autoplay, parent=mk, runall=self.runall)
                    ## mi.addRunAllMenu()

                ## mk.addRunAllMenu()

            #self.addRunAllMenu()
            self.files = []
            return True
        return False


    def loadSubmenus(self,dirs=[]):
        if not dirs:
            dirs = utils.listDirs(self.dir)
        filtr = lambda s:os.path.isdir(os.path.join(self.dir, s))
        dirs = [ d for d in dirs if filtr(d) ]
        filtr = lambda s: s[0]!='.' and s[0]!='_'
        dirs = sorted([ d for d in dirs if filtr(d) ])
        for d in dirs:
            m = AppMenu(d, os.path.join(self.dir, d), mode=self.mode, ext=self.ext, autoplay=self.autoplay, recursive=self.recursive, parent=self, runall=self.runall)


    def getFiles(self):
        """Get a list of scripts in self.dir"""
        files = utils.listFiles(self.dir)
        filtr = lambda s: s[0]!='.' and s[0]!='_'
        files = [ f for f in files if filtr(f) ]
        if self.ext:
            filtr = lambda s: s.endswith(self.ext)
            n = len(self.ext)
            files = [ f[:-n] for f in files if filtr(f) ]

        files = sorted(self.filterFiles(files))
        return files


    def filterFiles(self, files):
        """Filter a list of scripts"""
        filtr = lambda s:utils.is_pyFormex(self.fileName(s))
        files = [ f for f in files if filtr(f) ]

        if self.max > 0 and len(files) > self.max:
            files = files[:self.max]

        return files


    def loadFiles(self,files=None):
        """Load the app/script files in this menu"""
        if files is None:
            if self.mode == 'app':
                files = apps.detect(self.dir)
            else:
                files = self.getFiles()

        if self.mode != 'app':
            files = self.filterFiles(files)

        if self.max > 0 and len(files) > self.max:
            files = files[:self.max]

        self.files = files

        pf.debug("Found %ss in %s\n%s" % (self.mode.capitalize(), self.dir, self.files), pf.DEBUG.INFO)

        self.my_actions = [ self.insert_action(f) for f in self.files ]
        self.triggered.connect(self.run)

        self.current = ""


    ## def addRunAllMenu(self):
    ##     if self.runall and pf.cfg['gui/runalloption']:
    ##         self.insert_sep()
    ##         self.create_insert_action('Run next app',self.runNextApp)
    ##         self.create_insert_action('Run all next',self.runAllNext)
    ##         self.create_insert_action('Run all apps',self.runAllApps)


    def load(self):
        if self.dir is None:
            self.loadFiles(self.files)

        else:
            if self.files is None:
                self.loadCatalog()

            if self.recursive:
                self.loadSubmenus()

            if self.files or self.files is None:
                self.loadFiles(self.files)

            if self.toplevel:
                self.insert_sep()
                self.create_insert_action('Classify apps', self._classify)
                self.create_insert_action('Remove catalog', self._unclassify)

                # Confined to 'app', crashes when applied on script
                if self.mode == 'app':
                    self.create_insert_action('Reload apps', self.reload)


    def fileName(self, script):
        """Return the full pathname for a script."""
        fn = script + self.ext
        if self.dir:
            return os.path.join(self.dir, fn)
        else:
            return fn


    def fullAppName(self, app):
        """Return the pkg.module name for an app."""
        if self.pkg:
            return "%s.%s" % (self.pkg, app)
        else:
            return app


    def run(self, action):
        """Run the selected app.

        This function is executed when the menu item is selected.
        """
        app = str(action.text())
        if app in self.files:
            self.runApp(app, play=self.autoplay)


    def runApp(self,app,play=True):
        """Set/Run the specified app.

        Set the specified app as the current app,
        and run it if play==True.
        """
        self.current = app
        #if self.mode != 'app':
        pf.GUI.setcurfile(app)
        if play:
            if self.mode == 'app':
                appname = self.fullAppName(app)
            else:
                appname = self.fileName(app)
            pf.debug("Running application %s" % appname, pf.DEBUG.APPS|pf.DEBUG.MENU)
            script.runAny(appname)


    def runAll(self,startfrom='A',stopat='[',count=-1,recursive=True,random=False):
        """Run all apps with a name in the range [startfrom,stopat].

        Runs the apps with a name >= `startfrom` and < `stopat`.
        The default will run all apps starting with a capital (like
        the examples). Specify None to disable the limit.
        If count is positive, at most count scripts are executed.
        If recursive is True, also the files in submenu are played.
        If random is True, the files in any submenu are shuffled before running.
        """
        from pyformex.gui.draw import layout, reset, sleep
        from pyformex.gui import widgets

        if self.mode != 'app':
            print("I can only do this in 'App' mode, not 'Script' mode")
            return

        print(startfrom,stopat,count)

        pf.GUI.enableButtons(pf.GUI.actions, ['Stop'], True)

        files = self.files
        if startfrom is not None:
            files = [ f for f in files if f >= startfrom ]
        if stopat is not None:
            files = [ f for f in files if f < stopat ]
        if random:
            utils.shuffle(files)
        if count >= 0:
            files = files[:count]

        tcount = len(files)
        print(tcount,files)

        if tcount > 0:
            # Run these files
            print("Running %s applications from '%s'" % (len(files),self.title()))
            print(files)
            save = widgets.input_timeout
            pf.GUI.drawlock.free()
            widgets.input_timeout = 1.0
            tcount = 0
            try:
                for f in files:
                    layout(1)
                    reset()
                    pf.PF.clear()
                    print("Running app '%s'" % f)
                    self.runApp(f)
                    script.breakpt(msg="Breakpoint from runall")
                    sleep(1)#,msg="EXITREQUESTED: %s" % script.exitrequested)
                    if script.exitrequested:
                        break
                    tcount += 1
            finally:
                widgets.input_timeout = save
                pf.GUI.drawlock.allow()

        # if room for more, recurse into submenus
        if recursive and (count < 0 or tcount < count):
            for m in self._submenus_:
                n = m.runAll(startfrom, stopat, count=count-tcount, recursive=recursive, random=random)
                tcount += n
                if count > 0 and tcount >= count:
                    print("Ran %s examples; getting out" % tcount)
                    break
                else:
                    print("Still want %s more examples" % (count-tcount))

        pf.GUI.enableButtons(pf.GUI.actions, ['Stop'], False)
        return tcount


    def runAllNext(self,offset=1,count=-1):
        """Run a sequence of apps, starting with the current plus offset.

        If a positive count is specified, at most count scripts
        will be run.
        A nonzero offset may be specified to not start with the current
        script.
        """
        try:
            i = self.files.index(self.current) + offset
        except ValueError:
            i = 0
        self.runAll(first=i, count=count)


    def runCurrent(self):
        """Run the current app, or the first if none was played yet."""
        self.runAllNext(offset=0, count=1)


    def runNextApp(self):
        """Run the next app, or the first if none was played yet."""
        self.runAllNext(offset=1, count=1)


    def runRandom(self):
        """Run a random script."""
        i = random.randint(0, len(self.files)-1)
        self.runAll(first=i, count=1)

#
# TODO: the following examples currently pause indefinitely
#       when running with the timeout feature:
#       ColorScale, Curves, NurbsCurve, OpticalIllusions, Rubik
#       SuperShape, Sweep, TestDraw
#       This is likely related to the scriptlock feature

    def runAllApps(self):
        from pyformex.gui import draw
        res = draw.askItems([
            ('timeout', True),
            ('random', False),
            ('recursive', True),
            ('count', -1),
            ])
        if not res:
            return
        from pyformex.gui.toolbar import timeout
        timeout(res['timeout'])
        del res['timeout']
        self.runAll(**res)
        timeout(False)
        print("Finished running all examples")



    def reload(self):
        """Reload the scripts from dir.

        This is only available if a directory path was specified and
        no files.
        """
        pf.debug("Reloading this menu", pf.DEBUG.APPS)
        if self.dir:
            self.clear()
            self._submenus_ = []
            self.files = None
            self.load()


    def add(self,name,strict=True,skipconfig=True):
        """Add a new filename to the front of the menu.

        This function is used to add app/scripts to the history menus.
        By default, only legal pyFormex apps or scripts can be added, and
        scripts from the user config will not be added.
        Setting strict and or skipconfig to False will skip the filter(s).
        """
        if strict:
            if self.mode == 'app':
                appname = self.fullAppName(name)
                app = apps.load(appname,strict=strict)
                if app is None:
                    print("%s is not a pyFormex app!" % appname)
                    return
            else:
                if not utils.is_pyFormex(name):
                    return
                if skipconfig:
                    # This is here to skip the startup script
                    if os.path.dirname(os.path.abspath(name)) == pf.cfg['userconfdir']:
                        return

        files = self.files
        olist.toFront(files, name)
        if self.max > 0 and len(files) > self.max:
            files = files[:self.max]
        while len(self.my_actions) < len(files):
            self.my_actions.append(self.addAction(name))
        for a, f in zip(self.my_actions, self.files):
            a.setText(f)


    def _classify(self,nmax=20):
        """Classify, symlink and reload the scripts"""
        pf.debug("Classifying scripts", pf.DEBUG.APPS)
        if self.dir:
            f = os.path.join(self.dir, catname)
            all_apps, kat, cat, col = classify(self.dir, self.pkg, nmax)
            s = """# Do not edit this file! It was generated by %s.
#
all_apps = %r
#
kat = %r
#
cat = %r
#
col = %r
#
""" % (pf.fullVersion(),all_apps, kat, cat, col)
            open(f, 'w').writelines(s)
            print("Created catalog %s" % f)
            self.reload()


    def _unclassify(self):
        """Remove the catalog and reload the scripts unclassified"""
        if self.dir:
            f = os.path.join(self.dir, catname)
            if os.path.exists(f):
                os.remove(f)
                self.reload()


############### The pyFormex App menu ############################


def createAppMenu(mode='app',parent=None,before=None):
    """Create the menu(s) with pyFormex apps

    This creates a menu with all examples distributed with pyFormex.
    By default, this menu is put in the top menu bar with menu label 'Examples'.

    The user can add his own app directories through the configuration
    settings. In that case the 'Examples' menu and menus for all the
    configured app paths will be gathered in a top level popup menu labeled
    'Apps'.

    The menu will be placed in the top menu bar before the specified item.
    If a menu item named 'Examples' or 'Apps' already exists, it is
    replaced.
    """
    Mode = mode.capitalize()
    appmenu = menu.Menu('&%s'%Mode, parent=parent, before=before)
    appmenu.mode = mode
    if mode == 'app':
        appdirs = [ (d.name, d.path) for d in pf.appdirs ]
    else:
        appdirs = pf.cfg['scriptdirs']


    history = AppMenu('History', files=pf.cfg['gui/%shistory'%mode], max=pf.cfg['gui/history_max'], mode=mode, parent=appmenu, runall=False)

    # Fill in missing default locations : this enables the user
    # to keep the pyFormex installed examples in his config
    guessName = lambda n, s: s if len(s) > 0 else pf.cfg['%sdir' % n.lower()]
    appdirs = [ (d[0], guessName(*d)) for d in appdirs ]
    appdirs = [ d for d in appdirs if d[1] is not None and len(d[1]) > 0 ] # may be removed?
    appdirs = [ d for d in appdirs if os.path.exists(d[1]) ]
    for name, path in appdirs:
        pf.debug("Loading menu %s from %s" % (name, path), pf.DEBUG.MENU)
        m = AppMenu(name, path, mode=mode, autoplay=True, parent=appmenu, runall=True)

    setattr(pf.GUI, '%shistory'%mode, history)

    appmenu.insertItems([
        ('---', None),
        (_('&Configure %s Paths'%Mode), Settings.setDirs, {'data':'%sdirs'%mode}),
        (_('&Reload %s Menu'%Mode), reloadMenu, {'data':mode}),
        ])
    if mode == 'app':
        appmenu.insertItems([
            (_('&List loaded Apps'), script.printLoadedApps),
            (_('&Unload Current App'), Actions.unloadCurrentApp),
            ])

    return appmenu


def reloadMenu(mode='app'):
    """Reload the named menu."""
    name = mode#+'s'
    menu = pf.GUI.menu.item(name)
    if menu is not None:
        before = pf.GUI.menu.nextitem(name)
        pf.GUI.menu.removeItem(name)
        if mode == 'app':
            # reset pf.appdirs, we may have configuration changes
            from pyformex import apps
            apps.setAppDirs()
        newmenu = createAppMenu(mode, pf.GUI.menu, before)



# End
