import queue
import sys
import types

import numpy as np
import pytest  # type: ignore
from matplotlib.axes import Axes  # type: ignore
from matplotlib.figure import Figure  # type: ignore
from nxscli.idata import PluginData, PluginDataCb, PluginQueueData
from nxscli.trigger import (
    DTriggerConfig,
    DTriggerEvent,
    ETriggerType,
    TriggerHandler,
)
from nxslib.dev import DeviceChannel
from nxslib.nxscope import DNxscopeStreamBlock

from nxscli_mpl._animation_common import fetch_animation_frame
from nxscli_mpl._animation_lifecycle import (
    setup_writer,
    start_animation,
    stop_animation,
    update_animation_common,
)
from nxscli_mpl._plot_factory import (
    build_plot_surface as build_plot_surface_private,
)
from nxscli_mpl._plot_lifecycle import (
    attached_canvas_widget,
    clear_animations,
    close_surface,
    is_qwidget,
)
from nxscli_mpl.plot_mpl import (
    EPlotMode,
    MplManager,
    PlotDataAxesMpl,
    PlotDataCommon,
    PluginAnimationCommonMpl,
    PluginPlotMpl,
    build_plot_surface,
    create_plot_surface,
)
from tests.helpers import RecordingPluginHandler


def test_plotdatacommon():
    chan = DeviceChannel(0, 1, 2, "chan0")
    x = PlotDataCommon(chan)

    assert x.samples_max == 0
    x.samples_max = 100
    assert x.samples_max == 100

    assert x.xdata == [[], []]
    assert x.ydata == [[], []]

    x.xdata_extend([[1, 2], [3, 4]])
    x.ydata_extend([[5, 6], [7, 8]])
    assert x.xdata == [[1, 2], [3, 4]]
    assert x.ydata == [[5, 6], [7, 8]]
    x.xdata_extend([[9], [10]])
    x.ydata_extend([[11], [12]])
    assert x.xdata == [[1, 2, 9], [3, 4, 10]]
    assert x.ydata == [[5, 6, 11], [7, 8, 12]]

    x.samples_max = 5
    x.xdata_extend_max([[13, 14], [16, 17]])
    x.ydata_extend_max([[19, 20], [22, 23]])
    x.xdata_extend_max([[15], [18]])
    x.ydata_extend_max([[21], [24]])

    assert x.xdata == [[2, 9, 13, 14, 15], [4, 10, 16, 17, 18]]
    assert x.ydata == [[6, 11, 19, 20, 21], [8, 12, 22, 23, 24]]
    x.xdata_extend_max([[25], [26]])
    x.ydata_extend_max([[27], [28]])
    assert x.xdata == [[9, 13, 14, 15, 25], [10, 16, 17, 18, 26]]
    assert x.ydata == [[11, 19, 20, 21, 27], [12, 22, 23, 24, 28]]


def test_plotdataaxesmpl():
    fig = Figure()
    axes = Axes(fig, (1, 1, 2, 6))

    # not numerical channels
    chan = DeviceChannel(0, 1, 2, "chan0")
    with pytest.raises(TypeError):
        x = PlotDataAxesMpl(axes, chan)

    chan = DeviceChannel(chan=0, _type=2, vdim=2, name="chan0")
    x = PlotDataAxesMpl(axes, chan)

    assert x.ax is axes
    assert str(x) is not None

    x.set_xlim((0, 1))
    assert x.xlim == (0, 1)
    x.set_ylim((2, 3))
    assert x.ylim == (2, 3)

    x.plot_title = "test"
    assert x.plot_title == "test"

    x.xdata_extend([[0, 1], [0, 1]])
    x.ydata_extend([[2, 3], [4, 5]])
    x.plot()
    assert x.trigger_line.get_visible() is False
    x.set_trigger_marker(3.5)
    x.plot()
    assert x.trigger_line.get_visible() is True
    x.xaxis_disable()
    x.xaxis_set_ticks([])

    x = PlotDataAxesMpl(axes, chan, fmt=None)
    assert x._fmt == ["", ""]

    x = PlotDataAxesMpl(axes, chan, fmt=["o", "b"])
    assert x._fmt == ["o", "b"]


def test_plotdataaxesmpl_plot_without_explicit_xdata() -> None:
    fig = Figure()
    axes = Axes(fig, (1, 1, 2, 6))
    chan = DeviceChannel(chan=0, _type=2, vdim=1, name="chan0")
    pdata = PlotDataAxesMpl(axes, chan)

    pdata.ydata_extend([[1, 2, 3]])
    pdata.plot()

    assert len(axes.lines) >= 2


def test_mplmanager_func_animation_delegates(mocker) -> None:
    fig = Figure()
    func = mocker.Mock()
    frames = mocker.Mock(return_value=iter([1]))
    create = mocker.patch(
        "nxscli_mpl._mpl_manager.FuncAnimation", return_value="ani"
    )

    out = MplManager.func_animation(fig=fig, func=func, frames=frames)

    assert out == "ani"
    create.assert_called_once_with(fig=fig, func=func, frames=frames)


def test_start_animation_disables_blitting(mocker) -> None:
    fig = Figure()
    update = mocker.Mock()
    frames = mocker.Mock()
    init = mocker.Mock()
    create = mocker.patch(
        "nxscli_mpl._animation_lifecycle.MplManager.func_animation",
        return_value="ani",
    )

    out = start_animation(
        fig=fig,
        update=update,
        frames=frames,
        init=init,
    )

    assert out == "ani"
    create.assert_called_once_with(
        fig=fig,
        func=update,
        frames=frames,
        init_func=init,
        interval=1,
        blit=False,
        cache_frame_data=False,
    )


def test_pluginanimationcommonmpl():
    q = queue.Queue()
    chan = DeviceChannel(chan=0, _type=2, vdim=2, name="chan0")
    fig = Figure()
    axes = Axes(fig, (1, 1, 2, 6))
    pdata = PlotDataAxesMpl(axes, chan)
    dtc = DTriggerConfig(ETriggerType.ALWAYS_OFF)
    qdata = PluginQueueData(q, chan, dtc)
    x = PluginAnimationCommonMpl(fig, pdata, qdata, False)

    x.stop()
    # TODO


def test_pluginanimationcommonmpl_trim_and_hold_helpers() -> None:
    q = queue.Queue()
    chan = DeviceChannel(chan=0, _type=2, vdim=1, name="chan0")
    fig = Figure()
    axes = Axes(fig, (1, 1, 2, 6))
    pdata = PlotDataAxesMpl(axes, chan)
    dtc = DTriggerConfig(ETriggerType.ALWAYS_OFF)
    qdata = PluginQueueData(q, chan, dtc)
    ani = PluginAnimationCommonMpl(
        fig,
        pdata,
        qdata,
        "",
        hold_after_trigger=True,
        hold_post_samples=4,
    )

    frame1 = (
        [np.array([10.0, 11.0, 12.0])],
        [np.array([1.0, 2.0, 3.0])],
        10.5,
    )
    out1 = ani._trim_frame_for_hold(frame1)
    assert np.array_equal(out1[0][0], np.array([10.0, 11.0, 12.0]))
    assert ani._hold_ready(12.0) is False
    assert ani._hold_ready(None) is False

    frame2 = (
        [np.array([10.0, 11.0, 12.0, 13.0, 14.0, 15.0])],
        [np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0])],
        None,
    )
    out2 = ani._trim_frame_for_hold(frame2)
    assert np.array_equal(out2[0][0], np.array([10.0, 11.0, 12.0, 13.0, 14.0]))
    assert np.array_equal(out2[1][0], np.array([1.0, 2.0, 3.0, 4.0, 5.0]))
    assert ani._hold_ready(14.0) is True


def test_pluginanimationcommonmpl_xscale_extend_reaches_frame_max() -> None:
    q = queue.Queue()
    chan = DeviceChannel(chan=0, _type=2, vdim=1, name="chan0")
    fig = Figure()
    axes = fig.add_subplot(111)
    pdata = PlotDataAxesMpl(axes, chan)
    dtc = DTriggerConfig(ETriggerType.ALWAYS_OFF)
    qdata = PluginQueueData(q, chan, dtc)
    ani = PluginAnimationCommonMpl(fig, pdata, qdata, False)
    pdata.set_xlim((0.0, 0.055))

    ani.xscale_extend([np.array([0.0, 1.0, 127.0])], pdata)

    assert pdata.ax.get_xlim()[1] >= 127.0


def test_mplmanager_show_nonblocking(mocker) -> None:
    show_managers = mocker.patch.object(MplManager, "_show_managers")
    mocker.patch("nxscli_mpl._mpl_manager.plt.show")
    pause = mocker.patch("nxscli_mpl._mpl_manager.plt.pause")

    MplManager.show_nonblocking()

    show_managers.assert_called_once_with()
    pause.assert_called_once_with(0.001)


def test_mplmanager_show_blocking(mocker) -> None:
    import importlib

    import nxscli_mpl._mpl_manager as mpl_manager_mod

    mpl_manager_mod = importlib.reload(mpl_manager_mod)
    raw_manager = mpl_manager_mod.MplManager

    manager = mocker.Mock()
    mocker.patch(
        "nxscli_mpl._mpl_manager._pylab_helpers.Gcf.get_all_fig_managers",
        return_value=[manager],
    )
    show = mocker.patch("nxscli_mpl._mpl_manager.plt.show")

    raw_manager.show()

    manager.show.assert_called_once_with()
    show.assert_called_once_with(block=True)


def test_mplmanager_show_managers_ignores_backend_errors(mocker) -> None:
    good = mocker.Mock()
    bad = mocker.Mock()
    bad.show.side_effect = RuntimeError("boom")
    mocker.patch(
        "nxscli_mpl._mpl_manager._pylab_helpers.Gcf.get_all_fig_managers",
        return_value=[good, bad],
    )

    MplManager._show_managers()

    good.show.assert_called_once_with()
    bad.show.assert_called_once_with()


def test_mplmanager_present_manager_raises_and_activates(mocker) -> None:
    manager = mocker.Mock()
    window = mocker.Mock()
    manager.window = window

    MplManager._present_manager(manager)

    manager.show.assert_called_once_with()
    window.show.assert_called_once_with()
    window.raise_.assert_called_once_with()
    window.activateWindow.assert_called_once_with()


def test_mplmanager_present_manager_without_window(mocker) -> None:
    manager = mocker.Mock()
    manager.window = None

    MplManager._present_manager(manager)

    manager.show.assert_called_once_with()


def test_mplmanager_present_manager_ignores_window_errors(mocker) -> None:
    manager = mocker.Mock()
    window = mocker.Mock()
    window.show.side_effect = RuntimeError("show")
    window.raise_.side_effect = RuntimeError("raise")
    window.activateWindow.side_effect = RuntimeError("activate")
    manager.window = window

    MplManager._present_manager(manager)

    manager.show.assert_called_once_with()
    window.show.assert_called_once_with()
    window.raise_.assert_called_once_with()
    window.activateWindow.assert_called_once_with()


def test_fetch_animation_frame_returns_trigger_marker() -> None:
    class FakeQueueData:
        vdim = 1

        def __init__(self) -> None:
            self._payloads = [
                [
                    DNxscopeStreamBlock(
                        data=np.array([[1.0], [2.0], [3.0]]),
                        meta=None,
                    )
                ],
                [],
            ]
            self._event = DTriggerEvent(
                sample_index=1,
                channel=7,
                capture_mode="start_after",
            )

        def queue_get(self, block: bool = False):  # noqa: ANN001, ARG002
            return self._payloads.pop(0)

        def pop_trigger_event(self) -> DTriggerEvent | None:
            event = self._event
            self._event = None
            return event

    qdata = FakeQueueData()

    xdata, ydata, next_count, trigger_x = fetch_animation_frame(qdata, count=4)

    assert next_count == 7
    assert trigger_x == 5
    assert np.array_equal(xdata[0], np.array([4, 5, 6]))
    assert np.array_equal(ydata[0], np.array([1.0, 2.0, 3.0]))


def test_pluginanimationcommonmpl_animation_frames_repeat(mocker) -> None:
    ani = PluginAnimationCommonMpl(
        object(),
        object(),
        object(),
        "",
    )
    fetch = mocker.patch.object(
        ani,
        "_animation_frames_blocks",
        side_effect=[
            ([np.array([0.0])], [np.array([1.0])], None),
            ([np.array([1.0])], [np.array([2.0])], 1.0),
        ],
    )

    frames = ani._animation_frames(object())

    first = next(frames)
    assert np.array_equal(first[0][0], np.array([0.0]))
    assert np.array_equal(first[1][0], np.array([1.0]))
    assert first[2] is None

    second = next(frames)
    assert np.array_equal(second[0][0], np.array([1.0]))
    assert np.array_equal(second[1][0], np.array([2.0]))
    assert second[2] == 1.0
    assert fetch.call_count == 2


def test_pluginanimationcommonmpl_trim_for_hold_passthrough() -> None:
    ani = PluginAnimationCommonMpl(
        object(),
        object(),
        object(),
        "",
        hold_after_trigger=False,
    )
    frame = ([np.array([1.0, 2.0])], [np.array([3.0, 4.0])], None)

    assert ani._trim_frame_for_hold(frame) == frame


def test_pluginanimationcommonmpl_trim_handles_empty_xdata() -> None:
    ani = PluginAnimationCommonMpl(
        object(),
        object(),
        object(),
        "",
        hold_after_trigger=True,
        hold_post_samples=2,
    )
    ani._hold_trigger_x = 4.0
    ani._hold_stop_x = 5.5
    frame = ([np.array([])], [np.array([])], None)

    trimmed = ani._trim_frame_for_hold(frame)

    assert len(trimmed[0]) == 1
    assert len(trimmed[1]) == 1
    assert np.array_equal(trimmed[0][0], np.array([]))
    assert np.array_equal(trimmed[1][0], np.array([]))
    assert trimmed[2] is None


def test_fetch_animation_frame_stops_after_first_trigger_batch() -> None:
    class FakeQueueData:
        vdim = 1

        def __init__(self) -> None:
            self._payloads = [
                [
                    DNxscopeStreamBlock(
                        data=np.array([[1.0], [2.0]]),
                        meta=None,
                    )
                ],
                [
                    DNxscopeStreamBlock(
                        data=np.array([[3.0], [4.0]]),
                        meta=None,
                    )
                ],
            ]
            self._events = [
                DTriggerEvent(
                    sample_index=2.0,
                    channel=7,
                    capture_mode="start_after",
                ),
                None,
            ]

        def queue_get(self, block: bool = False):  # noqa: ANN001, ARG002
            if not self._payloads:
                return []
            return self._payloads.pop(0)

        def pop_trigger_event(self) -> DTriggerEvent | None:
            if not self._events:
                return None
            return self._events.pop(0)

    qdata = FakeQueueData()
    xdata, ydata, next_count, trigger_x = fetch_animation_frame(
        qdata,
        count=10,
        stop_on_trigger=True,
    )

    assert next_count == 12
    assert trigger_x == 12.0
    assert np.array_equal(xdata[0], np.array([10, 11]))
    assert np.array_equal(ydata[0], np.array([1.0, 2.0]))
    remaining = qdata.queue_get(block=False)
    assert len(remaining) == 1
    assert np.array_equal(remaining[0].data, np.array([[3.0], [4.0]]))
    assert qdata.queue_get(block=False) == []
    assert qdata.pop_trigger_event() is None
    assert qdata.pop_trigger_event() is None


def dummy_stream_sub(ch):
    pass


def dummy_stream_unsub(q):
    pass


def make_plot(chanlist, mode="detached", fmt=None):
    """Create a test plot with default callback and triggers."""
    dtc = DTriggerConfig(ETriggerType.ALWAYS_OFF)
    trig = [
        TriggerHandler(chan.data.chan, dtc)
        for chan in chanlist
        if chan.data.is_numerical
    ]
    cb = PluginDataCb(dummy_stream_sub, dummy_stream_unsub)
    return PluginPlotMpl(chanlist, trig, cb, mode=mode, fmt=fmt)


def test_pluginplotmpl():
    chanlist = [
        DeviceChannel(chan=0, _type=1, vdim=2, name="chan0"),  # not numerical
        DeviceChannel(chan=1, _type=2, vdim=1, name="chan1"),
        DeviceChannel(chan=2, _type=2, vdim=2, name="chan2"),
    ]
    x = make_plot(chanlist)

    assert x.fig is not None
    assert x.ani == []
    assert len(x.plist) > 0
    assert len(x._chanlist) == 2  # one channel not numerical
    assert x._fmt == [None, None]
    x.plot_clear()

    # test fmt configuration
    x = make_plot(chanlist, fmt="o")
    assert x._fmt == [["o"], ["o", "o"]]

    x = make_plot(chanlist, fmt=[["o"], ["b", "b"]])
    assert x._fmt == [["o"], ["b", "b"]]

    # invalid vector fmt for chan 2
    with pytest.raises(AssertionError):
        x = make_plot(chanlist, fmt=["o", "b"])
    # invalid channels fmt
    with pytest.raises(AssertionError):
        x = make_plot(chanlist, fmt=["o", "b", "c"])

    # TODO

    TriggerHandler.cls_cleanup()


def test_create_plot_surface_returns_plugin_plot() -> None:
    chanlist = [DeviceChannel(chan=1, _type=2, vdim=1, name="chan1")]
    dtc = DTriggerConfig(ETriggerType.ALWAYS_OFF)
    trig = [TriggerHandler(1, dtc)]
    cb = PluginDataCb(dummy_stream_sub, dummy_stream_unsub)

    plot = create_plot_surface(
        chanlist=chanlist,
        trig=trig,
        cb=cb,
        mode="attached",
    )

    assert isinstance(plot, PluginPlotMpl)
    assert plot.mode == EPlotMode.ATTACHED.value
    plot.close()
    TriggerHandler.cls_cleanup()


def test_pluginplotmpl_ani_clear_stops_handlers() -> None:
    """Animation cleanup should stop registered handlers before clearing."""
    chanlist = [DeviceChannel(chan=1, _type=2, vdim=1, name="chan1")]
    plot = make_plot(chanlist, mode="attached")

    class DummyAni:
        def __init__(self) -> None:
            self.stop_calls = 0

        def stop(self) -> None:
            self.stop_calls += 1

    ani1 = DummyAni()
    ani2 = DummyAni()
    plot.ani_append(ani1)  # type: ignore[arg-type]
    plot.ani_append(ani2)  # type: ignore[arg-type]

    plot.ani_clear()

    assert ani1.stop_calls == 1
    assert ani2.stop_calls == 1
    assert plot.ani == []

    plot.close()
    TriggerHandler.cls_cleanup()


def test_pluginanimationcommonmpl_init_and_start_delegate(mocker) -> None:
    q = queue.Queue()
    chan = DeviceChannel(chan=0, _type=2, vdim=1, name="chan0")
    fig = Figure()
    axes = Axes(fig, (1, 1, 2, 6))
    pdata = PlotDataAxesMpl(axes, chan)
    dtc = DTriggerConfig(ETriggerType.ALWAYS_OFF)
    qdata = PluginQueueData(q, chan, dtc)
    ani = PluginAnimationCommonMpl(fig, pdata, qdata, "")
    func_animation = mocker.patch.object(
        MplManager,
        "func_animation",
        return_value="animation",
    )

    assert ani._animation_init(pdata) == pdata.lns + [pdata.trigger_line]
    ani.start()

    assert ani._writer is None
    assert ani._ani == "animation"
    func_animation.assert_called_once()


def test_mpl_animation_lifecycle_helpers(mocker) -> None:
    """Private lifecycle helpers should handle writer and frame flow."""
    fig = Figure()
    gif_writer = mocker.patch(
        "nxscli_mpl._animation_lifecycle.PillowWriter",
        return_value=mocker.Mock(),
    )
    assert setup_writer(fig, "") is None
    writer = setup_writer(fig, "out.gif")
    gif_writer.assert_called_once_with(fps=10)
    writer.setup.assert_called_once_with(fig, "out.gif")
    mp4_writer = mocker.patch(
        "nxscli_mpl._animation_lifecycle.FFMpegWriter",
        return_value=mocker.Mock(),
    )
    writer_mp4 = setup_writer(fig, "out.mp4")
    mp4_writer.assert_called_once_with(fps=10, bitrate=200)
    writer_mp4.setup.assert_called_once_with(fig, "out.mp4")
    with pytest.raises(TypeError):
        setup_writer(fig, "out.bin")

    lines = [mocker.Mock()]
    updater = mocker.Mock(return_value=lines)
    out = update_animation_common(([[1.0]], [[2.0]]), lines, updater, writer)
    assert out is lines
    writer.grab_frame.assert_called_once()
    assert update_animation_common(([], []), lines, updater, None) is lines
    assert (
        update_animation_common(([[1.0]], [[2.0]]), lines, updater, None)
        is lines
    )

    ani = mocker.Mock()
    ani.event_source = object()
    stop_animation(ani, writer)
    ani.pause.assert_called_once()
    writer.finish.assert_called_once()
    stop_animation(mocker.Mock(event_source=None), None)


def test_mpl_plot_lifecycle_helpers(mocker) -> None:
    """Private plot lifecycle helpers should cover cleanup edge cases."""
    bad = mocker.Mock()
    bad.stop.side_effect = RuntimeError("boom")
    good = mocker.Mock()
    assert clear_animations([bad, good]) == []
    good.stop.assert_called_once()

    fig = Figure()
    close_figure = mocker.Mock()
    widget = mocker.Mock()
    assert close_surface(fig, widget, close_figure) is None
    close_figure.assert_called_once_with(fig)
    widget.close.assert_called_once()
    assert close_surface(None, object(), close_figure) is None

    fake_qtwidgets = types.SimpleNamespace(QWidget=type("FakeWidget", (), {}))
    fake_pyqt6 = types.SimpleNamespace(QtWidgets=fake_qtwidgets)
    mocker.patch.dict(
        sys.modules,
        {
            "PyQt6": fake_pyqt6,
            "PyQt6.QtWidgets": fake_qtwidgets,
        },
    )
    qt_widget = fake_qtwidgets.QWidget()
    assert is_qwidget(qt_widget) is True
    assert is_qwidget(object()) is False

    canvas = fig.canvas
    assert attached_canvas_widget(fig, lambda obj: obj is canvas) is canvas
    backend_qtagg = types.SimpleNamespace()
    create = mocker.Mock(return_value="canvas-widget")
    backend_qtagg.FigureCanvasQTAgg = create
    mocker.patch.dict(
        sys.modules,
        {"matplotlib.backends.backend_qtagg": backend_qtagg},
    )
    assert attached_canvas_widget(fig, lambda obj: False) == "canvas-widget"
    create.assert_called_once_with(fig)


def test_build_plot_surface_delegates_to_factory(mocker) -> None:
    """build_plot_surface should normalize handler callbacks once."""
    handler = RecordingPluginHandler()
    surface = object()
    create = mocker.patch(
        "nxscli_mpl.plot_mpl.create_plot_surface", return_value=surface
    )

    out = build_plot_surface(
        handler,
        {
            "channels": [1, 2],
            "trig": ["always"],
            "dpi": 123,
            "fmt": ["o"],
            "plot_mode": "attached",
            "plot_parent": "parent",
        },
    )

    assert out is surface
    create.assert_called_once_with(
        chanlist=["chan-1", "chan-2"],
        trig=[("trig", ["chan-1", "chan-2"], ["always"])],
        cb=handler.cb,
        dpi=123,
        fmt=["o"],
        mode="attached",
        parent="parent",
    )


def test_private_build_plot_surface_delegates_to_factory(mocker) -> None:
    """Private build helper should normalize handler callbacks once."""
    handler = RecordingPluginHandler()
    surface = object()
    create = mocker.patch(
        "nxscli_mpl._plot_factory.create_plot_surface", return_value=surface
    )

    out = build_plot_surface_private(
        handler,
        {
            "channels": [1, 2],
            "trig": ["always"],
            "dpi": 123,
            "fmt": ["o"],
            "plot_mode": "attached",
            "plot_parent": "parent",
        },
    )

    assert out is surface
    create.assert_called_once_with(
        chanlist=["chan-1", "chan-2"],
        trig=[("trig", ["chan-1", "chan-2"], ["always"])],
        cb=handler.cb,
        dpi=123,
        fmt=["o"],
        mode="attached",
        parent="parent",
    )


def test_pluginanimationcommonmpl_numpy_frames():
    class FakeQueueData:
        vdim = 2

        def __init__(self) -> None:
            self._payloads = [
                [
                    DNxscopeStreamBlock(
                        data=np.array([[1.0, 2.0], [3.0, 4.0]]),
                        meta=None,
                    )
                ],
                [],
            ]

        def queue_get(self, block: bool = False):  # noqa: ANN001, ARG002
            return self._payloads.pop(0)

    chan = DeviceChannel(chan=0, _type=2, vdim=2, name="chan0")
    fig = Figure()
    axes = Axes(fig, (1, 1, 2, 6))
    pdata = PlotDataAxesMpl(axes, chan)
    qdata = FakeQueueData()
    ani = PluginAnimationCommonMpl(fig, pdata, qdata, "")
    frames = ani._animation_frames(qdata)
    xdata, ydata, trigger_x = next(frames)
    assert [x.tolist() for x in xdata] == [[0, 1], [0, 1]]
    assert [y.tolist() for y in ydata] == [[1.0, 3.0], [2.0, 4.0]]
    assert trigger_x is None


def test_pluginanimationcommonmpl_rejects_sample_payload() -> None:
    class FakeQueueData:
        vdim = 1

        def __init__(self) -> None:
            self._payloads = [[type("Sample", (), {"data": (7.0,)})()], []]

        def queue_get(self, block: bool = False):  # noqa: ANN001, ARG002
            if not self._payloads:
                return []
            return self._payloads.pop(0)

    chan = DeviceChannel(chan=0, _type=2, vdim=1, name="chan0")
    fig = Figure()
    axes = Axes(fig, (1, 1, 2, 6))
    pdata = PlotDataAxesMpl(axes, chan)
    qdata = FakeQueueData()
    ani = PluginAnimationCommonMpl(fig, pdata, qdata, "")
    with pytest.raises(RuntimeError):
        _ = next(ani._animation_frames(qdata))
    qdata._payloads = []
    assert qdata.queue_get(block=False) == []


def test_pluginanimationcommonmpl_numpy_frames_rejects_non_block() -> None:
    class BadBlock:
        data = np.array([[1.0, 2.0]])

    class FakeQueueData:
        vdim = 2

        def queue_get(self, block: bool = False):  # noqa: ANN001, ARG002
            return [BadBlock()]

    chan = DeviceChannel(chan=0, _type=2, vdim=2, name="chan0")
    fig = Figure()
    axes = Axes(fig, (1, 1, 2, 6))
    pdata = PlotDataAxesMpl(axes, chan)
    qdata = FakeQueueData()
    ani = PluginAnimationCommonMpl(fig, pdata, qdata, "")
    with pytest.raises(RuntimeError):
        _ = next(ani._animation_frames(qdata))


def test_pluginanimationcommonmpl_rejects_non_list_payload() -> None:
    class FakeQueueData:
        vdim = 1

        def queue_get(self, block: bool = False):  # noqa: ANN001, ARG002
            return "bad"

    chan = DeviceChannel(chan=0, _type=2, vdim=1, name="chan0")
    fig = Figure()
    axes = Axes(fig, (1, 1, 2, 6))
    pdata = PlotDataAxesMpl(axes, chan)
    qdata = FakeQueueData()
    ani = PluginAnimationCommonMpl(fig, pdata, qdata, "")
    with pytest.raises(RuntimeError):
        _ = next(ani._animation_frames(qdata))


def test_pluginanimationcommonmpl_numpy_frames_empty_block_and_loop_limit():
    class FakeQueueData:
        vdim = 1

        def __init__(self) -> None:
            self._n = 0

        def queue_get(self, block: bool = False):  # noqa: ANN001, ARG002
            if self._n >= 100:
                return []
            self._n += 1
            if self._n == 1:
                return [DNxscopeStreamBlock(data=np.empty((0, 1)), meta=None)]
            return [DNxscopeStreamBlock(data=np.array([[1.0]]), meta=None)]

    chan = DeviceChannel(chan=0, _type=2, vdim=1, name="chan0")
    fig = Figure()
    axes = Axes(fig, (1, 1, 2, 6))
    pdata = PlotDataAxesMpl(axes, chan)
    qdata = FakeQueueData()
    ani = PluginAnimationCommonMpl(fig, pdata, qdata, "")
    xdata, ydata, trigger_x = next(ani._animation_frames(qdata))
    assert len(xdata[0]) == 99
    assert len(ydata[0]) == 99
    assert trigger_x is None
    assert qdata.queue_get(block=False) == []


def test_plot_mode_and_factory_attached():
    assert EPlotMode.from_text("attached") is EPlotMode.ATTACHED
    assert EPlotMode.from_text("invalid") is EPlotMode.DETACHED

    chanlist = [DeviceChannel(chan=1, _type=2, vdim=1, name="chan1")]
    plot = make_plot(chanlist, mode="attached")
    assert isinstance(plot, PluginPlotMpl)
    assert plot.mode == "attached"
    assert plot.widget is not None or plot.widget is None
    states = plot.get_vector_states()
    assert len(states) == 1
    plot.set_vector_visible(1, 0, False)
    with pytest.raises(ValueError):
        plot.set_vector_visible(1, 5, True)
    with pytest.raises(ValueError):
        plot.set_vector_visible(9, 0, True)
    assert plot._is_qwidget(object()) is False
    closed = {"v": False}

    class DummyWidget:
        def close(self) -> None:
            closed["v"] = True

    plot._widget = DummyWidget()
    plot.close()
    assert closed["v"] is True
    plot._widget = object()
    plot.close()
    TriggerHandler.cls_cleanup()


def test_set_vector_visible_without_canvas() -> None:
    class DummyLine:
        def __init__(self) -> None:
            self.visible = True

        def get_visible(self) -> bool:
            return self.visible

        def set_visible(self, visible: bool) -> None:
            self.visible = visible

    class DummyFigure:
        canvas = None

    class DummyAxis:
        figure = DummyFigure()

    class DummyPlotData:
        def __init__(self) -> None:
            self.chan = 5
            self.lns = [DummyLine()]
            self.ax = DummyAxis()

    plot = object.__new__(PluginPlotMpl)
    plot._plist = [DummyPlotData()]
    plot.close = lambda: None  # type: ignore[method-assign]
    plot._widget = None

    plot.set_vector_visible(5, 0, False)
    assert plot._plist[0].lns[0].get_visible() is False


def test_pluginplotmpl_del_ignores_close_errors(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    called = {"base_del": False}

    def fake_close() -> None:
        raise RuntimeError("close failed")

    def fake_base_del(self: PluginData) -> None:
        called["base_del"] = True

    plot = object.__new__(PluginPlotMpl)
    plot.close = fake_close  # type: ignore[method-assign]
    monkeypatch.setattr(PluginData, "__del__", fake_base_del)

    PluginPlotMpl.__del__(plot)

    assert called["base_del"] is True
