#
# Authors: The MNE-Python contributors.
# License: BSD-3-Clause
# Copyright the MNE-Python contributors.

import collections.abc
import functools
import os
import platform
import signal
import sys
from colorsys import rgb_to_hls
from contextlib import contextmanager
from ctypes import c_char_p, c_void_p, cdll
from pathlib import Path

import numpy as np

from ...fixes import _compare_version, _reshape_view
from ...utils import _check_qt_version, _validate_type, logger, warn
from ..utils import _get_cmap

VALID_BROWSE_BACKENDS = (
    "qt",
    "matplotlib",
)

VALID_3D_BACKENDS = (
    "pyvistaqt",  # default 3d backend
    "notebook",
)
ALLOWED_QUIVER_MODES = ("2darrow", "arrow", "cone", "cylinder", "sphere", "oct")
_ICONS_PATH = Path(__file__).parents[2] / "icons"


def _get_colormap_from_array(
    colormap=None, normalized_colormap=False, default_colormap="coolwarm"
):
    from matplotlib.colors import ListedColormap

    if colormap is None:
        cmap = _get_cmap(default_colormap)
    elif isinstance(colormap, str):
        cmap = _get_cmap(colormap)
    elif normalized_colormap:
        cmap = ListedColormap(colormap)
    else:
        cmap = ListedColormap(np.array(colormap) / 255.0)
    return cmap


def _check_color(color):
    from matplotlib.colors import colorConverter

    if isinstance(color, str):
        color = colorConverter.to_rgb(color)
    elif isinstance(color, collections.abc.Iterable):
        np_color = np.array(color)
        if np_color.size % 3 != 0 and np_color.size % 4 != 0:
            raise ValueError("The expected valid format is RGB or RGBA.")
        if np_color.dtype in (np.int64, np.int32):
            if (np_color < 0).any() or (np_color > 255).any():
                raise ValueError("Values out of range [0, 255].")
        elif np_color.dtype == np.float64:
            if (np_color < 0.0).any() or (np_color > 1.0).any():
                raise ValueError("Values out of range [0.0, 1.0].")
        else:
            raise TypeError(
                "Expected data type is `np.int64`, `np.int32`, or `np.float64` but "
                f"{np_color.dtype} was given."
            )
    else:
        raise TypeError(
            f"Expected type is `str` or iterable but {type(color)} was given."
        )
    return color


def _alpha_blend_background(ctable, background_color):
    alphas = ctable[:, -1][:, np.newaxis] / 255.0
    use_table = ctable.copy()
    use_table[:, -1] = 255.0
    return (use_table * alphas) + background_color * (1 - alphas)


@functools.lru_cache(1)
def _qt_init_icons():
    from qtpy.QtGui import QIcon

    QIcon.setThemeSearchPaths([str(_ICONS_PATH)] + QIcon.themeSearchPaths())
    QIcon.setFallbackThemeName("light")
    return str(_ICONS_PATH)


@contextmanager
def _qt_disable_paint(widget):
    paintEvent = widget.paintEvent
    widget.paintEvent = lambda *args, **kwargs: None
    try:
        yield
    finally:
        widget.paintEvent = paintEvent


_QT_ICON_KEYS = dict(app=None)


def _init_mne_qtapp(enable_icon=True, pg_app=False, splash=False):
    """Get QApplication-instance for MNE-Python.

    Parameter
    ---------
    enable_icon: bool
        If to set an MNE-icon for the app.
    pg_app: bool
        If to create the QApplication with pyqtgraph. For an until know
        undiscovered reason the pyqtgraph-browser won't show without
        mkQApp from pyqtgraph.
    splash : bool | str
        If not False, display a splash screen. If str, set the message
        to the given string.

    Returns
    -------
    app : ``qtpy.QtWidgets.QApplication``
        Instance of QApplication.
    splash : ``qtpy.QtWidgets.QSplashScreen``
        Instance of QSplashScreen. Only returned if splash is True or a
        string.
    """
    from qtpy.QtCore import Qt
    from qtpy.QtGui import QGuiApplication, QIcon, QPixmap
    from qtpy.QtWidgets import QApplication, QSplashScreen

    app_name = "MNE-Python"
    organization_name = "MNE"

    # Fix from cbrnr/mnelab for app name in menu bar
    # This has to come *before* the creation of the QApplication to work.
    # It also only affects the title bar, not the application dock.
    # There seems to be no way to change the application dock from "python"
    # at runtime.
    if sys.platform.startswith("darwin"):
        try:
            # set bundle name on macOS (app name shown in the menu bar)
            from Foundation import NSBundle

            bundle = NSBundle.mainBundle()
            info = bundle.localizedInfoDictionary() or bundle.infoDictionary()
            if "CFBundleName" not in info:
                info["CFBundleName"] = app_name
        except ModuleNotFoundError:
            pass

    # First we need to check to make sure the display is valid, otherwise
    # Qt might segfault on us
    app = QApplication.instance()
    if not (app or _display_is_valid()):
        raise RuntimeError("Cannot connect to a valid display")

    if pg_app:
        from pyqtgraph import mkQApp

        old_argv = sys.argv
        try:
            sys.argv = []
            app = mkQApp(app_name)
        finally:
            sys.argv = old_argv
    elif not app:
        app = QApplication([app_name])
    app.setApplicationName(app_name)
    app.setOrganizationName(organization_name)
    qt_version = _check_qt_version(check_usable_display=False)
    # HiDPI is enabled by default in Qt6, requires to be explicitly set for Qt5
    if _compare_version(qt_version, "<", "6.0"):
        app.setAttribute(Qt.AA_UseHighDpiPixmaps)

    if enable_icon or splash:
        icons_path = _qt_init_icons()

    if (
        enable_icon
        and app.windowIcon().cacheKey() != _QT_ICON_KEYS["app"]
        and app.windowIcon().isNull()  # don't overwrite existing icon (e.g. MNELAB)
    ):
        # Set icon
        kind = "bigsur_" if platform.mac_ver()[0] >= "10.16" else "default_"
        icon = QIcon(f"{icons_path}/mne_{kind}icon.png")
        app.setWindowIcon(icon)
        _QT_ICON_KEYS["app"] = app.windowIcon().cacheKey()

    out = app
    if splash:
        pixmap = QPixmap(f"{icons_path}/mne_splash.png")
        pixmap.setDevicePixelRatio(QGuiApplication.primaryScreen().devicePixelRatio())
        args = (pixmap,)
        if _should_raise_window():
            args += (Qt.WindowStaysOnTopHint,)
        qsplash = QSplashScreen(*args)
        qsplash.setAttribute(Qt.WA_ShowWithoutActivating, True)
        if isinstance(splash, str):
            alignment = int(Qt.AlignBottom | Qt.AlignHCenter)
            qsplash.showMessage(splash, alignment=alignment, color=Qt.white)
        qsplash.show()
        app.processEvents()
        out = (out, qsplash)

    return out


def _display_is_valid():
    # Adapted from matplotilb _c_internal_utils.py
    if sys.platform != "linux":
        return True
    if os.getenv("DISPLAY"):  # if it's not there, don't bother
        libX11 = cdll.LoadLibrary("libX11.so.6")
        libX11.XOpenDisplay.restype = c_void_p
        libX11.XOpenDisplay.argtypes = [c_char_p]
        display = libX11.XOpenDisplay(None)
        if display is not None:
            libX11.XCloseDisplay.argtypes = [c_void_p]
            libX11.XCloseDisplay(display)
            return True
    # not found, try Wayland
    if os.getenv("WAYLAND_DISPLAY"):
        libwayland = cdll.LoadLibrary("libwayland-client.so.0")
        if libwayland is not None:
            if all(
                hasattr(libwayland, f"wl_display_{kind}connect") for kind in ("", "dis")
            ):
                libwayland.wl_display_connect.restype = c_void_p
                libwayland.wl_display_connect.argtypes = [c_char_p]
                display = libwayland.wl_display_connect(None)
                if display:
                    libwayland.wl_display_disconnect.argtypes = [c_void_p]
                    libwayland.wl_display_disconnect(display)
                    return True
    return False


# https://stackoverflow.com/questions/5160577/ctrl-c-doesnt-work-with-pyqt
def _qt_app_exec(app):
    # adapted from matplotlib
    old_signal = signal.getsignal(signal.SIGINT)
    is_python_signal_handler = old_signal is not None
    if is_python_signal_handler:
        signal.signal(signal.SIGINT, signal.SIG_DFL)
    try:
        # Make IPython Console accessible again in Spyder
        app.lastWindowClosed.connect(app.quit)
        app.exec_()
    finally:
        # reset the SIGINT exception handler
        if is_python_signal_handler:
            signal.signal(signal.SIGINT, old_signal)


def _qt_detect_theme():
    try:
        import darkdetect

        theme = darkdetect.theme().lower()
    except ModuleNotFoundError:
        logger.info(
            'For automatic theme detection, "darkdetect" has to'
            " be installed! You can install it with "
            "`pip install darkdetect`"
        )
        theme = "light"
    except Exception:
        theme = "light"
    return theme


def _qt_get_stylesheet(theme):
    _validate_type(theme, ("path-like",), "theme")
    theme = str(theme)
    stylesheet = ""  # no stylesheet
    if theme in ("auto", "dark", "light"):
        if theme == "auto":
            return stylesheet
        assert theme in ("dark", "light")
        system_theme = _qt_detect_theme()
        if theme == system_theme:
            return stylesheet
        _, api = _check_qt_version(return_api=True)
        # On macOS or Qt 6, we shouldn't need to set anything when the requested
        # theme matches that of the current OS state
        try:
            import qdarkstyle
        except ModuleNotFoundError:
            logger.info(
                f'To use {theme} mode when in {system_theme} mode, "qdarkstyle" has'
                "to be installed! You can install it with:\n"
                "pip install qdarkstyle\n"
            )
        else:
            if api in ("PySide6", "PyQt6") and _compare_version(
                qdarkstyle.__version__, "<", "3.2.3"
            ):
                warn(
                    f"Setting theme={repr(theme)} is not supported for {api} in "
                    f"qdarkstyle {qdarkstyle.__version__}, it will be ignored. "
                    "Consider upgrading qdarkstyle to >=3.2.3."
                )
            else:
                stylesheet = qdarkstyle.load_stylesheet(
                    getattr(
                        getattr(qdarkstyle, theme).palette,
                        f"{theme.capitalize()}Palette",
                    )
                )
        return stylesheet
    else:
        try:
            file = open(theme)
        except OSError:
            warn(
                f"Requested theme file not found, will use light instead: {repr(theme)}"
            )
        else:
            with file as fid:
                stylesheet = fid.read()
        return stylesheet


def _should_raise_window():
    from matplotlib import rcParams

    return rcParams["figure.raise_window"]


def _qt_raise_window(widget):
    # Set raise_window like matplotlib if possible
    if _should_raise_window():
        widget.activateWindow()
        widget.raise_()


def _qt_is_dark(widget):
    # Ideally this would use CIELab, but this should be good enough
    win = widget.window()
    bgcolor = win.palette().color(win.backgroundRole()).getRgbF()[:3]
    return rgb_to_hls(*bgcolor)[1] < 0.5


def _pixmap_to_ndarray(pixmap):
    from qtpy.QtGui import QImage

    img = pixmap.toImage()
    img = img.convertToFormat(QImage.Format.Format_RGBA8888)
    ptr = img.bits()
    count = img.height() * img.width() * 4
    if hasattr(ptr, "setsize"):  # PyQt
        ptr.setsize(count)
    data = np.frombuffer(ptr, dtype=np.uint8, count=count).copy()
    data = _reshape_view(data, (img.height(), img.width(), 4))
    return data / 255.0


def _notebook_vtk_works():
    if sys.platform != "linux":
        return True
    # check if it's OSMesa -- if it is, continue
    try:
        from vtkmodules import vtkRenderingOpenGL2

        vtkRenderingOpenGL2.vtkOSOpenGLRenderWindow
    except Exception:
        pass
    else:
        return True  # has vtkOSOpenGLRenderWindow (OSMesa build)

    # if it's not OSMesa, we need to check display validity
    if _display_is_valid():
        return True
    return False


def _qt_safe_window(
    *, splash="figure.splash", window="figure.plotter.app_window", always_close=True
):
    def dec(meth, splash=splash, always_close=always_close):
        @functools.wraps(meth)
        def func(self, *args, **kwargs):
            close_splash = always_close
            error = False
            try:
                meth(self, *args, **kwargs)
            except Exception:
                close_splash = error = True
                raise
            finally:
                for attr, do_close in ((splash, close_splash), (window, error)):
                    if attr is None or not do_close:
                        continue
                    parent = self
                    name = attr.split(".")[-1]
                    try:
                        for n in attr.split(".")[:-1]:
                            parent = getattr(parent, n)
                        if name:
                            widget = getattr(parent, name, False)
                        else:  # empty string means "self"
                            widget = parent
                        if widget:
                            widget.close()
                        del widget
                    except Exception:
                        pass
                    finally:
                        try:
                            delattr(parent, name)
                        except Exception:
                            pass

        return func

    return dec
