import time
from collections import defaultdict
from enum import Enum
from io import StringIO
from itertools import permutations
from unittest.mock import MagicMock

import matplotlib.pyplot as plt
import numpy as np
import pytest
from event_model import DocumentNames, compose_run

import bluesky.plans as bp
import bluesky.preprocessors as bpp
from bluesky.callbacks import CallbackBase, CallbackCounter, LiveFit, LiveTable
from bluesky.callbacks.broker import BrokerCallbackBase
from bluesky.callbacks.core import make_callback_safe, make_class_safe
from bluesky.callbacks.mpl_plotting import LiveFitPlot, LiveGrid, LiveMesh, LivePlot, LiveRaster, LiveScatter
from bluesky.plan_stubs import pause
from bluesky.plans import count, grid_scan, inner_product_scan, scan
from bluesky.preprocessors import run_wrapper, subs_wrapper
from bluesky.run_engine import Msg, RunEngineInterrupted
from bluesky.tests.utils import DocCollector, MsgCollector, _print_redirect


# copied from examples.py to avoid import
def stepscan(det, motor):
    yield Msg("open_run")
    yield Msg("declare_stream", None, det, motor, name="primary")
    for i in range(-5, 5):
        yield Msg("create", name="primary")
        yield Msg("set", motor, i)
        yield Msg("trigger", det)
        yield Msg("read", motor)
        yield Msg("read", det)
        yield Msg("save")
    yield Msg("close_run")


def exception_raiser(name, doc):
    raise Exception("it's an exception that better not kill the scan!!")


def test_all(RE, hw):
    c = CallbackCounter()
    RE(stepscan(hw.det, hw.motor), {"all": c})
    assert c.value == 10 + 1 + 2  # events, descriptor, start and stop

    c = CallbackCounter()
    token = RE.subscribe(c)
    RE(stepscan(hw.det, hw.motor))
    RE.unsubscribe(token)
    assert c.value == 10 + 1 + 2


def test_raising_ignored_or_not(RE, hw):
    RE.ignore_callback_exceptions = True
    assert RE.ignore_callback_exceptions

    def cb(name, doc):
        raise Exception

    # by default (with ignore... = True) it warns
    with pytest.warns(UserWarning):
        RE(stepscan(hw.det, hw.motor), cb)

    RE.ignore_callback_exceptions = False
    with pytest.raises(Exception):  # noqa: B017
        RE(stepscan(hw.det, hw.motor), cb)


def test_subscribe_msg(RE, hw):
    assert RE.state == "idle"
    c = CallbackCounter()

    def counting_stepscan(det, motor):
        yield Msg("subscribe", None, c, "start")
        yield from stepscan(det, motor)

    RE(counting_stepscan(hw.det, hw.motor))  # should advance c
    assert c.value == 1
    RE(counting_stepscan(hw.det, hw.motor))  # should advance c
    assert c.value == 2
    RE(stepscan(hw.det, hw.motor))  # should not
    assert c.value == 2


def test_unknown_cb_raises(RE):
    def f(name, doc):
        pass

    with pytest.raises(KeyError):
        RE.subscribe(f, "not a thing")
    # back-compat alias for subscribe
    with pytest.raises(KeyError):
        RE.subscribe_lossless(f, "not a thing")
    with pytest.raises(KeyError):
        RE._subscribe_lossless(f, "not a thing")


def test_table_warns():
    table = LiveTable(["field"])
    table("start", {})
    with pytest.warns(UserWarning):
        table("descriptor", {"uid": "asdf", "name": "primary", "data_keys": {"field": {"dtype": "array"}}})


def test_table_external(RE, hw, db):
    RE.subscribe(db.insert)
    RE(count([hw.img]), LiveTable(["img"]))


def _compare_tables(fout, known_table):
    for ln, kn in zip(fout, known_table.split("\n")):
        # this is to strip the `\n` from the print output
        ln = ln.rstrip()
        if ln[0] == "+":
            # test the full line on the divider lines
            assert ln == kn
        else:
            # skip the 'time' column on data rows
            # this is easier than faking up times in the scan!
            assert ln[:16] == kn[:16]
            assert ln[26:] == kn[26:]


def test_table(RE, hw):
    with _print_redirect() as fout:
        hw.det.precision = 2
        hw.motor.precision = 2
        hw.motor.setpoint.put(0.0)  # Make dtype 'number' not 'integer'.
        hw.det.trigger()
        assert hw.det.describe()["det"]["precision"] == 2
        assert hw.motor.describe()["motor"]["precision"] == 2
        assert hw.det.describe()["det"]["dtype"] == "number"
        assert hw.motor.describe()["motor"]["dtype"] == "number"

        table = LiveTable(["det", "motor"], min_width=16, extra_pad=2, separator_lines=False)
        ad_scan = bp.adaptive_scan([hw.det], "det", hw.motor, -15.0, 5.0, 0.01, 1, 0.05, True)
        # use lossless sub here because rows can get dropped
        token = RE.subscribe(table)
        RE(ad_scan)
        RE.unsubscribe_lossless(token)

    fout.seek(0)
    _compare_tables(fout, KNOWN_TABLE)
    fout.close()


KNOWN_TABLE = """+------------+--------------+----------------+----------------+
|   seq_num  |        time  |           det  |         motor  |
+------------+--------------+----------------+----------------+
|         1  |  04:17:20.6  |          0.00  |        -15.00  |
|         2  |  04:17:20.6  |          0.00  |        -14.51  |
|         3  |  04:17:20.7  |          0.00  |        -13.91  |
|         4  |  04:17:20.8  |          0.00  |        -13.23  |
|         5  |  04:17:20.9  |          0.00  |        -12.49  |
|         6  |  04:17:20.9  |          0.00  |        -11.70  |
|         7  |  04:17:21.0  |          0.00  |        -10.86  |
|         8  |  04:17:21.1  |          0.00  |        -10.00  |
|         9  |  04:17:21.2  |          0.00  |         -9.10  |
|        10  |  04:17:21.2  |          0.00  |         -8.19  |
|        11  |  04:17:21.3  |          0.00  |         -7.25  |
|        12  |  04:17:21.4  |          0.00  |         -6.31  |
|        13  |  04:17:21.5  |          0.00  |         -5.35  |
|        14  |  04:17:21.5  |          0.00  |         -4.39  |
|        15  |  04:17:21.6  |          0.00  |         -3.41  |
|        16  |  04:17:21.7  |          0.05  |         -2.44  |
|        17  |  04:17:21.8  |          0.35  |         -1.45  |
|        18  |  04:17:21.8  |          0.08  |         -2.27  |
|        19  |  04:17:21.9  |          0.12  |         -2.07  |
|        20  |  04:17:22.0  |          0.18  |         -1.86  |
|        21  |  04:17:22.1  |          0.25  |         -1.66  |
|        22  |  04:17:22.1  |          0.22  |         -1.73  |
|        23  |  04:17:22.2  |          0.28  |         -1.59  |
|        24  |  04:17:22.3  |          0.34  |         -1.46  |
|        25  |  04:17:22.4  |          0.33  |         -1.49  |
|        26  |  04:17:22.4  |          0.38  |         -1.38  |
|        27  |  04:17:22.5  |          0.44  |         -1.28  |
|        28  |  04:17:22.6  |          0.50  |         -1.18  |
|        29  |  04:17:22.7  |          0.56  |         -1.08  |
|        30  |  04:17:22.7  |          0.62  |         -0.98  |
|        31  |  04:17:22.8  |          0.67  |         -0.89  |
|        32  |  04:17:22.9  |          0.73  |         -0.80  |
|        33  |  04:17:23.0  |          0.78  |         -0.71  |
|        34  |  04:17:23.1  |          0.82  |         -0.62  |
|        35  |  04:17:23.1  |          0.87  |         -0.53  |
|        36  |  04:17:23.2  |          0.91  |         -0.44  |
|        37  |  04:17:23.3  |          0.94  |         -0.34  |
|        38  |  04:17:23.4  |          0.97  |         -0.23  |
|        39  |  04:17:23.5  |          0.99  |         -0.11  |
|        40  |  04:17:23.5  |          1.00  |          0.04  |
|        41  |  04:17:23.6  |          0.94  |          0.36  |
|        42  |  04:17:23.7  |          0.96  |          0.30  |
|        43  |  04:17:23.8  |          0.85  |          0.56  |
|        44  |  04:17:23.9  |          0.91  |          0.42  |
|        45  |  04:17:23.9  |          0.86  |          0.56  |
|        46  |  04:17:24.0  |          0.79  |          0.69  |
|        47  |  04:17:24.1  |          0.81  |          0.66  |
|        48  |  04:17:24.2  |          0.75  |          0.75  |
|        49  |  04:17:24.3  |          0.70  |          0.85  |
+------------+--------------+----------------+----------------+
|   seq_num  |        time  |           det  |         motor  |
+------------+--------------+----------------+----------------+
|        50  |  04:17:24.3  |          0.64  |          0.94  |
|        51  |  04:17:24.4  |          0.58  |          1.04  |
|        52  |  04:17:24.5  |          0.53  |          1.13  |
|        53  |  04:17:24.6  |          0.48  |          1.22  |
|        54  |  04:17:24.7  |          0.43  |          1.30  |
|        55  |  04:17:24.7  |          0.38  |          1.39  |
|        56  |  04:17:24.8  |          0.33  |          1.48  |
|        57  |  04:17:24.9  |          0.29  |          1.57  |
|        58  |  04:17:25.0  |          0.25  |          1.66  |
|        59  |  04:17:25.0  |          0.21  |          1.76  |
|        60  |  04:17:25.1  |          0.18  |          1.87  |
|        61  |  04:17:25.2  |          0.14  |          1.98  |
|        62  |  04:17:25.3  |          0.11  |          2.10  |
|        63  |  04:17:25.4  |          0.08  |          2.24  |
|        64  |  04:17:25.4  |          0.06  |          2.39  |
|        65  |  04:17:25.5  |          0.04  |          2.58  |
|        66  |  04:17:25.6  |          0.02  |          2.82  |
|        67  |  04:17:25.7  |          0.01  |          3.16  |
|        68  |  04:17:25.8  |          0.00  |          3.62  |
|        69  |  04:17:25.8  |          0.00  |          4.20  |
|        70  |  04:17:25.9  |          0.00  |          4.85  |
+------------+--------------+----------------+----------------+"""


def test_evil_table_names(RE):
    from ophyd import Signal

    sigs = [
        Signal(value=0, name="a:b"),
        Signal(value=0, name="a,b"),
        Signal(value=0, name="a'b"),
        Signal(value=0, name="🐍"),
    ]
    table = LiveTable([s.name for s in sigs], min_width=5, extra_pad=2, separator_lines=False)
    with _print_redirect() as fout:
        print()  # get a blank line in camptured output
        RE(bpp.subs_wrapper(bp.count(sigs, num=2), table))
    reference = """
+------------+--------------+--------+--------+--------+--------+
|   seq_num  |        time  |   a:b  |   a,b  |   a'b  |     🐍  |
+------------+--------------+--------+--------+--------+--------+
|         1  |  12:47:09.7  |     0  |     0  |     0  |     0  |
|         2  |  12:47:09.7  |     0  |     0  |     0  |     0  |
+------------+--------------+--------+--------+--------+--------+"""
    _compare_tables(fout, reference)
    fout.close()


class TableTestEnum(str, Enum):
    """Enum for testing LiveTable with enum values."""

    OK = "OK"
    NOT_OK = "NOT_OK"


_BOOL_ENUM_DOCS = [
    (
        "descriptor",
        {
            "configuration": {},
            "data_keys": {
                "enum_test_sig": {
                    "dtype": "string",
                    "shape": [],
                    "dtype_numpy": "|S40",
                    "source": "ca://DEV:RBD9103:InRange_RBV",
                    "choices": ["OK", "Under", "Over"],
                    "object_name": "rbd9103",
                },
                "bool_test_sig": {
                    "dtype": "boolean",
                    "shape": [],
                    "dtype_numpy": "|b1",
                    "source": "ca://DEV:RBD9103:Stable_RBV",
                    "object_name": "rbd9103",
                },
            },
            "name": "primary",
            "object_keys": {"rbd9103": ["enum_test_sig", "bool_test_sig"]},
            "run_start": "d8e2afc6-2c79-4786-8a2e-b1245b2be578",
            "time": 1746210217.2738924,
            "uid": "2d29452a-a2e3-4697-8db2-423b8cae5065",
            "hints": {"rbd9103": {"fields": ["enum_test_sig", "bool_test_sig"]}},
        },
    ),
    (
        "event",
        {
            "uid": "69b921f3-6037-477b-8d76-731c330c654a",
            "time": 1746210217.2745216,
            "data": {"enum_test_sig": TableTestEnum.OK, "bool_test_sig": False},
            "timestamps": {"enum_test_sig": 631152000.0, "bool_test_sig": 631152000.0},
            "seq_num": 1,
            "filled": {},
            "descriptor": "2d29452a-a2e3-4697-8db2-423b8cae5065",
        },
    ),
    (
        "event",
        {
            "uid": "69b921f3-6037-477b-8d76-731c330c654a",
            "time": 1746210217.2745216,
            "data": {"enum_test_sig": TableTestEnum.NOT_OK, "bool_test_sig": True},
            "timestamps": {"enum_test_sig": 631152000.0, "bool_test_sig": 631152000.0},
            "seq_num": 2,
            "filled": {},
            "descriptor": "2d29452a-a2e3-4697-8db2-423b8cae5065",
        },
    ),
    (
        "stop",
        {
            "uid": "0c862732-f3a2-4ee6-8ef8-7aed0cf4a408",
            "time": 1746210217.2791321,
            "run_start": "d8e2afc6-2c79-4786-8a2e-b1245b2be578",
            "exit_status": "success",
            "reason": "",
            "num_events": {"primary": 1},
        },
    ),
]


def test_table_bool_enum_doc_inputs(RE):
    table = LiveTable(list(_BOOL_ENUM_DOCS[0][1]["data_keys"]))
    with _print_redirect() as fout:
        print()  # get a blank line in camptured output
        for name, doc in _BOOL_ENUM_DOCS:
            table(name, doc)
    reference = """
+-----------+------------+---------------+---------------+
|   seq_num |       time | enum_test_sig | bool_test_sig |
+-----------+------------+---------------+---------------+
|         1 | 14:23:37.2 |            OK |         False |
|         2 | 14:23:37.2 |        NOT_OK |          True |
+-----------+------------+---------------+---------------+"""
    _compare_tables(fout, reference)
    fout.close()


def test_live_fit(RE, hw):
    try:
        import lmfit
    except ImportError:
        raise pytest.skip("requires lmfit")  # noqa: B904

    def gaussian(x, A, sigma, x0):
        return A * np.exp(-((x - x0) ** 2) / (2 * sigma**2))

    model = lmfit.Model(gaussian)
    init_guess = {"A": 2, "sigma": lmfit.Parameter("sigma", 3, min=0), "x0": -0.2}
    cb = LiveFit(model, "det", {"x": "motor"}, init_guess, update_every=50)
    RE(scan([hw.det], hw.motor, -1, 1, 50), cb)
    # results are in cb.result.values

    expected = {"A": 1, "sigma": 1, "x0": 0}
    for k, v in expected.items():
        assert np.allclose(cb.result.values[k], v, atol=1e-6)


def test_live_fit_multidim(RE, hw):
    try:
        import lmfit
    except ImportError:
        raise pytest.skip("requires lmfit")  # noqa: B904

    hw.motor1.delay = 0
    hw.motor2.delay = 0
    hw.det4.exposure_time = 0

    def gaussian(x, y, A, sigma, x0, y0):
        return A * np.exp(-((x - x0) ** 2 + (y - y0) ** 2) / (2 * sigma**2))

    model = lmfit.Model(gaussian, ["x", "y"])
    init_guess = {"A": 2, "sigma": lmfit.Parameter("sigma", 3, min=0), "x0": -0.2, "y0": 0.3}
    cb = LiveFit(model, "det4", {"x": "motor1", "y": "motor2"}, init_guess, update_every=50)
    RE(grid_scan([hw.det4], hw.motor1, -1, 1, 10, hw.motor2, -1, 1, 10, False), cb)

    expected = {"A": 1, "sigma": 1, "x0": 0, "y0": 0}
    for k, v in expected.items():
        assert np.allclose(cb.result.values[k], v, atol=1e-6)


def test_live_plot_from_callbacks():
    import bluesky.callbacks.core

    # We don't want the shims in callbacks.core, see #1133 for discussion
    assert not hasattr(bluesky.callbacks.core, "LivePlot")
    # We still want the shims in callbacks.__init__
    from bluesky.callbacks import LivePlot as LivePlotFromCallbacks

    assert LivePlotFromCallbacks is LivePlot

    # Make sure we can subclass it
    class FromCallbacks(LivePlotFromCallbacks): ...

    FromCallbacks("det", "motor")


def test_live_fit_plot(RE, hw):
    try:
        import lmfit
    except ImportError:
        raise pytest.skip("requires lmfit")  # noqa: B904

    def gaussian(x, A, sigma, x0):
        return A * np.exp(-((x - x0) ** 2) / (2 * sigma**2))

    model = lmfit.Model(gaussian)
    init_guess = {"A": 2, "sigma": lmfit.Parameter("sigma", 3, min=0), "x0": -0.2}
    livefit = LiveFit(model, "det", {"x": "motor"}, init_guess, update_every=50)
    lfplot = LiveFitPlot(livefit, color="r")
    lplot = LivePlot("det", "motor", ax=plt.gca(), marker="o", ls="none")
    RE(scan([hw.det], hw.motor, -1, 1, 50), [lplot, lfplot])
    expected = {"A": 1, "sigma": 1, "x0": 0}
    for k, v in expected.items():
        assert np.allclose(livefit.result.values[k], v, atol=1e-6)


@pytest.mark.parametrize("int_meth, stop_num, msg_num", [("stop", 1, 5), ("abort", 1, 5), ("halt", 1, 3)])
def test_interrupted_with_callbacks(RE, int_meth, stop_num, msg_num):
    docs = defaultdict(list)

    def collector_cb(name, doc):
        nonlocal docs
        docs[name].append(doc)

    RE.msg_hook = MsgCollector()

    with pytest.raises(RunEngineInterrupted):
        RE(subs_wrapper(run_wrapper(pause()), {"all": collector_cb}))
    getattr(RE, int_meth)()

    assert len(docs["start"]) == 1
    assert len(docs["event"]) == 0
    assert len(docs["descriptor"]) == 0
    assert len(docs["stop"]) == stop_num
    assert len(RE.msg_hook.msgs) == msg_num


def test_live_grid(RE, hw):
    hw.motor1.delay = 0
    hw.motor2.delay = 0
    RE(grid_scan([hw.det4], hw.motor1, -3, 3, 6, hw.motor2, -5, 5, 10, False), LiveGrid((6, 10), "det4"))

    # Test the deprecated name.
    with pytest.warns(UserWarning):
        RE(grid_scan([hw.det4], hw.motor1, -3, 3, 6, hw.motor2, -5, 5, 10, False), LiveRaster((6, 10), "det4"))


def test_live_grid_title(RE, hw):
    hw.motor1.delay = 0
    hw.motor2.delay = 0
    RE(
        grid_scan([hw.det4], hw.motor1, -3, 3, 6, hw.motor2, -5, 5, 10, False),
        LiveGrid((6, 10), "det4", title="Det 4 Grid Plot"),
    )


def test_live_scatter(RE, hw):
    RE(
        grid_scan([hw.det5], hw.jittery_motor1, -3, 3, 6, hw.jittery_motor2, -5, 5, 10, False),
        LiveScatter("jittery_motor1", "jittery_motor2", "det5", xlim=(-3, 3), ylim=(-5, 5)),
    )

    # Test the deprecated name.
    with pytest.warns(UserWarning):
        RE(
            grid_scan([hw.det5], hw.jittery_motor1, -3, 3, 6, hw.jittery_motor2, -5, 5, 10, False),
            LiveMesh("jittery_motor1", "jittery_motor2", "det5", xlim=(-3, 3), ylim=(-5, 5)),
        )


def test_live_scatter_auto_range(RE, hw):
    cb = LiveScatter("motor1", "motor2", "det5")
    RE(grid_scan([hw.det5], hw.motor1, -3, 3, 6, hw.motor2, -5, 5, 10, False), cb)
    assert cb.minx is not None
    assert cb.maxx is not None
    assert cb.miny is not None
    assert cb.maxy is not None
    assert cb.ax.get_xlim()[0] == -3
    assert cb.ax.get_xlim()[1] == 3
    assert cb.ax.get_ylim()[0] == -5
    assert cb.ax.get_ylim()[1] == 5


@pytest.mark.xfail(
    raises=NotImplementedError, reason="This tests an API databroker has removed, and needs updating."
)
def test_broker_base(RE, hw, db):
    class BrokerChecker(BrokerCallbackBase):
        def __init__(self, field, *, db=None):
            super().__init__(field, db=db)

        def event(self, doc):
            super().event(doc)
            assert isinstance(doc["data"][self.fields[0]], np.ndarray)

    RE.subscribe(db.insert)
    bc = BrokerChecker(("img",), db=db)
    RE.subscribe(bc)
    RE(count([hw.img]))


def test_broker_base_no_unpack(RE, hw, db):
    class BrokerChecker(BrokerCallbackBase):
        def __init__(self, field, *, db=None):
            super().__init__(field, db=db)

        def event(self, doc):
            super().event(doc)
            assert isinstance(doc["data"][self.fields[0]], np.ndarray)

    bc = BrokerChecker(("img",), db=db)
    RE.subscribe(bc)
    RE(count([hw.direct_img]))


def test_plotting_hints(RE, hw, db):
    """This tests the run and checks that the correct hints are created.
    Hints are mainly created to help the BestEffortCallback in plotting the
    data.
    Use a callback to do the checking.
    """
    dc = DocCollector()
    RE.subscribe(dc.insert)

    # check that the inner product hints are passed correctly
    hint = {"dimensions": [([hw.motor1.name, hw.motor2.name, hw.motor3.name], "primary")]}
    RE(inner_product_scan([hw.det], 20, hw.motor1, -1, 1, hw.motor2, -1, 1, hw.motor3, -2, 0))
    assert dc.start[-1]["hints"] == hint

    # check that the outer product (grid_scan) hints are passed correctly
    hint = {"dimensions": [(["motor1"], "primary"), (["motor2"], "primary"), (["motor3"], "primary")]}
    # grid_scan passes "rectilinear" gridding as well
    # make sure this is also passed
    output_hint = hint.copy()
    output_hint["gridding"] = "rectilinear"
    RE(grid_scan([hw.det], hw.motor1, -1, 1, 2, hw.motor2, -1, 1, 2, True, hw.motor3, -2, 0, 2, True))

    assert dc.start[-1]["hints"] == output_hint

    # check that if gridding is supplied, it's not overwritten by grid_scan
    # check that the outer product (grid_scan) hints are passed correctly
    hint = {
        "dimensions": [(["motor1"], "primary"), (["motor2"], "primary"), (["motor3"], "primary")],
        "gridding": "rectilinear",
    }
    RE(grid_scan([hw.det], hw.motor1, -1, 1, 2, hw.motor2, -1, 1, 2, True, hw.motor3, -2, 0, 2, True))
    assert dc.start[-1]["hints"] == hint


def test_broken_table():
    start_doc, descriptor_factory, *_ = compose_run()
    desc, compose_event, _ = descriptor_factory(
        name="primary",
        data_keys={
            "x": {"dtype": "integer", "source": "", "shape": []},
            "y": {"dtype": "number", "source": "", "shape": []},
        },
    )

    ev1 = compose_event(data={"x": 1, "y": 2.0}, timestamps={k: time.time() for k in ("x", "y")})
    ev2 = compose_event(data={"x": 1, "y": 2}, timestamps={k: time.time() for k in ("x", "y")})
    ev3 = compose_event(data={"x": 1.0, "y": 2.0}, timestamps={k: time.time() for k in ("x", "y")})
    ev4 = compose_event(
        data={"x": 1.0, "y": "aardvark"},
        timestamps={k: time.time() for k in ("x", "y")},
    )

    sio = StringIO()
    LT = LiveTable(["x", "y"], out=lambda s: sio.write(s + "\n"))
    LT("start", start_doc)
    LT("descriptor", desc)
    LT("event", ev1)
    LT("event", ev2)
    LT("event", ev3)
    LT("event", ev4)

    sio.seek(0)
    lines = sio.readlines()

    # The instance of LiveTable will include two empty separator lines by default
    assert len(lines) == 9
    for ln in lines[-2:]:
        assert ln.strip() == "failed to format row"


def test_callback_safe():
    @make_callback_safe
    def test_function(to_fail):
        if to_fail:
            raise RuntimeError
        return to_fail

    assert test_function(True) is None
    assert test_function(False) is False


def test_callback_safe_logger():
    from types import SimpleNamespace
    from unittest.mock import MagicMock

    logger = SimpleNamespace(exception=MagicMock())

    @make_callback_safe(logger=logger)
    def test_function(to_fail):
        if to_fail:
            raise RuntimeError
        return to_fail

    assert test_function(True) is None
    assert logger.exception.call_count == 1
    assert test_function(False) is False
    assert logger.exception.call_count == 1


@pytest.fixture
def EvilBaseClass(request):
    class MyError(RuntimeError): ...

    class EvilCallback(CallbackBase):
        my_excepttion_type = MyError

        def event(self, doc):
            raise MyError

        def bulk_events(self, doc):
            raise MyError

        def resource(self, doc):
            raise MyError

        def datum(self, doc):
            raise MyError

        def bulk_datum(self, doc):
            raise MyError

        def descriptor(self, doc):
            raise MyError

        def start(self, doc):
            raise MyError

        def stop(self, doc):
            raise MyError

        def event_page(self, doc):
            raise MyError

        def datum_page(self, doc):
            raise MyError

        def stream_resource(self, doc):
            raise MyError

        def stream_datum(self, doc):
            raise MyError

    return EvilCallback


def test_callbackclass(EvilBaseClass):
    ecb = EvilBaseClass()
    for n in DocumentNames:
        with pytest.raises(EvilBaseClass.my_excepttion_type):
            ecb(n.name, {})


def test_callbackclass_safe(EvilBaseClass):
    @make_class_safe
    class SafeEvilBaseClass(EvilBaseClass): ...

    scb = SafeEvilBaseClass()
    for n in DocumentNames:
        scb(n.name, {})


def test_callbackclass_safe_logger(EvilBaseClass):
    logger = MagicMock()

    @make_class_safe(logger=logger)
    class SafeEvilBaseClass2(EvilBaseClass): ...

    scb = SafeEvilBaseClass2()
    for n in DocumentNames:
        scb(n.name, {})

    assert logger.exception.call_count == len(DocumentNames)


@pytest.mark.parametrize("strict", ["1", None])
@pytest.mark.parametrize(
    "documents",
    (
        list(set(tuple(sorted(x, key=lambda x: x.name)) for x in permutations(DocumentNames, 1)))  # noqa: C401
        + list(set(tuple(sorted(x, key=lambda x: x.name)) for x in permutations(DocumentNames, 2)))  # noqa: C401
        + list(set(tuple(sorted(x, key=lambda x: x.name)) for x in permutations(DocumentNames, 3)))  # noqa: C401
        + [list(DocumentNames)]
    ),
)
def test_callbackclass_safe_filtered(EvilBaseClass, documents, monkeypatch, strict):
    if strict is not None:
        monkeypatch.setenv("BLUESKY_DEBUG_CALLBACKS", strict)
    else:
        monkeypatch.delenv("BLUESKY_DEBUG_CALLBACKS", raising=False)
    logger = MagicMock()

    @make_class_safe(logger=logger, to_wrap=tuple(x.name for x in documents))
    class SafeEvilBaseClass2(EvilBaseClass): ...

    scb = SafeEvilBaseClass2()
    for n in documents:
        if strict:
            with pytest.raises(EvilBaseClass.my_excepttion_type):
                scb(n.name, {})
        else:
            scb(n.name, {})

    for n in set(DocumentNames) - set(documents):
        with pytest.raises(EvilBaseClass.my_excepttion_type):
            scb(n.name, {})

    assert logger.exception.call_count == len(documents)


def test_in_plan_qt_callback(RE, hw):
    from bluesky.callbacks.mpl_plotting import _get_teleporter

    _get_teleporter()

    def my_plan():
        motor = hw.motor
        det = hw.det

        motor.delay = 1

        plan = bp.scan([det], motor, -5, 5, 25)
        plan = subs_wrapper(bp.scan([det], motor, -5, 5, 25), LivePlot(det.name, motor.name))
        return (yield from plan)

    RE(my_plan())
