# Source code for pympress.config

    # -*- coding: utf-8 -*-
    #
    #       config.py
    #
    #       Copyright 2017 Cimbali <me@cimba.li>
    #
    #       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., 51 Franklin Street, Fifth Floor, Boston,
    #       MA 02110-1301, USA.
    """
    :mod:`pympress.config` -- Configuration
    ---------------------------------------
    """

    import logging
    logger = logging.getLogger(__name__)

    import os
    import pathlib
    import json
    import configparser
    from collections import deque

    import gi
    gi.require_version('Gtk', '3.0')
    from gi.repository import Gtk, GLib, Gio

    from pympress import util

    class Config(configparser.ConfigParser, object):  # python 2 fix
        """ Manage configuration :Get the configuration from its file and store its back.
        """
        #: `dict`-tree of presenter layouts for various modes
        layout = {}
        #: `dict`-tree of presenter layouts that are not configurable
        static_layout = {
            'deck-overview': 'deck',
        }

        #: `dict` of strings that are the valid representations of widgets from the presenter window
        #: that can be dynamically rearranged, mapping to their names
        placeable_widgets = {"notes": "p_frame_notes", "current": "p_frame_cur", "next": "grid_next",
                             "annotations": "p_frame_annot", "highlight": "scribble_overlay"}

        #: `dict` mapping layout ids to tuples of their expected and optional widgets
        widget_reqs = {
            'notes':      (set(placeable_widgets.keys()) - {"annotations", "highlight"}, {"annotations"}),
            'plain':      (set(placeable_widgets.keys()) - {"notes", "highlight"},),
            'note_pages': (set(placeable_widgets.keys()) - {"current", "highlight"},),
            'highlight':  ({"highlight"}, set(placeable_widgets.keys()) - {"highlight"}),
            'highlight_notes':  ({"highlight"}, set(placeable_widgets.keys()) - {"highlight"}),
        }

        #: `dict` mapping accelerator keys to actions
        shortcuts = {}

        @staticmethod
        def path_to_config(search_legacy_locations=False):
            """ Return the path to the currently used configuration file.

            Args:
                search_legacy_locations (`bool`): whether to look in previously used locations

            Returns:
                :class:`~pathlib.Path`: The path to the config file to use
            """
            if Config.using_portable_config():
                return util.get_portable_config()

            user_config = util.get_user_config()

            # migrate old configuration files from previously-used erroneous locations
            if search_legacy_locations and (util.IS_POSIX or util.IS_MAC_OS) and not user_config.exists():
                for legacy_location in ['~/.pympress', '~/.config/pympress']:
                    legacy_location = pathlib.Path(legacy_location).expanduser()
                    if legacy_location.exists():
                        legacy_location.rename(user_config)

            return user_config

        @staticmethod
        def toggle_portable_config(gaction, param=None):
            """ Create or remove a configuration file for portable installs.

            The portable install file will be used by default, and deleting it causes the config
            to fall back to the user profile location.

            No need to populate the new config file, this will be done on pympress exit.

            Args:
                gaction (:class:`~Gio.Action`): the action triggering the call
                param (:class:`~GLib.Variant`): the parameter as a variant, or None
            """
            portable_config = util.get_portable_config()
            if portable_config is None:
                return
            if Config.using_portable_config():
                portable_config.unlink()
            else:
                with open(portable_config, 'w'):
                    pass
            gaction.set_state(GLib.Variant.new_boolean(Config.using_portable_config()))

        @staticmethod
        def using_portable_config():
            """ Checks which configuration file location is in use.

            Returns:
                `bool`: `True` iff we are using the portable (i.e. in install dir) location
            """
            portable_config = util.get_portable_config()
            return portable_config is not None and portable_config.exists()

        def __init__(config):
            # Remove : from delimiters so we can use it in preferences
            super(Config, config).__init__(delimiters=('=',))

            # populate values first from the default config file, then from the proper one
            config.read(util.get_default_config())
            config.load_window_layouts()

            # modify defaults
            if 'I3SOCK' in os.environ:
                logger.info('Detected i3 window manager, disabled content window starting fullscreen by default')
                config.set('content', 'start_fullscreen', 'off')

            all_commands = dict(config.items('shortcuts')).keys()

            config.read(config.path_to_config(True))
            config.upgrade()
            config.load_window_layouts()

            for command in all_commands:
                # NB only parsing commands from defaults
                parse_ok, action_name, target_value = Gio.Action.parse_detailed_name('app.' + command)
                if not parse_ok or not Gio.action_name_is_valid(action_name):
                    logger.error('Failed parsing command ' + command)
                    continue

                parsed_accels = {keys: Gtk.accelerator_parse(keys) for keys in config.get('shortcuts', command).split()}
                failed = [keys for keys, parsed in parsed_accels.items() if parsed == (0, 0)]
                if failed:
                    logger.warning('Failed parsing shortcut(s) for "{}": "{}"'.format(command, '", "'.join(failed)))

                keep_accels = [keys for keys, parsed in parsed_accels.items() if parsed != (0, 0)]
                if keep_accels:
                    config.shortcuts[command] = keep_accels

        def register_actions(self, builder):
            """ Register actions that impact the config file only.

            Args:
                builder (:class:`pympress.builder.Builder`): a builder to setup the actions
            """
            p_full = self.getboolean('presenter', 'start_fullscreen')
            c_full = self.getboolean('content', 'start_fullscreen')
            blank = self.getboolean('content', 'start_blanked')
            portable = self.using_portable_config()
            can_port = util.get_portable_config() is not None

            builder.setup_actions({
                'start-content-fullscreen':   dict(activate=self.toggle_start, state=c_full),
                'start-presenter-fullscreen': dict(activate=self.toggle_start, state=p_full),
                'start-blanked':              dict(activate=self.toggle_start, state=blank),
                'portable-config':            dict(activate=self.toggle_portable_config, state=portable, enabled=can_port),
            })

        def upgrade(self):
            """ Update obsolete config options when pympress updates.
            """
            if self.get('presenter', 'pointer') == 'pointer_none':
                self.set('presenter', 'pointer', 'red')
                self.set('presenter', 'pointer_mode', 'disabled')

            if self.has_section('scribble') and self.has_option('scribble', 'color'):
                self.set('scribble', 'color_9', self.get('scribble', 'color'))
                self.remove_option('scribble', 'color')
                self.set('scribble', 'active_pen', '9')

            if self.has_section('scribble') and self.has_option('scribble', 'width'):
                self.set('scribble', 'width_9', self.get('scribble', 'width'))
                self.remove_option('scribble', 'width')
                self.set('scribble', 'active_pen', '9')

            if self.has_option('presenter', 'monitor'):
                self.remove_option('presenter', 'monitor')

            if self.has_option('content', 'monitor'):
                self.remove_option('content', 'monitor')

            if self.has_section('scribble'):
                for key, val in self.items('scribble'):
                    self.set('highlight', key, val)
                self.remove_section('scribble')

            if self.has_section('gst'):
                for key, val in self.items('gst'):
                    self.set('gstreamer', key, 'on' if key == 'enabled' else val)
                self.remove_section('gst')

            # When we went from gtk signal handlers to actions, some renaming had to be done
            for old, new in {
                'next':                 'next-page',
                'prev':                 'prev-page',
                'next_label':           'next-label',
                'prev_label':           'prev-label',
                'hist_back':            'hist-back',
                'hist_forward':         'hist-forward',
                'first':                'first-page',
                'last':                 'last-page',
                'goto_page':            'goto-page',
                'jumpto_label':         'jumpto-label',
                'fullscreen_content':   'content-fullscreen',
                'fullscreen_presenter': 'presenter-fullscreen',
                'pause_timer':          'pause-timer',
                'reset_timer':          'reset-timer',
                'talk_time':            'edit-talk-time',
                'blank_screen':         'blank-screen',
                'notes_mode':           'notes-mode',
                'swap_screens':         'swap-screens',
                'open_file':            'pick-file',
                'close_file':           'close-file',
                'validate':             'validate-input',
                'cancel':               'cancel-input',
                'undo_scribble':        'highlight-undo',
                'redo_scribble':        'highlight-redo',
                'scribble_preset_1':    'highlight-use-pen::1',
                'scribble_preset_2':    'highlight-use-pen::2',
                'scribble_preset_3':    'highlight-use-pen::3',
                'scribble_preset_4':    'highlight-use-pen::4',
                'scribble_preset_5':    'highlight-use-pen::5',
                'scribble_preset_6':    'highlight-use-pen::6',
                'scribble_preset_7':    'highlight-use-pen::7',
                'scribble_preset_8':    'highlight-use-pen::8',
                'scribble_preset_9':    'highlight-use-pen::9',
                'scribble_preset_0':    'highlight-use-pen::eraser',
                'toggle_pointermode':   'pointer-mode::toggle',
            }.items():
                shortcut = self.get('shortcuts', old, fallback=None)
                if shortcut is not None:
                    if old in {'hist_back', 'hist_forward'} and 'backspace' in shortcut.lower():
                        # In the Gio.Action operations, accelerators have precedence over widgets, which
                        # means it’s very annoying to edit a shortcut if backspace is mapped to anything.
                        logger.warning('Changing shortcut for "{}" to new default instead of keeping backspace'.format(new))
                    else:
                        self.set('shortcuts', new, shortcut)

                    self.remove_option('shortcuts', old)

        def getlist(self, *args):
            """ Parse a config value and return the list by splitting the value on commas.

            i.e. bar = foo,qux  returns the list ['foo', 'qux']

            Returns:
                `list`: a config value split into a list.
            """
            return [t.strip() for t in self.get(*args).split(',') if t.strip()]

        def getint(self, *args, **kwargs):
            """ Wrapper for configparser’s getint to handle parsing errors when a fallback is given.

            See :meth:`~configparser.Configparser.getint()`
            """
            try:
                return super(Config, self).getint(*args, **kwargs)
            except ValueError:
                if 'fallback' not in kwargs:
                    raise

                logger.warning(_('Error parsing option from config file {}.{} "{}" to int'.format(*args, self.get(*args))),
                               exc_info=True)
                return kwargs['fallback']

        def getfloat(self, *args, **kwargs):
            """ Wrapper for configparser’s to handle parsing errors when a fallback is given.

            See :meth:`~configparser.Configparser.getfloat()`
            """
            try:
                return super(Config, self).getfloat(*args, **kwargs)
            except ValueError:
                if 'fallback' not in kwargs:
                    raise

                logger.warning(_('Error parsing option from config file {}.{} "{}" to float')
                               .format(*args, self.get(*args)), exc_info=True)
                return kwargs['fallback']

        def getboolean(self, *args, **kwargs):
            """ Wrapper for configparser’s getboolean to handle parsing errors when a fallback is given.

            :meth:`~configparser.Configparser.getboolean()`
            """
            try:
                return super(Config, self).getboolean(*args, **kwargs)
            except ValueError:
                if 'fallback' not in kwargs:
                    raise

                logger.warning(_('Error parsing option from config file {}.{} "{}" to bool'.format(*args, self.get(*args))),
                               exc_info=True)
                return kwargs['fallback']

        def save_config(self):
            """ Save the configuration to its file.
            """
            # serialize the layouts
            for layout_name in self.layout:
                self.set('layout', layout_name, json.dumps(self.layout[layout_name], indent=4))

            with open(self.path_to_config(), 'w') as configfile:
                self.write(configfile)

        def toggle_start(self, gaction, param=None):
            """ Generic function to toggle some boolean startup configuration.

            Args:
                gaction (:class:`~Gio.Action`): the action triggering the call
                param (:class:`~GLib.Variant`): the parameter as a variant, or None
            """
            # action is named start(-presenter|-content)?-property
            start, *win, prop = gaction.get_name().split('-')
            window = win[0] if win else 'content'
            start_conf = 'start_' + prop

            new_state = not gaction.get_state().get_boolean()

            gaction.set_state(GLib.Variant.new_boolean(new_state))
            self.set(window, start_conf, 'on' if new_state else 'off')

        def validate_layout(self, layout, expected_widgets, optional_widgets = set()):
            """ Validate layout: check whether the layout of widgets built from the config string is valid.

            Args:
                layout (`dict`): the json-parsed config string
                expected_widgets (`set`): strings with the names of widgets that have to be used in this layout
                optional_widgets (`set`): strings with the names of widgets that may or may not be used in this layout

            Layout must have all self.placeable_widgets (leaves of the tree, as `str`) and only allowed properties
            on the nodes of the tree (as `dict`).

            Constraints on the only allowed properties of the nodes are:
            - resizeable: `bool` (optional, defaults to no),
            - orientation: `str`, either "vertical" or "horizontal" (mandatory)
            - children: `list` of size >= 2, containing `str`s or `dict`s (mandatory)
            - proportions: `list` of `float` with sum = 1, length == len(children), representing the relative sizes
            of all the resizeable items (if and only if resizeable).

            """
            next_visits = deque([layout])
            widget_seen = set()
            while next_visits:
                w_desc = next_visits.popleft()
                if type(w_desc) is str:
                    if w_desc not in expected_widgets and w_desc not in optional_widgets:
                        raise ValueError('Unrecognized widget "{}", pick one of: {}'
                                         .format(w_desc, ', '.join(expected_widgets)))
                    elif w_desc in widget_seen:
                        raise ValueError('Duplicate widget "{}", all expected_widgets can only appear once'.format(w_desc))
                    widget_seen.add(w_desc)

                elif type(w_desc) is dict:
                    if 'orientation' not in w_desc or w_desc['orientation'] not in ['horizontal', 'vertical']:
                        raise ValueError('"orientation" is mandatory and must be "horizontal" or "vertical" at node {}'
                                         .format(w_desc))
                    elif 'children' not in w_desc or type(w_desc['children']) is not list or len(w_desc['children']) < 2:
                        raise ValueError('"children" is mandatory and must be a list of 2+ items at node {}'.format(w_desc))
                    elif 'resizeable' in w_desc and type(w_desc['resizeable']) is not bool:
                        raise ValueError('"resizeable" must be boolean at node {}'.format(w_desc))

                    elif 'proportions' in w_desc:
                        if 'resizeable' not in w_desc or not w_desc['resizeable']:
                            raise ValueError('"proportions" is only valid for resizeable widgets at node {}'.format(w_desc))
                        elif type(w_desc['proportions']) is not list or \
                                any(type(n) is not float for n in w_desc['proportions']) or \
                                len(w_desc['proportions']) != len(w_desc['children']) or \
                                sum(w_desc['proportions']) < 1e-2:
                            raise ValueError('"proportions" must be a list of floats (one per separator), '
                                             'that sum to 1, at node {}'.format(w_desc))
                        elif abs(sum(w_desc['proportions']) - 1) > 1e-10:
                            w_desc['proportions'] = [p / sum(w_desc['proportions']) for p in w_desc['proportions']]

                    next_visits.extend(w_desc['children'])
                else:
                    raise ValueError('Unexpected type {}, nodes must be dicts or strings, at node {}'
                                     .format(type(w_desc), w_desc))
            widget_missing = expected_widgets - widget_seen
            if widget_missing:
                raise ValueError('Following placeable_widgets were not specified: {}'.format(', '.join(widget_missing)))

        def update_layout_tree(self, layout_name, layout):
            """ Update the layout named `~layout_name`. Throws ValueError on invalid layouts.

            Args:
                layout_name (`str`): the name of the layout to update
                layout (`dict`): the hierarchy of dictionaries, lists, and strings representing the layout
            """
            self.validate_layout(layout, *self.widget_reqs[layout_name])
            self.layout[layout_name] = layout

        def load_window_layouts(self):
            """ Parse and validate layouts loaded from config, with fallbacks if needed.
            """
            for layout_name in self.widget_reqs:
                # Log error and keep default layout
                try:
                    self.update_layout_tree(layout_name, json.loads(self.get('layout', layout_name)))
                except ValueError:
                    logger.exception('Invalid layout for {}'.format(layout_name))

        def widget_layout_to_tree(self, widget, pane_handle_pos):
            """ Build a tree representing a widget hierarchy, leaves are strings and nodes are `dict`.

            Recursive function. See validate_layout() for more info on the tree structure.

            Args:
                widget (:class:`~Gtk.Widget`): the widget where to start
                pane_handle_pos (`dict`): Map of :class:`~Gtk.Paned` to the relative handle position (float in 0..1)

            Returns:
                `dict`: A tree of dicts reprensenting the widget hierarchy
            """
            orientation_names = {Gtk.Orientation.HORIZONTAL: 'horizontal', Gtk.Orientation.VERTICAL: 'vertical'}

            name = widget.get_name()
            matching_widget_names = [k for k, v in self.placeable_widgets.items() if v == name]

            if matching_widget_names:
                return matching_widget_names[0]

            elif issubclass(type(widget), Gtk.Box):
                return {'resizeable': False, 'orientation': orientation_names[widget.get_orientation()],
                        'children': [self.widget_layout_to_tree(c, pane_handle_pos) for c in widget.get_children()]}

            elif issubclass(type(widget), Gtk.Paned):
                proportions = [1]
                reverse_children = []
                orientation = widget.get_orientation()
                if orientation == Gtk.Orientation.HORIZONTAL:
                    get_size = Gtk.Widget.get_allocated_width
                else:
                    get_size = Gtk.Widget.get_allocated_height

                while issubclass(type(widget), Gtk.Paned) and orientation == widget.get_orientation():
                    left_pane = widget.get_child1()
                    right_pane = widget.get_child2()

                    visible = left_pane.get_visible() and right_pane.get_visible()
                    position = widget.get_position()
                    widget_size = get_size(widget)

                    if not visible or widget_size <= 1:
                        # reuse number that was in config initially, otherwise gets overwritten with 0
                        ratio = pane_handle_pos[widget]
                    else:
                        ratio = float(position) / widget_size
                        pane_handle_pos[widget] = ratio

                    proportions = [ratio] + [(1 - ratio) * p for p in proportions]
                    reverse_children.append(right_pane)
                    widget = left_pane

                reverse_children.append(left_pane)

                return {'resizeable': True, 'proportions': proportions, 'orientation': orientation_names[orientation],
                        'children': [self.widget_layout_to_tree(c, pane_handle_pos) for c in reversed(reverse_children)]}

            raise ValueError('Error serializing layout: widget of type {} '.format(type(widget)) +
                             'is not an expected container or named widget: "{}"'.format(name))

        def get_layout(self, layout_name):
            """ Getter for the `~layout_name` layout.
            """
            try:
                return self.layout[layout_name]
            except KeyError:
                return self.static_layout[layout_name]

        def update_layout_from_widgets(self, layout_name, widget, pane_handle_pos):
            """ Setter for the notes layout.

            Args:
                layout_name (`str`): the name of the layout to update
                widget (:class:`~Gtk.Widget`): the widget that will contain the layout.
                pane_handle_pos (`dict`): Map of :class:`~Gtk.Paned` to the relative handle position (float in 0..1)
            """
            if layout_name not in self.static_layout:
                return
            self.update_layout_tree(layout_name, self.widget_layout_to_tree(widget, pane_handle_pos))