#
# QtHelp.py -- customized Qt widgets and convenience functions
#
# This is open-source software licensed under a BSD license.
# Please see the file LICENSE.txt for details.
#
import glob
import os
import math
import weakref
import time

import numpy as np

import ginga.toolkit
from ginga.util import iohelper
from ginga.misc import Callback, Bunch
from ginga.fonts import font_asst
from ginga import colors
from ginga.events import KeyEvent
from ginga.gw.text_model import TextModel

configured = False

toolkit = ginga.toolkit.toolkit

# if user wants to force a toolkit
if toolkit == 'qt6':
    os.environ['QT_API'] = 'pyqt6'

elif toolkit == 'qt5':
    os.environ['QT_API'] = 'pyqt5'

elif toolkit == 'pyside6':
    os.environ['QT_API'] = 'pyside6'

elif toolkit == 'pyside2':
    os.environ['QT_API'] = 'pyside2'

have_pyqt5 = False
have_pyqt6 = False
have_pyside2 = False
have_pyside6 = False
qtpy_import_error = ""

try:
    from qtpy import QtCore
    from qtpy import QtWidgets as QtGui
    from qtpy.QtGui import (QImage, QColor, QFont, QPixmap, QIcon,  # noqa
                            QPainter, QPen, QPolygonF, QPolygon, QTextCursor,
                            QDrag, QPainterPath, QBrush, QFontDatabase,
                            QCursor, QFontMetrics, QSurfaceFormat,
                            QTextOption, QWheelEvent, QKeySequence,
                            QPalette, QMovie, QFontInfo, QTextCharFormat)
    # QShortcut is in QtGui under PyQt6/PySide6 and in QtWidgets
    # under PyQt5/PySide2; qtpy typically normalises to QtGui, but
    # we try both to stay robust across binding versions.
    try:
        from qtpy.QtGui import QShortcut  # noqa
    except ImportError:
        from qtpy.QtWidgets import QShortcut  # noqa
    from qtpy.QtWidgets import QOpenGLWidget, QWIDGETSIZE_MAX  # noqa
    from qtpy.QtCore import QItemSelectionModel, QEvent  # noqa
    from qtpy.QtWidgets import QApplication, QWidget  # noqa
    from qtpy import QtSvg  # noqa

    # Let's see what qtpy configured for us...
    from qtpy import PYQT5, PYQT6, PYSIDE2, PYSIDE6
    have_pyqt5 = PYQT5
    have_pyqt6 = PYQT6
    have_pyside2 = PYSIDE2
    have_pyside6 = PYSIDE6

    configured = True
except ImportError as e:
    qtpy_import_error = "Error importing 'qtpy': {}".format(e)
    # for debugging purposes, uncomment this to get full traceback
    #raise e

if have_pyqt6:
    ginga.toolkit.use('qt6')
    from qtpy.QtGui import QInputDevice
    os.environ['QT_API'] = 'pyqt6'
elif have_pyqt5:
    ginga.toolkit.use('qt5')
    QInputDevice = None
    os.environ['QT_API'] = 'pyqt5'
elif have_pyside6:
    ginga.toolkit.use('pyside6')
    from qtpy.QtGui import QInputDevice
    os.environ['QT_API'] = 'pyside6'
elif have_pyside2:
    ginga.toolkit.use('pyside2')
    QInputDevice = None
    os.environ['QT_API'] = 'pyside2'
else:
    raise ImportError("Failed to configure qt5, qt6, pyside2 or pyside6. "
                      "Is the 'qtpy' package installed? (%s)" % (
                          qtpy_import_error))


tabwidget_style = """
QTabWidget::pane { margin: 0px,0px,0px,0px; padding: 0px; }
QMdiSubWindow { margin: 0px; padding: 2px; }
"""

font_weight_dct = {'thin': QFont.Thin,
                   'extra light': QFont.ExtraLight,
                   'light': QFont.Light,
                   'normal': QFont.Normal,
                   'medium': QFont.Medium,
                   'demibold': QFont.DemiBold,
                   'bold': QFont.Bold,
                   'extra bold': QFont.ExtraBold,
                   'black': QFont.Black}


class TopLevel(QWidget):

    app = None
    ## def __init__(self, *args, **kwdargs):
    ##     return super(TopLevel, self).__init__(self, *args, **kwdargs)

    def closeEvent(self, event):
        if self.app is not None:
            self.app.quit()

    def setApp(self, app):
        self.app = app


class QGrowingTextEdit(QtGui.QTextEdit):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.document().documentLayout().documentSizeChanged.connect(
            self.sizeChange)
        self.heightMin = 0
        self.heightMax = 65000

    def sizeChange(self):
        docHeight = self.document().size().height()
        # add some margin to prevent auto scrollbars
        docHeight += 20
        if self.heightMin <= docHeight <= self.heightMax:
            self.setMaximumHeight(int(docHeight))


class ComboBox(QtGui.QComboBox):

    def insert_alpha(self, text):
        index = 0
        while True:
            itemText = self.itemText(index)
            if len(itemText) == 0:
                break
            if itemText > text:
                self.insertItem(index, text)
                return
            index += 1
        self.addItem(text)

    def delete_alpha(self, text):
        index = self.findText(text)
        self.removeItem(index)

    def show_text(self, text):
        index = self.findText(text)
        self.setCurrentIndex(index)

    def append_text(self, text):
        self.addItem(text)


class VBox(QWidget):
    def __init__(self, *args, **kwdargs):
        super().__init__(*args, **kwdargs)

        layout = QtGui.QVBoxLayout()
        # because of ridiculous defaults
        layout.setContentsMargins(0, 0, 0, 0)
        self.setLayout(layout)

    def addWidget(self, w, **kwdargs):
        self.layout().addWidget(w, **kwdargs)

    def setSpacing(self, val):
        self.layout().setSpacing(val)


class HBox(QWidget):
    def __init__(self, *args, **kwdargs):
        super().__init__(*args, **kwdargs)

        layout = QtGui.QHBoxLayout()
        # because of ridiculous defaults
        layout.setContentsMargins(0, 0, 0, 0)
        self.setLayout(layout)

    def addWidget(self, w, **kwdargs):
        self.layout().addWidget(w, **kwdargs)

    def setSpacing(self, val):
        self.layout().setSpacing(val)


class FileSelection:
    """Handle Load Image file dialog from File menu."""
    # TODO: deprecate the functionality when all_at_once == False
    # and make the default to be True
    def __init__(self, parent_w, all_at_once=False):
        self.parent = parent_w
        self.all_at_once = all_at_once
        self.cb = None

    def popup(self, title, callfn, initialdir=None, filename=None):
        """Let user select and load file(s). This allows wildcards and
        extensions, like in FBrowser.

        Parameters
        ----------
        title : str
            Title for the file dialog.

        callfn : func
            Function used to open the file(s).

        initialdir : str or `None`
            Directory for file dialog.

        filename : str
            Filter for file dialog.

        """
        self.cb = callfn
        filenames = QtGui.QFileDialog.getOpenFileNames(
            self.parent, title, initialdir, filename)

        # Special handling for PyQt5, see
        # https://www.reddit.com/r/learnpython/comments/2xhagb/pyqt5_trouble_with_openinggetting_the_name_of_the/
        filenames = filenames[0]

        all_paths = []
        for filename in filenames:

            # Special handling for wildcard or extension.
            # This is similar to open_files() in FBrowser plugin.
            if '*' in filename or '[' in filename:
                info = iohelper.get_fileinfo(filename)
                ext = iohelper.get_hdu_suffix(info.numhdu)
                files = glob.glob(info.filepath)  # Expand wildcard
                paths = ['{0}{1}'.format(f, ext) for f in files]
                if self.all_at_once:
                    all_paths.extend(paths)
                else:
                    for path in paths:
                        self.cb(path)

            else:
                # Normal load
                if self.all_at_once:
                    all_paths.append(filename)
                else:
                    self.cb(filename)

        if self.all_at_once and len(all_paths) > 0:
            self.cb(all_paths)


class DirectorySelection:
    """Handle directory selection dialog."""
    def __init__(self, parent_w):
        self.parent = parent_w
        self.cb = None

    def popup(self, title, callfn, initialdir=None):
        """Let user select a directory.

        Parameters
        ----------
        title : str
            Title for the dialog.

        callfn : func
            Function used to handle selected directory.

        initialdir : str or `None`
            Directory for dialog.

        """
        self.cb = callfn
        dirname = QtGui.QFileDialog.getExistingDirectory(
            self.parent, title, initialdir)
        if dirname:
            self.cb(dirname)


class SaveDialog(QtGui.QFileDialog):

    def __init__(self, title=None, selectedfilter=None):
        super().__init__()

        self.title = title
        self.selectedfilter = selectedfilter
        self.widget = self

    def get_path(self):
        res = self.getSaveFileName(self, self.title, '', self.selectedfilter)

        if isinstance(res, tuple):
            res = res[0]

        if (res and self.selectedfilter is not None and
                not res.endswith(self.selectedfilter[1:])):
            res += self.selectedfilter[1:]
        if res == '':
            # user cancelled dialog
            res = None

        return res


class Timer(Callback.Callbacks):
    """Abstraction of a GUI-toolkit implemented timer."""

    def __init__(self, duration=0.0):
        """Create a timer set to expire after `duration` sec.
        """
        super().__init__()

        self.duration = duration
        # For storing aritrary data with timers
        self.data = Bunch.Bunch()

        self.timer = QtCore.QTimer()
        self.timer.setSingleShot(True)
        self.timer.setTimerType(QtCore.Qt.PreciseTimer)
        self.timer.timeout.connect(self._expired_cb)
        self.start_time = 0.0
        self.deadline = 0.0

        for name in ('expired', 'canceled'):
            self.enable_callback(name)

    def start(self, duration=None):
        """Start the timer.  If `duration` is not None, it should
        specify the time to expiration in seconds.
        """
        if duration is None:
            duration = self.duration

        self.set(duration)

    def set(self, duration):
        self.stop()

        self.start_time = time.time()
        self.deadline = self.start_time + duration
        # QTimer set in milliseconds
        ms = int(duration * 1000.0)
        self.timer.start(ms)

    def _expired_cb(self):
        self.make_callback('expired')

    def is_set(self):
        return self.timer.isActive()

    def cond_set(self, time_sec):
        if not self.is_set():
            # TODO: probably a race condition here
            self.set(time_sec)

    def elapsed_time(self):
        return time.time() - self.start_time

    def time_left(self):
        #return max(0.0, self.deadline - time.time())
        # remainingTime() returns value in msec, or -1 if timer is not set
        t = self.timer.remainingTime()
        t = max(0.0, t)
        return t / 1000.0

    def get_deadline(self):
        return self.deadline

    def stop(self):
        try:
            self.timer.stop()
        except Exception:
            pass

    def cancel(self):
        """Cancel this timer.  If the timer is not running, there
        is no error.
        """
        self.stop()
        self.make_callback('canceled')

    clear = cancel


def cmap2pixmap(cmap, steps=50):
    """Convert a Ginga colormap into a QPixmap
    """
    import numpy as np

    inds = np.linspace(0, 1, steps)
    n = len(cmap.clst) - 1
    tups = [cmap.clst[int(x * n)] for x in inds]
    rgbas = [QColor(int(r * 255), int(g * 255),
                    int(b * 255), 255).rgba() for r, g, b in tups]
    im = QImage(steps, 1, QImage.Format_Indexed8)
    im.setColorTable(rgbas)
    for i in range(steps):
        im.setPixel(i, 0, i)
    im = im.scaled(128, 32)
    pm = QPixmap.fromImage(im)
    return pm


def get_scroll_info(event):
    """
    Returns the (degrees, direction) of a scroll motion Qt event.
    """

    # 15 deg is standard 1-click turn for a wheel mouse
    # TODO: use pixelDelta() for better handling on hi-res devices?
    point = event.angleDelta()
    dx, dy = point.x(), point.y()
    delta = math.sqrt(dx ** 2 + dy ** 2)
    if dy < 0:
        delta = -delta

    ang_rad = math.atan2(dy, dx)
    direction = math.degrees(ang_rad) - 90.0
    direction = math.fmod(direction + 360.0, 360.0)

    num_degrees = abs(delta) / 8.0

    return (num_degrees, direction)


def get_image(iconpath, size=None, adjust_width=True):
    """Get a QImage that can be used in a button or label.

    Parameters
    ----------
    iconpath : str
        The path to the file containing the image of the icon

    size : tuple of int (width, height) or None, (defaults to (24, 24))
        The size of the icon to be returned in pixels

    adjust_width : bool, (optional, defaults to True)
        If True, adjust width to account for the aspect ratio of the image
    """
    if size is not None:
        wd, ht = size
    else:
        wd, ht = 24, 24

    if iconpath.endswith('.svg'):
        # Scalable Vector Graphics
        svg_renderer = QtSvg.QSvgRenderer(iconpath)
        if adjust_width:
            # get natural size
            qsize = svg_renderer.defaultSize()
            _w, _h = qsize.width(), qsize.height()
            aspect = _w / _h
            wd = int(wd * aspect)
        image = QImage(wd, ht, QImage.Format_ARGB32)
        image.fill(0x00000000)
        svg_renderer.render(QPainter(image))
    else:
        # PNG or similar
        image = QImage(iconpath)
        if adjust_width:
            qsize = image.size()
            _w, _h = qsize.width(), qsize.height()
            aspect = _w / _h
            wd = int(wd * aspect)
        qsize = QtCore.QSize(wd, ht)
        image = image.scaled(qsize)

    return image


def get_icon(iconpath, size=None, adjust_width=True):
    image = get_image(iconpath, size=size, adjust_width=adjust_width)
    pixmap = QPixmap.fromImage(image)
    iconw = QIcon(pixmap)
    return iconw


def get_color(color, alpha=None):
    clr = QColor()
    clr_tup = colors.resolve_color(color, alpha=alpha, format='tuple')
    clr.setRgbF(*clr_tup)
    return clr


# When Qt loads a scalable font, sometimes the family name is not what
# is expected. This dict handles the mapping between what the user thinks
# the font family is and what Qt thinks it is.  It also tracks which fonts
# have already been loaded or not.  See load_font() below for details
#
qt_fonts = dict()


def _qt_has_family(family):
    """Return True if Qt has the named font family available.

    QFont silently substitutes a fallback for an unknown family (it never
    raises), so we compare the family Qt actually resolves to (reported by
    QFontInfo) against the one requested.  Works across all Qt bindings
    (unlike QFontDatabase.hasFamily, whose static/instance form and even
    availability vary by binding).
    """
    return QFontInfo(QFont(family)).family().lower() == family.lower()


def get_font(font_spec, font_size):
    """Function to obtain a native font for the Qt backend.

    Parameters
    ----------
    font_spec : str or `~ginga.fonts.font_asst.Font`
        The desired font

    font_size : int
        The point size requested for the given font

    Returns
    -------
    font : QFont
        The desired font in native backend form
    """
    global qt_fonts

    key = ('qt', font_spec, font_size)
    try:
        return font_asst.get_cache(key)

    except KeyError:
        pass

    if isinstance(font_spec, str):
        font_tup = font_asst.parse_font(font_spec)
    elif isinstance(font_spec, font_asst.Font):
        font_tup = font_spec
    else:
        raise ValueError("not a valid font spec: {}".format(str(font_spec)))

    family = font_tup.family

    if font_tup in qt_fonts:
        # <-- this font is already loaded, but not in the desired size
        # just look up Qt's name for it
        family = qt_fonts[font_tup].family

    else:
        family = font_tup.family
        # font not loaded? try and load it
        if font_asst.have_loadable_font(font_tup):
            try:
                qfont_tup = load_font(font_tup)
                family = qfont_tup.family

            except Exception:
                pass

    # Honor the requested family if Qt actually has it.  QFont silently
    # substitutes a fallback for an unknown family (it never raises), so we
    # must check availability explicitly; otherwise fall back through our
    # known substitutes / default family.
    if _qt_has_family(family):
        families = [family]
    else:
        families = font_asst.get_substitutes(font_tup.family) + [family]
    for family in families:
        try:
            font = QFont(family, font_size)
            if font_tup.style == 'italic':
                font.setStyle(QFont.StyleItalic)
            font.setWeight(font_weight_dct[font_tup.weight])
            font.setStyleStrategy(QFont.PreferAntialias)
            font_asst.add_cache(key, font)
            if isinstance(font_spec, str):
                # also store the font under a secondary key with full font tuple
                key2 = ('qt', font_tup, font_size)
                font_asst.add_cache(key2, font)
            return font

        except Exception:
            continue

    raise ValueError(f"Couldn't create font for family '{font_tup.family}', "
                     f"style={font_tup.style}, weight={font_tup.weight}")


def load_font(font_tup):
    """Attempt to load a scalable font.
    """
    global qt_fonts

    # get any known information about this font
    # (may raise a KeyError if it's not known)
    font_info = font_asst.get_font_info(font_tup)

    # NOTE: you need to have created a QApplication() first (see
    # qtw.Widgets.Application) for this to work correctly, or you will get
    # a crash!
    font_path = font_info.font_path
    font_id = QFontDatabase.addApplicationFont(font_path)
    if font_id < 0:
        raise ValueError(f"Unspecified Qt problem loading font from '{font_path}'")

    qfont_families = QFontDatabase.applicationFontFamilies(font_id)
    # the family closest to what we want seems to be at the end of the list
    qfont_name = qfont_families[-1]
    # record this so we can look it up quickly next time
    new_tup = font_asst.Font(family=qfont_name, style=font_tup.style,
                             weight=font_tup.weight)
    qt_fonts[font_tup] = new_tup
    return new_tup


# cache of QPainters for surfaces
_painters = weakref.WeakKeyDictionary()


def get_painter(surface):
    # QImage is not hashable
    if not isinstance(surface, QImage):
        if surface in _painters:
            return _painters[surface]

    painter = QPainter(surface)
    painter.setRenderHint(QPainter.Antialiasing)
    painter.setRenderHint(QPainter.TextAntialiasing)
    if not isinstance(surface, QImage):
        _painters[surface] = painter
    return painter


def get_scroll_event_source(event):
    """Return the source of the scroll event: 'wheel' or 'touchpad'."""

    # Differentiate using the device type (if available)
    # if QInputDevice is not None:
    if hasattr(event, 'device'):
        # <-- Qt6
        if event.device().type() == QInputDevice.DeviceType.TouchPad:
            return 'touchpad'

    elif hasattr(event, 'source'):
        # <-- Qt5
        _src = event.source()
        if _src == QtCore.Qt.MouseEventSynthesizedBySystem:
            return 'touchpad'

    return 'wheel'


def get_rgb_array(widget):
    """Get a numpy RGB array corresponding to a widget."""
    qsize = widget.size()
    wd, ht = qsize.width(), qsize.height()
    qimg = QImage(wd, ht, QImage.Format_RGB32)

    # render into the qimage
    painter = QPainter(qimg)
    widget.render(painter)
    painter.end()

    # these should be the same as before, but just in case
    wd, ht = qimg.width(), qimg.height()

    # Get a pointer to the raw bytes
    ptr = qimg.bits()

    # Set the size of the buffer in bytes
    if hasattr(qimg, 'byteCount'):
        # Qt 5
        ptr.setsize(qimg.byteCount())
    else:
        # Qt 6
        ptr.setsize(qimg.sizeInBytes())

    # Create the numpy array from the buffer, reshape it to (height, width, 4)
    # for 32-bit formats
    # The channels might be in BGRA/RGBA order depending on the system/format
    arr = np.array(ptr).reshape(ht, wd, 4)

    # Qt's RGB32 is usually 0xffRRGGBB on little-endian systems, appearing as
    # BGRA in numpy array
    # To get RGB, we typically need to swap the R and B channels and drop the
    # alpha channel.
    # This might vary by platform/Qt version

    # Assuming typical BGRA order in the numpy array created from QImage.Format_RGB32/ARGB32:
    # arr is [B, G, R, A] or [B, G, R, 255]
    # We want [R, G, B]
    rgb_array = arr[:, :, [2, 1, 0]]

    # want the numpy array to persist after the QImage is modified or deleted
    return rgb_array.copy()


def set_default_opengl_context():
    from ginga.opengl.glsl import req
    fmt = QSurfaceFormat()
    fmt.setVersion(req.major, req.minor)
    fmt.setProfile(QSurfaceFormat.CoreProfile)
    fmt.setDefaultFormat(fmt)
    QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_ShareOpenGLContexts, True)


def delete_widget(w):
    ## sip.delete(w)
    w.deleteLater()


def mkformat(fgcolor=None, bgcolor=None, style=None, baseformat=None):
    """Return a QTextCharFormat with the given attributes."""
    style = [] if style is None else list(style)
    _format = QTextCharFormat()
    if baseformat is not None:
        _format.merge(baseformat)

    if fgcolor is not None:
        _fgcolor = QColor()
        rgb = colors.resolve_color(fgcolor, format='tuple')
        _fgcolor.setRgbF(rgb[0], rgb[1], rgb[2], 1.0)
        _format.setForeground(_fgcolor)

    if bgcolor is not None:
        _bgcolor = QColor()
        rgb = colors.resolve_color(bgcolor, format='tuple')
        _bgcolor.setRgbF(rgb[0], rgb[1], rgb[2], 1.0)
        _format.setBackground(_bgcolor)

    if 'bold' in style:
        _format.setFontWeight(QFont.Bold)
    if 'italic' in style:
        _format.setFontItalic(True)

    return _format


def _resolve_qt_enum(enum_name, attr):
    """Resolve a scoped Qt enum member across PyQt5/6 and PySide2/6.

    In Qt6 members live under a nested enum (e.g. ``Qt.Key.Key_Up``); in
    Qt5 they are attributes of ``Qt`` directly.
    """
    enum = getattr(QtCore.Qt, enum_name, None)
    if enum is not None and hasattr(enum, attr):
        return getattr(enum, attr)
    return getattr(QtCore.Qt, attr, None)


def _resolve_qt_key(attr):
    return _resolve_qt_enum('Key', attr)


def _enum_int(val):
    """Return the integer value of a Qt enum/flags member.

    PyQt6 scoped enums/flags are not always directly ``int()``-convertible,
    but expose a ``.value``; PyQt5/PySide return plain ints.
    """
    try:
        return int(val)
    except (TypeError, ValueError):
        return int(val.value)


# Map Qt key codes to ginga-style (lowercase) key names for keys page code
# cares about.  Printable keys fall through to the event's text().
_KEY_NAMES = {}
for _attr, _name in [
        ('Key_Up', 'up'), ('Key_Down', 'down'),
        ('Key_Left', 'left'), ('Key_Right', 'right'),
        ('Key_Shift', 'shift_l'), ('Key_Control', 'control_l'),
        ('Key_Alt', 'alt_l'), ('Key_Meta', 'meta_l'),
        ('Key_Return', 'return'), ('Key_Enter', 'return'),
        ('Key_Escape', 'escape'), ('Key_Tab', 'tab'),
        ('Key_Backspace', 'backspace'), ('Key_Delete', 'delete'),
        ('Key_Home', 'home'), ('Key_End', 'end'),
        ('Key_PageUp', 'page_up'), ('Key_PageDown', 'page_down'),
        ('Key_Space', 'space')]:
    _code = _resolve_qt_key(_attr)
    if _code is not None:
        _KEY_NAMES[_enum_int(_code)] = _name
del _attr, _name, _code

# Qt keyboard-modifier flags -> ginga modifier names (matches Bindings.py).
_MODIFIERS = []
for _attr, _name in [('ControlModifier', 'ctrl'), ('ShiftModifier', 'shift'),
                     ('AltModifier', 'alt'), ('MetaModifier', 'win')]:
    _flag = _resolve_qt_enum('KeyboardModifier', _attr)
    if _flag is not None:
        _MODIFIERS.append((_enum_int(_flag), _name))
del _attr, _name, _flag


def _make_key_event(qt_event, viewer=None):
    """Build a ginga ``KeyEvent`` from a Qt key event, or return None.

    Key names follow ginga's lowercase convention and modifiers are reported
    as a set (``ctrl``/``shift``/``alt``/``win``), so page handlers can read
    ``event.key`` and ``event.modifiers`` the same way ginga viewers do.
    """
    key = _enum_int(qt_event.key())
    mods = _enum_int(qt_event.modifiers())
    modset = set(name for flag, name in _MODIFIERS if mods & flag)

    name = _KEY_NAMES.get(key)
    if name is None:
        if modset & {'ctrl', 'alt', 'win'}:
            # text() is a control character under these modifiers, so derive
            # the base key from the key code instead.
            name = chr(key).lower() if 0x20 <= key <= 0x7e else None
        else:
            text = qt_event.text()
            name = text.lower() if text and text.isprintable() else None
    if name is None:
        return None
    return KeyEvent(key=name, state='down', modifiers=frozenset(modset),
                    viewer=viewer)


class QNumberBar(QWidget):
    """Specialty class used by QTextSource to provide line numbers
    and line marking icons.  See QTextSource for use.
    """

    def __init__(self, *args, **kwargs):
        QWidget.__init__(self, *args, **kwargs)
        self.icon_px = 0
        self.edit = None
        self.highest_line = 0
        self.nb_enabled = False

        self.icon_dct = dict()

    def setTextEdit(self, edit):
        self.edit = edit

    def show_line_numbers(self, tf):
        self.nb_enabled = tf
        self.update()

    def set_icon_column_px(self, pad_px):
        self.icon_px = pad_px
        self.update()

    def set_icon_for_line(self, line, image):
        self.icon_dct[line] = image
        self.update()

    def unset_icon_for_line(self, line):
        if line in self.icon_dct:
            del self.icon_dct[line]
        self.update()

    def clear_icons(self):
        self.icon_dct = dict()
        self.update()

    def update(self, *args):
        if not self.nb_enabled:
            width = self.icon_px
        else:
            width = self.fontMetrics().width(str(self.highest_line)) + 4 + self.icon_px
        if self.width() != width:
            self.setFixedWidth(width)
        QWidget.update(self, *args)

    def paintEvent(self, event):
        contents_y = self.edit.verticalScrollBar().value()
        page_bottom = contents_y + self.edit.viewport().height()
        font_metrics = self.fontMetrics()
        current_block = self.edit.document().findBlock(self.edit.textCursor().position())

        painter = QPainter(self)

        line_count = 0
        block = self.edit.document().begin()
        while block.isValid():
            line_count += 1
            position = self.edit.document().documentLayout().blockBoundingRect(block).topLeft()
            if position.y() > page_bottom:
                break

            icon_img = self.icon_dct.get(line_count, None)
            if icon_img is not None:
                x = self.width() - self.icon_px
                y = round(position.y()) - contents_y
                painter.drawImage(x, y, icon_img)

            bold = False
            if block == current_block:
                bold = True
                font = painter.font()
                font.setBold(True)
                painter.setFont(font)

            x = self.width() - self.icon_px - font_metrics.width(str(line_count)) - 3
            y = round(position.y()) - contents_y + font_metrics.ascent()
            painter.drawText(x, y, str(line_count))

            if bold:
                font = painter.font()
                font.setBold(False)
                painter.setFont(font)

            block = block.next()

        self.highest_line = self.edit.document().blockCount()
        painter.end()

        QWidget.paintEvent(self, event)


class QEnhancedTextEdit(QtGui.QTextEdit):
    """Speciality class that enhances a QTextEdit to be able to easily color
    lines and provides some convenience functions for syntax highlighting.
    See QTextSource for use.
    """
    def __init__(self, *args, **kwargs):
        QtGui.QTextEdit.__init__(self, *args, **kwargs)

        self.syntax_hl = None
        # When growing is True the widget caps its max height to the document
        # height (auto-sizing to content).  Default off so the editor instead
        # fills the space it is given, with text anchored at the top rather
        # than floating in the vertical center of an oversized cell.
        self.growing = False
        self.document().documentLayout().documentSizeChanged.connect(
            self.sizeChange_cb)
        self.heightMin = 0
        self.heightMax = 65000
        self.tt_cb = None
        self.tt_args = []

        # Undo/redo is modeled at the TextModel level (see its _undo_stack)
        # so that refs and tag intervals are restored along with the text.
        # Qt's native document undo would edit the document directly,
        # bypassing that model, so we disable it here.
        self.setUndoRedoEnabled(False)

    def sizeChange_cb(self):
        if not self.growing:
            return
        docHeight = self.document().size().height()
        docHeight += 20
        if self.heightMin <= docHeight <= self.heightMax:
            self.setMaximumHeight(int(docHeight))

    def set_syntax_highlighter_class(self, klass):
        self.syntax_hl = klass(self.document())

    def get_syntax_highlighter(self):
        return self.syntax_hl

    def event(self, event):
        if self.tt_cb is None or event.type() != QEvent.ToolTip:
            return super().event(event)

        args = [event]
        if len(self.tt_args) > 0:
            args.extend(self.tt_args)
        return self.tt_cb(*args)

    def set_tooltip_callback(self, func, *args):
        self.tt_cb = func
        self.tt_args = args

    def color_line(self, line_num, fgcolor=None, bgcolor=None):
        blk = self.document().findBlockByLineNumber(line_num - 1)
        cur = QTextCursor(blk)
        cur.movePosition(QTextCursor.StartOfBlock)
        cur.setPosition(cur.position() + blk.length(), QTextCursor.KeepAnchor)
        fmt = mkformat(fgcolor=fgcolor, bgcolor=bgcolor)
        cur.mergeCharFormat(fmt)


class QTextSource(QtGui.QFrame, TextModel):
    """Qt-native realization of the toolkit-neutral :class:`TextModel`.

    The buffer model (content, refs, tag table, find/replace, undo) lives in
    ``TextModel``; this class supplies the render hooks that mirror model
    changes into a ``QTextEdit`` plus a companion line-number/icon gutter.
    """

    def __init__(self, *args, **kwargs):
        QtGui.QFrame.__init__(self, *args, **kwargs)
        TextModel.__init__(self)

        self.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Sunken)

        self.tw = QEnhancedTextEdit()
        self.tw.setFrameStyle(QtGui.QFrame.NoFrame)
        self.tw.setAcceptRichText(False)
        self.tw.setMouseTracking(True)

        self.nb = QNumberBar()
        self.nb.setTextEdit(self.tw)

        hbox = QtGui.QHBoxLayout(self)
        hbox.setSpacing(0)
        hbox.setContentsMargins(0, 0, 0, 0)
        hbox.addWidget(self.nb)
        hbox.addWidget(self.tw)

        self.tw.installEventFilter(self)
        self.tw.viewport().installEventFilter(self)

        # render-side state
        self._show_icon_gutter = False
        self._syncing = False
        self._applying_formats = False

        for name in ('key-press', 'line-clicked'):
            self.enable_callback(name)

        self.tw.textChanged.connect(self._on_editor_text_changed)
        self.tw.cursorPositionChanged.connect(self._on_cursor_position_changed)

    def get_number_bar(self):
        return self.nb

    def get_internal_text_widget(self):
        return self.tw

    def eventFilter(self, obj, event):
        if obj in (self.tw, self.tw.viewport()):
            etype = event.type()
            if etype == QEvent.KeyPress:
                kev = _make_key_event(event)
                if kev is not None:
                    # A truthy return from a 'key-press' callback means the
                    # key was handled, so we consume the event.
                    if self.make_callback('key-press', kev):
                        return True
            elif (etype == QEvent.MouseButtonPress and
                  obj is self.tw.viewport()):
                # Report the clicked line (0-based); does not consume the
                # event, so normal cursor placement still happens.
                cur = self.tw.cursorForPosition(event.pos())
                self.make_callback('line-clicked', cur.blockNumber())
            self.nb.update()
            return False
        return QtGui.QFrame.eventFilter(self, obj, event)

    # ------------------------------------------------------------------
    # Qt widget operations (no model state)
    # ------------------------------------------------------------------
    def set_editable(self, tf):
        self.tw.setReadOnly(not tf)

    def set_wrap(self, kind):
        if isinstance(kind, bool):
            kind = 'hard' if kind else 'none'
        if kind == 'none':
            self.tw.setLineWrapMode(QtGui.QTextEdit.NoWrap)
        else:
            self.tw.setLineWrapMode(QtGui.QTextEdit.WidgetWidth)
            if kind == 'hard':
                self.tw.setWordWrapMode(QTextOption.WrapAnywhere)
            elif kind == 'word':
                self.tw.setWordWrapMode(QTextOption.WrapAtWordBoundaryOrAnywhere)
            else:
                raise ValueError("Invalid wrap mode: %s" % (kind,))

    def set_line_numbers(self, tf):
        self.nb.show_line_numbers(tf)

    def show_line_numbers(self, tf):
        self.set_line_numbers(tf)

    def set_icon_gutter(self, tf, pad_px=24):
        self._show_icon_gutter = bool(tf)
        self.nb.set_icon_column_px(pad_px if self._show_icon_gutter else 0)
        self._refresh_icon_gutter()

    def set_icon_column_px(self, pad_px):
        self._show_icon_gutter = pad_px > 0
        self.nb.set_icon_column_px(pad_px)
        self._refresh_icon_gutter()

    def set_icon_for_line(self, line, image):
        self.nb.set_icon_for_line(line, image)

    def unset_icon_for_line(self, line):
        self.nb.unset_icon_for_line(line)

    def clear_icons(self):
        self._icon_refs.clear()
        self.nb.clear_icons()

    def scroll_to_ref(self, ref):
        """Ensure the line containing ``ref`` is visible."""
        cursor = QTextCursor(self.tw.document())
        cursor.setPosition(self._offset_of(ref))
        self.tw.setTextCursor(cursor)
        self.tw.ensureCursorVisible()

    def set_scroll_position(self, h_pct, v_pct):
        hbar = self.tw.horizontalScrollBar()
        vbar = self.tw.verticalScrollBar()
        if hbar.maximum() > 0:
            hbar.setValue(int(max(0.0, min(1.0, h_pct)) * hbar.maximum()))
        if vbar.maximum() > 0:
            vbar.setValue(int(max(0.0, min(1.0, v_pct)) * vbar.maximum()))

    def set_scroll_pos(self, pos):
        vsb = self.tw.verticalScrollBar()
        if pos == -1:
            vsb.setValue(vsb.maximum())
        else:
            vsb.setValue(pos)

    def set_font(self, qfont):
        self.tw.setFont(qfont)
        self.nb.setFont(qfont)
        self._apply_all_formats()

    def color_line(self, line_num, fgcolor=None, bgcolor=None):
        return self.tw.color_line(line_num, fgcolor=fgcolor, bgcolor=bgcolor)

    def set_syntax_highlighter_class(self, klass):
        return self.tw.set_syntax_highlighter_class(klass)

    def get_syntax_highlighter(self):
        return self.tw.get_syntax_highlighter()

    def set_tooltip_callback(self, func, *args):
        self.tw.set_tooltip_callback(func, *args)

    def get_modified(self):
        return self.tw.document().isModified()

    def get_end_lineno(self):
        return max(0, self.tw.document().blockCount() - 1)

    def scroll_to_lineno(self, lineno):
        cursor = QTextCursor(self.tw.document())
        block = self.tw.document().findBlockByLineNumber(max(0, lineno))
        if block.isValid():
            cursor.setPosition(block.position())
        else:
            cursor.movePosition(QTextCursor.End)
        self.tw.setTextCursor(cursor)
        self.tw.ensureCursorVisible()

    def scroll_to_end(self):
        cursor = self.tw.textCursor()
        cursor.movePosition(QTextCursor.End)
        self.tw.setTextCursor(cursor)
        self.tw.ensureCursorVisible()

    # ------------------------------------------------------------------
    # Render hooks (called by TextModel to reflect changes in the widget)
    # ------------------------------------------------------------------
    def _merged_format(self, tag_names):
        attrs = self._merged_attrs(tag_names)
        return mkformat(fgcolor=attrs.get('foreground'),
                        bgcolor=attrs.get('background'),
                        style=self._style_list_from_attrs(attrs))

    def _apply_all_formats(self, region=None):
        """Rebuild character formatting from the tag interval model.

        Qt stores formatting on the document itself, so before reapplying tag
        styles we first reset the affected range back to the base widget
        palette and font.  This keeps stale formatting from surviving after
        tags move or are removed.

        ``region`` is an ``(start, end)`` offset pair limiting the work to a
        subrange; when ``None`` the entire document is reformatted.  Callers
        that only changed a bounded span pass that span so streaming edits
        stay O(edit) instead of O(buffer).
        """
        if self._applying_formats:
            return
        self._applying_formats = True
        # Applying tag colors via setCharFormat dirties the document's
        # modified flag, but coloring (syntax/execution highlighting) is not a
        # user edit -- preserve the modified state across the operation so a
        # freshly loaded/colored buffer is not treated as modified.
        doc = self.tw.document()
        was_modified = doc.isModified()
        try:
            if region is None:
                r_start, r_end = 0, len(self._text)
            else:
                r_start = max(0, min(region[0], region[1]))
                r_end = min(len(self._text), max(region[0], region[1]))
            self._reset_document_format(r_start, r_end)
            for seg_start, seg_end, tag_names in self._segments_for_range(r_start, r_end):
                if not tag_names:
                    continue
                # Formatting is applied with a QTextCursor selection because
                # QTextEdit/QTextDocument do not provide a simpler range-style
                # API for plain-text documents.
                cursor = QTextCursor(self.tw.document())
                cursor.setPosition(seg_start)
                cursor.setPosition(seg_end, QTextCursor.KeepAnchor)
                cursor.setCharFormat(self._merged_format(tag_names))
        finally:
            self._applying_formats = False
            doc.setModified(was_modified)

    def _reset_document_format(self, start=None, end=None):
        """Restore a range (default: whole document) to the base text format."""
        cursor = QTextCursor(self.tw.document())
        if start is None:
            cursor.select(QTextCursor.Document)
        else:
            cursor.setPosition(start)
            cursor.setPosition(end, QTextCursor.KeepAnchor)
        fmt = QTextCharFormat()
        fmt.setFont(self.tw.font())
        fmt.setForeground(self.tw.palette().text())
        fmt.setBackground(self.tw.palette().base())
        cursor.setCharFormat(fmt)

    def _replace_editor_range(self, start, end, new_text):
        """Replace ``[start, end)`` in the QTextEdit with ``new_text``.

        Uses a QTextCursor so only the changed span is touched (unlike
        setPlainText, which rewrites the whole document and drops all
        existing character formatting).
        """
        self._syncing = True
        try:
            cursor = QTextCursor(self.tw.document())
            cursor.setPosition(start)
            if end > start:
                cursor.setPosition(end, QTextCursor.KeepAnchor)
            cursor.insertText(new_text)
        finally:
            self._syncing = False

    def _set_editor_text(self, text):
        """Push model text into ``QTextEdit`` without reentering sync hooks."""
        self._syncing = True
        try:
            self.tw.setPlainText(text)
        finally:
            self._syncing = False

    def _apply_selection_to_editor(self):
        """Mirror model selection state into the QTextEdit cursor.

        ``QTextEdit`` represents selection entirely through ``QTextCursor``, so
        this method reconstructs the selection each time the model moves the
        caret or selection endpoints.
        """
        cursor = QTextCursor(self.tw.document())
        cursor.setPosition(self._clamp_offset(self._sel_start))
        cursor.setPosition(self._clamp_offset(self._sel_end),
                           QTextCursor.KeepAnchor)
        self._syncing = True
        try:
            self.tw.setTextCursor(cursor)
        finally:
            self._syncing = False

    def _mark_clean(self):
        self.tw.document().setModified(False)

    def _refresh_icon_gutter(self):
        """Recompute visible gutter icons from ref-anchored registrations."""
        self.nb.clear_icons()
        if not self._show_icon_gutter:
            return
        line_icons = {}
        for ref, image in list(self._icon_refs.items()):
            if not ref.is_valid():
                self._icon_refs.pop(ref, None)
                continue
            line_icons[ref.get_line() + 1] = image
        for line, image in line_icons.items():
            self.nb.set_icon_for_line(line, image)

    def _on_editor_text_changed(self):
        """Pull user edits from QTextEdit back into the buffer model.

        Qt edits the document directly during typing, paste, and deletion.  We
        diff the previous model text against the new widget text, then replay
        that edit through ``_replace_range`` so refs and tag intervals are
        updated with the same logic used by programmatic edits.
        """
        # Applying character formats (setCharFormat) also emits textChanged,
        # but never changes the text.  Ignore those, otherwise every format
        # pass triggers a full-document toPlainText() diff -- an O(n^2) storm
        # while streaming appends.
        if self._syncing or self._applying_formats:
            return
        new_text = self.tw.toPlainText()
        if new_text == self._text:
            return
        start = 0
        old_text = self._text
        old_len = len(old_text)
        new_len = len(new_text)
        while start < old_len and start < new_len and old_text[start] == new_text[start]:
            start += 1
        old_end = old_len
        new_end = new_len
        while old_end > start and new_end > start and old_text[old_end - 1] == new_text[new_end - 1]:
            old_end -= 1
            new_end -= 1
        cursor = self.tw.textCursor()
        selection_after = (cursor.position(), cursor.anchor(), cursor.position())
        self._replace_range(start, old_end, new_text[start:new_end],
                            selection_after=selection_after,
                            sync_editor=False)

    def _on_cursor_position_changed(self):
        """Capture cursor and selection state after user interaction."""
        if self._syncing:
            return
        cursor = self.tw.textCursor()
        self._cursor = cursor.position()
        self._sel_start = cursor.anchor()
        self._sel_end = cursor.position()
        self._refresh_icon_gutter()
        self.make_callback('cursor-moved')


# END
