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

import itertools
import os
from copy import deepcopy
from pathlib import Path

import matplotlib.colors as mcolors
import matplotlib.pyplot as plt
import numpy as np
import pytest
from matplotlib import backend_bases, rc_context
from numpy.testing import assert_allclose, assert_array_equal

import mne
from mne import Annotations, create_info, pick_types
from mne._fiff.pick import _DATA_CH_TYPES_ORDER_DEFAULT, _PICK_TYPES_DATA_DICT
from mne.annotations import _sync_onset
from mne.datasets import testing
from mne.io import RawArray
from mne.utils import (
    _assert_no_instances,
    _dt_to_stamp,
    _record_warnings,
    catch_logging,
    check_version,
    get_config,
    set_config,
)
from mne.viz import plot_raw, plot_sensors
from mne.viz.utils import _fake_click, _fake_keypress

base_dir = Path(__file__).parents[2] / "io" / "tests" / "data"
raw_fname = base_dir / "test_raw.fif"


def _get_button_xy(buttons, idx):
    from mne.viz._mpl_figure import _OLD_BUTTONS

    if _OLD_BUTTONS:
        return buttons.circles[idx].center
    else:
        # Each transform is to display coords, and our offsets are in Axes
        # coords. We want data coords, so we go Axes -> display -> data.
        return buttons.ax.transData.inverted().transform(
            buttons.ax.transAxes.transform(buttons.ax.collections[0].get_offsets()[idx])
        )


def _annotation_helper(raw, browse_backend, events=False):
    """Test interactive annotations."""
    from cycler import cycler  # dep of matplotlib, should be okay but nest just in case

    ismpl = browse_backend.name == "matplotlib"
    # Some of our checks here require modern mpl to work properly
    n_anns = len(raw.annotations)
    browse_backend._close_all()

    if events:
        events = np.array([[raw.first_samp + 100, 0, 1], [raw.first_samp + 300, 0, 3]])
        n_events = len(events)
    else:
        events = None
        n_events = 0
    # matplotlib 2.0 default cycler
    default_cycler = cycler(
        "color",
        [
            "#1f77b4",
            "#ff7f0e",
            "#2ca02c",
            "#d62728",
            "#9467bd",
            "#8c564b",
            "#e377c2",
            "#7f7f7f",
            "#bcbd22",
            "#17becf",
        ],
    )  # noqa: E501
    with catch_logging("debug") as log, rc_context({"axes.prop_cycle": default_cycler}):
        fig = raw.plot(events=events)
    log = log.getvalue()
    assert "Removing from color cycle: #d62728" in log
    assert log.count("Removing from color cycle") == 1
    if ismpl:
        assert browse_backend._get_n_figs() == 1

    fig._fake_keypress("a")  # annotation mode
    ann_fig = fig.mne.fig_annotation
    if ismpl:
        assert browse_backend._get_n_figs() == 2
        # +3 from the scale bars
        n_scale = 3
        assert len(fig.mne.ax_main.texts) == n_anns + n_events + n_scale
    else:
        assert ann_fig.isVisible()

    # modify description to create label "BAD test"
    # semicolon is ignored
    if ismpl:
        for key in ["backspace"] + list(" test;") + ["enter"]:
            fig._fake_keypress(key, fig=ann_fig)
        # change annotation label
        for ix in (-1, 0):
            xy = _get_button_xy(ann_fig.mne.radio_ax.buttons, ix)
            fig._fake_click(xy, fig=ann_fig, ax=ann_fig.mne.radio_ax, xform="data")
    else:
        # The modal dialogs of the Qt-backend would block the test,
        # thus a new description will be added programmatically.
        ann_fig._add_description("BAD test")

    # draw annotation
    fig._fake_click(
        (1.0, 1.0), add_points=[(5.0, 1.0)], xform="data", button=1, kind="drag"
    )
    if ismpl:
        assert len(fig.mne.ax_main.texts) == n_anns + 1 + n_events + n_scale
        # test hover event
        fig._fake_keypress("p")  # first turn on draggable mode
        assert fig.mne.draggable_annotations
        hover_kwargs = dict(xform="data", button=None, kind="motion")
        fig._fake_click((4.6, 1.0), **hover_kwargs)  # well inside ann.
        fig._fake_click((4.9, 1.0), **hover_kwargs)  # almost at edge
        assert fig.mne.annotation_hover_line is not None
        fig._fake_click((5.5, 1.0), **hover_kwargs)  # well outside ann.
        assert fig.mne.annotation_hover_line is None
        # more tests of hover line
        fig._fake_click((4.6, 1.0), **hover_kwargs)  # well inside ann.
        fig._fake_click((4.9, 1.0), **hover_kwargs)  # almost at edge
        assert fig.mne.annotation_hover_line is not None
        fig._fake_keypress("p")  # turn off draggable mode, then move a bit
        fig._fake_click((4.95, 1.0), **hover_kwargs)
        assert fig.mne.annotation_hover_line is None
        fig._fake_keypress("p")  # turn draggable mode back on
    assert len(raw.annotations.onset) == n_anns + 1
    assert len(raw.annotations.duration) == n_anns + 1
    assert len(raw.annotations.description) == n_anns + 1
    assert raw.annotations.description[n_anns] == "BAD test"
    onset = raw.annotations.onset[n_anns]
    want_onset = _sync_onset(raw, 1.0, inverse=True)
    # pyqtgraph: during the transformation from pixel-coordinates
    # to scene-coordinates when the click is simulated on QGraphicsView
    # with QTest, there seems to happen a rounding of pixels to integers
    # internally. This deviatian also seems to change between runs
    # (maybe device-dependent?).
    atol = 1e-10 if ismpl else 2e-2
    assert_allclose(onset, want_onset, atol=atol)
    assert_allclose(raw.annotations.duration[n_anns], 4.0, atol=atol)
    # modify annotation from end (duration 4 → 1.5)
    fig._fake_click((4.9, 1.0), xform="data", button=1, kind="motion")  # ease up to it
    fig._fake_click(
        (5.0, 1.0), add_points=[(2.5, 1.0)], xform="data", button=1, kind="drag"
    )
    assert raw.annotations.onset[n_anns] == onset
    # 4 → 1.5
    assert_allclose(raw.annotations.duration[n_anns], 1.5, atol=atol)
    # modify annotation from beginning (duration 1.5 → 2.0)
    fig._fake_click(
        (1.0, 1.0), add_points=[(0.5, 1.0)], xform="data", button=1, kind="drag"
    )
    assert_allclose(raw.annotations.onset[n_anns], onset - 0.5, atol=atol)
    # 1.5 → 2.0
    assert_allclose(raw.annotations.duration[n_anns], 2.0, atol=atol)
    assert len(raw.annotations.onset) == n_anns + 1
    assert len(raw.annotations.duration) == n_anns + 1
    assert len(raw.annotations.description) == n_anns + 1
    assert raw.annotations.description[n_anns] == "BAD test"
    if ismpl:
        assert len(fig.axes[0].texts) == n_anns + 1 + n_events + n_scale
        fig._fake_keypress("shift+right")
        assert len(fig.axes[0].texts) == n_scale
        fig._fake_keypress("shift+left")
        assert len(fig.axes[0].texts) == n_anns + 1 + n_events + n_scale

    # draw another annotation merging the two
    fig._fake_click(
        (5.5, 1.0), add_points=[(2.0, 1.0)], xform="data", button=1, kind="drag"
    )
    # delete the annotation
    assert len(raw.annotations.onset) == n_anns + 1
    assert len(raw.annotations.duration) == n_anns + 1
    assert len(raw.annotations.description) == n_anns + 1
    assert_allclose(raw.annotations.onset[n_anns], onset - 0.5, atol=atol)
    assert_allclose(raw.annotations.duration[n_anns], 5.0, atol=atol)
    if ismpl:
        assert len(fig.axes[0].texts) == n_anns + 1 + n_events + n_scale
    # Delete
    fig._fake_click((1.5, 1.0), xform="data", button=3, kind="press")
    # exit, re-enter, then exit a different way
    fig._fake_keypress("a")  # exit
    fig._fake_keypress("a")  # enter
    assert len(raw.annotations.onset) == n_anns
    if ismpl:
        fig._fake_keypress("escape", fig=fig.mne.fig_annotation)  # exit again
        assert len(fig.axes[0].texts) == n_anns + n_events + n_scale
        fig._fake_keypress("shift+right")
        assert len(fig.axes[0].texts) == n_scale
        fig._fake_keypress("shift+left")
        assert len(fig.axes[0].texts) == n_anns + n_events + n_scale


def _proj_status(ssp_fig, browse_backend):
    if browse_backend == "matplotlib" or browse_backend.name == "matplotlib":
        return ssp_fig.mne.proj_checkboxes.get_status()
    else:
        return [chkbx.isChecked() for chkbx in ssp_fig.checkboxes]


def _proj_label(ssp_fig, browse_backend):
    if browse_backend.name == "matplotlib":
        return [lb.get_text() for lb in ssp_fig.mne.proj_checkboxes.labels]
    else:
        return [chkbx.text() for chkbx in ssp_fig.checkboxes]


def _proj_click(idx, fig, browse_backend):
    ssp_fig = fig.mne.fig_proj
    if browse_backend.name == "matplotlib":
        text_lab = ssp_fig.mne.proj_checkboxes.labels[idx]
        pos = np.mean(
            text_lab.get_tightbbox(renderer=fig.canvas.get_renderer()), axis=0
        )
        fig._fake_click(
            pos, fig=ssp_fig, ax=ssp_fig.mne.proj_checkboxes.ax, xform="pix"
        )
    else:
        # _fake_click on QCheckBox is inconsistent across platforms
        # (also see comment in test_plot_raw_selection).
        ssp_fig._proj_changed(not fig.mne.projs_on[idx], idx)
        # Update Checkbox
        ssp_fig.checkboxes[idx].setChecked(bool(fig.mne.projs_on[idx]))


def _proj_click_all(fig, browse_backend):
    ssp_fig = fig.mne.fig_proj
    if browse_backend.name == "matplotlib":
        fig._fake_click((0.5, 0.5), fig=ssp_fig, ax=ssp_fig.mne.proj_all.ax)
        fig._fake_click(
            (0.5, 0.5), fig=ssp_fig, ax=ssp_fig.mne.proj_all.ax, kind="release"
        )
    else:
        # _fake_click on QPushButton is inconsistent across platforms.
        ssp_fig.toggle_all()


def _spawn_child_fig(fig, attr, browse_backend, key):
    # starting state
    n_figs = browse_backend._get_n_figs()
    n_children = len(fig.mne.child_figs)
    # spawn the child fig
    fig._fake_keypress(key)
    # make sure the figure was actually spawned
    assert len(fig.mne.child_figs) == n_children + 1
    assert browse_backend._get_n_figs() == n_figs + 1
    # make sure the parent fig knows the child fig's name
    child_fig = getattr(fig.mne, attr)
    assert child_fig is not None
    return child_fig


def _destroy_child_fig(fig, child_fig, attr, browse_backend, key, key_target):
    # starting state
    n_figs = browse_backend._get_n_figs()
    n_children = len(fig.mne.child_figs)
    # destroy child fig (_close_event is MPL agg backend workaround)
    fig._fake_keypress(key, fig=key_target)
    fig._close_event(child_fig)
    # make sure the figure was actually destroyed
    assert len(fig.mne.child_figs) == n_children - 1
    assert browse_backend._get_n_figs() == n_figs - 1
    assert getattr(fig.mne, attr) is None


def _child_fig_helper(fig, key, attr, browse_backend):
    # Spawn and close child figs of raw.plot()
    assert getattr(fig.mne, attr) is None
    # spawn, then close via main window toggle
    child_fig = _spawn_child_fig(fig, attr, browse_backend, key)
    _destroy_child_fig(fig, child_fig, attr, browse_backend, key, key_target=fig)
    # spawn again, then close via child window's close key
    child_fig = _spawn_child_fig(fig, attr, browse_backend, key)
    _destroy_child_fig(
        fig,
        child_fig,
        attr,
        browse_backend,
        key=child_fig.mne.close_key,
        key_target=child_fig,
    )


def test_scale_bar(browser_backend):
    """Test scale bar for raw."""
    ismpl = browser_backend.name == "matplotlib"
    sfreq = 1000.0
    t = np.arange(10000) / sfreq
    data = np.sin(2 * np.pi * 10.0 * t)
    # ± 1000 fT, 400 fT/cm, 20 µV
    data = data * np.array([[1000e-15, 400e-13, 20e-6]]).T
    info = create_info(3, sfreq, ("mag", "grad", "eeg"))
    raw = RawArray(data, info)
    fig = raw.plot()
    texts = fig._get_scale_bar_texts()
    assert len(texts) == 3  # ch_type scale-bars
    wants = ("800.0 fT/cm", "2000.0 fT", "40.0 µV")
    assert texts == wants
    if ismpl:
        # 1 green vline, 3 data, 3 scalebars
        assert len(fig.mne.ax_main.lines) == 7
    else:
        assert len(fig.mne.scalebars) == 3
    for data, bar in zip(fig.mne.traces, fig.mne.scalebars.values()):
        y = data.get_ydata()
        y_lims = [y.min(), y.max()]
        bar_lims = bar.get_ydata()
        assert_allclose(y_lims, bar_lims, atol=1e-4)

    # Per-channel color overrides via channel names (matplotlib only).
    if ismpl:
        sfreq = 100.0
        ch_names = ["SFG, Left", "SFG, Right", "MFG, Left"]
        info = create_info(ch_names=ch_names, sfreq=sfreq, ch_types="eeg")
        data = np.zeros((len(ch_names), int(sfreq)))  # 1 second of zeros
        raw2 = RawArray(data, info)

        color = {"eeg": "k", "SFG, Left": "red"}
        browser_backend._close_all()
        fig2 = plot_raw(raw2, color=color, show=False)

        # ch_colors stores the "good" (non-bad) colors, in visible channel order
        assert fig2.mne.ch_colors[0] == "red"
        assert fig2.mne.ch_colors[1] == "k"
        assert fig2.mne.ch_colors[2] == "k"

        # check colours on the plot are also correct
        for trace, ch_color in zip(fig2.mne.traces, fig2.mne.ch_colors):
            assert np.allclose(
                mcolors.to_rgba(trace.get_color()), mcolors.to_rgba(ch_color)
            ), f"Expected {ch_color}, got {trace.get_color()}"

        browser_backend._close_all()


def test_plot_raw_selection(raw, browser_backend):
    """Test selection mode of plot_raw()."""
    ismpl = browser_backend.name == "matplotlib"
    with raw.info._unlock():
        raw.info["lowpass"] = 10.0  # allow heavy decim during plotting
    browser_backend._close_all()  # ensure all are closed
    assert browser_backend._get_n_figs() == 0
    fig = raw.plot(group_by="selection", proj=False)
    assert browser_backend._get_n_figs() == 2
    sel_fig = fig.mne.fig_selection
    assert sel_fig is not None
    # test changing selection with arrow keys
    left_temp = "Left-temporal"
    sel_dict = fig.mne.ch_selections
    assert len(fig.mne.traces) == len(sel_dict[left_temp])  # 6
    fig._fake_keypress("down", fig=sel_fig)
    assert len(fig.mne.traces) == len(sel_dict["Left-frontal"])  # 3
    fig._fake_keypress("down", fig=sel_fig)
    assert len(fig.mne.traces) == len(sel_dict["Misc"])  # 1
    fig._fake_keypress("down", fig=sel_fig)  # ignored; no custom sel defined
    assert len(fig.mne.traces) == len(sel_dict["Misc"])  # 1
    # switch to butterfly mode
    fig._fake_keypress("b", fig=sel_fig)

    # ToDo: For Qt-backend the framework around RawTraceItem makes
    #  it difficult to show the same channel multiple times which is why
    #  it is currently not implemented.
    #  This would be relevant if you wanted to plot several selections in
    #  butterfly-mode which have some channels in common.
    sel_picks = len(np.concatenate(list(sel_dict.values())))
    if ismpl:
        assert len(fig.mne.traces) == sel_picks
    else:
        assert len(fig.mne.traces) == sel_picks - 1
    assert fig.mne.butterfly
    # test clicking on radio buttons → should cancel butterfly mode
    if ismpl:
        print(f"Clicking button: {repr(left_temp)}")
        assert sel_fig.mne.radio_ax.buttons.labels[0].get_text() == left_temp
        xy = _get_button_xy(sel_fig.mne.radio_ax.buttons, 0)
        lim = sel_fig.mne.radio_ax.get_xlim()
        assert lim[0] < xy[0] < lim[1]
        lim = sel_fig.mne.radio_ax.get_ylim()
        assert lim[0] < xy[1] < lim[1]
        fig._fake_click(xy, fig=sel_fig, ax=sel_fig.mne.radio_ax, xform="data")
    else:
        # For an unknown reason test-clicking on checkboxes is inconsistent
        # across platforms.
        # (QTest.mouseClick works isolated on all platforms but somehow
        # not in this context. _fake_click isn't working on linux)
        sel_fig._chkbx_changed(list(sel_fig.chkbxs.keys())[0])
    assert not fig.mne.butterfly
    assert len(fig.mne.traces) == len(sel_dict[left_temp])  # 6
    # test clicking on "custom" when not defined: should be no-op
    if ismpl:
        before_state = sel_fig.mne.radio_ax.buttons.value_selected
        xy = _get_button_xy(sel_fig.mne.radio_ax.buttons, -1)
        fig._fake_click(xy, fig=sel_fig, ax=sel_fig.mne.radio_ax, xform="data")
        lasso = sel_fig.lasso
        sensor_ax = sel_fig.mne.sensor_ax
        assert sel_fig.mne.radio_ax.buttons.value_selected == before_state
    else:
        before_state = sel_fig.mne.old_selection
        chkbx = sel_fig.chkbxs[list(sel_fig.chkbxs.keys())[-1]]
        fig._fake_click((0.5, 0.5), fig=chkbx)
        lasso = sel_fig.channel_fig.lasso
        sensor_ax = sel_fig.channel_widget
        assert before_state == sel_fig.mne.old_selection  # unchanged
    assert len(fig.mne.traces) == len(sel_dict[left_temp])  # unchanged
    # test marking bad channel in selection mode → should make sensor red
    assert lasso.ec[:, 0].sum() == 0  # R of RGBA zero for all chans
    fig._click_ch_name(ch_index=1, button=1)  # mark bad
    assert lasso.ec[:, 0].sum() == 1  # one channel red
    fig._click_ch_name(ch_index=1, button=1)  # mark good
    assert lasso.ec[:, 0].sum() == 0  # all channels black
    # test lasso
    # Testing lasso-interactivity of sensor-plot within Qt-backend
    # with QTest doesn't seem to work.
    want = ["MEG 0111", "MEG 0112", "MEG 0113", "MEG 0131", "MEG 0132", "MEG 0133"]
    assert want == sorted(fig.mne.ch_names[fig.mne.picks])
    want = ["MEG 0121", "MEG 0122", "MEG 0123"]
    if ismpl:
        sel_fig._set_custom_selection()  # lasso empty → should do nothing
        # Lasso with 1 mag/grad sensor unit (upper left)
        fig._fake_click(
            (0, 1),
            add_points=[(0.65, 1), (0.65, 0.7), (0, 0.7)],
            fig=sel_fig,
            ax=sensor_ax,
            xform="ax",
            kind="drag",
        )
    else:
        lasso.selection = want
        sel_fig._set_custom_selection()
    assert sorted(want) == sorted(fig.mne.ch_names[fig.mne.picks])
    # test joint closing of selection & data windows
    fig._fake_keypress(sel_fig.mne.close_key, fig=sel_fig)
    fig._close_event(sel_fig)
    assert browser_backend._get_n_figs() == 0


def test_plot_raw_ssp_interaction(raw, browser_backend):
    """Test SSP projector UI of plot_raw()."""
    with raw.info._unlock():
        raw.info["lowpass"] = 10.0  # allow heavy decim during plotting
    # apply some (not all) projs to test our proj UI (greyed out applied projs)
    projs = raw.info["projs"][-2:]
    raw.del_proj([-2, -1])
    raw.apply_proj()
    raw.add_proj(projs)
    fig = raw.plot()
    # open SSP window
    fig._fake_keypress("j")
    assert browser_backend._get_n_figs() == 2
    ssp_fig = fig.mne.fig_proj
    assert _proj_status(ssp_fig, browser_backend) == [True, True, True]
    # this should have no effect (proj 0 is already applied)
    assert _proj_label(ssp_fig, browser_backend)[0].endswith("(already applied)")
    _proj_click(0, fig, browser_backend)
    assert _proj_status(ssp_fig, browser_backend) == [True, True, True]
    # this should work (proj 1 not applied)
    _proj_click(1, fig, browser_backend)
    assert _proj_status(ssp_fig, browser_backend) == [True, False, True]
    # turn it back on
    _proj_click(1, fig, browser_backend)
    assert _proj_status(ssp_fig, browser_backend) == [True, True, True]
    # toggle all off (button axes need both press and release)
    _proj_click_all(fig, browser_backend)
    assert _proj_status(ssp_fig, browser_backend) == [True, False, False]
    fig._fake_keypress("J")
    assert _proj_status(ssp_fig, browser_backend) == [True, True, True]
    fig._fake_keypress("J")
    assert _proj_status(ssp_fig, browser_backend) == [True, False, False]
    # turn all on
    _proj_click_all(fig, browser_backend)
    assert fig.mne.projector is not None  # on
    assert _proj_status(ssp_fig, browser_backend) == [True, True, True]


def test_plot_raw_child_figures(raw, browser_backend):
    """Test spawning and closing of child figures."""
    ismpl = browser_backend.name == "matplotlib"
    with raw.info._unlock():
        raw.info["lowpass"] = 10.0  # allow heavy decim during plotting
    # make sure we start clean
    assert browser_backend._get_n_figs() == 0
    fig = raw.plot()
    assert browser_backend._get_n_figs() == 1
    # test child fig toggles
    _child_fig_helper(fig, "?", "fig_help", browser_backend)
    _child_fig_helper(fig, "j", "fig_proj", browser_backend)
    if ismpl:  # in mne-qt-browser, annotation is a dock-widget, not a window
        _child_fig_helper(fig, "a", "fig_annotation", browser_backend)
    # test right-click → channel location popup
    fig._redraw()
    fig._click_ch_name(ch_index=2, button=3)
    assert len(fig.mne.child_figs) == 1
    assert browser_backend._get_n_figs() == 2
    fig._fake_keypress("escape", fig=fig.mne.child_figs[0])
    if ismpl:
        fig._close_event(fig.mne.child_figs[0])
    assert len(fig.mne.child_figs) == 0
    assert browser_backend._get_n_figs() == 1
    # test right-click on non-data channel
    ix = raw.get_channel_types().index("ias")  # find the shielding channel
    trace_ix = fig.mne.ch_order.tolist().index(ix)  # get its plotting position
    fig._redraw()
    fig._click_ch_name(ch_index=trace_ix, button=3)  # should be no-op
    assert len(fig.mne.child_figs) == 0
    assert browser_backend._get_n_figs() == 1
    # test resize of main window
    fig._resize_by_factor(0.5)


def test_orphaned_annot_fig(raw, browser_backend):
    """Test that annotation window is not orphaned (GH #10454)."""
    if browser_backend.name != "matplotlib":
        return
    assert browser_backend._get_n_figs() == 0
    fig = raw.plot()
    _spawn_child_fig(fig, "fig_annotation", browser_backend, "a")
    fig._fake_keypress(key=fig.mne.close_key)
    fig._close_event()
    assert len(fig.mne.child_figs) == 0
    assert browser_backend._get_n_figs() == 0


def _monkeypatch_fig(fig, browser_backend):
    if browser_backend.name == "matplotlib":
        fig.canvas.manager.full_screen_toggle = lambda: None
    else:
        # Monkeypatch the Qt methods
        def _full():
            fig.isFullScreen = lambda: True

        def _norm():
            fig.isFullScreen = lambda: False

        fig.showFullScreen = _full
        fig.showNormal = _norm


def test_plot_raw_keypresses(raw, browser_backend):
    """Test keypress interactivity of plot_raw()."""
    with raw.info._unlock():
        raw.info["lowpass"] = 10.0  # allow heavy decim during plotting
    fig = raw.plot()
    # test twice → once in normal, once in butterfly view.
    # NB: keys a, j, and ? are tested in test_plot_raw_child_figures()
    keys = (
        "pagedown",
        "down",
        "up",
        "down",
        "right",
        "left",
        "-",
        "+",
        "=",
        "d",
        "d",
        "pageup",
        "home",
        "end",
        "z",
        "z",
        "s",
        "s",
        "f11",
        "t",
        "b",
    )
    # Avoid annoying fullscreen issues by monkey-patching our handlers
    _monkeypatch_fig(fig, browser_backend)
    # test for group_by='original'
    for key in 2 * keys + ("escape",):
        fig._fake_keypress(key)
    # test for group_by='selection'
    fig = plot_raw(raw, group_by="selection")
    _monkeypatch_fig(fig, browser_backend)
    for key in 2 * keys + ("escape",):
        fig._fake_keypress(key)


def test_plot_raw_traces(raw, events, browser_backend):
    """Test plotting of raw data."""
    ismpl = browser_backend.name == "matplotlib"
    with raw.info._unlock():
        raw.info["lowpass"] = 10.0  # allow heavy decim during plotting
    assert raw.info["bads"] == []
    fig = raw.plot(
        events=events, order=[1, 7, 5, 2, 3], n_channels=3, group_by="original"
    )
    assert hasattr(fig, "mne")  # make sure fig.mne param object is present
    if ismpl:
        assert len(fig.axes) == 5

    # setup
    x = fig.mne.traces[0].get_xdata()[5]
    y = fig.mne.traces[0].get_ydata()[5]
    hscroll = fig.mne.ax_hscroll
    vscroll = fig.mne.ax_vscroll
    # test marking bad channels
    label = fig._get_ticklabels("y")[0]
    assert label not in fig.mne.info["bads"]
    # click data to mark bad
    fig._fake_click((x, y), xform="data")
    assert label in fig.mne.info["bads"]
    # click data to unmark bad
    fig._fake_click((x, y), xform="data")
    assert label not in fig.mne.info["bads"]
    # click name to mark bad
    fig._click_ch_name(ch_index=0, button=1)
    assert label in fig.mne.info["bads"]
    # test other kinds of clicks
    fig._fake_click((0.5, 0.98))  # click elsewhere (add vline)
    assert fig.mne.vline_visible is True
    fig._fake_click((0.5, 0.98), button=3)  # remove vline
    assert fig.mne.vline_visible is False
    fig._fake_click((0.5, 0.5), ax=hscroll)  # change time
    t_start = fig.mne.t_start
    fig._fake_click((0.5, 0.5), ax=hscroll)  # shouldn't change time this time
    assert round(t_start, 6) == round(fig.mne.t_start, 6)
    # test scrolling through channels
    labels = fig._get_ticklabels("y")
    assert labels == [raw.ch_names[1], raw.ch_names[7], raw.ch_names[5]]
    fig._fake_click((0.5, 0.05), ax=vscroll)  # change channels to end
    labels = fig._get_ticklabels("y")
    assert labels == [raw.ch_names[5], raw.ch_names[2], raw.ch_names[3]]
    for _ in (0, 0):
        # first click changes channels to mid; second time shouldn't change
        # This needs to be changed for Qt, because there scrollbars are
        # drawn differently (value of slider at lower end, not at middle)
        yclick = 0.5 if ismpl else 0.7
        fig._fake_click((0.5, yclick), ax=vscroll)
        labels = fig._get_ticklabels("y")
        assert labels == [raw.ch_names[7], raw.ch_names[5], raw.ch_names[2]]

    # test clicking a channel name in butterfly mode
    bads = fig.mne.info["bads"].copy()
    fig._fake_keypress("b")
    fig._click_ch_name(ch_index=0, button=1)  # should be no-op
    assert fig.mne.info["bads"] == bads  # unchanged
    fig._fake_keypress("b")

    # test starting up in zen mode
    fig = plot_raw(raw, show_scrollbars=False)
    # test order, title, & show_options kwargs
    with pytest.raises(ValueError, match="order should be array-like; got"):
        raw.plot(order="foo")
    with pytest.raises(TypeError, match="title must be None or a string, got"):
        raw.plot(title=1)
    raw.plot(show_options=True)
    browser_backend._close_all()

    # annotations outside data range
    annot = Annotations(
        [10, 10 + raw.first_samp / raw.info["sfreq"]],
        [10, 10],
        ["test", "test"],
        raw.info["meas_date"],
    )
    with pytest.warns(RuntimeWarning, match="outside data range"):
        raw.set_annotations(annot)

    # Color setting
    with pytest.raises(KeyError, match="must be strictly positive, or -1"):
        raw.plot(event_color={0: "r"})
    with pytest.raises(TypeError, match="event_color key must be an int, got"):
        raw.plot(event_color={"foo": "r"})
    plot_raw(raw, events=events, event_color={-1: "r", 998: "b"})

    # gh-12547
    raw.info["bads"] = raw.ch_names[1:2]
    picks = [1, 7, 5, 2, 3]
    fig = raw.plot(events=events, order=picks, group_by="original")
    assert_array_equal(fig.mne.picks, picks)


def test_plot_raw_picks(raw, browser_backend):
    """Test functionality of picks and order arguments."""
    with raw.info._unlock():
        raw.info["lowpass"] = 10.0  # allow heavy decim during plotting

    fig = raw.plot(picks=["MEG 0112"])
    assert len(fig.mne.traces) == 1

    fig = raw.plot(picks=["meg"])
    assert len(fig.mne.traces) == len(raw.get_channel_types(picks="meg"))

    fig = raw.plot(order=[4, 3])
    assert_array_equal(fig.mne.ch_order, np.array([4, 3]))

    fig = raw.plot(picks=[4, 3])
    assert_array_equal(fig.mne.ch_order, np.array([3, 4]))


@pytest.mark.parametrize("group_by", ("position", "selection"))
def test_plot_raw_groupby(raw, browser_backend, group_by):
    """Test group-by plotting of raw data."""
    with raw.info._unlock():
        raw.info["lowpass"] = 10.0  # allow heavy decim during plotting
    order = (
        np.arange(len(raw.ch_names))[::-3] if group_by == "position" else [1, 2, 4, 6]
    )
    fig = raw.plot(group_by=group_by, order=order)
    x = fig.mne.traces[0].get_xdata()[10]
    y = fig.mne.traces[0].get_ydata()[10]
    fig._fake_keypress("down")  # change selection
    fig._fake_click((x, y), xform="data")  # mark bad
    fig._fake_click((0.5, 0.5), ax=fig.mne.ax_vscroll)  # change channels
    if browser_backend.name == "matplotlib":
        # Test lasso-selection
        # (test difficult with Qt-backend, set plot_raw_selection)
        sel_fig = fig.mne.fig_selection
        topo_ax = sel_fig.mne.sensor_ax
        fig._fake_click([-0.425, 0.20223853], fig=sel_fig, ax=topo_ax, xform="data")
        fig._fake_click(
            (-0.5, 0.0),
            add_points=[(0.5, 0.0), (0.5, 0.5), (-0.5, 0.5)],
            fig=sel_fig,
            ax=topo_ax,
            xform="data",
            kind="drag",
        )
        fig._fake_keypress("down")
        fig._fake_keypress("up")
    fig._fake_keypress("up")
    fig._fake_scroll(0.5, 0.5, -1)  # scroll down
    fig._fake_scroll(0.5, 0.5, 1)  # scroll up


def test_plot_raw_meas_date(raw, browser_backend):
    """Test effect of mismatched meas_date in raw.plot()."""
    raw.set_meas_date(_dt_to_stamp(raw.info["meas_date"])[0])
    annot = Annotations([1 + raw.first_samp / raw.info["sfreq"]], [5], ["bad"])
    with pytest.warns(RuntimeWarning, match="outside data range"):
        raw.set_annotations(annot)
    with _record_warnings():  # sometimes projection
        raw.plot(group_by="position", order=np.arange(8))
    fig = raw.plot()
    for key in ["down", "up", "escape"]:
        fig._fake_keypress(key, fig=fig.mne.fig_selection)


def test_plot_raw_nan(raw, browser_backend):
    """Test plotting all NaNs."""
    raw._data[:] = np.nan
    # this should (at least) not die, the output should pretty clearly show
    # that there is a problem so probably okay to just plot something blank
    with _record_warnings():
        raw.plot(scalings="auto")


@testing.requires_testing_data
def test_plot_raw_white(raw_orig, noise_cov_io, browser_backend):
    """Test plotting whitened raw data."""
    raw_orig.crop(0, 1)
    fig = raw_orig.plot(noise_cov=noise_cov_io)
    # toggle whitening
    fig._fake_keypress("w")
    fig._fake_keypress("w")


@testing.requires_testing_data
def test_plot_ref_meg(raw_ctf, browser_backend):
    """Test plotting ref_meg."""
    raw_ctf.crop(0, 1)
    raw_ctf.plot()
    pytest.raises(ValueError, raw_ctf.plot, group_by="selection")


def test_plot_misc_auto(browser_backend):
    """Test plotting of data with misc auto scaling."""
    data = np.random.RandomState(0).randn(1, 1000)
    raw = RawArray(data, create_info(1, 1000.0, "misc"))
    raw.plot()
    raw = RawArray(data, create_info(1, 1000.0, "dipole"))
    raw.plot(order=[0])  # plot, even though it's not "data"
    browser_backend._close_all()


@pytest.mark.slowtest
def test_plot_annotations(raw, browser_backend):
    """Test annotation mode of the plotter."""
    ismpl = browser_backend.name == "matplotlib"
    with raw.info._unlock():
        raw.info["lowpass"] = 10.0
    _annotation_helper(raw, browser_backend)
    _annotation_helper(raw, browser_backend, events=True)

    annot = Annotations([42], [1], "test", raw.info["meas_date"])
    with pytest.warns(RuntimeWarning, match="expanding outside"):
        raw.set_annotations(annot)
    _annotation_helper(raw, browser_backend)
    # test annotation visibility toggle
    fig = raw.plot()
    if ismpl:
        assert len(fig.mne.annotations) == 1
        assert len(fig.mne.annotation_texts) == 1
    else:
        assert len(fig.mne.regions) == 1
    fig._fake_keypress("a")  # start annotation mode
    if ismpl:
        checkboxes = fig.mne.show_hide_annotation_checkboxes
        checkboxes.set_active(0)
        assert len(fig.mne.annotations) == 0
        assert len(fig.mne.annotation_texts) == 0
        checkboxes.set_active(0)
        assert len(fig.mne.annotations) == 1
        assert len(fig.mne.annotation_texts) == 1
    else:
        fig.mne.visible_annotations["test"] = False
        fig._update_regions_visible()
        assert not fig.mne.regions[0].isVisible()
        fig.mne.visible_annotations["test"] = True
        fig._update_regions_visible()
        assert fig.mne.regions[0].isVisible()

    # Check if single annotation toggle works
    ch_pick = fig.mne.inst.ch_names[0]
    fig._toggle_single_channel_annotation(ch_pick, 0)
    assert fig.mne.inst.annotations.ch_names[0] == (ch_pick,)

    # Check if annotation filtering works - All annotations
    annot = Annotations([42, 50], [1, 1], ["test", "test2"], raw.info["meas_date"])
    with pytest.warns(RuntimeWarning, match="expanding outside"):
        raw.set_annotations(annot)

    fig = raw.plot()

    assert fig.mne.visible_annotations["test"] and fig.mne.visible_annotations["test2"]

    # Check if annotation filtering works - filtering annotations
    # This should only make test2 visible and hide test
    fig = raw.plot(annotation_regex="2$")

    assert (
        not fig.mne.visible_annotations["test"] and fig.mne.visible_annotations["test2"]
    )

    if ismpl:
        # gh-13511: hide the middle annotation to get non-contiguous is_onscreen=[T,F,T]
        annot = Annotations(
            onset=[2, 2, 2], duration=[3, 3, 3], description=["A", "B", "C"]
        )
        raw.set_annotations(annot)
        fig = raw.plot(start=0, duration=10)
        fig._fake_keypress("a")
        # hide "B" so is_onscreen = [T, F, T]
        fig.mne.show_hide_annotation_checkboxes.set_active(1)
        buttons = fig.mne.fig_annotation.mne.radio_ax.buttons
        # set active to the hidden "B" so deletion falls back to zorder
        buttons.set_active(1)
        fig._fake_click((2.5, 1.0), xform="data", button=3)
        # C has the highest zorder and should be deleted; the bug deleted A instead
        assert "C" not in raw.annotations.description
        assert "A" in raw.annotations.description


def test_annotation_colors(raw, browser_backend):
    """Test that annotation_colors overrides default colors."""
    from matplotlib.colors import to_hex

    with raw.info._unlock():
        raw.info["lowpass"] = 10.0

    raw.set_annotations(
        Annotations(
            onset=[1, 3, 5],
            duration=[1, 1, 1],
            description=["BAD_test", "BAD_other", "stimulus"],
        )
    )

    # User-provided colors override defaults (including bad* → red rule).
    # BAD_other has no override and should remain red.
    fig = raw.plot(
        annotation_colors={"BAD_test": "orange", "stimulus": "#00ff00"},
    )
    colors = fig.mne.annotation_segment_colors
    assert colors["BAD_test"] == to_hex("orange"), (
        "User color for BAD_test should override red default"
    )
    assert colors["stimulus"] == "#00ff00"
    assert colors["BAD_other"] == "#ff0000", (
        "BAD_other has no user override and should remain red"
    )

    # Unknown label key triggers a warning
    with pytest.warns(RuntimeWarning, match="do not match"):
        fig = raw.plot(annotation_colors={"nonexistent_label": "blue"})

    # Invalid color value raises ValueError
    with pytest.raises(ValueError, match="not a valid matplotlib color"):
        raw.plot(annotation_colors={"BAD_test": "not_a_color"}, show=False)


@pytest.mark.parametrize("active_annot_idx", (0, 1, 2))
def test_overlapping_annotation_deletion(raw, browser_backend, active_annot_idx):
    """Test deletion of annotations via right-click."""
    ismpl = browser_backend.name == "matplotlib"
    if not ismpl and not check_version("mne_qt_browser", "0.5.2"):
        pytest.xfail("Old mne-qt-browser")
    with raw.info._unlock():
        raw.info["lowpass"] = 10.0
    annot_labels = list("abc")
    # the test applies to the middle three annotations; those before and after are
    # there to ensure our bookkeeping works
    annot = Annotations(
        onset=[3, 3.4, 3.7, 13, 13.4, 13.7, 19, 19.4, 19.7],
        duration=[2, 1, 3] * 3,
        description=annot_labels * 3,
    )
    raw.set_annotations(annot)
    start = 10
    duration = 8
    fig = raw.plot(start=start, duration=duration)

    def _get_visible_labels(fig_dot_mne):
        if ismpl:
            # MPL backend's `fig.mne.annotation_texts` → only the visible ones
            visible_labels = [x.get_text() for x in fig_dot_mne.annotation_texts]
        else:
            # PyQtGraph backend's `fig.mne.regions` → all annots (even offscreen ones)
            # so we need to (1) get annotations from fig.mne.inst, and (2) compute
            # ourselves which ones are visible.
            _annot = fig_dot_mne.inst.annotations
            _start = start + fig_dot_mne.inst.first_time
            _end = _start + duration
            visible_indices = np.nonzero(
                np.logical_and(_annot.onset > _start, _annot.onset < _end)
            )
            visible_labels = np.array(
                [x.label_item.toPlainText() for x in fig_dot_mne.regions]
            )[visible_indices].tolist()
        return visible_labels

    assert annot_labels == _get_visible_labels(fig.mne)
    fig._fake_keypress("a")  # start annotation mode
    if ismpl:
        buttons = fig.mne.fig_annotation.mne.radio_ax.buttons
        buttons.set_active(active_annot_idx)
        current_active = buttons.value_selected
    else:
        buttons = fig.mne.fig_annotation.description_cmbx
        buttons.setCurrentIndex(active_annot_idx)
        current_active = buttons.currentText()
    assert current_active == annot_labels[active_annot_idx]
    # x value of 14 is in area that overlaps all 3 visible annotations
    fig._fake_click((14, 1.0), xform="data", button=3)
    expected = set(annot_labels) - set(annot_labels[active_annot_idx])
    assert expected == set(_get_visible_labels(fig.mne))


@pytest.mark.parametrize("hide_which", ([], [0], [1], [0, 1]))
def test_remove_annotations(raw, hide_which, browser_backend):
    """Test that right-click doesn't remove hidden annotation spans."""
    descriptions = ["foo", "bar"]
    ann = Annotations(onset=[2, 1], duration=[1, 3], description=descriptions)
    raw.set_annotations(ann)
    assert len(raw.annotations) == 2
    fig = raw.plot()
    fig._fake_keypress("a")  # start annotation mode
    if browser_backend.name == "matplotlib":
        checkboxes = fig.mne.show_hide_annotation_checkboxes
        for which in hide_which:
            checkboxes.set_active(which)
    else:
        for hide_idx in hide_which:
            hide_key = descriptions[hide_idx]
            fig.mne.visible_annotations[hide_key] = False
        fig._update_regions_visible()
    # always click twice: should not affect hidden annotation spans
    for _ in descriptions:
        fig._fake_click((2.5, 0.1), xform="data", button=3)
    assert len(raw.annotations) == len(hide_which)


def test_merge_annotations(raw, pg_backend):
    """Test merging of annotations in the Qt backend.

    Let's not bother in figuring out on which sample the _fake_click actually
    dropped the annotation, especially with the 600.614 Hz weird sampling rate.
    -> atol = 10 / raw.info["sfreq"]
    """
    if not check_version("mne_qt_browser", "0.5.3"):
        pytest.xfail("mne_qt_browser < 0.5.3 does not merge annotations properly")
    annot = Annotations(
        onset=[1, 3, 4, 5, 7, 8],
        duration=[1, 0.5, 0.8, 1, 0.5, 0.5],
        description=["bad_test", "bad_test", "bad_test", "test", "test", "test"],
    )
    raw.set_annotations(annot)
    fig = raw.plot()
    fig._fake_keypress("a")  # start annotation mode
    assert len(raw.annotations) == 6
    assert_allclose(
        raw.annotations.onset,
        np.array([1, 3, 4, 5, 7, 8]) + raw.first_samp / raw.info["sfreq"],
        atol=10 / raw.info["sfreq"],
    )
    # drag edge and merge 2 annotations in focus (selected description)
    fig._fake_click(
        (3.5, 1.0), add_points=[(4.2, 1.0)], xform="data", button=1, kind="drag"
    )
    assert len(raw.annotations) == 5
    assert_allclose(
        raw.annotations.onset,
        np.array([1, 3, 5, 7, 8]) + raw.first_samp / raw.info["sfreq"],
        atol=10 / raw.info["sfreq"],
    )
    assert_allclose(
        raw.annotations.duration,
        np.array([1, 1.8, 1, 0.5, 0.5]),
        atol=10 / raw.info["sfreq"],
    )
    # drag annotation and merge 2 annotations in focus (selected description)
    fig._fake_click(
        (1.5, 1.0), add_points=[(3, 1.0)], xform="data", button=1, kind="drag"
    )
    assert len(raw.annotations) == 4
    assert_allclose(
        raw.annotations.onset,
        np.array([2.5, 5, 7, 8]) + raw.first_samp / raw.info["sfreq"],
        atol=10 / raw.info["sfreq"],
    )
    assert_allclose(
        raw.annotations.duration,
        np.array([2.3, 1, 0.5, 0.5]),
        atol=10 / raw.info["sfreq"],
    )
    # drag edge and merge 2 annotations not in focus
    fig._fake_click(
        (7.5, 1.0), add_points=[(8.2, 1.0)], xform="data", button=1, kind="drag"
    )
    assert len(raw.annotations) == 3
    assert_allclose(
        raw.annotations.onset,
        np.array([2.5, 5, 7]) + raw.first_samp / raw.info["sfreq"],
        atol=10 / raw.info["sfreq"],
    )
    assert_allclose(
        raw.annotations.duration, np.array([2.3, 1, 1.5]), atol=10 / raw.info["sfreq"]
    )
    # drag annotation and merge 2 annotations not in focus
    fig._fake_click(
        (5.6, 1.0), add_points=[(7.2, 1.0)], xform="data", button=1, kind="drag"
    )
    assert len(raw.annotations) == 2
    assert_allclose(
        raw.annotations.onset,
        np.array([2.5, 6.6]) + raw.first_samp / raw.info["sfreq"],
        atol=10 / raw.info["sfreq"],
    )
    assert_allclose(
        raw.annotations.duration, np.array([2.3, 1.9]), atol=10 / raw.info["sfreq"]
    )


@pytest.mark.parametrize("filtorder", (0, 2))  # FIR, IIR
def test_plot_raw_filtered(filtorder, raw, browser_backend):
    """Test filtering of raw plots."""
    # Opening that many plots can cause a Segmentation fault
    # if multithreading is activated in Qt-backend
    pg_kwargs = {"precompute": False}
    with pytest.raises(ValueError, match="lowpass.*Nyquist"):
        raw.plot(lowpass=raw.info["sfreq"] / 2.0, filtorder=filtorder, **pg_kwargs)
    with pytest.raises(ValueError, match="highpass must be > 0"):
        raw.plot(highpass=0, filtorder=filtorder, **pg_kwargs)
    with pytest.raises(ValueError, match="Filter order must be"):
        raw.plot(lowpass=1, filtorder=-1, **pg_kwargs)
    with pytest.raises(ValueError, match="Invalid value for the 'clipping'"):
        raw.plot(clipping="foo", **pg_kwargs)
    raw.plot(lowpass=40, clipping="transparent", filtorder=filtorder, **pg_kwargs)
    raw.plot(highpass=1, clipping="clamp", filtorder=filtorder, **pg_kwargs)
    raw.plot(lowpass=40, butterfly=True, filtorder=filtorder, **pg_kwargs)
    # shouldn't break if all shown are non-data
    RawArray(np.zeros((1, 100)), create_info(1, 20.0, "stim")).plot(lowpass=5)


def _check_ylabel_psd(ylabel, amplitude, dB, unit):
    """Check that the ylabel is correct."""
    numerator = "dB" if dB else unit
    denominator = r"\sqrt{\mathrm{Hz}}" if amplitude else r"\mathrm{Hz}"
    reference_val = rf"\ \mathrm{{re}}\ 1\ \mathrm{{{unit}}}" if dB else ""
    pattern = rf"\mathrm{{{numerator}}}/{denominator}{reference_val}"
    assert pattern in ylabel, ylabel


def test_plot_raw_psd(raw, raw_orig):
    """Test plotting of raw psds."""
    raw_unchanged = raw.copy()
    spectrum = raw.compute_psd()
    # change handler
    old_defaults = dict(picks="data", exclude="bads")
    fig = spectrum.plot(average=False, amplitude=False)
    # normal mode
    fig = spectrum.plot(average=False, amplitude=False, **old_defaults)
    fig.canvas.callbacks.process(
        "resize_event", backend_bases.ResizeEvent("resize_event", fig.canvas)
    )
    # specific mode
    picks = pick_types(spectrum.info, meg="mag", eeg=False)[:4]
    spectrum.plot(
        picks=picks, ci="range", spatial_colors=True, exclude="bads", amplitude=False
    )
    raw.compute_psd(tmax=20.0).plot(
        color="yellow", dB=False, alpha=0.4, amplitude=True, **old_defaults
    )
    plt.close("all")
    # one axes supplied
    ax = plt.axes()
    spectrum.plot(picks=picks, axes=ax, average=True, exclude="bads", amplitude=False)
    plt.close("all")
    # two axes supplied
    _, axs = plt.subplots(2)
    spectrum.plot(axes=axs, average=True, amplitude=False, **old_defaults)
    plt.close("all")
    # need 2, got 1
    ax = plt.axes()
    with pytest.raises(ValueError, match="of length 2.*the length is 1"):
        spectrum.plot(axes=ax, average=True, amplitude=False, **old_defaults)
    plt.close("all")
    # topo psd
    ax = plt.subplot()
    spectrum.plot_topo(axes=ax)
    plt.close("all")
    # with channel information not available
    for idx in range(len(raw.info["chs"])):
        raw.info["chs"][idx]["loc"] = np.zeros(12)
    with (
        _record_warnings(),
        pytest.warns(RuntimeWarning, match="locations not available"),
    ):
        raw.compute_psd().plot(spatial_colors=True, average=False)
    # with a flat channel
    raw[5, :] = 0
    with pytest.warns(UserWarning, match="[Infinite|Zero]"):
        spectrum = raw.compute_psd()
    for dB, amplitude in itertools.product((True, False), (True, False)):
        with pytest.warns(UserWarning, match="(Infinite|Zero)"):
            fig = spectrum.plot(average=True, dB=dB, amplitude=amplitude)
        # check grad axes
        title = fig.axes[0].get_title()
        ylabel = fig.axes[0].get_ylabel()
        unit = "fT/cm" if amplitude else "(fT/cm)^2"
        assert title == "Gradiometers", title
        _check_ylabel_psd(ylabel, amplitude, dB, unit)
        # check mag axes
        title = fig.axes[1].get_title()
        ylabel = fig.axes[1].get_ylabel()
        unit = "fT" if amplitude else "fT^2"
        assert title == "Magnetometers", title
        _check_ylabel_psd(ylabel, amplitude, dB, unit)

    # test xscale value checking
    raw = raw_unchanged
    spectrum = raw.compute_psd()
    with pytest.raises(ValueError, match="Invalid value for the 'xscale'"):
        spectrum.plot(xscale="blah")

    # gh-5046
    raw = raw_orig.crop(0, 1)
    picks = pick_types(raw.info, meg=True)
    spectrum = raw.compute_psd(picks=picks)
    spectrum.plot(average=False, amplitude=False, **old_defaults)
    spectrum.plot(average=True, amplitude=False, **old_defaults)
    plt.close("all")
    raw.set_channel_types(
        {
            "MEG 0113": "hbo",
            "MEG 0112": "hbr",
            "MEG 0122": "fnirs_cw_amplitude",
            "MEG 0123": "fnirs_od",
        },
        verbose="error",
    )
    fig = raw.compute_psd().plot(amplitude=False, **old_defaults)
    assert len(fig.axes) == 10
    plt.close("all")

    # gh-7631
    n_times = sfreq = n_fft = 100
    data = 1e-3 * np.random.rand(2, n_times)
    info = create_info(["CH1", "CH2"], sfreq)  # ch_types defaults to 'misc'
    raw = RawArray(data, info)
    picks = pick_types(raw.info, misc=True)
    spectrum = raw.compute_psd(picks=picks, n_fft=n_fft)
    spectrum.plot(spatial_colors=False, picks=picks, exclude="bads", amplitude=False)
    plt.close("all")


def test_plot_sensors(raw):
    """Test plotting of sensor array."""
    plt.close("all")
    fig = raw.plot_sensors("3d")
    _fake_click(fig, fig.gca(), (-0.08, 0.67))
    raw.plot_sensors("topomap", ch_type="mag", show_names=["MEG 0111", "MEG 0131"])
    plt.close("all")
    ax = plt.subplot(111)
    raw.plot_sensors(ch_groups="position", axes=ax)
    raw.plot_sensors(ch_groups="selection", to_sphere=False)
    raw.plot_sensors(ch_groups=[[0, 1, 2], [3, 4]])
    raw.plot_sensors(ch_groups=np.array([[0, 1, 2], [3, 4, 5]]))
    raw.plot_sensors(ch_groups=np.array([[0, 1, 2], [3, 4]], dtype=object))
    pytest.raises(ValueError, raw.plot_sensors, ch_groups="asd")
    pytest.raises(TypeError, plot_sensors, raw)  # needs to be info
    pytest.raises(ValueError, plot_sensors, raw.info, kind="sasaasd")
    plt.close("all")

    # Test lasso selection.
    fig, sels = raw.plot_sensors("select", show_names=True)
    ax = fig.axes[0]
    # Lasso a single sensor.
    _fake_click(fig, ax, (-0.13, 0.13), xform="data")
    _fake_click(fig, ax, (-0.11, 0.13), xform="data", kind="motion")
    _fake_click(fig, ax, (-0.11, 0.06), xform="data", kind="motion")
    _fake_click(fig, ax, (-0.13, 0.06), xform="data", kind="motion")
    _fake_click(fig, ax, (-0.13, 0.13), xform="data", kind="motion")
    _fake_click(fig, ax, (-0.13, 0.13), xform="data", kind="release")
    assert fig.lasso.selection == ["MEG 0121"]

    # Add another sensor with a single click.
    _fake_keypress(fig, "control")
    _fake_click(fig, ax, (-0.1278, 0.0318), xform="data")
    _fake_click(fig, ax, (-0.1278, 0.0318), xform="data", kind="release")
    _fake_keypress(fig, "control", kind="release")
    assert fig.lasso.selection == ["MEG 0121", "MEG 0131"]
    plt.close("all")

    raw.info["dev_head_t"] = None  # like empty room
    with pytest.warns(RuntimeWarning, match="identity"):
        raw.plot_sensors()

    # Test plotting with sphere='eeglab'
    info = create_info(ch_names=["Fpz", "Oz", "T7", "T8"], sfreq=100, ch_types="eeg")
    data = 1e-6 * np.random.rand(4, 100)
    raw_eeg = RawArray(data=data, info=info)
    raw_eeg.set_montage("biosemi64")
    raw_eeg.plot_sensors(sphere="eeglab")

    # Should work with "FPz" as well
    raw_eeg.rename_channels({"Fpz": "FPz"})
    raw_eeg.plot_sensors(sphere="eeglab")

    # Should still work without Fpz/FPz, as long as we still have Oz
    raw_eeg.drop_channels("FPz")
    raw_eeg.plot_sensors(sphere="eeglab")

    # Should raise if Oz is missing too, as we cannot reconstruct Fpz anymore
    raw_eeg.drop_channels("Oz")
    with pytest.raises(ValueError, match="could not find: Fpz"):
        raw_eeg.plot_sensors(sphere="eeglab")

    # Should raise if we don't have a montage
    chs = deepcopy(raw_eeg.info["chs"])
    raw_eeg.set_montage(None)
    with raw_eeg.info._unlock():
        raw_eeg.info["chs"] = chs
    with pytest.raises(ValueError, match="No montage was set"):
        raw_eeg.plot_sensors(sphere="eeglab")


@pytest.mark.parametrize("cfg_value", (None, "0.1,0.1"))
def test_min_window_size(raw, cfg_value, browser_backend):
    """Test minimum window plot size."""
    old_cfg = get_config("MNE_BROWSE_RAW_SIZE")
    set_config("MNE_BROWSE_RAW_SIZE", cfg_value)
    fig = raw.plot()
    # For an unknown reason, the Windows-CI is a bit off
    # (on local Windows 10 the size is exactly as expected).
    atol = 0 if not os.name == "nt" else 0.2
    # 8 × 8 inches is default minimum size.
    assert_allclose(fig._get_size(), (8, 8), atol=atol)
    set_config("MNE_BROWSE_RAW_SIZE", old_cfg)


def test_scalings_int(browser_backend):
    """Test that auto scalings access samples using integers."""
    raw = RawArray(np.zeros((1, 500)), create_info(1, 1000.0, "eeg"))
    raw.plot(scalings="auto")


@pytest.mark.parametrize("dur, n_dec", [(20, 1), (1.8, 2), (0.01, 4)])
def test_clock_xticks(raw, dur, n_dec, browser_backend):
    """Test if decimal seconds of xticks have appropriate length."""
    fig = raw.plot(duration=dur, time_format="clock")
    fig._redraw()
    tick_texts = fig._get_ticklabels("x")
    assert tick_texts[0].startswith("19:01:53")
    if len(tick_texts[0].split(".")) > 1:
        assert len(tick_texts[0].split(".")[1]) == n_dec


def test_plotting_order_consistency():
    """Test that our internal variables have some consistency."""
    pick_data_set = set(_PICK_TYPES_DATA_DICT)
    pick_data_set.remove("meg")
    pick_data_set.remove("fnirs")
    pick_data_set.remove("eyetrack")
    missing = pick_data_set.difference(set(_DATA_CH_TYPES_ORDER_DEFAULT))
    assert missing == set()


def test_plotting_temperature_gsr(browser_backend):
    """Test that we can plot temperature and GSR."""
    data = np.random.RandomState(0).randn(2, 1000)
    data[0] += 37  # deg C
    # no idea what the scale should be for GSR
    info = create_info(2, 1000.0, ["temperature", "gsr"])
    raw = RawArray(data, info)
    fig = raw.plot()
    tick_texts = fig._get_ticklabels("y")
    assert len(tick_texts) == 2


@pytest.mark.pgtest
def test_plotting_memory_garbage_collection(raw, pg_backend):
    """Test that memory can be garbage collected properly."""
    pytest.importorskip("mne_qt_browser", minversion="0.4")
    raw.plot().close()
    import mne_qt_browser
    from mne_qt_browser._pg_figure import MNEQtBrowser

    assert len(mne_qt_browser._browser_instances) == 0
    _assert_no_instances(MNEQtBrowser, "after closing")


def test_plotting_scalebars(browser_backend, qtbot):
    """Test that raw scalebars are not overplotted."""
    ismpl = browser_backend.name == "matplotlib"
    raw = mne.io.read_raw_fif(raw_fname).crop(0, 1).load_data()
    fig = raw.plot(butterfly=True)
    if ismpl:
        ch_types = [text.get_text() for text in fig.mne.ax_main.get_yticklabels()]
        assert ch_types == ["mag", "grad", "eeg", "eog", "stim"]
        delta = 0.25
        offset = 0
    else:
        qtbot.wait_exposed(fig)
        for _ in range(10):
            ch_types = list(fig.mne.channel_axis.ch_texts)  # keys
            if len(ch_types) > 0:
                break
            qtbot.wait(100)  # pragma: no cover
        # the grad/mag difference here is intentional in _pg_figure.py
        assert ch_types == ["grad", "mag", "eeg", "eog", "stim"]
        delta = 0.5  # TODO: Probably should also be 0.25?
        offset = 1
    assert ch_types.pop(-1) == "stim"
    for ci, ch_type in enumerate(ch_types, offset):
        err_msg = f"{ch_type=} should be centered around y={ci}"
        this_bar = fig.mne.scalebars[ch_type]
        if ismpl:
            yvals = this_bar.get_data()[1]
        else:
            yvals = this_bar.get_ydata()
        assert_allclose(yvals, [ci - delta, ci + delta], err_msg=err_msg)
