import os
from pathlib import Path
from types import SimpleNamespace

import logging
import moderngl as mgl
import numpy as np

import pytest

os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")

pytest.importorskip("PySide6")

from PySide6 import QtCore, QtGui, QtWidgets

from topovec.mgl.camera import Camera
from topovec.core import BooleanProperty, ListProperty, System, UpdateMask
from topovec.viewer.document import StateEntry, ViewerDocument
from topovec.viewer.qt.main_window import TvViewWindow
from topovec.viewer.qt.theme import THEME_SETTING_KEY
from topovec.viewer.qt.viewport import QtOpenGLLoader, QtViewport, QtViewportWindow
from topovec.viewer.session import ViewerSession, camera_state_signature
from topovec.viewer.qt.widgets import (
    LogPanel,
    NUMERIC_INPUT_DECIMALS,
    PROPERTY_CONTROL_HEIGHT,
    PROPERTY_RESET_WIDTH,
    PropertyPanel,
)

REAL_QSETTINGS = QtCore.QSettings


@pytest.fixture(scope="module")
def qapp():
    app = QtWidgets.QApplication.instance()
    if app is None:
        app = QtWidgets.QApplication([])
    return app


@pytest.fixture(autouse=True)
def topovec_view_settings_path(monkeypatch, tmp_path):
    settings_path = tmp_path / "topovec.view.ini"

    def _make_settings(organization, application):
        assert organization == "topovec"
        assert application == "topovec.view"
        return REAL_QSETTINGS(str(settings_path), REAL_QSETTINGS.Format.IniFormat)

    monkeypatch.setattr("topovec.viewer.qt.main_window.QtCore.QSettings", _make_settings)
    return settings_path


class _QtSceneBase:
    def __init__(self, mglctx=None, system=None):
        self._mglctx = mglctx
        self._system = system
        self.camera = Camera()
        self.flag = False
        self.uploaded_state = None
        self.release_count = 0

    @property
    def system(self):
        return self._system

    @classmethod
    def supports_system(cls, system):
        return True

    def upload(self, state=None):
        self.uploaded_state = state

    def list_properties(self):
        return {
            "Render": [
                BooleanProperty(self, "flag", title="Flag"),
            ]
        }

    def render_to(self, target):
        return None

    def release(self):
        self.release_count += 1


class _QtLayerScene(_QtSceneBase):
    pass


class _QtBallScene(_QtSceneBase):
    pass


class _QtScopedVectorScene(_QtSceneBase):
    SHAPES = ["Cone", "Double cone", "Cylinder", "Arrow"]

    def __init__(self, *args, mutation_state=None, **kwargs):
        super().__init__(*args, **kwargs)
        self._mutation_state = mutation_state if mutation_state is not None else {}
        self._mesh_shape = "Cone"

    def get_mesh_shape(self):
        return self._mesh_shape

    def set_mesh_shape(self, value):
        if not self._mutation_state.get("active", False):
            raise RuntimeError("qt scene mutation scope is not active")
        if value not in self.SHAPES:
            raise ValueError(f"Unsupported mesh shape: {value!r}")
        self._mesh_shape = str(value)
        return UpdateMask.REDRAW

    mesh_shape = property(get_mesh_shape, set_mesh_shape)

    def list_properties(self):
        return {
            "Vector": [
                ListProperty(
                    self,
                    "mesh_shape",
                    title="Shape",
                    values=list(self.SHAPES),
                )
            ]
        }


class _QtSchemaScene(_QtSceneBase):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.mode = "alpha"

    def list_properties(self):
        return {
            "Render": [
                ListProperty(
                    self,
                    "mode",
                    title="Mode",
                    values=["alpha", "beta"],
                    reset=True,
                )
            ]
        }


def _install_qt_scene_registry(monkeypatch):
    registry = {
        "Layer": _QtLayerScene,
        "Ball": _QtBallScene,
    }
    monkeypatch.setattr("topovec.viewer.qt.main_window.SCENES", dict(registry))
    monkeypatch.setattr(
        "topovec.viewer.session._load_builtin_scene_registry",
        lambda: dict(registry),
    )
    return registry


def _make_document(
    name: str,
    system,
    states: list[np.ndarray],
    *,
    format_name: str = "SAD",
) -> ViewerDocument:
    entries = [
        StateEntry(
            label=f"PATH[{index}]",
            index=(int(index),),
            load_state=(lambda s=np.array(state, copy=True): np.array(s, copy=True)),
            metadata={"index": int(index)},
        )
        for index, state in enumerate(states)
    ]
    return ViewerDocument(
        path=Path(f"/tmp/{name}"),
        label=name,
        format_name=format_name,
        system=system,
        entries=entries,
        metadata={},
    )


def _read_recent_settings_paths(settings_path: Path) -> list[str]:
    settings = REAL_QSETTINGS(str(settings_path), REAL_QSETTINGS.Format.IniFormat)
    raw = settings.value("recent_files", [])
    if raw is None:
        return []
    if isinstance(raw, str):
        return [raw]
    return [str(value) for value in raw]


def _read_theme_setting(settings_path: Path) -> str | None:
    settings = REAL_QSETTINGS(str(settings_path), REAL_QSETTINGS.Format.IniFormat)
    value = settings.value(THEME_SETTING_KEY)
    return None if value is None else str(value)


def _menu_for_title(window: TvViewWindow, title: str) -> QtWidgets.QMenu:
    for menu in window.menuBar().findChildren(QtWidgets.QMenu):
        if menu.title() == title:
            return menu
    raise AssertionError(f"Menu {title!r} not found")


def test_property_panel_builds_generated_controls(qapp):
    panel = PropertyPanel()
    panel.set_snapshot(
        {
            "property_groups": [
                {
                    "id": "Render",
                    "title": "Render",
                    "properties": [
                        {
                            "index": 0,
                            "kind": "numeric",
                            "title": "Level",
                            "field": "level",
                            "value": 0.5,
                            "min": 0.0,
                            "max": 1.0,
                            "count": 5,
                            "step": 0.25,
                            "input_kind": "number",
                            "row_kind": "inline",
                            "default_value": 0.5,
                            "is_default": True,
                            "can_reset": True,
                        },
                        {
                            "index": 1,
                            "kind": "boolean",
                            "title": "Flag",
                            "field": "flag",
                            "value": True,
                            "input_kind": "switch",
                            "row_kind": "inline",
                            "default_value": True,
                            "is_default": True,
                            "can_reset": True,
                        },
                        {
                            "index": 2,
                            "kind": "list",
                            "title": "Mode",
                            "field": "mode",
                            "value": "alpha",
                            "values": ["alpha", "beta"],
                            "selected_index": 0,
                            "input_kind": "dropdown",
                            "row_kind": "inline",
                            "default_value": "alpha",
                            "is_default": True,
                            "can_reset": True,
                        },
                    ],
                }
            ]
        }
    )

    assert not panel.findChildren(QtWidgets.QSlider)
    assert panel.findChildren(QtWidgets.QDoubleSpinBox)
    assert panel.findChildren(QtWidgets.QCheckBox)
    assert panel.findChildren(QtWidgets.QComboBox)


def test_property_panel_sections_start_collapsed(qapp):
    panel = PropertyPanel()
    panel.set_snapshot(
        {
            "property_groups": [
                {
                    "id": "Render",
                    "title": "Render",
                    "properties": [
                        {
                            "index": 0,
                            "kind": "boolean",
                            "title": "Flag",
                            "field": "flag",
                            "value": False,
                            "default_value": False,
                            "is_default": True,
                            "can_reset": True,
                        }
                    ],
                }
            ]
        }
    )

    section = panel.widget().layout().itemAt(0).widget()
    toggle = section.layout().itemAt(0).widget()
    content = section.layout().itemAt(1).widget()

    assert toggle.isChecked() is False
    assert content.isHidden() is True


def test_property_panel_expanding_one_section_collapses_others(qapp):
    panel = PropertyPanel()
    panel.set_snapshot(
        {
            "property_groups": [
                {
                    "id": "Render",
                    "title": "Render",
                    "properties": [
                        {
                            "index": 0,
                            "kind": "boolean",
                            "title": "Flag",
                            "field": "flag",
                            "value": False,
                            "default_value": False,
                            "is_default": True,
                            "can_reset": True,
                        }
                    ],
                },
                {
                    "id": "Vector",
                    "title": "Vector",
                    "properties": [
                        {
                            "index": 0,
                            "kind": "boolean",
                            "title": "Visible",
                            "field": "visible",
                            "value": True,
                            "default_value": True,
                            "is_default": True,
                            "can_reset": True,
                        }
                    ],
                },
            ]
        }
    )

    first = panel.widget().layout().itemAt(0).widget()
    second = panel.widget().layout().itemAt(1).widget()

    first_toggle = first.layout().itemAt(0).widget()
    second_toggle = second.layout().itemAt(0).widget()
    first_content = first.layout().itemAt(1).widget()
    second_content = second.layout().itemAt(1).widget()

    first_toggle.setChecked(True)
    assert first_toggle.isChecked() is True
    assert first_content.isHidden() is False
    assert second_toggle.isChecked() is False
    assert second_content.isHidden() is True

    second_toggle.setChecked(True)
    assert second_toggle.isChecked() is True
    assert second_content.isHidden() is False
    assert first_toggle.isChecked() is False
    assert first_content.isHidden() is True


def test_property_panel_updates_in_place_without_emitting_changes(qapp):
    panel = PropertyPanel()
    emitted = []
    snapshot = {
        "property_groups": [
            {
                "id": "Render",
                "title": "Render",
                "properties": [
                    {
                        "index": 0,
                        "kind": "numeric",
                        "title": "Level",
                        "field": "level",
                        "value": 0.5,
                        "min": 0.0,
                        "max": 1.0,
                        "count": 5,
                        "step": 0.25,
                        "input_kind": "number",
                        "row_kind": "inline",
                        "default_value": 0.5,
                        "is_default": True,
                        "can_reset": True,
                    },
                    {
                        "index": 1,
                        "kind": "boolean",
                        "title": "Flag",
                        "field": "flag",
                        "value": True,
                        "input_kind": "switch",
                        "row_kind": "inline",
                        "default_value": True,
                        "is_default": True,
                        "can_reset": True,
                    },
                    {
                        "index": 2,
                        "kind": "list",
                        "title": "Mode",
                        "field": "mode",
                        "value": "alpha",
                        "values": ["alpha", "beta"],
                        "selected_index": 0,
                        "input_kind": "dropdown",
                        "row_kind": "inline",
                        "default_value": "alpha",
                        "is_default": True,
                        "can_reset": True,
                    },
                ],
            }
        ]
    }
    updated = {
        "property_groups": [
            {
                "id": "Render",
                "title": "Render",
                "properties": [
                    {
                        "index": 0,
                        "kind": "numeric",
                        "title": "Level",
                        "field": "level",
                        "value": 0.75,
                        "min": 0.0,
                        "max": 1.0,
                        "count": 5,
                        "step": 0.25,
                        "input_kind": "number",
                        "row_kind": "inline",
                        "default_value": 0.5,
                        "is_default": False,
                        "can_reset": True,
                    },
                    {
                        "index": 1,
                        "kind": "boolean",
                        "title": "Flag",
                        "field": "flag",
                        "value": False,
                        "input_kind": "switch",
                        "row_kind": "inline",
                        "default_value": True,
                        "is_default": False,
                        "can_reset": True,
                    },
                    {
                        "index": 2,
                        "kind": "list",
                        "title": "Mode",
                        "field": "mode",
                        "value": "beta",
                        "values": ["alpha", "beta"],
                        "selected_index": 1,
                        "input_kind": "dropdown",
                        "row_kind": "inline",
                        "default_value": "alpha",
                        "is_default": False,
                        "can_reset": True,
                    },
                ],
            }
        ]
    }
    panel.property_edited.connect(lambda *args: emitted.append(args))
    panel.set_snapshot(snapshot)

    spin_before = panel.findChild(QtWidgets.QDoubleSpinBox)
    checkbox_before = panel.findChild(QtWidgets.QCheckBox)
    combo_before = panel.findChild(QtWidgets.QComboBox)

    panel.set_snapshot(updated, rebuild=False)

    assert panel.findChild(QtWidgets.QDoubleSpinBox) is spin_before
    assert panel.findChild(QtWidgets.QCheckBox) is checkbox_before
    assert panel.findChild(QtWidgets.QComboBox) is combo_before
    assert spin_before.value() == 0.75
    assert spin_before.decimals() == NUMERIC_INPUT_DECIMALS
    assert spin_before.singleStep() == 0.25
    assert checkbox_before.isChecked() is False
    assert combo_before.currentText() == "beta"
    assert emitted == []


def test_property_panel_reset_button_emits_default_value(qapp):
    panel = PropertyPanel()
    emitted = []
    panel.property_edited.connect(lambda *args: emitted.append(args))
    panel.set_snapshot(
        {
            "property_groups": [
                {
                    "id": "Render",
                    "title": "Render",
                    "properties": [
                        {
                            "index": 0,
                            "kind": "numeric",
                            "title": "Level",
                            "field": "level",
                            "value": 0.75,
                            "min": 0.0,
                            "max": 1.0,
                            "count": 5,
                            "step": 0.25,
                            "input_kind": "number",
                            "row_kind": "inline",
                            "default_value": 0.5,
                            "is_default": False,
                            "can_reset": True,
                        }
                    ],
                }
            ]
        }
    )

    reset_buttons = [
        button
        for button in panel.findChildren(QtWidgets.QToolButton)
        if button.property("inspectorRole") == "reset"
    ]

    assert len(reset_buttons) == 1
    assert reset_buttons[0].isEnabled() is True
    assert reset_buttons[0].text() == ""
    assert reset_buttons[0].icon().isNull() is False

    reset_buttons[0].click()

    assert emitted == [("Render", 0, 0.5, "level", "Level")]


def test_property_panel_numeric_step_commits_immediately(qapp):
    panel = PropertyPanel()
    emitted = []
    panel.property_edited.connect(lambda *args: emitted.append(args))
    panel.set_snapshot(
        {
            "property_groups": [
                {
                    "id": "Render",
                    "title": "Render",
                    "properties": [
                        {
                            "index": 0,
                            "kind": "numeric",
                            "title": "Level",
                            "field": "level",
                            "value": 0.5,
                            "min": 0.0,
                            "max": 1.0,
                            "count": 5,
                            "step": 0.25,
                            "input_kind": "number",
                            "row_kind": "inline",
                            "default_value": 0.5,
                            "is_default": True,
                            "can_reset": True,
                        }
                    ],
                }
            ]
        }
    )

    spin = panel.findChild(QtWidgets.QDoubleSpinBox)

    spin.stepUp()
    spin.editingFinished.emit()

    assert emitted == [("Render", 0, 0.75, "level", "Level")]


def test_property_panel_numeric_text_commits_on_editing_finished_only(qapp):
    panel = PropertyPanel()
    emitted = []
    panel.property_edited.connect(lambda *args: emitted.append(args))
    panel.set_snapshot(
        {
            "property_groups": [
                {
                    "id": "Render",
                    "title": "Render",
                    "properties": [
                        {
                            "index": 0,
                            "kind": "numeric",
                            "title": "Level",
                            "field": "level",
                            "value": 0.5,
                            "min": 0.0,
                            "max": 1.0,
                            "count": 5,
                            "step": 0.25,
                            "input_kind": "number",
                            "row_kind": "inline",
                            "default_value": 0.5,
                            "is_default": True,
                            "can_reset": True,
                        }
                    ],
                }
            ]
        }
    )

    spin = panel.findChild(QtWidgets.QDoubleSpinBox)
    spin.lineEdit().setText("0.75")
    spin.interpretText()

    assert emitted == []

    spin.editingFinished.emit()

    assert emitted == [("Render", 0, 0.75, "level", "Level")]


def test_property_panel_uses_reset_button_state_as_changed_signal(qapp):
    panel = PropertyPanel()
    panel.set_snapshot(
        {
            "property_groups": [
                {
                    "id": "Render",
                    "title": "Render",
                    "properties": [
                        {
                            "index": 0,
                            "kind": "numeric",
                            "title": "Level",
                            "field": "level",
                            "value": 0.5,
                            "min": 0.0,
                            "max": 1.0,
                            "count": 5,
                            "step": 0.25,
                            "input_kind": "number",
                            "row_kind": "inline",
                            "default_value": 0.5,
                            "is_default": True,
                            "can_reset": True,
                        },
                        {
                            "index": 1,
                            "kind": "boolean",
                            "title": "Flag",
                            "field": "flag",
                            "value": False,
                            "input_kind": "switch",
                            "row_kind": "inline",
                            "default_value": True,
                            "is_default": False,
                            "can_reset": True,
                        },
                    ],
                }
            ]
        }
    )

    changed_labels = [
        label
        for label in panel.findChildren(QtWidgets.QLabel)
        if label.property("inspectorRole") == "changed"
    ]
    reset_buttons = [
        button
        for button in panel.findChildren(QtWidgets.QToolButton)
        if button.property("inspectorRole") == "reset"
    ]

    assert changed_labels == []
    assert len(reset_buttons) == 2
    assert reset_buttons[0].isEnabled() is False
    assert reset_buttons[1].isEnabled() is True


def test_property_panel_uses_uniform_control_heights(qapp):
    panel = PropertyPanel()
    panel.set_snapshot(
        {
            "property_groups": [
                {
                    "id": "Render",
                    "title": "Render",
                    "properties": [
                        {
                            "index": 0,
                            "kind": "numeric",
                            "title": "Level",
                            "field": "level",
                            "value": 0.5,
                            "min": 0.0,
                            "max": 1.0,
                            "count": 5,
                            "step": 0.25,
                            "input_kind": "number",
                            "row_kind": "inline",
                            "default_value": 0.5,
                            "is_default": True,
                            "can_reset": True,
                        },
                        {
                            "index": 1,
                            "kind": "boolean",
                            "title": "Flag",
                            "field": "flag",
                            "value": True,
                            "input_kind": "switch",
                            "row_kind": "inline",
                            "default_value": True,
                            "is_default": True,
                            "can_reset": True,
                        },
                        {
                            "index": 2,
                            "kind": "list",
                            "title": "Mode",
                            "field": "mode",
                            "value": "alpha",
                            "values": ["alpha", "beta"],
                            "selected_index": 0,
                            "input_kind": "dropdown",
                            "row_kind": "inline",
                            "default_value": "alpha",
                            "is_default": True,
                            "can_reset": True,
                        },
                    ],
                }
            ]
        }
    )

    spin = panel.findChild(QtWidgets.QDoubleSpinBox)
    checkbox = panel.findChild(QtWidgets.QCheckBox)
    combo = panel.findChild(QtWidgets.QComboBox)
    reset = next(
        button
        for button in panel.findChildren(QtWidgets.QToolButton)
        if button.property("inspectorRole") == "reset"
    )

    assert spin.minimumHeight() == PROPERTY_CONTROL_HEIGHT
    assert checkbox.minimumHeight() == PROPERTY_CONTROL_HEIGHT
    assert combo.minimumHeight() == PROPERTY_CONTROL_HEIGHT
    assert reset.minimumHeight() == PROPERTY_CONTROL_HEIGHT
    assert reset.width() == PROPERTY_RESET_WIDTH


def test_topovec_view_window_constructs_default_docks(qapp, monkeypatch):
    monkeypatch.setattr(TvViewWindow, "_install_log_handler", lambda self: None)
    window = TvViewWindow()
    window.show()
    qapp.processEvents()
    toolbar = window.findChild(QtWidgets.QToolBar, "topovec.view.toolbar")

    assert window.windowTitle() == "topovec view"
    assert window.sources_dock.windowTitle() == "Data Sources"
    assert window.properties_dock.windowTitle() == "Properties"
    assert window.snippet_dock.windowTitle() == "Python Snippet"
    assert window.log_dock.windowTitle() == "Log"
    assert (
        window.dockWidgetArea(window.snippet_dock)
        == QtCore.Qt.DockWidgetArea.BottomDockWidgetArea
    )
    assert (
        window.dockWidgetArea(window.log_dock)
        == QtCore.Qt.DockWidgetArea.BottomDockWidgetArea
    )
    assert window.scene_combo.isEnabled() is False
    assert isinstance(window.viewport, QtViewport)
    assert window.viewport.testAttribute(QtCore.Qt.WidgetAttribute.WA_OpaquePaintEvent)
    assert window.viewport.autoFillBackground() is False
    assert window.sources_dock.isHidden() is False
    assert window.properties_dock.isHidden() is False
    assert window.snippet_dock.isHidden() is True
    assert window.log_dock.isHidden() is True
    assert window.left_panel_action.isChecked() is True
    assert window.bottom_panel_action.isChecked() is False
    assert window.right_panel_action.isChecked() is True
    assert toolbar is not None
    assert toolbar.toolButtonStyle() == QtCore.Qt.ToolButtonStyle.ToolButtonIconOnly
    assert window.open_action.icon().isNull() is False
    assert window.save_image_action.icon().isNull() is False
    assert window.reset_camera_action.icon().isNull() is False
    assert window.snap_camera_action.icon().isNull() is False
    assert window.toggle_projection_action.icon().isNull() is False
    assert set(window._theme_actions) == {"system", "dark", "light"}
    assert window._theme_actions["system"].isChecked() is True
    window.close()


def test_toolbar_icon_helper_prefers_theme_and_falls_back_to_qstyle(qapp, monkeypatch):
    window = TvViewWindow()
    fallback_icon = QtGui.QIcon()
    fallback_icon.addPixmap(QtGui.QPixmap(16, 16))
    theme_icon = QtGui.QIcon()
    theme_icon.addPixmap(QtGui.QPixmap(16, 16))

    class DummyStyle:
        def standardIcon(self, _pixmap):
            return fallback_icon

    monkeypatch.setattr(TvViewWindow, "style", lambda self: DummyStyle())

    monkeypatch.setattr(
        QtGui.QIcon,
        "fromTheme",
        staticmethod(lambda name: theme_icon if name == "document-open" else QtGui.QIcon()),
    )

    selected = window._icon_from_theme(
        ("document-open", "folder-open"),
        QtWidgets.QStyle.StandardPixmap.SP_DialogOpenButton,
    )
    fallback_selected = window._icon_from_theme(
        ("missing-icon",),
        QtWidgets.QStyle.StandardPixmap.SP_DialogOpenButton,
    )

    assert selected.cacheKey() == theme_icon.cacheKey()
    assert fallback_selected.cacheKey() == fallback_icon.cacheKey()
    window.close()


def test_toolbar_icon_support_bootstraps_system_theme_paths(monkeypatch, tmp_path):
    icons_root = tmp_path / "icons"
    theme_dir = icons_root / "Adwaita"
    theme_dir.mkdir(parents=True)
    (theme_dir / "index.theme").write_text("[Icon Theme]\nName=Adwaita\n", encoding="utf-8")

    state = {
        "search_paths": [":/icons"],
        "theme_name": "",
        "fallback_theme_name": "",
    }

    monkeypatch.setenv("XDG_DATA_DIRS", str(tmp_path))
    monkeypatch.setattr(QtGui.QIcon, "themeSearchPaths", staticmethod(lambda: list(state["search_paths"])))
    monkeypatch.setattr(
        QtGui.QIcon,
        "setThemeSearchPaths",
        staticmethod(lambda paths: state.__setitem__("search_paths", list(paths))),
    )
    monkeypatch.setattr(QtGui.QIcon, "themeName", staticmethod(lambda: state["theme_name"]))
    monkeypatch.setattr(
        QtGui.QIcon,
        "fallbackThemeName",
        staticmethod(lambda: state["fallback_theme_name"]),
    )
    monkeypatch.setattr(
        QtGui.QIcon,
        "setFallbackThemeName",
        staticmethod(lambda name: state.__setitem__("fallback_theme_name", str(name))),
    )

    TvViewWindow._ensure_theme_icon_support()

    assert str(icons_root) in state["search_paths"]
    assert state["fallback_theme_name"] == "Adwaita"


def test_toolbar_separates_camera_actions_from_other_icons(qapp):
    window = TvViewWindow()
    toolbar = window.findChild(QtWidgets.QToolBar, "topovec.view.toolbar")
    actions = toolbar.actions()

    assert actions[0] is window.open_action
    assert actions[1] is window.save_image_action
    assert actions[2].isSeparator() is True
    assert actions[3] is window.left_panel_action
    assert actions[4] is window.bottom_panel_action
    assert actions[5] is window.right_panel_action
    assert actions[6].isSeparator() is True
    assert actions[7] is window.reset_camera_action
    assert actions[8] is window.snap_camera_action
    assert actions[9] is window.toggle_projection_action
    assert actions[10].isSeparator() is False
    assert toolbar.widgetForAction(actions[12]) is window.scene_combo
    window.close()


def test_view_menu_contains_area_actions_above_per_dock_toggles(qapp):
    window = TvViewWindow()
    view_menu = _menu_for_title(window, "&View")
    action_texts = [action.text() for action in view_menu.actions() if not action.isSeparator()]

    left_index = action_texts.index("Left Panel")
    bottom_index = action_texts.index("Bottom Panel")
    right_index = action_texts.index("Right Panel")
    sources_index = action_texts.index(window.sources_dock.toggleViewAction().text())
    properties_index = action_texts.index(window.properties_dock.toggleViewAction().text())
    snippet_index = action_texts.index(window.snippet_dock.toggleViewAction().text())
    log_index = action_texts.index(window.log_dock.toggleViewAction().text())

    assert left_index < sources_index
    assert bottom_index < snippet_index
    assert right_index < properties_index
    assert log_index > snippet_index
    window.close()


def test_default_dock_sizes_use_decluttered_widths(qapp, monkeypatch):
    window = TvViewWindow()
    calls = []

    monkeypatch.setattr(
        window,
        "resizeDocks",
        lambda docks, sizes, orientation: calls.append((list(docks), list(sizes), orientation)),
    )

    window._apply_default_dock_sizes()

    assert calls == [
        (
            [window.sources_dock, window.properties_dock],
            [240, 320],
            QtCore.Qt.Orientation.Horizontal,
        )
    ]
    window.close()


def test_reset_layout_restores_decluttered_default_docks(qapp, monkeypatch):
    monkeypatch.setattr(TvViewWindow, "_install_log_handler", lambda self: None)
    window = TvViewWindow()
    window.show()
    qapp.processEvents()

    window.bottom_panel_action.trigger()
    qapp.processEvents()
    window.left_panel_action.trigger()
    qapp.processEvents()

    assert window.snippet_dock.isHidden() is False
    assert window.log_dock.isHidden() is False
    assert window.sources_dock.isHidden() is True

    window.reset_layout()
    qapp.processEvents()

    assert window.sources_dock.isHidden() is False
    assert window.properties_dock.isHidden() is False
    assert window.snippet_dock.isHidden() is True
    assert window.log_dock.isHidden() is True
    assert window.left_panel_action.isChecked() is True
    assert window.bottom_panel_action.isChecked() is False
    assert window.right_panel_action.isChecked() is True
    window.close()


def test_area_actions_toggle_corresponding_dock_areas(qapp, monkeypatch):
    monkeypatch.setattr(TvViewWindow, "_install_log_handler", lambda self: None)
    window = TvViewWindow()
    window.show()
    qapp.processEvents()

    window.left_panel_action.trigger()
    qapp.processEvents()
    assert window.sources_dock.isHidden() is True
    assert window.left_panel_action.isChecked() is False

    window.left_panel_action.trigger()
    qapp.processEvents()
    assert window.sources_dock.isHidden() is False
    assert window.left_panel_action.isChecked() is True

    window.right_panel_action.trigger()
    qapp.processEvents()
    assert window.properties_dock.isHidden() is True
    assert window.right_panel_action.isChecked() is False

    window.right_panel_action.trigger()
    qapp.processEvents()
    assert window.properties_dock.isHidden() is False
    assert window.right_panel_action.isChecked() is True

    window.bottom_panel_action.trigger()
    qapp.processEvents()
    assert window.snippet_dock.isHidden() is False
    assert window.log_dock.isHidden() is False
    assert window.bottom_panel_action.isChecked() is True

    window.bottom_panel_action.trigger()
    qapp.processEvents()
    assert window.snippet_dock.isHidden() is True
    assert window.log_dock.isHidden() is True
    assert window.bottom_panel_action.isChecked() is False
    window.close()


def test_bottom_area_action_applies_to_additional_bottom_docks(qapp, monkeypatch):
    monkeypatch.setattr(TvViewWindow, "_install_log_handler", lambda self: None)
    window = TvViewWindow()
    extra_dock = QtWidgets.QDockWidget("Extra", window)
    extra_dock.setObjectName("topovec.view.extra")
    extra_dock.setWidget(QtWidgets.QLabel("extra"))
    window.addDockWidget(QtCore.Qt.DockWidgetArea.BottomDockWidgetArea, extra_dock)
    window.show()
    qapp.processEvents()

    assert window.bottom_panel_action.isChecked() is True

    window.bottom_panel_action.trigger()
    qapp.processEvents()

    assert window.snippet_dock.isHidden() is True
    assert window.log_dock.isHidden() is True
    assert extra_dock.isHidden() is True
    assert window.bottom_panel_action.isChecked() is False

    window.bottom_panel_action.trigger()
    qapp.processEvents()

    assert window.snippet_dock.isHidden() is False
    assert window.log_dock.isHidden() is False
    assert extra_dock.isHidden() is False
    assert window.bottom_panel_action.isChecked() is True
    window.close()


def test_dock_area_actions_track_manual_visibility_location_and_floating(qapp):
    window = TvViewWindow()
    window.show()
    qapp.processEvents()

    window.sources_dock.hide()
    qapp.processEvents()
    assert window.left_panel_action.isChecked() is False

    window.sources_dock.show()
    qapp.processEvents()
    assert window.left_panel_action.isChecked() is True

    window.properties_dock.setFloating(True)
    qapp.processEvents()
    assert window.right_panel_action.isChecked() is False

    window.properties_dock.setFloating(False)
    window.addDockWidget(QtCore.Qt.DockWidgetArea.LeftDockWidgetArea, window.properties_dock)
    qapp.processEvents()
    assert window.right_panel_action.isChecked() is False
    assert window.left_panel_action.isChecked() is True
    window.close()


def test_theme_selection_persists_and_restores(qapp, topovec_view_settings_path):
    window = TvViewWindow()
    initial_window = qapp.palette().color(QtGui.QPalette.ColorRole.Window)

    window._theme_actions["dark"].trigger()

    assert _read_theme_setting(topovec_view_settings_path) == "dark"
    assert window._theme_actions["dark"].isChecked() is True
    assert qapp.palette().color(QtGui.QPalette.ColorRole.Window) != initial_window
    window.close()

    restored = TvViewWindow()
    assert restored._theme_actions["dark"].isChecked() is True
    assert _read_theme_setting(topovec_view_settings_path) == "dark"
    restored.close()


def test_theme_modes_apply_distinct_palettes(qapp, topovec_view_settings_path):
    window = TvViewWindow()
    initial = qapp.style().standardPalette().color(QtGui.QPalette.ColorRole.Window)

    window._theme_actions["dark"].trigger()
    dark = qapp.palette().color(QtGui.QPalette.ColorRole.Window)
    window._theme_actions["light"].trigger()
    light = qapp.palette().color(QtGui.QPalette.ColorRole.Window)
    window._theme_actions["system"].trigger()
    system = qapp.palette().color(QtGui.QPalette.ColorRole.Window)

    assert dark != light
    assert system == qapp.style().standardPalette().color(QtGui.QPalette.ColorRole.Window)
    assert dark != initial or light != initial
    window.close()


def test_system_theme_change_only_reapplies_in_system_mode(qapp, monkeypatch):
    window = TvViewWindow()
    calls = []
    original_apply_theme = window._apply_theme

    def tracked_apply_theme(mode, *, persist):
        calls.append((mode, persist))
        return original_apply_theme(mode, persist=persist)

    monkeypatch.setattr(window, "_apply_theme", tracked_apply_theme)

    window._theme_actions["dark"].trigger()
    calls.clear()
    window._on_system_color_scheme_changed(QtCore.Qt.ColorScheme.Light)
    assert calls == []

    window._theme_actions["system"].trigger()
    calls.clear()
    window._on_system_color_scheme_changed(QtCore.Qt.ColorScheme.Dark)
    assert calls == [("system", False)]
    window.close()


def test_property_panel_updates_theme_sensitive_reset_icon(qapp):
    panel = PropertyPanel()
    panel.set_snapshot(
        {
            "property_groups": [
                {
                    "id": "Render",
                    "title": "Render",
                    "properties": [
                        {
                            "index": 0,
                            "kind": "numeric",
                            "title": "Level",
                            "field": "level",
                            "value": 0.75,
                            "min": 0.0,
                            "max": 1.0,
                            "count": 5,
                            "step": 0.25,
                            "input_kind": "number",
                            "row_kind": "inline",
                            "default_value": 0.5,
                            "is_default": False,
                            "can_reset": True,
                        }
                    ],
                }
            ]
        }
    )

    reset = next(
        button
        for button in panel.findChildren(QtWidgets.QToolButton)
        if button.property("inspectorRole") == "reset"
    )
    before = reset.icon().cacheKey()

    from topovec.viewer.qt.theme import theme_spec_for_mode

    panel.apply_theme(theme_spec_for_mode("dark", qapp))
    after = reset.icon().cacheKey()

    assert before != after


def test_viewport_empty_clear_color_follows_theme(qapp):
    viewport = QtViewportWindow()
    from topovec.viewer.qt.theme import theme_spec_for_mode

    viewport.apply_theme(theme_spec_for_mode("dark", qapp))
    dark = viewport._empty_clear_color()
    viewport.apply_theme(theme_spec_for_mode("light", qapp))
    light = viewport._empty_clear_color()

    assert dark != light
    assert dark == viewport._theme_spec.viewport_clear_rgba or light == viewport._theme_spec.viewport_clear_rgba


def test_load_paths_updates_recent_files_mru_and_caps_history(
    qapp, monkeypatch, topovec_view_settings_path
):
    window = TvViewWindow()
    documents = {}
    paths = []
    for index in range(11):
        path = Path(f"/tmp/recent_{index}.npz")
        paths.append(path)
        path.touch()
        documents[str(path.resolve())] = _make_document(
            f"recent_{index}.npz",
            System.cubic((4, 4, 4)),
            [np.zeros((4, 4, 4, 1, 3), dtype=np.float32)],
            format_name="NPZ",
        )
    monkeypatch.setattr(
        "topovec.viewer.qt.main_window.load_viewer_document",
        lambda raw_path: documents[str(Path(raw_path).expanduser().resolve())],
    )

    window.load_paths([str(path) for path in paths])
    qapp.processEvents()

    stored = _read_recent_settings_paths(topovec_view_settings_path)
    assert stored == [str(path.resolve()) for path in paths[:10]]

    window.load_paths([str(paths[5])])
    qapp.processEvents()

    stored = _read_recent_settings_paths(topovec_view_settings_path)
    assert stored[0] == str(paths[5].resolve())
    assert len(stored) == 10
    assert len(set(stored)) == 10
    window.close()


def test_recent_menu_rebuilds_from_persisted_settings_and_disambiguates_labels(
    qapp, topovec_view_settings_path
):
    path_a = (topovec_view_settings_path.parent / "dir_a" / "shared.npz").resolve()
    path_b = (topovec_view_settings_path.parent / "dir_b" / "shared.npz").resolve()
    path_a.parent.mkdir(parents=True, exist_ok=True)
    path_b.parent.mkdir(parents=True, exist_ok=True)
    path_a.touch()
    path_b.touch()
    settings = REAL_QSETTINGS(
        str(topovec_view_settings_path),
        REAL_QSETTINGS.Format.IniFormat,
    )
    settings.setValue("recent_files", [str(path_a), str(path_b)])

    window = TvViewWindow()
    window._refresh_recent_files_menu()

    labels = [action.text() for action in window.open_recent_menu.actions() if action.text()]

    assert labels[:2] == [f"shared.npz — {path_a.parent}", f"shared.npz — {path_b.parent}"]
    assert labels[-1] == "Clear Recent Files"
    assert window.open_recent_menu.menuAction().isEnabled() is True
    window.close()


def test_open_recent_file_focuses_existing_document_without_duplicate_source(
    qapp, monkeypatch, topovec_view_settings_path
):
    window = TvViewWindow()
    window.viewport._ctx = object()
    _install_qt_scene_registry(monkeypatch)

    document_a = _make_document(
        "field_a.sad",
        System.cubic((4, 4, 4)),
        [np.zeros((4, 4, 4, 1, 3), dtype=np.float32)],
    )
    document_b = _make_document(
        "field_b.sad",
        System.cubic((4, 4, 4)),
        [np.ones((4, 4, 4, 1, 3), dtype=np.float32)],
    )
    documents = {
        str(document_a.path): document_a,
        str(document_b.path): document_b,
    }
    document_a.path.touch()
    document_b.path.touch()
    monkeypatch.setattr(
        "topovec.viewer.qt.main_window.load_viewer_document",
        lambda raw_path: documents[str(Path(raw_path).expanduser().resolve())],
    )

    window.load_paths([str(document_a.path), str(document_b.path)])
    qapp.processEvents()
    second_item = window.sources_tree.topLevelItem(1).child(0)
    window.sources_tree.setCurrentItem(second_item)
    qapp.processEvents()

    window.open_recent_file(str(document_a.path))
    qapp.processEvents()

    current_payload = window.sources_tree.currentItem().data(0, QtCore.Qt.ItemDataRole.UserRole)
    assert window.sources_tree.topLevelItemCount() == 2
    assert current_payload[0] == "state"
    assert current_payload[1] is document_a
    assert window._current_document is document_a
    stored = _read_recent_settings_paths(topovec_view_settings_path)
    assert stored[0] == str(document_a.path)
    window.close()


def test_recent_menu_prunes_missing_paths_and_clear_empties_settings(
    qapp, topovec_view_settings_path
):
    existing = (topovec_view_settings_path.parent / "kept.npz").resolve()
    existing.touch()
    missing = (topovec_view_settings_path.parent / "missing.npz").resolve()
    settings = REAL_QSETTINGS(
        str(topovec_view_settings_path),
        REAL_QSETTINGS.Format.IniFormat,
    )
    settings.setValue("recent_files", [str(existing), str(missing)])

    window = TvViewWindow()
    window._refresh_recent_files_menu()

    stored = _read_recent_settings_paths(topovec_view_settings_path)
    labels = [action.text() for action in window.open_recent_menu.actions() if action.text()]
    assert stored == [str(existing)]
    assert labels == ["kept.npz", "Clear Recent Files"]

    window.clear_recent_files()

    assert _read_recent_settings_paths(topovec_view_settings_path) == []
    assert window.open_recent_menu.menuAction().isEnabled() is False
    window.close()


def test_closing_current_source_keeps_recent_history(
    qapp, monkeypatch, topovec_view_settings_path
):
    window = TvViewWindow()
    document = _make_document(
        "close_me.npz",
        System.cubic((4, 4, 4)),
        [np.zeros((4, 4, 4, 1, 3), dtype=np.float32)],
        format_name="NPZ",
    )
    document.path.touch()
    monkeypatch.setattr(
        "topovec.viewer.qt.main_window.load_viewer_document",
        lambda _raw_path: document,
    )

    window.load_paths([str(document.path)])
    qapp.processEvents()
    window.close_selected_source()
    qapp.processEvents()

    assert _read_recent_settings_paths(topovec_view_settings_path) == [str(document.path)]
    window.close()


def test_window_ignores_legacy_tvview_settings_namespace(qapp, monkeypatch, tmp_path):
    settings_path = tmp_path / "topovec.view.ini"
    legacy_path = tmp_path / "tvview.ini"
    legacy_state = (legacy_path.parent / "legacy_state.npz").resolve()
    legacy_state.touch()

    legacy_settings = REAL_QSETTINGS(str(legacy_path), REAL_QSETTINGS.Format.IniFormat)
    legacy_settings.setValue(THEME_SETTING_KEY, "dark")
    legacy_settings.setValue("recent_files", [str(legacy_state)])

    def _make_settings(organization, application):
        assert organization == "topovec"
        assert application == "topovec.view"
        return REAL_QSETTINGS(str(settings_path), REAL_QSETTINGS.Format.IniFormat)

    monkeypatch.setattr("topovec.viewer.qt.main_window.QtCore.QSettings", _make_settings)

    window = TvViewWindow()
    window._refresh_recent_files_menu()

    assert window._theme_actions["system"].isChecked() is True
    assert _read_theme_setting(settings_path) is None
    assert _read_recent_settings_paths(settings_path) == []
    assert window.open_recent_menu.menuAction().isEnabled() is False
    window.close()


def test_log_panel_supports_append_clear_and_copy(qapp):
    panel = LogPanel()

    panel.append_message("12:00:00 INFO topovec.viewer.qt: hello", logging.INFO)
    assert "hello" in panel.text()

    panel.copy_to_clipboard()
    assert QtWidgets.QApplication.clipboard().text() == panel.text()

    panel.clear()
    assert panel.text() == ""


def test_warning_log_records_raise_log_dock(qapp, monkeypatch):
    window = TvViewWindow()
    raised = []

    monkeypatch.setattr(window.log_dock, "raise_", lambda: raised.append("raised"))

    window._on_log_record_appended(logging.INFO)
    assert raised == []

    window._on_log_record_appended(logging.WARNING)
    assert raised == ["raised"]
    assert window.log_dock.isHidden() is False
    assert window.bottom_panel_action.isChecked() is True
    window.close()


def test_bottom_output_docks_default_to_log_tab(qapp, monkeypatch):
    snippet_raised = []
    log_raised = []

    monkeypatch.setattr(
        TvViewWindow,
        "_install_log_handler",
        lambda self: None,
    )
    monkeypatch.setattr(
        QtWidgets.QDockWidget,
        "raise_",
        lambda dock: (
            log_raised.append(dock.objectName())
            if dock.objectName() == "topovec.view.log"
            else snippet_raised.append(dock.objectName())
        ),
    )

    window = TvViewWindow()

    assert log_raised
    assert snippet_raised == []
    window.close()


def test_activation_failure_is_logged_and_reported(qapp, monkeypatch):
    window = TvViewWindow()
    window.viewport._ctx = object()

    document = ViewerDocument(
        path=Path("/tmp/example.npz"),
        label="example.npz",
        format_name="NPZ",
        system=SimpleNamespace(size=np.asarray((4, 4, 4), dtype=int)),
        entries=[],
        metadata={},
    )
    entry = StateEntry(
        label="PATH[0]",
        index=(0,),
        load_state=lambda: np.zeros((4, 4, 4, 1, 3), dtype=np.float32),
        metadata={},
    )

    monkeypatch.setattr(window, "_preferred_scene_for_system", lambda _system: "Layer")
    monkeypatch.setattr(
        window.viewport.host,
        "create_scene",
        lambda _scene_cls, _system: (_ for _ in ()).throw(RuntimeError("boom")),
    )

    window._activate_entry(document, entry)
    qapp.processEvents()

    assert "Failed to activate" in window.statusBar().currentMessage()
    assert "boom" in window.log_panel.text()
    window.close()


def test_load_failure_is_logged_and_reported(qapp, monkeypatch):
    window = TvViewWindow()
    warnings = []

    monkeypatch.setattr(
        "topovec.viewer.qt.main_window.load_viewer_document",
        lambda _path: (_ for _ in ()).throw(ValueError("broken file")),
    )
    monkeypatch.setattr(
        QtWidgets.QMessageBox,
        "warning",
        lambda *args: warnings.append(args),
    )

    window.load_paths(["/tmp/bad.npz"])
    qapp.processEvents()

    assert warnings
    assert "Failed to open bad.npz" in window.statusBar().currentMessage()
    assert "broken file" in window.log_panel.text()
    window.close()


def test_qt_opengl_loader_uses_get_proc_address():
    calls = []

    class DummyContext:
        def getProcAddress(self, name):
            calls.append(name)
            return 12345

    loader = QtOpenGLLoader(DummyContext())

    assert loader.load_opengl_function("glExample") == 12345
    assert calls == ["glExample"]


def test_initialize_gl_prefers_qt_loader(qapp, monkeypatch):
    viewport = QtViewportWindow()
    dummy_ctx = object()
    called = {}

    class DummyQtContext:
        def getProcAddress(self, name):
            called.setdefault("proc_names", []).append(name)
            return 1

    monkeypatch.setattr(QtGui.QOpenGLContext, "currentContext", staticmethod(lambda: DummyQtContext()))
    monkeypatch.setattr(viewport, "context", lambda: DummyQtContext())

    def fake_init_context(loader):
        called["loader_type"] = type(loader).__name__
        called["proc"] = loader.load_opengl_function("glGetString")

    monkeypatch.setattr("topovec.viewer.qt.viewport.mgl.init_context", fake_init_context)
    monkeypatch.setattr("topovec.viewer.qt.viewport.mgl.get_context", lambda: dummy_ctx)
    monkeypatch.setattr("topovec.viewer.qt.viewport.mgl.create_context", lambda: (_ for _ in ()).throw(AssertionError("fallback should not run")))

    viewport.initializeGL()

    assert viewport._ctx is dummy_ctx
    assert called["loader_type"] == "QtOpenGLLoader"
    assert called["proc"] == 1


def test_initialize_gl_falls_back_to_default_loader(qapp, monkeypatch):
    viewport = QtViewportWindow()
    fallback_ctx = object()

    class DummyQtContext:
        def getProcAddress(self, name):
            return 1

    messages = []

    monkeypatch.setattr(QtGui.QOpenGLContext, "currentContext", staticmethod(lambda: DummyQtContext()))
    monkeypatch.setattr(viewport, "context", lambda: DummyQtContext())
    monkeypatch.setattr(
        "topovec.viewer.qt.viewport.mgl.init_context",
        lambda loader: (_ for _ in ()).throw(RuntimeError("qt loader failed")),
    )
    monkeypatch.setattr("topovec.viewer.qt.viewport.mgl.create_context", lambda: fallback_ctx)
    viewport.runtime_error.connect(messages.append)

    viewport.initializeGL()

    assert viewport._ctx is fallback_ctx
    assert messages == []


def test_current_widget_target_uses_detected_default_framebuffer(monkeypatch):
    viewport = QtViewportWindow()
    detected = []
    detected_fbo = SimpleNamespace(glo=23, size=(320, 240))

    class DummyContext:
        def detect_framebuffer(self, framebuffer_id):
            detected.append(framebuffer_id)
            return detected_fbo

    monkeypatch.setattr(viewport, "defaultFramebufferObject", lambda: 23)
    monkeypatch.setattr(viewport, "current_render_size", lambda: (320, 240))
    viewport._ctx = DummyContext()

    target = viewport._current_widget_target()

    assert detected == [23]
    assert target.fbo is detected_fbo
    assert target.size == (320, 240)


def test_prepare_render_target_restores_known_baseline():
    viewport = QtViewportWindow()

    class DummyFramebuffer:
        def __init__(self):
            self.glo = 77
            self.size = (320, 240)
            self.color_mask = (False, False, False, False)
            self.depth_mask = False
            self.viewport = (1, 2, 3, 4)
            self.scissor = (5, 6, 7, 8)

    class DummyContext:
        def __init__(self):
            self.fbo = DummyFramebuffer()
            self._blend_func = (1, 1)
            self._blend_equation = 12345
            self.enabled = []
            self.viewport = None
            self._scissor = (5, 6, 7, 8)

        def enable_only(self, flags):
            self.enabled.append(flags)

        @property
        def scissor(self):
            return self._scissor

        @scissor.setter
        def scissor(self, value):
            self._scissor = value

        @property
        def blend_func(self):
            raise NotImplementedError()

        @blend_func.setter
        def blend_func(self, value):
            self._blend_func = tuple(value)

        @property
        def blend_equation(self):
            raise NotImplementedError()

        @blend_equation.setter
        def blend_equation(self, value):
            self._blend_equation = value

    viewport._ctx = DummyContext()
    bind_calls = []

    class DummyTarget:
        size = (320, 240)

        def __init__(self):
            self.fbo = viewport._ctx.fbo

        def bind(self):
            bind_calls.append("bind")
            return self

    viewport._prepare_render_target("paintGL", target=DummyTarget())

    assert bind_calls == ["bind"]
    assert viewport._ctx.fbo.color_mask == (True, True, True, True)
    assert viewport._ctx.fbo.depth_mask is True
    assert viewport._ctx.fbo.scissor is None
    assert viewport._ctx.fbo.viewport == (0, 0, 320, 240)
    assert viewport._ctx.viewport == (0, 0, 320, 240)
    assert viewport._ctx.enabled == [mgl.NOTHING]
    assert viewport._ctx.scissor is None
    assert viewport._ctx._blend_func == mgl.DEFAULT_BLENDING
    assert viewport._ctx._blend_equation == mgl.FUNC_ADD


def test_runtime_diagnostics_collects_gl_and_qt_context_details():
    viewport = QtViewport()
    viewport._ctx = SimpleNamespace(
        info={
            "GL_VENDOR": "Vendor",
            "GL_RENDERER": "Renderer",
            "GL_VERSION": "4.6",
            "GL_SHADING_LANGUAGE_VERSION": "4.60",
        },
        version_code=460,
    )

    monkey_format = QtGui.QSurfaceFormat()
    monkey_format.setVersion(4, 1)
    monkey_format.setProfile(QtGui.QSurfaceFormat.OpenGLContextProfile.CoreProfile)
    monkey_format.setRenderableType(QtGui.QSurfaceFormat.RenderableType.OpenGL)
    monkey_format.setSwapInterval(1)
    monkey_format.setSamples(4)
    monkey_format.setDepthBufferSize(24)
    monkey_format.setStencilBufferSize(8)
    monkey_format.setAlphaBufferSize(8)

    class DummyQtContext:
        def format(self):
            return monkey_format

        def isOpenGLES(self):
            return False

    viewport.setFormat(monkey_format)
    diagnostics = viewport._runtime_diagnostics(DummyQtContext())

    assert diagnostics["vendor"] == "Vendor"
    assert diagnostics["renderer"] == "Renderer"
    assert diagnostics["version"] == "4.6"
    assert diagnostics["glsl"] == "4.60"
    assert diagnostics["version_code"] == 460
    assert diagnostics["is_opengles"] is False
    assert diagnostics["actual_format"]["version"] == (4, 1)
    assert diagnostics["requested_format"]["version"] == (4, 1)


def test_paint_gl_without_scene_clears_current_framebuffer(monkeypatch):
    viewport = QtViewportWindow()
    finish_calls = []
    clear_calls = []
    prepared = []

    class DummyContext:
        def finish(self):
            finish_calls.append("finish")

    viewport._ctx = DummyContext()

    class DummyTarget:
        size = (320, 240)
        fbo = SimpleNamespace(glo=77)
        def clear_color(self, *args):
            clear_calls.append(args)

    target = DummyTarget()
    monkeypatch.setattr(viewport, "_current_widget_target", lambda: target)
    monkeypatch.setattr(
        viewport,
        "_prepare_render_target",
        lambda reason, *, target: prepared.append((reason, target)),
    )

    viewport.paintGL()

    assert clear_calls == [viewport._empty_clear_color()]
    assert prepared == [("paintGL", target)]
    assert finish_calls == ["finish"]


def test_render_image_uses_explicit_render_target(monkeypatch):
    viewport = QtViewport()
    make_current_calls = []
    done_current_calls = []
    prepared = []
    release_calls = []

    class DummyContext:
        def finish(self):
            return None

    class DummyTarget:
        def __init__(self, ctx, size):
            self.ctx = ctx
            self.size = tuple(size)
            self.fbo = SimpleNamespace(glo=77)
            self.color_texture = SimpleNamespace(glo=15)

        def bind(self):
            return self

        def read_rgb(self):
            width, height = self.size
            return b"\x00" * (width * height * 3)

        def release(self):
            release_calls.append(self.size)

    class DummyScene:
        def __init__(self):
            self.targets = []

        def render_to(self, target):
            self.targets.append(target)

    monkeypatch.setattr("topovec.viewer.qt.viewport.OwnedRenderTarget", DummyTarget)
    monkeypatch.setattr(viewport, "makeCurrent", lambda: make_current_calls.append("makeCurrent"))
    monkeypatch.setattr(viewport, "doneCurrent", lambda: done_current_calls.append("doneCurrent"))
    monkeypatch.setattr(
        viewport,
        "_prepare_render_target",
        lambda reason, *, target: prepared.append((reason, target.size)),
    )
    viewport._ctx = DummyContext()
    viewport._scene = DummyScene()

    image = viewport.host.render_image(size=(64, 48))

    assert image.size == (64, 48)
    assert prepared == [("render_image", (64, 48))]
    assert len(viewport._scene.targets) == 1
    assert viewport._scene.targets[0].size == (64, 48)
    assert release_calls == [(64, 48)]
    assert make_current_calls == ["makeCurrent"]
    assert done_current_calls == ["doneCurrent"]


def test_camera_session_state_change_does_not_refresh_properties_panel(qapp, monkeypatch):
    window = TvViewWindow()
    property_calls = []
    summary_calls = []

    class DummySession:
        selected_scene = "Layer"

        def camera_snapshot(self):
            return {"projection_label": "Perspective"}

        def render_snippet(self):
            return "snippet"

    monkeypatch.setattr(window.properties_panel, "set_snapshot", lambda *args, **kwargs: property_calls.append((args, kwargs)))
    monkeypatch.setattr(window.snippet_dock, "isVisible", lambda: True)
    monkeypatch.setattr(window.snippet_panel, "set_text", lambda text: summary_calls.append(text))
    window._session = DummySession()
    window._current_document = SimpleNamespace(label="doc.sad")
    window._current_entry = SimpleNamespace(label="PATH[0]")

    window._on_session_state_changed(False)
    window._on_session_state_changed(True)

    assert property_calls == []
    assert summary_calls == ["snippet"]
    window.close()


def test_qt_viewport_host_scene_mutation_scope_balances_context(monkeypatch):
    viewport = QtViewportWindow()
    calls = []

    monkeypatch.setattr(viewport, "makeCurrent", lambda: calls.append("makeCurrent"))
    monkeypatch.setattr(viewport, "doneCurrent", lambda: calls.append("doneCurrent"))

    with viewport.host.scene_mutation_scope("apply_property_change"):
        calls.append("body")

    assert calls == ["makeCurrent", "body", "doneCurrent"]


def test_qt_viewport_host_scene_mutation_scope_unwinds_after_failure(monkeypatch):
    viewport = QtViewportWindow()
    calls = []

    monkeypatch.setattr(viewport, "makeCurrent", lambda: calls.append("makeCurrent"))
    monkeypatch.setattr(viewport, "doneCurrent", lambda: calls.append("doneCurrent"))

    with pytest.raises(RuntimeError, match="boom"):
        with viewport.host.scene_mutation_scope("apply_property_change"):
            calls.append("body")
            raise RuntimeError("boom")

    assert calls == ["makeCurrent", "body", "doneCurrent"]


def test_attach_upload_and_set_session_do_not_trigger_implicit_redraws(monkeypatch):
    viewport = QtViewport()
    update_calls = []

    class DummyScene:
        def __init__(self):
            self.uploaded = []

        def upload(self, state):
            self.uploaded.append(state)

    monkeypatch.setattr(viewport, "update", lambda: update_calls.append("update"))
    monkeypatch.setattr(viewport, "makeCurrent", lambda: None)
    monkeypatch.setattr(viewport, "doneCurrent", lambda: None)
    viewport._ctx = object()
    scene = DummyScene()

    viewport.host.attach_scene(scene)
    viewport.host.upload(np.asarray([1.0], dtype=np.float32))
    viewport.set_session(object())

    assert len(scene.uploaded) == 1
    assert np.allclose(scene.uploaded[0], np.asarray([1.0], dtype=np.float32))
    assert update_calls == []


def test_refresh_ui_skips_snippet_generation_when_snippet_tab_hidden(qapp, monkeypatch):
    window = TvViewWindow()
    snippet_calls = []

    class DummySession:
        available_scenes = ["Layer"]
        selected_scene = "Layer"

        def properties_snapshot(self):
            return {"property_groups": [], "selected_scene": "Layer", "available_scenes": ["Layer"]}

        def render_snippet(self):
            snippet_calls.append("called")
            return "snippet"

        def camera_snapshot(self):
            return {"projection_label": "Perspective"}

    monkeypatch.setattr(window.snippet_dock, "isVisible", lambda: False)
    window._session = DummySession()
    window._current_document = SimpleNamespace(label="doc.npz")
    window._current_entry = SimpleNamespace(label="PATH[0]")

    window._refresh_ui(full=True)

    assert snippet_calls == []
    window.close()


def test_activation_with_visible_snippet_does_not_create_extra_scene(qapp, monkeypatch):
    window = TvViewWindow()
    window.viewport._ctx = object()
    create_calls = []
    update_calls = []

    class DummyScene:
        def __init__(self, mglctx=None, system=None):
            self._mglctx = mglctx
            self._system = system
            self.camera = Camera()
            self.uploaded_state = None

        @property
        def system(self):
            return self._system

        @classmethod
        def supports_system(cls, system):
            return True

        def upload(self, state=None):
            self.uploaded_state = state

        def list_properties(self):
            return {"Render": []}

    registry = {"Layer": DummyScene}
    monkeypatch.setattr("topovec.viewer.qt.main_window.SCENES", dict(registry))
    monkeypatch.setattr(
        "topovec.viewer.session._load_builtin_scene_registry",
        lambda: dict(registry),
    )
    original_create_scene = window.viewport.host.create_scene

    def counted_create_scene(scene_cls, system):
        create_calls.append(getattr(scene_cls, "__name__", repr(scene_cls)))
        return original_create_scene(scene_cls, system)

    monkeypatch.setattr(window.viewport.host, "create_scene", counted_create_scene)
    monkeypatch.setattr(window.snippet_dock, "isVisible", lambda: True)
    monkeypatch.setattr(window.viewport, "update", lambda: update_calls.append("update"))

    document = ViewerDocument(
        path=Path("/tmp/example.npz"),
        label="example.npz",
        format_name="NPZ",
        system=SimpleNamespace(size=np.asarray((4, 4, 4), dtype=int)),
        entries=[],
        metadata={},
    )
    entry = StateEntry(
        label="PATH[0]",
        index=(0,),
        load_state=lambda: np.zeros((4, 4, 4, 1, 3), dtype=np.float32),
        metadata={},
    )

    window._activate_entry(document, entry)
    qapp.processEvents()

    assert create_calls == ["DummyScene"]
    assert update_calls == ["update"]
    assert "render_prepare('Layer', system)" in window.snippet_panel._editor.toPlainText()
    window.close()


def test_same_system_activation_reuses_session_and_preserves_state(qapp, monkeypatch):
    window = TvViewWindow()
    window.viewport._ctx = object()
    create_calls = []

    _install_qt_scene_registry(monkeypatch)
    original_create_scene = window.viewport.host.create_scene

    def counted_create_scene(scene_cls, system):
        create_calls.append(getattr(scene_cls, "__name__", repr(scene_cls)))
        return original_create_scene(scene_cls, system)

    monkeypatch.setattr(window.viewport.host, "create_scene", counted_create_scene)

    system = System.cubic((4, 4, 4))
    document = _make_document(
        "field.sad",
        system,
        [
            np.zeros((4, 4, 4, 1, 3), dtype=np.float32),
            np.ones((4, 4, 4, 1, 3), dtype=np.float32),
        ],
    )

    window._activate_entry(document, document.entries[0])
    qapp.processEvents()

    session = window.session
    assert session is not None
    assert session.switch_scene("Ball") is True
    session.apply_property_change(
        "Render",
        0,
        True,
        expected_field="flag",
        expected_title="Flag",
    )
    session.camera.look_scale *= 1.75
    session.commit_camera_interaction()
    camera_before = camera_state_signature(session.camera_snapshot())

    window._activate_entry(document, document.entries[1])
    qapp.processEvents()

    assert window.session is session
    assert create_calls == ["_QtLayerScene", "_QtBallScene"]
    assert window.session.selected_scene == "Ball"
    assert camera_state_signature(window.session.camera_snapshot()) == camera_before
    props = window.session.properties_snapshot()["property_groups"][0]["properties"]
    assert props[0]["value"] is True
    window.close()


def test_same_system_activation_reuses_session_across_documents(qapp, monkeypatch):
    window = TvViewWindow()
    window.viewport._ctx = object()
    create_calls = []

    _install_qt_scene_registry(monkeypatch)
    original_create_scene = window.viewport.host.create_scene

    def counted_create_scene(scene_cls, system):
        create_calls.append(getattr(scene_cls, "__name__", repr(scene_cls)))
        return original_create_scene(scene_cls, system)

    monkeypatch.setattr(window.viewport.host, "create_scene", counted_create_scene)

    document_a = _make_document(
        "field_a.sad",
        System.cubic((4, 4, 4)),
        [np.zeros((4, 4, 4, 1, 3), dtype=np.float32)],
    )
    document_b = _make_document(
        "field_b.sad",
        System.cubic((4, 4, 4)),
        [np.ones((4, 4, 4, 1, 3), dtype=np.float32)],
    )

    window._activate_entry(document_a, document_a.entries[0])
    qapp.processEvents()
    session = window.session
    assert session is not None
    session.camera.look_scale *= 1.5
    session.commit_camera_interaction()
    camera_before = camera_state_signature(session.camera_snapshot())

    window._activate_entry(document_b, document_b.entries[0])
    qapp.processEvents()

    assert window.session is session
    assert create_calls == ["_QtLayerScene"]
    assert camera_state_signature(window.session.camera_snapshot()) == camera_before
    window.close()


def test_qt_property_edit_runs_under_scene_mutation_scope(qapp, monkeypatch):
    window = TvViewWindow()
    window.viewport._ctx = object()
    mutation_state = {"active": False}
    original_make_current = window.viewport.makeCurrent
    original_done_current = window.viewport.doneCurrent
    calls = []

    monkeypatch.setattr(
        "topovec.viewer.qt.main_window.SCENES",
        {"Layer": _QtScopedVectorScene},
    )
    monkeypatch.setattr(
        "topovec.viewer.session._load_builtin_scene_registry",
        lambda: {"Layer": _QtScopedVectorScene},
    )

    def tracked_make_current():
        mutation_state["active"] = True
        calls.append("makeCurrent")
        return original_make_current()

    def tracked_done_current():
        calls.append("doneCurrent")
        try:
            return original_done_current()
        finally:
            mutation_state["active"] = False

    monkeypatch.setattr(window.viewport, "makeCurrent", tracked_make_current)
    monkeypatch.setattr(window.viewport, "doneCurrent", tracked_done_current)

    original_create_scene = window.viewport.host.create_scene

    def counted_create_scene(scene_cls, system):
        scene = original_create_scene(scene_cls, system)
        scene._mutation_state = mutation_state
        return scene

    monkeypatch.setattr(window.viewport.host, "create_scene", counted_create_scene)

    document = _make_document(
        "field.sad",
        System.cubic((4, 4, 4)),
        [np.zeros((4, 4, 4, 1, 3), dtype=np.float32)],
    )

    window._activate_entry(document, document.entries[0])
    qapp.processEvents()

    window._on_property_edited("Vector", 0, "Arrow", "mesh_shape", "Shape")
    qapp.processEvents()

    assert window.session is not None
    assert window.session.scene.mesh_shape == "Arrow"
    assert (
        window.session.properties_snapshot()["property_groups"][0]["properties"][0][
            "value"
        ]
        == "Arrow"
    )
    assert calls[-2:] == ["makeCurrent", "doneCurrent"]
    window.close()


def test_qt_non_structural_property_edit_updates_panel_without_rebuild(
    qapp, monkeypatch
):
    window = TvViewWindow()
    window.viewport._ctx = object()
    property_calls = []
    mutation_state = {"active": False}
    original_make_current = window.viewport.makeCurrent
    original_done_current = window.viewport.doneCurrent

    monkeypatch.setattr(
        "topovec.viewer.qt.main_window.SCENES",
        {"Layer": _QtScopedVectorScene},
    )
    monkeypatch.setattr(
        "topovec.viewer.session._load_builtin_scene_registry",
        lambda: {"Layer": _QtScopedVectorScene},
    )

    original_set_snapshot = window.properties_panel.set_snapshot

    def tracked_set_snapshot(snapshot, *, rebuild=True):
        property_calls.append(rebuild)
        return original_set_snapshot(snapshot, rebuild=rebuild)

    def tracked_make_current():
        mutation_state["active"] = True
        return original_make_current()

    def tracked_done_current():
        try:
            return original_done_current()
        finally:
            mutation_state["active"] = False

    monkeypatch.setattr(window.viewport, "makeCurrent", tracked_make_current)
    monkeypatch.setattr(window.viewport, "doneCurrent", tracked_done_current)
    monkeypatch.setattr(window.properties_panel, "set_snapshot", tracked_set_snapshot)

    original_create_scene = window.viewport.host.create_scene

    def counted_create_scene(scene_cls, system):
        scene = original_create_scene(scene_cls, system)
        scene._mutation_state = mutation_state
        return scene

    monkeypatch.setattr(window.viewport.host, "create_scene", counted_create_scene)

    document = _make_document(
        "field.sad",
        System.cubic((4, 4, 4)),
        [np.zeros((4, 4, 4, 1, 3), dtype=np.float32)],
    )

    window._activate_entry(document, document.entries[0])
    qapp.processEvents()
    property_calls.clear()

    window._on_property_edited("Vector", 0, "Arrow", "mesh_shape", "Shape")
    qapp.processEvents()

    assert property_calls == [False]
    window.close()


def test_qt_structural_property_edit_rebuilds_panel_once(qapp, monkeypatch):
    window = TvViewWindow()
    window.viewport._ctx = object()
    property_calls = []

    monkeypatch.setattr(
        "topovec.viewer.qt.main_window.SCENES",
        {"Layer": _QtSchemaScene},
    )
    monkeypatch.setattr(
        "topovec.viewer.session._load_builtin_scene_registry",
        lambda: {"Layer": _QtSchemaScene},
    )

    original_set_snapshot = window.properties_panel.set_snapshot

    def tracked_set_snapshot(snapshot, *, rebuild=True):
        property_calls.append(rebuild)
        return original_set_snapshot(snapshot, rebuild=rebuild)

    monkeypatch.setattr(window.properties_panel, "set_snapshot", tracked_set_snapshot)

    document = _make_document(
        "field.sad",
        System.cubic((4, 4, 4)),
        [np.zeros((4, 4, 4, 1, 3), dtype=np.float32)],
    )

    window._activate_entry(document, document.entries[0])
    qapp.processEvents()
    property_calls.clear()

    window._on_property_edited("Render", 0, "beta", "mode", "Mode")
    qapp.processEvents()

    assert property_calls == [True]
    window.close()


def test_cached_live_scene_is_restored_after_switching_to_different_system(
    qapp, monkeypatch
):
    window = TvViewWindow()
    window.viewport._ctx = object()
    create_calls = []

    _install_qt_scene_registry(monkeypatch)
    original_create_scene = window.viewport.host.create_scene

    def counted_create_scene(scene_cls, system):
        create_calls.append(getattr(scene_cls, "__name__", repr(scene_cls)))
        return original_create_scene(scene_cls, system)

    monkeypatch.setattr(window.viewport.host, "create_scene", counted_create_scene)

    document_a = _make_document(
        "field_a.sad",
        System.cubic((4, 4, 4)),
        [
            np.zeros((4, 4, 4, 1, 3), dtype=np.float32),
            np.ones((4, 4, 4, 1, 3), dtype=np.float32),
        ],
    )
    document_b = _make_document(
        "field_b.sad",
        System.cubic((5, 4, 4)),
        [np.full((5, 4, 4, 1, 3), 2.0, dtype=np.float32)],
    )

    window._activate_entry(document_a, document_a.entries[0])
    qapp.processEvents()

    session_a = window.session
    assert session_a is not None
    assert session_a.switch_scene("Ball") is True
    ball_scene = session_a.scene
    session_a.apply_property_change(
        "Render",
        0,
        True,
        expected_field="flag",
        expected_title="Flag",
    )
    session_a.camera.look_scale *= 1.75
    session_a.commit_camera_interaction()
    camera_a = camera_state_signature(session_a.camera_snapshot())

    window._activate_entry(document_b, document_b.entries[0])
    qapp.processEvents()

    session_b = window.session
    assert session_b is not None
    assert session_b is not session_a

    window._activate_entry(document_a, document_a.entries[1])
    qapp.processEvents()

    assert window.session is not session_a
    assert window.session is not session_b
    assert window.session.scene is ball_scene
    assert window.session.selected_scene == "Ball"
    assert camera_state_signature(window.session.camera_snapshot()) == camera_a
    props = window.session.properties_snapshot()["property_groups"][0]["properties"]
    assert props[0]["value"] is True
    assert create_calls == ["_QtLayerScene", "_QtBallScene", "_QtLayerScene"]
    window.close()


def test_different_system_activation_creates_new_session(qapp, monkeypatch):
    window = TvViewWindow()
    window.viewport._ctx = object()
    create_calls = []

    _install_qt_scene_registry(monkeypatch)
    original_create_scene = window.viewport.host.create_scene

    def counted_create_scene(scene_cls, system):
        create_calls.append(getattr(scene_cls, "__name__", repr(scene_cls)))
        return original_create_scene(scene_cls, system)

    monkeypatch.setattr(window.viewport.host, "create_scene", counted_create_scene)

    document_a = _make_document(
        "field_a.sad",
        System.cubic((4, 4, 4)),
        [np.zeros((4, 4, 4, 1, 3), dtype=np.float32)],
    )
    document_b = _make_document(
        "field_b.sad",
        System.cubic((5, 4, 4)),
        [np.ones((5, 4, 4, 1, 3), dtype=np.float32)],
    )

    window._activate_entry(document_a, document_a.entries[0])
    qapp.processEvents()
    session = window.session

    window._activate_entry(document_b, document_b.entries[0])
    qapp.processEvents()

    assert session is not None
    assert window.session is not session
    assert create_calls == ["_QtLayerScene", "_QtLayerScene"]
    window.close()


def test_cached_scene_restore_failure_keeps_previous_active_session(qapp, monkeypatch):
    window = TvViewWindow()
    window.viewport._ctx = object()

    _install_qt_scene_registry(monkeypatch)
    document_a = _make_document(
        "field_a.sad",
        System.cubic((4, 4, 4)),
        [
            np.zeros((4, 4, 4, 1, 3), dtype=np.float32),
            np.ones((4, 4, 4, 1, 3), dtype=np.float32),
        ],
    )
    document_b = _make_document(
        "field_b.sad",
        System.cubic((5, 4, 4)),
        [np.full((5, 4, 4, 1, 3), 2.0, dtype=np.float32)],
    )

    window._activate_entry(document_a, document_a.entries[0])
    qapp.processEvents()

    session_a = window.session
    assert session_a is not None

    window._activate_entry(document_b, document_b.entries[0])
    qapp.processEvents()

    session_b = window.session
    assert session_b is not None
    camera_before = camera_state_signature(session_b.camera_snapshot())
    monkeypatch.setattr(
        ViewerSession,
        "restore_residency_snapshot",
        lambda self, _snapshot: (_ for _ in ()).throw(RuntimeError("cache boom")),
    )

    window._activate_entry(document_a, document_a.entries[1])
    qapp.processEvents()

    assert window.session is session_b
    assert window._current_document is document_b
    assert window._current_entry is document_b.entries[0]
    assert camera_state_signature(window.session.camera_snapshot()) == camera_before
    assert "Failed to activate" in window.statusBar().currentMessage()
    assert "cache boom" in window.log_panel.text()
    window.close()


def test_closing_current_source_keeps_cached_scene_for_same_system(qapp, monkeypatch):
    window = TvViewWindow()
    window.viewport._ctx = object()
    create_calls = []

    _install_qt_scene_registry(monkeypatch)
    original_create_scene = window.viewport.host.create_scene

    def counted_create_scene(scene_cls, system):
        create_calls.append(getattr(scene_cls, "__name__", repr(scene_cls)))
        return original_create_scene(scene_cls, system)

    monkeypatch.setattr(window.viewport.host, "create_scene", counted_create_scene)

    document_a = _make_document(
        "field_a.sad",
        System.cubic((4, 4, 4)),
        [np.zeros((4, 4, 4, 1, 3), dtype=np.float32)],
    )
    document_b = _make_document(
        "field_b.sad",
        System.cubic((4, 4, 4)),
        [np.ones((4, 4, 4, 1, 3), dtype=np.float32)],
    )

    documents = {
        "/tmp/field_a.sad": document_a,
        "/tmp/field_b.sad": document_b,
    }
    monkeypatch.setattr(
        "topovec.viewer.qt.main_window.load_viewer_document",
        lambda raw_path: documents[str(raw_path)],
    )

    window.load_paths(["/tmp/field_a.sad", "/tmp/field_b.sad"])
    qapp.processEvents()

    session = window.session
    assert session is not None
    active_scene = session.scene
    session.camera.look_scale *= 1.5
    session.commit_camera_interaction()
    camera_before = camera_state_signature(session.camera_snapshot())

    first_state_item = window.sources_tree.topLevelItem(0).child(0)
    window.sources_tree.setCurrentItem(first_state_item)
    qapp.processEvents()
    window.close_selected_source()
    qapp.processEvents()

    assert window.session is not session
    assert window.session.scene is active_scene
    assert window._current_document is document_b
    assert window._current_entry is document_b.entries[0]
    assert camera_state_signature(window.session.camera_snapshot()) == camera_before
    assert create_calls == ["_QtLayerScene"]
    window.close()


def test_viewport_shutdown_releases_context(qapp, monkeypatch):
    viewport = QtViewportWindow()
    calls = []

    class DummyContext:
        def gc(self):
            calls.append("gc")

        def release(self):
            calls.append("release")

    monkeypatch.setattr(viewport, "makeCurrent", lambda: calls.append("makeCurrent"))
    monkeypatch.setattr(viewport, "doneCurrent", lambda: calls.append("doneCurrent"))
    viewport._ctx = DummyContext()
    viewport._session = object()
    viewport._scene = object()

    viewport.shutdown()

    assert calls == [
        "makeCurrent",
        "gc",
        "release",
        "doneCurrent",
    ]
    assert viewport._ctx is None
    assert viewport._scene is None
    assert viewport._session is None
