#!/usr/bin/env python3
# --------------------( LICENSE                           )--------------------
# Copyright 2017-2018 by Alexis Pietak & Cecil Curry.
# See "LICENSE" for further details.

'''
High-level support facilities for integrating :mod:`PySide2` widget classes
with XML-formatted user interface (UI) files exported by the external Qt
Designer application.
'''

# ....................{ IMPORTS                           }....................
from PySide2 import QtWidgets
from PySide2.QtCore import QCoreApplication, QObject
from betse.util.io import iofiles
from betse.util.io.log import logs
from betse.util.path import files, pathnames
from betse.util.py import pyident, pymodule, pys
from betse.util.py.pyident import IDENTIFIER_UNQUALIFIED_REGEX
from betse.util.type import types
from betse.util.type.cls import classes
from betse.util.type.text import regexes
from betse.util.type.types import type_check, MappingType, SequenceTypes
from betsee import guimetadata
from betsee.guiexception import BetseeCacheException
from betsee.lib import guilib
from io import StringIO

# ....................{ GLOBALS                           }....................
# To avoid conflict with PySide2-generated attribute names, obfuscate this
# global's name in an application-specific manner.
BASE_CLASSES_GLOBAL_NAME = '__{}_BASE_CLASSES'.format(guimetadata.NAME)
'''
Unqualified name of the application-specific global appended to the Python code
generated by the :func:`convert_ui_to_py_file` function.

This global declares the sequence of all base classes that the main window Qt
widget subclass for this application is expected to subclass (in order).
'''

# ....................{ GETTERS                           }....................
@type_check
def get_ui_module_base_classes(ui_module_name: str) -> SequenceTypes:
    '''
    Sequence of all base classes declared by the user interface (UI) module
    with the passed name and intended to be subclassed by the Qt widget
    subclass implementing this UI.

    The :func:`convert_ui_to_py_file` function is assumed to have generated
    this module from an XML-formatted file exported by the external Qt Designer
    GUI.

    Parameters
    ----------
    ui_module_name : str
        Fully-qualified name of the UI module to be imported and inspected by
        this function. Since this module is dynamically generated at runtime
        and hence *not* guaranteed to exist, this function explicitly validates
        this module to both exist and declare this sequence..

    Raises
    ----------
    BetseModuleException
        If this module is unimportable.
    BetseeCacheException
        If this module declares no such sequence.
    '''

    # UI module if importable *OR* raise an exception otherwise.
    ui_module = pymodule.import_module(
        module_name=ui_module_name,
        exception_message=QCoreApplication.translate(
            'get_ui_module_base_classes',
            'Module "{0}" not found (e.g., as'
            'module "betsee.gui.window.guimainwindow" imported before '
            'module "betsee.gui.guicache").'.format(ui_module_name)
        ))

    # Sequence of all base classes if declared by this module or None otherwise.
    ui_base_classes = getattr(ui_module, BASE_CLASSES_GLOBAL_NAME, None)

    # Fully-qualified name of this sequence for use in exception messages.
    ui_base_classes_name = '{}.{}'.format(
        ui_module_name, BASE_CLASSES_GLOBAL_NAME)

    # If no such sequence is declared, raise an exception.
    if ui_base_classes is None:
        raise BetseeCacheException(QCoreApplication.translate(
            'get_ui_module_base_classes',
            'Sequence "{0}" undefined.'.format(ui_base_classes_name)))

    # If this sequence is *NOT* a sequence, raise an exception. While this
    # check is also performed by the @type_check decorator, the following
    # length check implicitly assumes this variable to be a sequence.
    if not types.is_sequence_nonstr(ui_base_classes):
        raise BetseeCacheException(QCoreApplication.translate(
            'get_ui_module_base_classes',
            'Variable "{0}" type {1!r} not a non-string sequence.'.format(
                ui_base_classes_name, type(ui_base_classes))))

    # If this sequence does *NOT* contain two items, raise an exception.
    if len(ui_base_classes) != 2:
        raise BetseeCacheException(QCoreApplication.translate(
            'get_ui_module_base_classes',
            'Sequence "{0}" length {1} not 2.'.format(
                ui_base_classes_name, len(ui_base_classes))))

    # Else, return this sequence.
    return ui_base_classes

# ....................{ CONVERTERS                        }....................
@type_check
def convert_ui_to_py_file_if_able(
    ui_filename: str,
    py_filename: str,
    promote_obj_name_to_class: MappingType,
) -> None:
    '''
    Convert the XML-formatted file with the passed ``.ui``-suffixed filename
    exported by the external Qt Designer GUI into the :mod:`PySide2`-based
    Python module with the passed ``.py``-suffixed filename if capable of doing
    so *or* log a non-fatal warning and return otherwise.

    Dependencies
    ----------
    This function requires the optional third-party dependency
    ``pyside2-tools`` distributed by The Qt Company. Specifically, this
    high-level function wraps the low-level
    :meth:`pyside2uic.Compiler.compiler.UICompiler.compileUi` method installed
    by this dependency with a human-usable API.

    If the :mod:`pyside2uic` package is unimportable, this function logs a
    non-fatal warning and returns *without* raising a fatal exception.

    Design
    ----------
    This function silently overwrites this output file with the dynamically
    compiled contents of a pure-Python model declaring:

    * A custom helper class defining the following methods:

      * ``setupUi()``, accepting an instance of the :mod:`PySide2` widget base
        class listed as the last item of the global sequence described below
        and customizing this widget as specified by this input UI file.
      * ``retranslateUi()``, accepting a similar instance and translating all
        strings displayed by this widget from their default language
        (typically, English) into the locale of the current user.

    * A global sequence variable whose:

      * Name is the value of the
        :data:`BASE_CLASSES_GLOBAL_NAME` global string variable,
      * Value is the sequence of all base classes that the main Qt window
        widget class for this application *must* subclass (in order):

        #. The custom helper class described above.
        #. The :mod:`PySide2` widget base class of the object to be passed to
           all methods defined by this class (e.g., ``setupUi()``). These
           methods do *not* validate this to be the case but do raise
           non-human-readable exceptions when passed objects that are *not*
           instances of this base class. This base class is defined by this
           input UI file and is typically one of the following:

           * :class:`PySide2.QWidget.QDialog`.
           * :class:`PySide2.QWidget.QWidget`.
           * :class:`PySide2.QWidget.QMainWindow`.

    This function provides a :mod:`PySide2`-specific implementation of the
    PyQt-specific :func:`PyQt5.uic.loadUiType` function, albeit with a more
    typesafe API. For unknown reasons (presumably laziness), :mod:`PySide2`
    lacks an analogue to this function.

    For equally unknown reasons, the class returned by that function is
    commonly referred to as the "form class" in most Qt documentation. This
    class has no relation to the :class:`PySide2.QLayout.QFormLayout` base
    class and is thus arguably *not* a "form class." For disambiguity, we
    prefer to refer to this class as simply the "generated UI class."

    Parameters
    ----------
    ui_filename : str
        Absolute or relative path of the input ``.ui``-suffixed file.
    py_filename : str
        Absolute or relative path of the output ``.py``-suffixed file.
    promote_obj_name_to_class : MappingType
        Dictionary mapping from the name of each instance variable of the main
        window to manually reinstantiate to the application-specific widget
        subclass to declare that variable to be an instance of. This dictionary
        facilitates the manual "promotion" of widgets for which the Qt
        (Creator|Designer) GUI currently provides no means of official
        promotion, notably including :class:`QButtonGroup` widgets.

    Returns
    ----------
    type
        UI class generated from the XML-formatted file with this filename
        exported by the external Qt Designer application.

    See Also
    ----------
    http://pyqt.sourceforge.net/Docs/PyQt5/designer.html
        :mod:`PyQt5`-specific documentation detailing this conversion. Although
        :mod:`PyQt5`-specific, this documentation probably serves as the
        canonical resource for understanding this function's internal
        behaviour.
    '''

    # Log this conversion attempt.
    logs.log_info(
        'Generating PySide2 module "%s" from "%s"...',
        pathnames.get_basename(py_filename),
        pathnames.get_basename(ui_filename))

    # If the optional third-party dependency required by this function is
    # unavailable, log a non-fatal warning and return.
    if not guilib.is_runtime_optional('pyside2uic'):
        logs.log_warning(
            'Skipping! Package "pyside2uic" unimportable, '
            'probably due to "pyside2-tools" not being installed.')
        return

    # If this output file is unwritable, log a non-fatal warning and return.
    if not files.is_exists_writable(py_filename):
        logs.log_warning(
            'Skipping! PySide2 module "%s" unwritable, '
            'probably due to a system-wide installation.', py_filename)
        return

    # Optional third-party dependencies required by this function.
    pyside2uic = guilib.import_runtime_optional('pyside2uic')
    UICompiler = pyside2uic.Compiler.compiler.UICompiler

    # If this input file does *NOT* exist, raise an exception.
    files.die_unless_file(ui_filename)

    # If these files do *NOT* have the expected filetypes, raise an exception.
    pathnames.die_unless_filetype_equals(pathname=ui_filename, filetype='ui')
    pathnames.die_unless_filetype_equals(pathname=py_filename, filetype='py')

    # File-like string buffer containing the Python code converted from this
    # file to be subsequently evaluated.
    ui_code_str_buffer = StringIO()

    # Dictionary of high-level metadata describing the high-level types
    # produced by converting this file into this string buffer, containing the
    # following key strings:
    #
    # * "baseclass", whose value is the name of the PySide2-specific widget
    #   base class that objects passed to the setupUi() and retranslateUi()
    #   methods of the generated UI class are required to be instances of.
    # * "uiclass", whose value is the name of the generated UI class defining
    #   the setupUi() and retranslateUi() methods.
    # * "widgetname", whose value is... an unknown string. (Not our fault.)
    #
    # See the UICompiler.compileUi() method implementation for details.
    #
    # Note that most online examples call the slightly higher-level
    # pyside2uic.compileUi() function internally calling the lower-level
    # UICompiler.compileUi() method in the exact same manner as below. The
    # former has significant disadvantages and is thus ignored in favour of the
    # latter. In particular, the pyside2uic.compileUi() function:
    #
    # * Does *NOT* return the dictionary returned by the UICompiler.compileUi()
    #   method required below. While the contents of this dictionary are
    #   technically reverse engineerable by manually parsing the XML of this
    #   ".ui" file for the corresponding elements (e.g., via the
    #   "xml.etree.ElementTree" package), doing so needlessly incurs space,
    #   time, and code complexity costs. Due presumably to path dependency,
    #   most online examples (insanely) do so.
    # * Adds no meaningful advantages over the UICompiler.compileUi() method
    #   for most common cases, including this case.
    #
    # In short, the pyside2uic.compileUi() function is useless and no one
    # should ever call it.
    ui_code_metadata = UICompiler().compileUi(
        input_stream=ui_filename,
        output_stream=ui_code_str_buffer,

        # Force all generated imports to be absolute rather than relative.
        from_imports=False,
    )

    # Name of the custom class to be generated.
    ui_form_class_name = ui_code_metadata['uiclass']

    # Name of the PySide2 base class expected by methods of this custom class.
    ui_base_class_name = ui_code_metadata['baseclass']

    # This base class or "None" if no such class exists.
    ui_base_class = getattr(QtWidgets, ui_base_class_name, None)

    # If no such base class exists, raise an exception.
    if ui_base_class is None:
        raise BetseeCacheException(
            title=QCoreApplication.translate(
                'convert_ui_to_py_file', 'PySide2 UI Compiler Error'),
            synopsis=QCoreApplication.translate(
                'convert_ui_to_py_file',
                'PySide2 widget base class "{0}" not found.'.format(
                    ui_base_class_name)))

    # Append an application-specific global to the Python code generated above,
    # declaring the sequence of all base classes that the main Qt window widget
    # class for this application *MUST* subclass (in order). In particular,
    # note that the base Qt widget class is subclassed last and thus remains
    # the "parent" base class of this multiple inheritance.
    ui_code_str_buffer.write('''
from PySide2.QtWidgets import {base_class}
{var_name} = ({form_class}, {base_class})
'''.format(
        var_name=BASE_CLASSES_GLOBAL_NAME,
        form_class=ui_form_class_name,
        base_class=ui_base_class_name,
    ))

    # Munge (i.e., modify) the Python code comprising the contents of the file
    # implementing this output module.
    ui_code_str = _munge_ui_code(
        # Original contents of this file.
        ui_code_str=ui_code_str_buffer.getvalue(),
        promote_obj_name_to_class=promote_obj_name_to_class,
    )

    # Write this Python code to this file, silently overwriting this file if
    # this file already exists.
    iofiles.write_str_to_filename(
        text=ui_code_str,
        output_filename=py_filename,
        is_overwritable=True,
    )


@type_check
def _munge_ui_code(
    ui_code_str: str,
    promote_obj_name_to_class: MappingType,
) -> str:
    '''
    Munge (i.e., modify) the passed string of Python code comprising the
    contents of the current ``.ui``-suffixed file to be subsequently written.

    Specifically, this function:

    * Prepends this code by a shebang line unambiguously running the executable
      binary for the active Python interpreter and machine architecture.
    * Globally replaces all lines of this code reducing vector SVG icons to
      non-vector in-memory pixmaps with lines preserving these icons as is. See
      this function's body for detailed commentary.
    * For the name of each instance variable of the main window and
      application-specific subclass to instantiate that variable to in the
      passed dictonary, replaces the single line of this code instantiating
      this variable to a non-promoted stock Qt widget class with a line instead
      instantiating this variable to this application-specific widget subclass.

    Parameters
    ----------
    ui_code_str : str
        String of Python code comprising the original contents of this file.
    promote_obj_name_to_class : MappingType
        See the :func:`convert_ui_to_py_file_if_able` function for comments.

    Returns
    ----------
    str
        String of Python code comprising the modified contents of this file.
    '''

    #FIXME: Consider filing an upstream PySide2 issue documenting this, ideally
    #with a minimal UI file as an example.
    #FIXME: Actually, we've done that now. When this issue is resolved, remove
    #this ad-hack kludge.

    # Globally replace all lines of this code reducing vector SVG icons to
    # non-vector in-memory pixmaps with lines preserving these icons as is.
    # This reduction has a variety of harmful side effects, including:
    #
    # * Preventing these icons from being upscaled. Qt unconditionally refuses
    #   to upscale in-memory pixmaps, which are of finite resolution and hence
    #   *NOT* upscalable without introducing spurious artifacts. Vector images
    #   are of infinite resolution and hence suffer no such issues. SVG icons
    #   should thus be upscalable, but (due to this reduction) are *NOT*. This
    #   issue is exacerbated by modern high-DPI displays and SVG icons
    #   rasterized at inappropriately small scale, rendering such icons
    #   effectively illegible.
    # * Pixellating these icons, even when rasterized at appropriate scale.
    #
    # For example, the QIcon created from a "lock_fill.svg" icon is as follows:
    #
    #     icon = QtGui.QIcon()
    #     icon.addPixmap(QtGui.QPixmap("://icon/open_iconic/lock_fill.svg"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
    #
    # This QIcon should instead be created as:
    #
    #    # Note the need to pass an additional size, which remains
    #    # unconstrained due to the infinitely rescalable nature of vector
    #    # icons.
    #    icon = QtGui.QIcon()
    #    icon.addFile("://icon/open_iconic/lock_fill.svg", QtCore.QSize(), QtGui.QIcon.Normal, QtGui.QIcon.Off)
    #
    # Indeed, there no longer appears to be any benefit to *EVER* calling the
    # icon.addPixmap() versus icon.addFile() method. The latter suffices for all
    # filetypes and is thus always preferable -- regardless of filetype.
    ui_code_str = regexes.replace_substrs_line(
        text=ui_code_str,
        regex=(
            r'^(\s*icon\d*\.add)'
            r'Pixmap\(QtGui\.QPixmap\('
            r'("[^"]+\.svg")'
            r'\)(.*)$'
        ),
        replacement=r'\1File(\2, QtCore.QSize()\3',
    )

    # For the name of each instance variable of the main window and
    # application-specific subclass to instantiate that variable to...
    for promote_obj_name, promote_class in (
        promote_obj_name_to_class.items()):
        # If this name is *NOT* a valid Python identifier, raise an exception.
        # This is essential to avoid edge-case issues in which this name
        # erroneously contains regular expression syntax.
        pyident.die_unless_unqualified(promote_obj_name)

        # If this subclass is *NOT* a Qt object subclass, raise an exception.
        classes.die_unless_subclass(
            subclass=promote_class, superclass=QObject)

        # Unqualified name of this class.
        promote_class_name = classes.get_name(promote_class)

        # Fully-qualified name of the module defining this class.
        promote_class_module_name = classes.get_module_name(promote_class)

        # Append this code with a line importing this class.
        ui_code_str += 'from {} import {}\n'.format(
            promote_class_module_name, promote_class_name)

        # Regular expression matching the single line of this code
        # instantiating this variable to a non-promoted stock Qt widget class
        # into the following two numeric groups:
        #
        # 1. The assignment statement prefix for this instantiation.
        # 2. The parameters to be passed to this instantiation.
        promote_regex = (
            # Assignment statement.
            r'^(\s*self\.{promote_obj_name}\s*=\s*)'
            # Name of the non-promoted stock Qt widget class to be ignored.
            r'QtWidgets\.{widget_class_name}'
            # Parameters passed to this instantiation.
            r'(\([^)]*\))$'
        ).format(
            promote_obj_name=promote_obj_name,
            widget_class_name=IDENTIFIER_UNQUALIFIED_REGEX,
        )

        # Globally replace this line with a line instead instantiating this
        # variable to this application-specific widget subclass, implicitly
        # raising an exception if no such line exists.
        ui_code_str = regexes.die_unless_replace_substrs_line(
            text=ui_code_str,
            regex=promote_regex,
            replacement=r'\1{}\2'.format(promote_class_name),
        )

    # Prepend this code by a shebang running the active Python interpreter.
    ui_code_str = '{}\n{}'.format(pys.get_shebang(), ui_code_str)

    # Return this code.
    return ui_code_str
