# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations

import inspect
import os
import re
import shutil
import subprocess
import sys
from functools import partial, wraps
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Literal, Sequence, Union

from send2trash import send2trash

import aqt
from anki._legacy import DeprecatedNamesMixinForModule
from anki.collection import Collection, HelpPage
from anki.lang import TR, tr_legacyglobal  # pylint: disable=unused-import
from anki.utils import (
    invalid_filename,
    is_mac,
    is_win,
    no_bundled_libs,
    version_with_build,
)
from aqt.qt import *
from aqt.qt import (
    PYQT_VERSION_STR,
    QT_VERSION_STR,
    QAction,
    QApplication,
    QCheckBox,
    QColor,
    QComboBox,
    QDesktopServices,
    QDialog,
    QDialogButtonBox,
    QEvent,
    QFileDialog,
    QFrame,
    QHeaderView,
    QIcon,
    QKeySequence,
    QLabel,
    QLineEdit,
    QListWidget,
    QMainWindow,
    QMenu,
    QMessageBox,
    QMouseEvent,
    QNativeGestureEvent,
    QOffscreenSurface,
    QOpenGLContext,
    QPalette,
    QPixmap,
    QPlainTextEdit,
    QPoint,
    QPushButton,
    QShortcut,
    QSize,
    QSplitter,
    QStandardPaths,
    Qt,
    QTextBrowser,
    QTextOption,
    QTimer,
    QUrl,
    QVBoxLayout,
    QWheelEvent,
    QWidget,
    pyqtSlot,
    qconnect,
    qtmajor,
    qtminor,
    qVersion,
    traceback,
)
from aqt.theme import theme_manager

if TYPE_CHECKING:
    TextFormat = Literal["plain", "rich"]


def aqt_data_path() -> Path:
    # packaged?
    if getattr(sys, "frozen", False):
        prefix = Path(sys.prefix)
        path = prefix / "lib/_aqt/data"
        if path.exists():
            return path
        else:
            return prefix / "../Resources/_aqt/data"
    else:
        import _aqt.colors

        data_folder = Path(inspect.getfile(_aqt.colors)).with_name("data")
        if data_folder.exists():
            return data_folder.absolute()
        else:
            # should only happen when running unit tests
            print("warning, data folder not found")
            return Path(".")


def aqt_data_folder() -> str:
    return str(aqt_data_path())


# shortcut to access Fluent translations; set as
tr = tr_legacyglobal

HelpPageArgument = Union["HelpPage.V", str]


def openHelp(section: HelpPageArgument) -> None:
    if isinstance(section, str):
        link = tr.backend().help_page_link(page=HelpPage.INDEX) + section
    else:
        link = tr.backend().help_page_link(page=section)
    openLink(link)


def openLink(link: str | QUrl) -> None:
    tooltip(tr.qt_misc_loading(), period=1000)
    with no_bundled_libs():
        QDesktopServices.openUrl(QUrl(link))


class MessageBox(QMessageBox):
    def __init__(
        self,
        text: str,
        callback: Callable[[int], None] | None = None,
        parent: QWidget | None = None,
        icon: QMessageBox.Icon = QMessageBox.Icon.NoIcon,
        help: HelpPageArgument | None = None,
        title: str = "Anki",
        buttons: Sequence[str | QMessageBox.StandardButton] | None = None,
        default_button: int = 0,
        textFormat: Qt.TextFormat = Qt.TextFormat.PlainText,
    ) -> None:
        parent = parent or aqt.mw.app.activeWindow() or aqt.mw
        super().__init__(parent)
        self.setText(text)
        self.setWindowTitle(title)
        self.setWindowModality(Qt.WindowModality.WindowModal)
        self.setIcon(icon)
        if icon == QMessageBox.Icon.Question and theme_manager.night_mode:
            img = self.iconPixmap().toImage()
            img.invertPixels()
            self.setIconPixmap(QPixmap(img))
        self.setTextFormat(textFormat)
        if buttons is None:
            buttons = [QMessageBox.StandardButton.Ok]
        for i, button in enumerate(buttons):
            if isinstance(button, str):
                b = self.addButton(button, QMessageBox.ButtonRole.ActionRole)
            elif isinstance(button, QMessageBox.StandardButton):
                b = self.addButton(button)
            else:
                continue
            if callback is not None:
                qconnect(b.clicked, partial(callback, i))
            if i == default_button:
                self.setDefaultButton(b)
        if help is not None:
            b = self.addButton(QMessageBox.StandardButton.Help)
            qconnect(b.clicked, lambda: openHelp(help))
        self.open()


def ask_user(
    text: str,
    callback: Callable[[bool], None],
    defaults_yes: bool = True,
    **kwargs: Any,
) -> MessageBox:
    "Shows a yes/no question, passes the answer to the callback function as a bool."
    return MessageBox(
        text,
        callback=lambda response: callback(not response),
        icon=QMessageBox.Icon.Question,
        buttons=[QMessageBox.StandardButton.Yes, QMessageBox.StandardButton.No],
        default_button=not defaults_yes,
        **kwargs,
    )


def ask_user_dialog(
    text: str,
    callback: Callable[[int], None],
    buttons: Sequence[str | QMessageBox.StandardButton] | None = None,
    default_button: int = 1,
    **kwargs: Any,
) -> MessageBox:
    "Shows a question to the user, passes the index of the button clicked to the callback."
    if buttons is None:
        buttons = [QMessageBox.StandardButton.Yes, QMessageBox.StandardButton.No]
    return MessageBox(
        text,
        callback=callback,
        icon=QMessageBox.Icon.Question,
        buttons=buttons,
        default_button=default_button,
        **kwargs,
    )


def show_info(text: str, callback: Callable | None = None, **kwargs: Any) -> MessageBox:
    "Show a small info window with an OK button."
    if "icon" not in kwargs:
        kwargs["icon"] = QMessageBox.Icon.Information
    return MessageBox(
        text,
        callback=(lambda _: callback()) if callback is not None else None,
        **kwargs,
    )


def show_warning(
    text: str, callback: Callable | None = None, **kwargs: Any
) -> MessageBox:
    "Show a small warning window with an OK button."
    return show_info(text, icon=QMessageBox.Icon.Warning, callback=callback, **kwargs)


def show_critical(
    text: str, callback: Callable | None = None, **kwargs: Any
) -> MessageBox:
    "Show a small critical error window with an OK button."
    return show_info(text, icon=QMessageBox.Icon.Critical, callback=callback, **kwargs)


def showWarning(
    text: str,
    parent: QWidget | None = None,
    help: HelpPageArgument | None = None,
    title: str = "Anki",
    textFormat: TextFormat | None = None,
) -> int:
    "Show a small warning with an OK button."
    return showInfo(text, parent, help, "warning", title=title, textFormat=textFormat)


def showCritical(
    text: str,
    parent: QDialog | None = None,
    help: str = "",
    title: str = "Anki",
    textFormat: TextFormat | None = None,
) -> int:
    "Show a small critical error with an OK button."
    return showInfo(text, parent, help, "critical", title=title, textFormat=textFormat)


def showInfo(
    text: str,
    parent: QWidget | None = None,
    help: HelpPageArgument | None = None,
    type: str = "info",
    title: str = "Anki",
    textFormat: TextFormat | None = None,
    customBtns: list[QMessageBox.StandardButton] | None = None,
) -> int:
    "Show a small info window with an OK button."
    parent_widget: QWidget
    if parent is None:
        parent_widget = aqt.mw.app.activeWindow() or aqt.mw
    else:
        parent_widget = parent
    if type == "warning":
        icon = QMessageBox.Icon.Warning
    elif type == "critical":
        icon = QMessageBox.Icon.Critical
    else:
        icon = QMessageBox.Icon.Information
    mb = QMessageBox(parent_widget)  #
    if textFormat == "plain":
        mb.setTextFormat(Qt.TextFormat.PlainText)
    elif textFormat == "rich":
        mb.setTextFormat(Qt.TextFormat.RichText)
    elif textFormat is not None:
        raise Exception("unexpected textFormat type")
    mb.setText(text)
    mb.setIcon(icon)
    mb.setWindowTitle(title)
    if customBtns:
        default = None
        for btn in customBtns:
            b = mb.addButton(btn)
            if not default:
                default = b
        mb.setDefaultButton(default)
    else:
        b = mb.addButton(QMessageBox.StandardButton.Ok)
        b.setDefault(True)
    if help is not None:
        b = mb.addButton(QMessageBox.StandardButton.Help)
        qconnect(b.clicked, lambda: openHelp(help))
        b.setAutoDefault(False)
    return mb.exec()


def showText(
    txt: str,
    parent: QWidget | None = None,
    type: str = "text",
    run: bool = True,
    geomKey: str | None = None,
    minWidth: int = 500,
    minHeight: int = 400,
    title: str = "Anki",
    copyBtn: bool = False,
    plain_text_edit: bool = False,
) -> tuple[QDialog, QDialogButtonBox] | None:
    if not parent:
        parent = aqt.mw.app.activeWindow() or aqt.mw
    diag = QDialog(parent)
    diag.setWindowTitle(title)
    disable_help_button(diag)
    layout = QVBoxLayout(diag)
    diag.setLayout(layout)
    text: QPlainTextEdit | QTextBrowser
    if plain_text_edit:
        # used by the importer
        text = QPlainTextEdit()
        text.setReadOnly(True)
        text.setWordWrapMode(QTextOption.WrapMode.NoWrap)
        text.setPlainText(txt)
    else:
        text = QTextBrowser()
        text.setOpenExternalLinks(True)
        if type == "text":
            text.setPlainText(txt)
        else:
            text.setHtml(txt)
    layout.addWidget(text)
    box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
    layout.addWidget(box)
    if copyBtn:

        def onCopy() -> None:
            QApplication.clipboard().setText(text.toPlainText())

        btn = QPushButton(tr.qt_misc_copy_to_clipboard())
        qconnect(btn.clicked, onCopy)
        box.addButton(btn, QDialogButtonBox.ButtonRole.ActionRole)

    def onReject() -> None:
        if geomKey:
            saveGeom(diag, geomKey)
        QDialog.reject(diag)

    qconnect(box.rejected, onReject)

    def onFinish() -> None:
        if geomKey:
            saveGeom(diag, geomKey)

    qconnect(box.accepted, onFinish)
    diag.setMinimumHeight(minHeight)
    diag.setMinimumWidth(minWidth)
    if geomKey:
        restoreGeom(diag, geomKey)
    if run:
        diag.exec()
        return None
    else:
        return diag, box


def askUser(
    text: str,
    parent: QWidget = None,
    help: HelpPageArgument = None,
    defaultno: bool = False,
    msgfunc: Callable | None = None,
    title: str = "Anki",
) -> bool:
    "Show a yes/no question. Return true if yes."
    if not parent:
        parent = aqt.mw.app.activeWindow()
    if not msgfunc:
        msgfunc = QMessageBox.question
    sb = QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
    if help:
        sb |= QMessageBox.StandardButton.Help
    while 1:
        if defaultno:
            default = QMessageBox.StandardButton.No
        else:
            default = QMessageBox.StandardButton.Yes
        r = msgfunc(parent, title, text, sb, default)
        if r == QMessageBox.StandardButton.Help:
            openHelp(help)
        else:
            break
    return r == QMessageBox.StandardButton.Yes


class ButtonedDialog(QMessageBox):
    def __init__(
        self,
        text: str,
        buttons: list[str],
        parent: QWidget | None = None,
        help: HelpPageArgument = None,
        title: str = "Anki",
    ):
        QMessageBox.__init__(self, parent)
        self._buttons: list[QPushButton] = []
        self.setWindowTitle(title)
        self.help = help
        self.setIcon(QMessageBox.Icon.Warning)
        self.setText(text)
        for b in buttons:
            self._buttons.append(self.addButton(b, QMessageBox.ButtonRole.AcceptRole))
        if help:
            self.addButton(tr.actions_help(), QMessageBox.ButtonRole.HelpRole)
            buttons.append(tr.actions_help())

    def run(self) -> str:
        self.exec()
        but = self.clickedButton().text()
        if but == "Help":
            # FIXME stop dialog closing?
            openHelp(self.help)
        txt = self.clickedButton().text()
        # work around KDE 'helpfully' adding accelerators to button text of Qt apps
        return txt.replace("&", "")

    def setDefault(self, idx: int) -> None:
        self.setDefaultButton(self._buttons[idx])


def askUserDialog(
    text: str,
    buttons: list[str],
    parent: QWidget | None = None,
    help: HelpPageArgument = None,
    title: str = "Anki",
) -> ButtonedDialog:
    if not parent:
        parent = aqt.mw
    diag = ButtonedDialog(text, buttons, parent, help, title=title)
    return diag


class GetTextDialog(QDialog):
    def __init__(
        self,
        parent: QWidget | None,
        question: str,
        help: HelpPageArgument = None,
        edit: QLineEdit | None = None,
        default: str = "",
        title: str = "Anki",
        minWidth: int = 400,
    ) -> None:
        QDialog.__init__(self, parent)
        self.setWindowTitle(title)
        disable_help_button(self)
        self.question = question
        self.help = help
        self.qlabel = QLabel(question)
        self.setMinimumWidth(minWidth)
        v = QVBoxLayout()
        v.addWidget(self.qlabel)
        if not edit:
            edit = QLineEdit()
        self.l = edit
        if default:
            self.l.setText(default)
            self.l.selectAll()
        v.addWidget(self.l)
        buts = (
            QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
        )
        if help:
            buts |= QDialogButtonBox.StandardButton.Help
        b = QDialogButtonBox(buts)  # type: ignore
        v.addWidget(b)
        self.setLayout(v)
        qconnect(b.button(QDialogButtonBox.StandardButton.Ok).clicked, self.accept)
        qconnect(b.button(QDialogButtonBox.StandardButton.Cancel).clicked, self.reject)
        if help:
            qconnect(
                b.button(QDialogButtonBox.StandardButton.Help).clicked,
                self.helpRequested,
            )

    def accept(self) -> None:
        return QDialog.accept(self)

    def reject(self) -> None:
        return QDialog.reject(self)

    def helpRequested(self) -> None:
        openHelp(self.help)


def getText(
    prompt: str,
    parent: QWidget | None = None,
    help: HelpPageArgument = None,
    edit: QLineEdit | None = None,
    default: str = "",
    title: str = "Anki",
    geomKey: str | None = None,
    **kwargs: Any,
) -> tuple[str, int]:
    "Returns (string, succeeded)."
    if not parent:
        parent = aqt.mw.app.activeWindow() or aqt.mw
    d = GetTextDialog(
        parent, prompt, help=help, edit=edit, default=default, title=title, **kwargs
    )
    d.setWindowModality(Qt.WindowModality.WindowModal)
    if geomKey:
        restoreGeom(d, geomKey)
    ret = d.exec()
    if geomKey and ret:
        saveGeom(d, geomKey)
    return (str(d.l.text()), ret)


def getOnlyText(*args: Any, **kwargs: Any) -> str:
    (s, r) = getText(*args, **kwargs)
    if r:
        return s
    else:
        return ""


# fixme: these utilities could be combined into a single base class
# unused by Anki, but used by add-ons
def chooseList(
    prompt: str, choices: list[str], startrow: int = 0, parent: Any = None
) -> int:
    if not parent:
        parent = aqt.mw.app.activeWindow()
    d = QDialog(parent)
    disable_help_button(d)
    d.setWindowModality(Qt.WindowModality.WindowModal)
    l = QVBoxLayout()
    d.setLayout(l)
    t = QLabel(prompt)
    l.addWidget(t)
    c = QListWidget()
    c.addItems(choices)
    c.setCurrentRow(startrow)
    l.addWidget(c)
    bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
    qconnect(bb.accepted, d.accept)
    l.addWidget(bb)
    d.exec()
    return c.currentRow()


def getTag(
    parent: QWidget, deck: Collection, question: str, **kwargs: Any
) -> tuple[str, int]:
    from aqt.tagedit import TagEdit

    te = TagEdit(parent)
    te.setCol(deck)
    ret = getText(question, parent, edit=te, geomKey="getTag", **kwargs)
    te.hideCompleter()
    return ret


def disable_help_button(widget: QWidget) -> None:
    "Disable the help button in the window titlebar."
    widget.setWindowFlags(
        widget.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint
    )


def setWindowIcon(widget: QWidget) -> None:
    icon = QIcon()
    icon.addPixmap(QPixmap("icons:anki.png"), QIcon.Mode.Normal, QIcon.State.Off)
    widget.setWindowIcon(icon)


# File handling
######################################################################


def getFile(
    parent: QWidget,
    title: str,
    # single file returned unless multi=True
    cb: Callable[[str | Sequence[str]], None] | None,
    filter: str = "*",
    dir: str | None = None,
    key: str | None = None,
    multi: bool = False,  # controls whether a single or multiple files is returned
) -> Sequence[str] | str | None:
    "Ask the user for a file."
    if dir and key:
        raise Exception("expected dir or key")
    if not dir:
        dirkey = f"{key}Directory"
        dir = aqt.mw.pm.profile.get(dirkey, "")
    else:
        dirkey = None
    d = QFileDialog(parent)
    mode = (
        QFileDialog.FileMode.ExistingFiles
        if multi
        else QFileDialog.FileMode.ExistingFile
    )
    d.setFileMode(mode)
    if os.path.exists(dir):
        d.setDirectory(dir)
    d.setWindowTitle(title)
    d.setNameFilter(filter)
    ret = []

    def accept() -> None:
        files = list(d.selectedFiles())
        if dirkey:
            dir = os.path.dirname(files[0])
            aqt.mw.pm.profile[dirkey] = dir
        result = files if multi else files[0]
        if cb:
            cb(result)
        ret.append(result)

    qconnect(d.accepted, accept)
    if key:
        restoreState(d, key)
    d.exec()
    if key:
        saveState(d, key)
    return ret[0] if ret else None


def getSaveFile(
    parent: QDialog,
    title: str,
    dir_description: str,
    key: str,
    ext: str,
    fname: str | None = None,
) -> str:
    """Ask the user for a file to save. Use DIR_DESCRIPTION as config
    variable. The file dialog will default to open with FNAME."""
    config_key = f"{dir_description}Directory"

    defaultPath = QStandardPaths.writableLocation(
        QStandardPaths.StandardLocation.DocumentsLocation
    )
    base = aqt.mw.pm.profile.get(config_key, defaultPath)
    path = os.path.join(base, fname)
    file = QFileDialog.getSaveFileName(
        parent,
        title,
        path,
        f"{key} (*{ext})",
        options=QFileDialog.Option.DontConfirmOverwrite,
    )[0]
    if file:
        # add extension
        if not file.lower().endswith(ext):
            file += ext
        # save new default
        dir = os.path.dirname(file)
        aqt.mw.pm.profile[config_key] = dir
        # check if it exists
        if os.path.exists(file):
            if not askUser(tr.qt_misc_this_file_exists_are_you_sure(), parent):
                return None
    return file


def saveGeom(widget: QWidget, key: str) -> None:
    # restoring a fullscreen window is buggy
    # (at the time of writing; Qt 6.2.2 and 5.15)
    if not widget.isFullScreen():
        aqt.mw.pm.profile[f"{key}Geom"] = widget.saveGeometry()


def restoreGeom(
    widget: QWidget,
    key: str,
    offset: int | None = None,
    adjustSize: bool = False,
    default_size: tuple[int, int] | None = None,
) -> None:
    key += "Geom"
    if existing_geom := aqt.mw.pm.profile.get(key):
        widget.restoreGeometry(existing_geom)
        if is_mac and offset:
            if qtmajor > 5 or qtminor > 6:
                # bug in osx toolkit
                s = widget.size()
                widget.resize(s.width(), s.height() + offset * 2)
        ensureWidgetInScreenBoundaries(widget)
    elif adjustSize:
        widget.adjustSize()
    elif default_size:
        widget.resize(*default_size)


def ensureWidgetInScreenBoundaries(widget: QWidget) -> None:
    handle = widget.window().windowHandle()
    if not handle:
        # window has not yet been shown, retry later
        aqt.mw.progress.timer(
            50, lambda: ensureWidgetInScreenBoundaries(widget), False, parent=widget
        )
        return

    # ensure widget is smaller than screen bounds
    geom = handle.screen().availableGeometry()
    wsize = widget.size()
    cappedWidth = min(geom.width(), wsize.width())
    cappedHeight = min(geom.height(), wsize.height())
    if cappedWidth > wsize.width() or cappedHeight > wsize.height():
        widget.resize(QSize(cappedWidth, cappedHeight))

    # ensure widget is inside top left
    wpos = widget.pos()
    x = max(geom.x(), wpos.x())
    y = max(geom.y(), wpos.y())
    # and bottom right
    x = min(x, geom.width() + geom.x() - cappedWidth)
    y = min(y, geom.height() + geom.y() - cappedHeight)
    if x != wpos.x() or y != wpos.y():
        widget.move(x, y)


def saveState(widget: QFileDialog | QMainWindow, key: str) -> None:
    key += "State"
    aqt.mw.pm.profile[key] = widget.saveState()


def restoreState(widget: QFileDialog | QMainWindow, key: str) -> None:
    key += "State"
    if aqt.mw.pm.profile.get(key):
        widget.restoreState(aqt.mw.pm.profile[key])


def saveSplitter(widget: QSplitter, key: str) -> None:
    key += "Splitter"
    aqt.mw.pm.profile[key] = widget.saveState()


def restoreSplitter(widget: QSplitter, key: str) -> None:
    key += "Splitter"
    if aqt.mw.pm.profile.get(key):
        widget.restoreState(aqt.mw.pm.profile[key])


def _header_key(key: str) -> str:
    # not compatible across major versions
    qt_suffix = f"Qt{qtmajor}" if qtmajor > 5 else ""
    return f"{key}Header{qt_suffix}"


def saveHeader(widget: QHeaderView, key: str) -> None:
    aqt.mw.pm.profile[_header_key(key)] = widget.saveState()


def restoreHeader(widget: QHeaderView, key: str) -> None:
    if state := aqt.mw.pm.profile.get(_header_key(key)):
        widget.restoreState(state)


def save_is_checked(widget: QCheckBox, key: str) -> None:
    key += "IsChecked"
    aqt.mw.pm.profile[key] = widget.isChecked()


def restore_is_checked(widget: QCheckBox, key: str) -> None:
    key += "IsChecked"
    if aqt.mw.pm.profile.get(key) is not None:
        widget.setChecked(aqt.mw.pm.profile[key])


def save_combo_index_for_session(widget: QComboBox, key: str) -> None:
    textKey = f"{key}ComboActiveText"
    indexKey = f"{key}ComboActiveIndex"
    aqt.mw.pm.session[textKey] = widget.currentText()
    aqt.mw.pm.session[indexKey] = widget.currentIndex()


def restore_combo_index_for_session(
    widget: QComboBox, history: list[str], key: str
) -> None:
    textKey = f"{key}ComboActiveText"
    indexKey = f"{key}ComboActiveIndex"
    text = aqt.mw.pm.session.get(textKey)
    index = aqt.mw.pm.session.get(indexKey)
    if text is not None and index is not None:
        if index < len(history) and history[index] == text:
            widget.setCurrentIndex(index)


def save_combo_history(comboBox: QComboBox, history: list[str], name: str) -> str:
    name += "BoxHistory"
    text_input = comboBox.lineEdit().text()
    if text_input in history:
        history.remove(text_input)
    history.insert(0, text_input)
    history = history[:50]
    comboBox.clear()
    comboBox.addItems(history)
    aqt.mw.pm.session[name] = text_input
    aqt.mw.pm.profile[name] = history
    return text_input


def restore_combo_history(comboBox: QComboBox, name: str) -> list[str]:
    name += "BoxHistory"
    history = aqt.mw.pm.profile.get(name, [])
    comboBox.addItems([""] + history)
    if history:
        session_input = aqt.mw.pm.session.get(name)
        if session_input and session_input == history[0]:
            comboBox.lineEdit().setText(session_input)
            comboBox.lineEdit().selectAll()
    return history


def mungeQA(col: Collection, txt: str) -> str:
    print("mungeQA() deprecated; use mw.prepare_card_text_for_display()")
    txt = col.media.escape_media_filenames(txt)
    return txt


def openFolder(path: str) -> None:
    if is_win:
        subprocess.run(["explorer", f"file://{path}"], check=False)
    else:
        with no_bundled_libs():
            QDesktopServices.openUrl(QUrl(f"file://{path}"))


def shortcut(key: str) -> str:
    if is_mac:
        return re.sub("(?i)ctrl", "Command", key)
    return key


def maybeHideClose(bbox: QDialogButtonBox) -> None:
    if is_mac:
        b = bbox.button(QDialogButtonBox.StandardButton.Close)
        if b:
            bbox.removeButton(b)


def addCloseShortcut(widg: QDialog) -> None:
    if not is_mac:
        return
    shortcut = QShortcut(QKeySequence("Ctrl+W"), widg)
    qconnect(shortcut.activated, widg.reject)
    setattr(widg, "_closeShortcut", shortcut)


def add_close_shortcut(widg: QWidget) -> None:
    if not is_mac:
        return
    shortcut = QShortcut(QKeySequence("Ctrl+W"), widg)
    qconnect(shortcut.activated, widg.close)


def downArrow() -> str:
    if is_win:
        return "▼"
    # windows 10 is lacking the smaller arrow on English installs
    return "▾"


def current_window() -> QWidget | None:
    if widget := QApplication.focusWidget():
        return widget.window()
    else:
        return None


def send_to_trash(path: Path) -> None:
    "Place file/folder in recycling bin, or delete permanently on failure."
    if not path.exists():
        return
    try:
        send2trash(path)
    except Exception as exc:
        # Linux users may not have a trash folder set up
        print("trash failure:", path, exc)
        if path.is_dir:
            shutil.rmtree(path)
        else:
            path.unlink()


# Tooltips
######################################################################

_tooltipTimer: QTimer | None = None
_tooltipLabel: QLabel | None = None


def tooltip(
    msg: str,
    period: int = 3000,
    parent: QWidget | None = None,
    x_offset: int = 0,
    y_offset: int = 100,
) -> None:
    global _tooltipTimer, _tooltipLabel

    class CustomLabel(QLabel):
        silentlyClose = True

        def mousePressEvent(self, evt: QMouseEvent) -> None:
            evt.accept()
            self.hide()

    closeTooltip()
    aw = parent or aqt.mw.app.activeWindow() or aqt.mw
    lab = CustomLabel(
        f"""<table cellpadding=10>
<tr>
<td>{msg}</td>
</tr>
</table>""",
        aw,
    )
    lab.setFrameStyle(QFrame.Shape.Panel)
    lab.setLineWidth(2)
    lab.setWindowFlags(Qt.WindowType.ToolTip)
    if not theme_manager.night_mode:
        p = QPalette()
        p.setColor(QPalette.ColorRole.Window, QColor("#feffc4"))
        p.setColor(QPalette.ColorRole.WindowText, QColor("#000000"))
        lab.setPalette(p)
    lab.move(aw.mapToGlobal(QPoint(0 + x_offset, aw.height() - y_offset)))
    lab.show()
    _tooltipTimer = aqt.mw.progress.timer(
        period, closeTooltip, False, requiresCollection=False, parent=aw
    )
    _tooltipLabel = lab


def closeTooltip() -> None:
    global _tooltipLabel, _tooltipTimer
    if _tooltipLabel:
        try:
            _tooltipLabel.deleteLater()
        except RuntimeError:
            # already deleted as parent window closed
            pass
        _tooltipLabel = None
    if _tooltipTimer:
        try:
            _tooltipTimer.deleteLater()
        except RuntimeError:
            pass
        _tooltipTimer = None


# true if invalid; print warning
def checkInvalidFilename(str: str, dirsep: bool = True) -> bool:
    bad = invalid_filename(str, dirsep)
    if bad:
        showWarning(tr.qt_misc_the_following_character_can_not_be(val=bad))
        return True
    return False


# Menus
######################################################################
# This code will be removed in the future, please don't rely on it.

MenuListChild = Union["SubMenu", QAction, "MenuItem", "MenuList"]


class MenuList:
    def __init__(self) -> None:
        traceback.print_stack(file=sys.stdout)
        print(
            "MenuList will be removed; please copy it into your add-on's code if you need it."
        )
        self.children: list[MenuListChild] = []

    def addItem(self, title: str, func: Callable) -> MenuItem:
        item = MenuItem(title, func)
        self.children.append(item)
        return item

    def addSeparator(self) -> None:
        self.children.append(None)

    def addMenu(self, title: str) -> SubMenu:
        submenu = SubMenu(title)
        self.children.append(submenu)
        return submenu

    def addChild(self, child: SubMenu | QAction | MenuList) -> None:
        self.children.append(child)

    def renderTo(self, qmenu: QMenu) -> None:
        for child in self.children:
            if child is None:
                qmenu.addSeparator()
            elif isinstance(child, QAction):
                qmenu.addAction(child)
            else:
                child.renderTo(qmenu)

    def popupOver(self, widget: QPushButton) -> None:
        qmenu = QMenu()
        self.renderTo(qmenu)
        qmenu.exec(widget.mapToGlobal(QPoint(0, 0)))


class SubMenu(MenuList):
    def __init__(self, title: str) -> None:
        super().__init__()
        self.title = title

    def renderTo(self, menu: QMenu) -> None:
        submenu = menu.addMenu(self.title)
        super().renderTo(submenu)


class MenuItem:
    def __init__(self, title: str, func: Callable) -> None:
        self.title = title
        self.func = func

    def renderTo(self, qmenu: QMenu) -> None:
        a = qmenu.addAction(self.title)
        qconnect(a.triggered, self.func)


def qtMenuShortcutWorkaround(qmenu: QMenu) -> None:
    for act in qmenu.actions():
        act.setShortcutVisibleInContextMenu(True)


######################################################################


def disallow_full_screen() -> bool:
    """Test for OpenGl on Windows, which is known to cause issues with full screen mode.
    On Qt6, the driver is not detectable, so check if it has been set explicitly.
    """
    from aqt import mw
    from aqt.profiles import VideoDriver

    return is_win and (
        (qtmajor == 5 and mw.pm.video_driver() == VideoDriver.OpenGL)
        or (
            qtmajor == 6
            and not os.environ.get("ANKI_SOFTWAREOPENGL")
            and os.environ.get("QT_OPENGL") != "software"
        )
    )


def add_ellipsis_to_action_label(*actions: QAction) -> None:
    """Pass actions to add '...' to their labels, indicating that more input is
    required before they can be performed.

    This approach is used so that the same fluent translations can be used on
    mobile, where the '...' convention does not exist.
    """
    for action in actions:
        action.setText(tr.actions_with_ellipsis(action=action.text()))


def supportText() -> str:
    import platform
    import time

    from aqt import mw

    platname = platform.platform()

    def schedVer() -> str:
        try:
            if mw.col.v3_scheduler():
                return "3"
            else:
                return str(mw.col.sched_ver())
        except:
            return "?"

    lc = mw.pm.last_addon_update_check()
    lcfmt = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(lc))

    return """\
Anki {} Python {} Qt {} PyQt {}
Platform: {}
Flags: frz={} ao={} sv={}
Add-ons, last update check: {}
""".format(
        version_with_build(),
        platform.python_version(),
        qVersion(),
        PYQT_VERSION_STR,
        platname,
        getattr(sys, "frozen", False),
        mw.addonManager.dirty,
        schedVer(),
        lcfmt,
    )


######################################################################

# adapted from version detection in qutebrowser
def opengl_vendor() -> str | None:
    if qtmajor != 5:
        return "unknown"
    old_context = QOpenGLContext.currentContext()
    old_surface = None if old_context is None else old_context.surface()

    surface = QOffscreenSurface()
    surface.create()

    ctx = QOpenGLContext()
    ok = ctx.create()
    if not ok:
        return None

    ok = ctx.makeCurrent(surface)
    if not ok:
        return None

    try:
        if ctx.isOpenGLES():
            # Can't use versionFunctions there
            return None

        vp = QOpenGLVersionProfile()  # type: ignore  # pylint: disable=undefined-variable
        vp.setVersion(2, 0)

        try:
            vf = ctx.versionFunctions(vp)  # type: ignore
        except ImportError as e:
            return None

        if vf is None:
            return None

        return vf.glGetString(vf.GL_VENDOR)
    finally:
        ctx.doneCurrent()
        if old_context and old_surface:
            old_context.makeCurrent(old_surface)


def gfxDriverIsBroken() -> bool:
    driver = opengl_vendor()
    return driver == "nouveau"


######################################################################


def startup_info() -> Any:
    "Use subprocess.Popen(startupinfo=...) to avoid opening a console window."
    if sys.platform != "win32":
        return None
    si = subprocess.STARTUPINFO()  # pytype: disable=module-attr
    si.dwFlags |= subprocess.STARTF_USESHOWWINDOW  # pytype: disable=module-attr
    return si


def ensure_editor_saved(func: Callable) -> Callable:
    """Ensure the current editor's note is saved before running the wrapped function.

    Must be used on functions that may be invoked from a shortcut key while the
    editor has focus. For functions that can't be activated while the editor has
    focus, you don't need this.

    Will look for the editor as self.editor.
    """

    @wraps(func)
    def decorated(self: Any, *args: Any, **kwargs: Any) -> None:
        self.editor.call_after_note_saved(lambda: func(self, *args, **kwargs))

    return decorated


def skip_if_selection_is_empty(func: Callable) -> Callable:
    """Make the wrapped method a no-op and show a hint if the table selection is empty."""

    @wraps(func)
    def decorated(self: Any, *args: Any, **kwargs: Any) -> None:
        if self.table.len_selection() > 0:
            func(self, *args, **kwargs)
        else:
            tooltip(tr.browsing_no_selection())

    return decorated


def no_arg_trigger(func: Callable) -> Callable:
    """Tells Qt this function takes no args.

    This ensures PyQt doesn't attempt to pass a `toggled` arg
    into functions connected to a `triggered` signal.
    """

    return pyqtSlot()(func)  # type: ignore


def is_gesture_or_zoom_event(evt: QEvent) -> bool:
    """If the event is a gesture and/or will trigger zoom.

    Includes zoom by pinching, and Ctrl-scrolling on Win and Linux.
    """

    return isinstance(evt, QNativeGestureEvent) or (
        isinstance(evt, QWheelEvent)
        and not is_mac
        and KeyboardModifiersPressed().control
    )


class KeyboardModifiersPressed:
    "Util for type-safe checks of currently-pressed modifier keys."

    def __init__(self) -> None:
        from aqt import mw

        self._modifiers = mw.app.keyboardModifiers()

    @property
    def shift(self) -> bool:
        return bool(self._modifiers & Qt.KeyboardModifier.ShiftModifier)

    @property
    def control(self) -> bool:
        return bool(self._modifiers & Qt.KeyboardModifier.ControlModifier)

    @property
    def alt(self) -> bool:
        return bool(self._modifiers & Qt.KeyboardModifier.AltModifier)

    @property
    def meta(self) -> bool:
        return bool(self._modifiers & Qt.KeyboardModifier.MetaModifier)


# add-ons attempting to import isMac from this module :-(
_deprecated_names = DeprecatedNamesMixinForModule(globals())


if not TYPE_CHECKING:

    def __getattr__(name: str) -> Any:
        return _deprecated_names.__getattr__(name)
