#!/usr/libexec/platform-python -s
#  -*- coding: utf-8 -*-
# pylint: disable=invalid-name
# *****************************************************************************
# MLZ library of Tango servers
# Copyright (c) 2015-2020 by the authors, see LICENSE
#
# 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 2 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, write to the Free Software Foundation, Inc.,
# 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
#
# Module authors:
#   Georg Brandl <g.brandl@fz-juelich.de>
#
# *****************************************************************************

import os
import re
import sys
import argparse
import textwrap
from os import path
from collections import OrderedDict

from urwid import MainLoop, AttrWrap, Columns, LineBox, Padding, SolidFill, \
    ListBox, SimpleFocusListWalker, Pile, Divider, Edit, Text, Button, \
    Overlay, GridFlow, ExitMainLoop

# Add import path for inplace usage
sys.path.insert(0, path.abspath(path.join(path.dirname(__file__), '..')))

# pylint: disable=wrong-import-position
from entangle.core import defs, InvalidValue
from entangle.lib import devlist, toml
from entangle.lib.pycompat import string_types, unescape
from entangle.server import get_global_config


# The urwid color palette to use for different widgets.
PALETTE = [
    ('body', 'default', 'default'),
    ('head', 'white', 'dark blue'),
    ('bold', 'bold', 'default'),
    ('item', 'default', 'default'),
    ('sel', 'standout', 'default'),
    ('doc', 'dark green', 'default'),
    ('msgbox', 'white', 'dark red'),
]


# Sort order for standard Device properties.
SORT_KEY = {
    'iodev': -10,
    'devfile': -1,
    'host': -2,
    'port': -1,
    'sol': 1,
    'eol': 2,
    'absmin': 1,
    'absmax': 2,
    'inFormula': 5,
    'outFormula': 6,
    'offset': 7,
    'loglevel': 10,
    'polling': 20,
}


def clean_docstring(cls):
    """Minimal docstring reformatting for inline display."""
    lines = (cls.__doc__ or '').splitlines()
    if not lines:
        return '<no documentation>'
    final = lines[0] + '\n' + textwrap.dedent('\n'.join(lines[1:]))
    return final.strip()


def get_typename(cls, offset=len('entangle.device.')):
    """Format the canonical class name."""
    return cls.__module__[offset:] + '.' + cls.__name__


def filtered_proplist(cls):
    """Return sorted list of properties that should be prompted for
    as (name, prop) tuples.
    """
    proplist = sorted(cls.properties.items(),
                      key=lambda kv: (SORT_KEY.get(kv[0], 0), kv[0]))
    return [(k, v) for (k, v) in proplist if k != 'type' and
            'Deprecated' not in v.description]


QUOTED_STRING = re.compile(r'''
    \s* (                    # leading whitespace
    ( [^"'] [^,]*? ) |       # unquoted string: non-quote, then anything
    (?: (["']) ((?:\\\\|\\"|\\'|[^"])*) \3 )
                             # quoted string: quote, content, quote
    \s* ) (?=,|$)            # trailing whitespace, comma or end of string
    ''', re.X)


def match_string(instr):
    if not instr:
        raise ValueError('empty string must be given with quotes')
    m = QUOTED_STRING.match(instr)
    if not m:
        raise ValueError('invalid quoted or unquoted string: %r' % instr)
    if m.group(2) is not None:
        # unquoted
        return m.group(2).strip(), m.end()
    return unescape(m.group(4)), m.end()


def convert_string_array(instr):
    instr = instr.strip()
    if not (instr.startswith('[') and instr.endswith(']')):
        raise ValueError('arrays must be comma-separated and enclosed '
                         'in brackets')
    instr = instr[1:-1].strip()
    elements = []
    while instr:
        el, end = match_string(instr)
        elements.append(el)
        instr = instr[end:].lstrip()
        if instr and not instr.startswith(','):
            raise ValueError('arrays must be comma-separated and enclosed '
                             'in brackets')
        instr = instr[1:].strip()
    return elements


def convert_num_array(instr, innervalidator):
    instr = instr.strip()
    if not (instr.startswith('[') and instr.endswith(']')):
        raise ValueError('arrays must be comma-separated and enclosed '
                         'in brackets')
    instr = instr[1:-1]
    return [convert_input(innervalidator, v.strip())
            for v in instr.split(',') if v]


def convert_input(validator, instr):
    if validator in (bool, defs.boolean):
        if instr in ('1', 'true', 'True'):
            return True
        elif instr in ('0', 'false', 'False'):
            return False
        else:
            raise ValueError('cannot convert to bool: %r' % instr)
    elif validator is float or isinstance(validator, defs.floatrange):
        return float(instr)
    elif isinstance(validator, defs.intrange):
        if instr.startswith('\'') and instr.endswith('\''):
            s = match_string(instr)[0]
            if len(s) == 1:
                return ord(s)
            raise ValueError('cannot convert to single integer: %r' % s)
        return int(instr, 0)
    elif isinstance(validator, (defs.listof, defs.nonemptylistof,
                                defs.pairsof)):
        inner = validator.conv
        if isinstance(inner, (defs.floatrange, defs.intrange)) or \
           inner in (defs.boolean, bool, float):
            return convert_num_array(instr, inner)
        return convert_string_array(str(instr))
    else:
        instr = str(instr)
        if instr.startswith(('"', "'")):
            return match_string(str(instr))[0]
        return instr


class NotConfigured(object):
    """Standin singleton for "use the default value"."""
    def __str__(self):
        return '<use default>'

not_conf = NotConfigured()


class NonselectListBox(ListBox):
    """A ListBox whose items cannot be selected."""
    _selectable = False


class Input(Pile):
    """Input widget that displays an edit box as well as a list of selections.

    In `select_from_list` mode, a list item is highlighted and the user can
    select it by pressing the up/down keys while entering text in the edit box
    that is used to filter the list.

    Otherwise, the edit box is the real value, and the list items, if present,
    are just shown without a selection shown.
    """
    def __init__(self,
                 caption,
                 preset='',
                 select_from_list=False,
                 items=None,
                 initial_doc='',
                 label_callback=str,
                 doc_callback=lambda _: ''):
        self.on_select = None
        self.all_items = items or []
        self.label_callback = label_callback
        self.doc_callback = doc_callback
        self.select_from_list = select_from_list
        self.pos = None
        self.editor = Edit(edit_text=preset)
        self.walker = SimpleFocusListWalker([])
        self.listbox = NonselectListBox(self.walker)
        self.docbox = AttrWrap(Text(initial_doc), 'doc')
        columns = [Pile([
            ('pack', self.docbox),
            SolidFill()
        ])]
        if items:
            columns.insert(0, self.listbox)
        Pile.__init__(self, [
            ('pack', Text(caption)),
            ('pack', LineBox(AttrWrap(self.editor, 'bold'))),
            Columns(columns, 2, focus_column=0),
        ], focus_item=1)
        self.filter_update()
        self.move_focus(0)

    def keypress(self, size, key):
        if key == 'enter':
            sel = self.get_selection()
            if sel is not None:
                self.on_select(sel)  # pylint: disable=not-callable
        elif key == 'esc':
            self.on_select(Ellipsis)  # pylint: disable=not-callable
        elif key in ('up', 'down'):
            if self.pos is not None:
                self.move_focus(self.pos + (-1 if key == 'up' else 1))
        else:
            Pile.keypress(self, size, key)
            self.filter_update()

    def get_selection(self):
        """Get the current selection/edit text, depending on mode."""
        if not self.select_from_list:
            return self.editor.get_text()[0]
        if self.pos is not None:
            return self.walker[self.pos].original_widget.obj
        return None

    def move_focus(self, pos):
        """Move focus in the list items."""
        if pos < 0 or pos >= len(self.walker):
            return
        if self.select_from_list and self.pos is not None:
            self.walker[self.pos].set_attr('item')
        self.pos = pos
        if self.select_from_list:
            self.walker[pos].set_attr('sel')
        self.listbox.set_focus(self.pos)
        self.docbox.set_text(self.doc_callback(self.get_selection()))

    def filter_update(self):
        """Update the shown list items from the entered filter."""
        filt_text = self.editor.get_text()[0].lower()
        cur = self.get_selection()
        new_list = []
        new_pos = None
        for item in self.all_items:
            item_text = self.label_callback(item)
            if filt_text in item_text.lower():
                text = Text(item_text)
                text.obj = item
                if item is cur:
                    new_pos = len(new_list)
                    new_list.append(AttrWrap(text, 'sel'))
                else:
                    new_list.append(AttrWrap(text, 'item'))
        self.walker[:] = new_list
        self.pos = None
        if new_pos is None:
            if new_list:
                self.move_focus(0)
        else:
            self.move_focus(new_pos)


class Configurator(object):
    """The main UI object used for configuring the new server."""

    def __init__(self, args):
        self.servername = args.servername
        self.domain = args.domain
        if args.resdir:
            self.resdir = args.resdir
        else:
            config = get_global_config()
            self.resdir = config[0]

        # Collect all Entangle device classes.
        self.all_classes = list(devlist.enumerate_devices([]))
        self.all_classes.sort(key=get_typename)

        # Configured devices.
        self.devices = []
        # Names of configured devices.
        self.devnames = set()
        # Names of "iodev" properties that are not configured yet.
        self.pending_iodevs = {}

        # Create Urwid UI elements.
        self.inputarea = Pile([('pack', Text(''))])
        self.infowalker = SimpleFocusListWalker([])
        self.infobox = NonselectListBox(self.infowalker)
        self.title = Text('Entangle configurator')
        self.view = Padding(
            Pile([
                ('pack', AttrWrap(self.title, 'head')),
                ('pack', Divider(' ')),
                Columns([
                    ('weight', 0.35, LineBox(self.infobox)),
                    self.inputarea
                ], 2, focus_column=1),
                ('pack', Divider(' ')),
                ('pack', AttrWrap(
                    Text('%d classes found.' % len(self.all_classes)),
                    'head')),
            ], focus_item=2), left=2, right=2)

    def state_coroutine(self):
        """Coroutine for the state handling."""
        # Check access to resfile directory.
        if not os.access(self.resdir, os.W_OK):
            yield ('Cannot write to resfile directory %r. Setting output '
                   'directory to current directory %r instead.' %
                   (self.resdir, os.getcwd()))
            self.resdir = os.getcwd()

        # Collect all existing servers.
        if path.isdir(self.resdir):
            existing_servers = [fn[:-4] for fn in os.listdir(self.resdir)
                                if fn.endswith('.res')]
            existing_servers.sort()
        else:
            existing_servers = []

        # Determine the domain.
        if not self.domain:
            self.domain = self.find_domain()

        # Get a server name that doesn't exist yet.
        while not self.servername or self.servername in existing_servers:
            if self.servername:
                yield 'Server %s is already configured!' % self.servername
            self.servername = yield Input(
                'Enter name of the new server (must not be '
                'one of the existing ones below):',
                items=existing_servers)
            if self.servername is Ellipsis:
                return
        self.title.set_text('Entangle configurator - ' + self.servername)

        tomlwriter = toml.Writer(None)

        # Add devices.
        while True:
            devname_suggestion = next(iter(self.pending_iodevs)) \
                if self.pending_iodevs else self.domain + '/'
            devname = ''

            # Select device name.
            while True:
                if devname in self.devnames:
                    yield 'Device %r already configured.' % devname
                elif devname and not re.match(r'([\w-]+/){2}[\w-]+$', devname):
                    yield 'Invalid device name: %r.' % devname
                elif devname:
                    break
                devname = yield Input('New device name:', devname_suggestion)
                if devname is Ellipsis:
                    break

            prefix = 'Configuring device %s for server %s.\n\n' % \
                (devname, self.servername)
            presets = self.pending_iodevs.pop(devname, {})

            # Select device class.
            devcls = yield Input(prefix + 'Select class:',
                                 preset=presets.get('class', ''),
                                 select_from_list=True,
                                 items=self.all_classes,
                                 label_callback=get_typename,
                                 doc_callback=clean_docstring)
            if devcls is Ellipsis:
                continue

            properties = OrderedDict([('type', get_typename(devcls))])
            info = '%s - %s' % (devname, properties['type'])
            self.infowalker.append(AttrWrap(Text(info), 'bold'))

            # Go through properties.
            proplist = filtered_proplist(devcls)
            proptexts = {}
            for prop, info in proplist:
                proptext = AttrWrap(Text('    ' + prop + (
                    ' *' if info.default is None else '')), 'item')
                proptexts[prop] = proptext
                self.infowalker.append(proptext)
            self.infobox.set_focus(len(self.infowalker) - 1)

            propindex = 0

            # Not a for loop to allow going back.
            while propindex < len(proplist):
                propindex = max(propindex, 0)
                prop, info = proplist[propindex]
                proptexts[prop].set_attr('sel')

                # Collect docstring.
                doc = info.description
                doc += '\n\nType: %s' % defs.validatordoc(info.validator)
                if info.default is not None:
                    doc += '\n\nDefault: %r' % info.default

                if prop in properties:
                    preset = tomlwriter.inline(properties[prop])
                elif prop in presets:
                    # Is there a preset from the IO dev?
                    preset = tomlwriter.inline(presets[prop])
                elif prop == 'iodev':
                    preset = self.domain + '/'
                else:
                    preset = ''

                if isinstance(info.validator, defs.oneof):
                    selections = list(info.validator.vals)
                    if info.default is not None:
                        selections.insert(0, not_conf)
                    value = yield Input(prefix + 'Select property %s:' % prop,
                                        preset=preset,
                                        select_from_list=True,
                                        items=selections,
                                        doc_callback=lambda _: doc)
                elif info.validator is defs.boolean:
                    selections = [True, False]
                    if info.default is not None:
                        selections.insert(0, not_conf)
                    value = yield Input(prefix + 'Select property %s:' % prop,
                                        preset=preset,
                                        select_from_list=True,
                                        items=selections,
                                        label_callback=lambda v: str(v).lower(),
                                        doc_callback=lambda _: doc)
                else:
                    prompt = ('Enter property %s (return to use default):' % prop
                              if info.default is not None
                              else 'Enter property %s:' % prop)
                    value = yield Input(prefix + prompt,
                                        preset=preset,
                                        initial_doc=doc)
                    if not value and info.default is not None:
                        value = not_conf
                    elif value is not Ellipsis:
                        while True:
                            try:
                                value = info.validator(
                                    convert_input(info.validator, value))
                            except (ValueError, InvalidValue) as err:
                                errstr = 'Not a valid property value: %s.' % err
                            else:
                                break
                            yield errstr
                            value = yield Input(prefix + prompt,
                                                # no preset, it may be invalid
                                                initial_doc=doc)
                            if not value and info.default is not None:
                                value = not_conf
                                break
                            elif value is Ellipsis:
                                break

                if value is Ellipsis:
                    proptexts[prop].set_attr('item')
                    propindex -= 1
                    continue

                if value is not not_conf:
                    properties[prop] = value
                    proptexts[prop].original_widget.set_text(
                        '    %s = %r' % (prop, value))

                proptexts[prop].set_attr('item')
                if prop == 'iodev' and value not in self.devnames:
                    self.pending_iodevs[value] = devcls.iodev_defaults.copy()

                # Go to next property.
                propindex += 1
                if propindex == len(proplist):
                    if (yield ('Device ok?', ['Yes', 'No'])) == 'No':
                        propindex -= 1

            self.devices.append((devcls.priority, devname, properties))
            self.devnames.add(devname)

            if (yield ('Add another device?', ['Yes', 'No'])) == 'No':
                break

        conffile = path.join(self.resdir, self.servername + '.res')
        try:
            self.write_config(conffile)
            yield 'New config written to %s.' % conffile
        except Exception as err:
            with open('%s-config.py' % self.servername, 'w') as fp:
                fp.write(repr(self.devices))
            yield ('Cannot write: %s. Raw data dumped to %s-config.py.' %
                   (err, self.servername))

    def advance_state(self, inp=None):
        """Event handler for user selection. Advances the state machine
        generator to get the next UI to show to the user.
        """
        try:
            todo = self.state.send(inp)
        except StopIteration:
            raise ExitMainLoop
        if isinstance(todo, string_types):
            todo = (todo, ['OK'])
        if isinstance(todo, tuple):
            # Show a message box.
            buttons = [Button(text, on_press=lambda _, t=text:
                              self.advance_state(t)) for text in todo[1]]
            msgbox_content = AttrWrap(LineBox(Padding(Pile([
                ('pack', Text(todo[0])),
                SolidFill(),
                ('pack', GridFlow(buttons, 10, 2, 2, 'center'))
            ]), left=1, right=1)), 'msgbox')
            self.msgbox = Overlay(msgbox_content, self.view, 'center',
                                  ('relative', 35), 'middle', 10)
            self.loop.widget = self.msgbox
        elif isinstance(todo, Input):
            # Query for input.
            todo.on_select = self.advance_state
            self.inputarea.contents = [(todo, ('weight', 1))]
            self.loop.widget = self.view

    def find_domain(self):
        """Find the Tango device domain from an existing config file."""
        try:
            for fn in os.listdir(self.resdir):
                if fn.endswith('.res'):
                    for line in open(path.join(self.resdir, fn)):
                        if line.startswith('["'):
                            return line.strip('[]" ').split('/')[0]
        except Exception:
            pass
        return 'test'

    def write_config(self, conffile):
        """Write out the config in self.devices as TOML."""
        config = dict((devname, props) for (_, devname, props) in
                      sorted(self.devices))
        with open(conffile, 'w') as fp:
            toml.Writer(fp).write(config)

    def run(self):
        self.state = self.state_coroutine()
        self.loop = MainLoop(self.view, PALETTE)
        self.advance_state(None)
        self.loop.run()


def main(argv):
    parser = argparse.ArgumentParser(description='Create a basic Entangle '
                                     'server configuration')
    parser.add_argument('servername', type=str, help='Name of new server',
                        nargs='?', default='')
    parser.add_argument('-d', '--domain', action='store',
                        help='Domain for devices (autodetected from '
                        'existing servers if not given)', default='')
    parser.add_argument('-r', '--resdir', action='store',
                        help='Resource directory (read from /etc/entangle/'
                        'entangle.conf if not given)', default='')
    args = parser.parse_args(argv[1:])
    Configurator(args).run()


if __name__ == '__main__':
    sys.exit(main(sys.argv))
