"""Main window for the `topovec view` desktop application."""

from __future__ import annotations

import logging
import os
from pathlib import Path
from typing import Any

import numpy as np
from PySide6 import QtCore, QtGui, QtWidgets

from ...mgl import SCENES
from ...core import UpdateMask
from ..document import StateEntry, ViewerDocument, load_viewer_document
from ..residency import SceneResidencyManager
from ..session import ViewerSession
from .logging import QtLogHandler
from .theme import (
    THEME_MODE_DARK,
    THEME_MODE_LIGHT,
    THEME_MODE_SYSTEM,
    THEME_SETTING_KEY,
    apply_theme_mode,
    normalize_theme_mode,
)
from .viewport import QtViewport
from .widgets import ExportImageDialog, LogPanel, PropertyPanel, SnippetPanel

logger = logging.getLogger(__name__)


def _scene_supports_system(scene_cls: Any, system: Any) -> bool:
    supports = getattr(scene_cls, "supports_system", None)
    if not callable(supports):
        return True
    return bool(supports(system))


class TvViewWindow(QtWidgets.QMainWindow):
    """Single-window multi-document desktop viewer."""

    _WINDOW_STATE_VERSION = 4
    _RECENT_FILES_KEY = "recent_files"
    _MAX_RECENT_FILES = 10
    _ICON_THEME_FALLBACKS = ("Adwaita", "hicolor")
    _DEFAULT_THEME_MODE = THEME_MODE_SYSTEM

    def __init__(
        self,
        parent: QtWidgets.QWidget | None = None,
        *,
        max_live_scenes: int = 2,
    ):
        super().__init__(parent)
        logger.debug("Initializing TvViewWindow")
        self.setWindowTitle("topovec view")
        self.resize(1400, 900)

        self._settings = QtCore.QSettings("topovec", "topovec.view")
        self._documents: list[ViewerDocument] = []
        self._current_document: ViewerDocument | None = None
        self._current_entry: StateEntry | None = None
        self._pending_entry: tuple[ViewerDocument, StateEntry] | None = None
        self._session: ViewerSession | None = None
        self._scene_combo_updating = False
        self._preferred_scene_name = "Layer"
        self._status_override: str | None = None
        self._log_handler: QtLogHandler | None = None
        self._max_live_scenes = int(max_live_scenes)
        self._theme_action_group: QtGui.QActionGroup | None = None
        self._theme_actions: dict[str, QtGui.QAction] = {}
        self._theme_mode = self._load_theme_mode()
        self._theme_spec = None
        self._dock_area_actions: dict[QtCore.Qt.DockWidgetArea, QtGui.QAction] = {}
        self._dock_area_recomputing = False

        logger.debug("Creating central QtViewport")
        self.viewport = QtViewport(self)
        logger.debug("Applying QtViewport surface format")
        self.viewport.setFormat(QtViewport.create_surface_format())
        self.viewport.host_ready.connect(self._on_viewport_ready)
        self.viewport.session_state_changed.connect(self._on_session_state_changed)
        self.viewport.runtime_error.connect(self._on_viewport_runtime_error)
        self.setCentralWidget(self.viewport)
        self._scene_residency = SceneResidencyManager(
            self.viewport.host,
            scene_registry_loader=lambda: dict(SCENES),
            session_factory=ViewerSession,
            max_live_scenes=self._max_live_scenes,
        )

        logger.debug("Building topovec view docks")
        self._build_docks()
        self._apply_theme(self._theme_mode, persist=False)
        logger.debug("Installing topovec view GUI log handler")
        self._install_log_handler()
        logger.debug("Building topovec view actions and toolbar")
        self._build_actions()
        self._build_toolbar()
        logger.debug("Restoring topovec view window settings")
        restored_layout = self._restore_settings()
        if not restored_layout:
            self._apply_default_layout()
        self._sync_dock_area_actions()
        self._refresh_ui()
        style_hints = QtWidgets.QApplication.instance().styleHints()
        style_hints.colorSchemeChanged.connect(self._on_system_color_scheme_changed)
        logger.debug("TvViewWindow initialization complete")

    @property
    def session(self) -> ViewerSession | None:
        return self._session

    def _build_docks(self) -> None:
        self.sources_tree = QtWidgets.QTreeWidget()
        self.sources_tree.setHeaderLabels(["Source"])
        self.sources_tree.itemSelectionChanged.connect(self._on_tree_selection_changed)

        self.sources_dock = QtWidgets.QDockWidget("Data Sources", self)
        self.sources_dock.setObjectName("topovec.view.sources")
        self.sources_dock.setWidget(self.sources_tree)
        self.addDockWidget(QtCore.Qt.DockWidgetArea.LeftDockWidgetArea, self.sources_dock)

        self.properties_panel = PropertyPanel(self)
        self.properties_panel.property_edited.connect(self._on_property_edited)
        self.properties_dock = QtWidgets.QDockWidget("Properties", self)
        self.properties_dock.setObjectName("topovec.view.properties")
        self.properties_dock.setWidget(self.properties_panel)
        self.addDockWidget(
            QtCore.Qt.DockWidgetArea.RightDockWidgetArea,
            self.properties_dock,
        )

        self.snippet_panel = SnippetPanel(self)
        self.snippet_dock = QtWidgets.QDockWidget("Python Snippet", self)
        self.snippet_dock.setObjectName("topovec.view.snippet")
        self.snippet_dock.setWidget(self.snippet_panel)
        self.snippet_dock.visibilityChanged.connect(self._on_snippet_visibility_changed)
        self.addDockWidget(
            QtCore.Qt.DockWidgetArea.BottomDockWidgetArea,
            self.snippet_dock,
        )

        self.log_panel = LogPanel(self)
        self.log_panel.record_appended.connect(self._on_log_record_appended)
        self.log_dock = QtWidgets.QDockWidget("Log", self)
        self.log_dock.setObjectName("topovec.view.log")
        self.log_dock.setWidget(self.log_panel)
        self.addDockWidget(
            QtCore.Qt.DockWidgetArea.BottomDockWidgetArea,
            self.log_dock,
        )
        self._arrange_bottom_output_docks()
        self._ensure_dock_area_signal_connections()

    def _arrange_bottom_output_docks(self) -> None:
        for dock in (self.snippet_dock, self.log_dock):
            dock.setFloating(False)
            self.addDockWidget(QtCore.Qt.DockWidgetArea.BottomDockWidgetArea, dock)
        self.tabifyDockWidget(self.snippet_dock, self.log_dock)
        self.log_dock.raise_()

    def _install_log_handler(self) -> None:
        if self._log_handler is not None:
            return
        self._log_handler = QtLogHandler()
        self._log_handler.relay.message_logged.connect(
            self.log_panel.append_message,
            QtCore.Qt.ConnectionType.QueuedConnection,
        )
        logging.getLogger("topovec").addHandler(self._log_handler)

    def _uninstall_log_handler(self) -> None:
        if self._log_handler is None:
            return
        logging.getLogger("topovec").removeHandler(self._log_handler)
        self._log_handler.close()
        self._log_handler = None

    @classmethod
    def _candidate_icon_search_paths(cls) -> list[str]:
        candidates = list(QtGui.QIcon.themeSearchPaths())
        xdg_data_dirs = os.environ.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share")
        for base in xdg_data_dirs.split(":"):
            if not base:
                continue
            candidates.append(os.fspath(Path(base) / "icons"))
        candidates.append("/usr/share/icons")
        candidates.append("/usr/local/share/icons")
        candidates.append(os.fspath(Path.home() / ".icons"))

        paths = []
        seen = set()
        for path in candidates:
            normalized = os.fspath(Path(path).expanduser())
            if normalized in seen or not Path(normalized).is_dir():
                continue
            seen.add(normalized)
            paths.append(normalized)
        return paths

    @classmethod
    def _available_icon_theme(cls, search_paths: list[str]) -> str | None:
        for theme_name in cls._ICON_THEME_FALLBACKS:
            for root in search_paths:
                if (Path(root) / theme_name / "index.theme").is_file():
                    return theme_name
        return None

    @classmethod
    def _ensure_theme_icon_support(cls) -> None:
        search_paths = cls._candidate_icon_search_paths()
        if search_paths:
            current_paths = list(QtGui.QIcon.themeSearchPaths())
            if current_paths != search_paths:
                QtGui.QIcon.setThemeSearchPaths(search_paths)
        if QtGui.QIcon.themeName() or QtGui.QIcon.fallbackThemeName():
            return
        fallback_theme = cls._available_icon_theme(search_paths)
        if fallback_theme:
            QtGui.QIcon.setFallbackThemeName(fallback_theme)

    def _icon_from_theme(
        self,
        names: tuple[str, ...],
        fallback: QtWidgets.QStyle.StandardPixmap,
    ) -> QtGui.QIcon:
        style = self.style()
        for name in names:
            icon = QtGui.QIcon.fromTheme(name)
            if not icon.isNull():
                return icon
        for fallback_pixmap in (
            fallback,
            QtWidgets.QStyle.StandardPixmap.SP_FileIcon,
            QtWidgets.QStyle.StandardPixmap.SP_DialogOpenButton,
        ):
            icon = style.standardIcon(fallback_pixmap)
            if not icon.isNull():
                return icon
        return QtGui.QIcon()

    def _configure_action_icons(self) -> None:
        self._ensure_theme_icon_support()
        self.open_action.setIcon(
            self._icon_from_theme(
                ("document-open", "folder-open"),
                QtWidgets.QStyle.StandardPixmap.SP_DialogOpenButton,
            )
        )
        self.save_image_action.setIcon(
            self._icon_from_theme(
                ("document-save-as", "document-save"),
                QtWidgets.QStyle.StandardPixmap.SP_DialogSaveButton,
            )
        )
        self.reset_camera_action.setIcon(
            self._icon_from_theme(
                ("go-home", "view-refresh"),
                QtWidgets.QStyle.StandardPixmap.SP_RestoreDefaultsButton,
            )
        )
        self.snap_camera_action.setIcon(
            self._icon_from_theme(
                (
                    "orientation-landscape-symbolic",
                    "camera-switch-symbolic",
                    "object-select-symbolic",
                ),
                QtWidgets.QStyle.StandardPixmap.SP_ArrowUp,
            )
        )
        self.toggle_projection_action.setIcon(
            self._icon_from_theme(
                ("view-grid-symbolic", "view-app-grid-symbolic", "view-grid"),
                QtWidgets.QStyle.StandardPixmap.SP_FileDialogDetailedView,
            )
        )

    def _build_actions(self) -> None:
        self.open_action = QtGui.QAction("Open...", self)
        self.open_action.setShortcut(QtGui.QKeySequence.StandardKey.Open)
        self.open_action.triggered.connect(self.open_files)

        self.open_recent_menu = QtWidgets.QMenu("Open Recent", self)
        self.open_recent_menu.aboutToShow.connect(self._refresh_recent_files_menu)
        self.clear_recent_files_action = QtGui.QAction("Clear Recent Files", self)
        self.clear_recent_files_action.triggered.connect(self.clear_recent_files)

        self.save_image_action = QtGui.QAction("Save Image...", self)
        self.save_image_action.setShortcut(QtGui.QKeySequence.StandardKey.SaveAs)
        self.save_image_action.triggered.connect(self.save_image)

        self.close_source_action = QtGui.QAction("Close Selected Source", self)
        self.close_source_action.triggered.connect(self.close_selected_source)

        self.exit_action = QtGui.QAction("Exit", self)
        self.exit_action.setShortcut(QtGui.QKeySequence.StandardKey.Quit)
        self.exit_action.triggered.connect(self.close)

        self.reset_camera_action = QtGui.QAction("Reset Camera", self)
        self.reset_camera_action.triggered.connect(
            lambda: self.viewport.apply_camera_button("reset")
        )

        self.snap_camera_action = QtGui.QAction("Snap Camera", self)
        self.snap_camera_action.triggered.connect(
            lambda: self.viewport.apply_camera_button("snap")
        )

        self.toggle_projection_action = QtGui.QAction("Toggle Projection", self)
        self.toggle_projection_action.triggered.connect(
            lambda: self.viewport.apply_camera_button("toggle_perspective")
        )

        self.reset_layout_action = QtGui.QAction("Reset Layout", self)
        self.reset_layout_action.triggered.connect(self.reset_layout)
        self._configure_action_icons()
        self.left_panel_action = QtGui.QAction("Left Panel", self)
        self.left_panel_action.setCheckable(True)
        self.left_panel_action.triggered.connect(
            lambda checked=False: self._set_area_visible(
                QtCore.Qt.DockWidgetArea.LeftDockWidgetArea,
                bool(checked),
            )
        )
        self.bottom_panel_action = QtGui.QAction("Bottom Panel", self)
        self.bottom_panel_action.setCheckable(True)
        self.bottom_panel_action.triggered.connect(
            lambda checked=False: self._set_area_visible(
                QtCore.Qt.DockWidgetArea.BottomDockWidgetArea,
                bool(checked),
            )
        )
        self.right_panel_action = QtGui.QAction("Right Panel", self)
        self.right_panel_action.setCheckable(True)
        self.right_panel_action.triggered.connect(
            lambda checked=False: self._set_area_visible(
                QtCore.Qt.DockWidgetArea.RightDockWidgetArea,
                bool(checked),
            )
        )
        self._dock_area_actions = {
            QtCore.Qt.DockWidgetArea.LeftDockWidgetArea: self.left_panel_action,
            QtCore.Qt.DockWidgetArea.BottomDockWidgetArea: self.bottom_panel_action,
            QtCore.Qt.DockWidgetArea.RightDockWidgetArea: self.right_panel_action,
        }
        self.left_panel_action.setIcon(
            self._icon_from_theme(
                ("sidebar-show-symbolic",),
                QtWidgets.QStyle.StandardPixmap.SP_ArrowLeft,
            )
        )
        self.bottom_panel_action.setIcon(
            self._icon_from_theme(
                ("pan-down-symbolic", "go-down-symbolic"),
                QtWidgets.QStyle.StandardPixmap.SP_ArrowDown,
            )
        )
        self.right_panel_action.setIcon(
            self._icon_from_theme(
                ("sidebar-show-right-symbolic",),
                QtWidgets.QStyle.StandardPixmap.SP_ArrowRight,
            )
        )

        file_menu = self.menuBar().addMenu("&File")
        file_menu.addAction(self.open_action)
        file_menu.addMenu(self.open_recent_menu)
        file_menu.addAction(self.save_image_action)
        file_menu.addAction(self.close_source_action)
        file_menu.addSeparator()
        file_menu.addAction(self.exit_action)

        view_menu = self.menuBar().addMenu("&View")
        view_menu.addAction(self.reset_camera_action)
        view_menu.addAction(self.snap_camera_action)
        view_menu.addAction(self.toggle_projection_action)
        view_menu.addSeparator()
        view_menu.addAction(self.left_panel_action)
        view_menu.addAction(self.bottom_panel_action)
        view_menu.addAction(self.right_panel_action)
        view_menu.addSeparator()
        self._theme_action_group = QtGui.QActionGroup(self)
        self._theme_action_group.setExclusive(True)
        theme_menu = view_menu.addMenu("Theme")
        self._theme_actions = {}
        for mode, label in (
            (THEME_MODE_SYSTEM, "System"),
            (THEME_MODE_DARK, "Dark"),
            (THEME_MODE_LIGHT, "Light"),
        ):
            action = QtGui.QAction(label, self)
            action.setCheckable(True)
            action.triggered.connect(
                lambda checked=False, selected_mode=mode: self._on_theme_action_triggered(
                    selected_mode, checked
                )
            )
            self._theme_action_group.addAction(action)
            theme_menu.addAction(action)
            self._theme_actions[mode] = action
        view_menu.addAction(self.reset_layout_action)
        view_menu.addSeparator()
        view_menu.addAction(self.sources_dock.toggleViewAction())
        view_menu.addAction(self.properties_dock.toggleViewAction())
        view_menu.addAction(self.snippet_dock.toggleViewAction())
        view_menu.addAction(self.log_dock.toggleViewAction())
        self._sync_theme_actions()
        self._refresh_recent_files_menu()

    def _build_toolbar(self) -> None:
        toolbar = self.addToolBar("Main")
        toolbar.setObjectName("topovec.view.toolbar")
        toolbar.setToolButtonStyle(QtCore.Qt.ToolButtonStyle.ToolButtonIconOnly)
        toolbar.setIconSize(QtCore.QSize(18, 18))
        toolbar.addAction(self.open_action)
        toolbar.addAction(self.save_image_action)
        toolbar.addSeparator()
        toolbar.addAction(self.left_panel_action)
        toolbar.addAction(self.bottom_panel_action)
        toolbar.addAction(self.right_panel_action)
        toolbar.addSeparator()
        toolbar.addAction(self.reset_camera_action)
        toolbar.addAction(self.snap_camera_action)
        toolbar.addAction(self.toggle_projection_action)
        spacer = QtWidgets.QWidget()
        spacer.setSizePolicy(
            QtWidgets.QSizePolicy.Policy.Expanding,
            QtWidgets.QSizePolicy.Policy.Preferred,
        )
        toolbar.addWidget(spacer)
        toolbar.addWidget(QtWidgets.QLabel("Scene"))
        self.scene_combo = QtWidgets.QComboBox()
        self.scene_combo.currentTextChanged.connect(self._on_scene_changed)
        toolbar.addWidget(self.scene_combo)
        self._sync_dock_area_actions()

    def _restore_settings(self) -> bool:
        geometry = self._settings.value("geometry")
        if geometry is not None:
            self.restoreGeometry(geometry)
        state = self._settings.value("window_state")
        state_version = int(self._settings.value("window_state_version", 0) or 0)
        if state is not None and state_version == self._WINDOW_STATE_VERSION:
            self.restoreState(state)
            return True
        if state is not None:
            logger.info(
                "Ignoring incompatible topovec view window_state version stored=%s expected=%s",
                state_version,
                self._WINDOW_STATE_VERSION,
            )
        return False

    def _store_settings(self) -> None:
        self._settings.setValue("geometry", self.saveGeometry())
        self._settings.setValue("window_state", self.saveState())
        self._settings.setValue("window_state_version", self._WINDOW_STATE_VERSION)

    def _load_theme_mode(self) -> str:
        return normalize_theme_mode(
            self._settings.value(THEME_SETTING_KEY, self._DEFAULT_THEME_MODE)
        )

    def _sync_theme_actions(self) -> None:
        for mode, action in self._theme_actions.items():
            action.setChecked(mode == self._theme_mode)

    def _apply_theme(self, mode: str, *, persist: bool) -> None:
        app = QtWidgets.QApplication.instance()
        self._theme_mode = normalize_theme_mode(mode)
        self._theme_spec = apply_theme_mode(app, self._theme_mode)
        self.properties_panel.apply_theme(self._theme_spec)
        self.viewport.apply_theme(self._theme_spec)
        self._sync_theme_actions()
        if persist:
            self._settings.setValue(THEME_SETTING_KEY, self._theme_mode)

    def _dock_widgets(self) -> list[QtWidgets.QDockWidget]:
        return list(
            self.findChildren(
                QtWidgets.QDockWidget,
                options=QtCore.Qt.FindChildOption.FindDirectChildrenOnly,
            )
        )

    def _connect_dock_area_signals(self, dock: QtWidgets.QDockWidget) -> None:
        if bool(dock.property("_topovecViewDockAreaSignalsConnected")):
            return
        dock.visibilityChanged.connect(self._on_dock_visibility_changed)
        dock.topLevelChanged.connect(self._on_dock_top_level_changed)
        dock.dockLocationChanged.connect(self._on_dock_location_changed)
        dock.setProperty("_topovecViewDockAreaSignalsConnected", True)

    def _ensure_dock_area_signal_connections(self) -> None:
        for dock in self._dock_widgets():
            self._connect_dock_area_signals(dock)

    def _docks_in_area(self, area: QtCore.Qt.DockWidgetArea) -> list[QtWidgets.QDockWidget]:
        self._ensure_dock_area_signal_connections()
        docks = []
        for dock in self._dock_widgets():
            if dock.isFloating():
                continue
            if self.dockWidgetArea(dock) == area:
                docks.append(dock)
        return docks

    def _set_area_visible(
        self,
        area: QtCore.Qt.DockWidgetArea,
        visible: bool,
    ) -> None:
        docks = self._docks_in_area(area)
        for dock in docks:
            dock.setVisible(bool(visible))
        self._sync_dock_area_actions()

    def _sync_dock_area_actions(self) -> None:
        if self._dock_area_recomputing:
            return
        self._dock_area_recomputing = True
        try:
            for area, action in self._dock_area_actions.items():
                visible = any(not dock.isHidden() for dock in self._docks_in_area(area))
                blocker = QtCore.QSignalBlocker(action)
                action.setChecked(bool(visible))
                del blocker
        finally:
            self._dock_area_recomputing = False

    def _on_dock_visibility_changed(self, _visible: bool) -> None:
        self._sync_dock_area_actions()

    def _on_dock_top_level_changed(self, _floating: bool) -> None:
        self._sync_dock_area_actions()

    def _on_dock_location_changed(self, _area: QtCore.Qt.DockWidgetArea) -> None:
        self._sync_dock_area_actions()

    def childEvent(self, event: QtCore.QChildEvent) -> None:
        super().childEvent(event)
        if event.added():
            child = event.child()
            if isinstance(child, QtWidgets.QDockWidget):
                self._connect_dock_area_signals(child)
                self._sync_dock_area_actions()

    def _on_theme_action_triggered(self, mode: str, checked: bool) -> None:
        if not checked:
            return
        self._apply_theme(mode, persist=True)

    def _on_system_color_scheme_changed(self, _scheme) -> None:
        if self._theme_mode != THEME_MODE_SYSTEM:
            return
        self._apply_theme(self._theme_mode, persist=False)

    def _recent_file_paths(self) -> list[Path]:
        raw = self._settings.value(self._RECENT_FILES_KEY, [])
        if raw is None:
            return []
        if isinstance(raw, str):
            values = [raw]
        elif isinstance(raw, (list, tuple)):
            values = [str(value) for value in raw]
        else:
            values = [str(raw)]

        paths = []
        seen = set()
        for value in values:
            try:
                path = Path(value).expanduser().resolve()
            except Exception:
                logger.debug("Ignoring unreadable recent file entry value=%r", value)
                continue
            key = os.fspath(path)
            if key in seen:
                continue
            seen.add(key)
            paths.append(path)
        return paths

    def _store_recent_file_paths(self, paths: list[Path]) -> None:
        self._settings.setValue(
            self._RECENT_FILES_KEY,
            [os.fspath(path) for path in paths[: self._MAX_RECENT_FILES]],
        )

    def _prune_recent_file_paths(self) -> list[Path]:
        paths = self._recent_file_paths()
        existing = [path for path in paths if path.exists()]
        if existing != paths:
            logger.info(
                "Pruned stale recent files removed=%s kept=%s",
                len(paths) - len(existing),
                len(existing),
            )
            self._store_recent_file_paths(existing)
        return existing

    def _remember_recent_paths(self, paths: list[Path]) -> None:
        remembered = self._recent_file_paths()
        merged = []
        seen = set()
        for path in [*paths, *remembered]:
            key = os.fspath(path)
            if key in seen:
                continue
            seen.add(key)
            merged.append(path)
            if len(merged) >= self._MAX_RECENT_FILES:
                break
        self._store_recent_file_paths(merged)
        self._refresh_recent_files_menu()

    def clear_recent_files(self) -> None:
        logger.info("Clearing remembered recent files")
        self._settings.remove(self._RECENT_FILES_KEY)
        self._refresh_recent_files_menu()

    def _recent_file_labels(self, paths: list[Path]) -> dict[Path, str]:
        counts = {}
        for path in paths:
            counts[path.name] = counts.get(path.name, 0) + 1
        labels = {}
        for path in paths:
            if counts[path.name] == 1:
                labels[path] = path.name
            else:
                labels[path] = f"{path.name} — {path.parent}"
        return labels

    def _refresh_recent_files_menu(self) -> None:
        paths = self._prune_recent_file_paths()
        self.open_recent_menu.clear()
        labels = self._recent_file_labels(paths)
        for path in paths:
            action = self.open_recent_menu.addAction(labels[path])
            action.setToolTip(os.fspath(path))
            action.setStatusTip(os.fspath(path))
            action.triggered.connect(
                lambda _checked=False, path_str=os.fspath(path): self.open_recent_file(path_str)
            )
        if paths:
            self.open_recent_menu.addSeparator()
        self.clear_recent_files_action.setEnabled(bool(paths))
        self.open_recent_menu.addAction(self.clear_recent_files_action)
        self.open_recent_menu.menuAction().setEnabled(bool(paths))

    def _apply_default_dock_sizes(self) -> None:
        self.resizeDocks(
            [self.sources_dock, self.properties_dock],
            [240, 320],
            QtCore.Qt.Orientation.Horizontal,
        )

    def _apply_default_layout(self) -> None:
        for dock, area in (
            (self.sources_dock, QtCore.Qt.DockWidgetArea.LeftDockWidgetArea),
            (self.properties_dock, QtCore.Qt.DockWidgetArea.RightDockWidgetArea),
            (self.snippet_dock, QtCore.Qt.DockWidgetArea.BottomDockWidgetArea),
            (self.log_dock, QtCore.Qt.DockWidgetArea.BottomDockWidgetArea),
        ):
            dock.setFloating(False)
            self.addDockWidget(area, dock)
            dock.show()
        self._arrange_bottom_output_docks()
        self.snippet_dock.hide()
        self.log_dock.hide()
        self._apply_default_dock_sizes()
        self._sync_dock_area_actions()

    def reset_layout(self) -> None:
        logger.info("Resetting topovec view dock layout to defaults")
        self._settings.remove("window_state")
        self._settings.remove("window_state_version")
        self._apply_default_layout()

    def closeEvent(self, event: QtGui.QCloseEvent) -> None:
        self._store_settings()
        try:
            self.viewport.makeCurrent()
            self._scene_residency.release()
        except Exception:
            logger.exception("Failed to release residency-managed scenes during shutdown")
        finally:
            try:
                self.viewport.doneCurrent()
            except Exception:
                pass
        self.viewport.shutdown()
        self._uninstall_log_handler()
        super().closeEvent(event)

    def _focus_open_document(self, document: ViewerDocument) -> bool:
        for index in range(self.sources_tree.topLevelItemCount()):
            item = self.sources_tree.topLevelItem(index)
            payload = item.data(0, QtCore.Qt.ItemDataRole.UserRole)
            if not payload or payload[0] != "document" or payload[1] is not document:
                continue
            if item.childCount() > 0:
                self.sources_tree.setCurrentItem(item.child(0))
            else:
                self.sources_tree.setCurrentItem(item)
            return True
        return False

    def _open_document_by_path(self, path: Path) -> ViewerDocument | None:
        for document in self._documents:
            if document.path == path:
                return document
        return None

    def load_paths(self, paths: list[str] | tuple[str, ...]) -> list[ViewerDocument]:
        logger.info("Loading viewer documents count=%s", len(paths))
        first_state_item = None
        loaded_documents = []
        for raw_path in paths:
            try:
                document = load_viewer_document(raw_path)
            except Exception as exc:
                logger.exception("Failed to open viewer document path=%s", raw_path)
                self._set_status_override(
                    f"Failed to open {Path(raw_path).name}. See Log for details."
                )
                QtWidgets.QMessageBox.warning(
                    self,
                    "Open Failed",
                    f"Failed to open {raw_path!r}.\n\n{exc}",
                )
                continue
            logger.debug(
                "Loaded document path=%s format=%s entries=%s system_size=%s",
                document.path,
                document.format_name,
                len(document.entries),
                tuple(int(v) for v in getattr(document.system, "size", ())),
            )
            self._documents.append(document)
            loaded_documents.append(document)
            doc_item = QtWidgets.QTreeWidgetItem(
                [f"{document.label} [{document.format_name}]"]
            )
            doc_item.setData(0, QtCore.Qt.ItemDataRole.UserRole, ("document", document))
            self.sources_tree.addTopLevelItem(doc_item)
            for entry in document.entries:
                child = QtWidgets.QTreeWidgetItem([entry.label])
                child.setData(
                    0,
                    QtCore.Qt.ItemDataRole.UserRole,
                    ("state", document, entry),
                )
                doc_item.addChild(child)
                if first_state_item is None:
                    first_state_item = child
            doc_item.setExpanded(True)
        if loaded_documents:
            self._remember_recent_paths([document.path for document in loaded_documents])
        if first_state_item is not None and self.sources_tree.currentItem() is None:
            self.sources_tree.setCurrentItem(first_state_item)
        self._refresh_ui()
        return loaded_documents

    def open_files(self) -> None:
        filenames, _filter = QtWidgets.QFileDialog.getOpenFileNames(
            self,
            "Open Sources",
            "",
            "Viewer files (*.npz *.sad);;NPZ files (*.npz);;SAD files (*.sad);;All files (*)",
        )
        if filenames:
            self.load_paths(list(filenames))

    def open_recent_file(self, raw_path: str) -> None:
        path = Path(raw_path).expanduser().resolve()
        if not path.exists():
            logger.info("Recent file no longer exists path=%s", path)
            self._prune_recent_file_paths()
            self._set_status_override(f"Recent file is no longer available: {path.name}")
            return
        existing = self._open_document_by_path(path)
        if existing is not None:
            logger.info("Focusing already opened recent file path=%s", path)
            self._remember_recent_paths([path])
            self._focus_open_document(existing)
            self._status_override = None
            self._refresh_status_and_actions()
            return
        loaded_documents = self.load_paths([os.fspath(path)])
        if loaded_documents:
            self._focus_open_document(loaded_documents[0])

    def close_selected_source(self) -> None:
        item = self.sources_tree.currentItem()
        if item is None:
            return
        payload = item.data(0, QtCore.Qt.ItemDataRole.UserRole)
        if not payload:
            return
        if payload[0] == "state":
            document = payload[1]
            item = item.parent() or item
        else:
            document = payload[1]
        if document in self._documents:
            self._documents.remove(document)
        index = self.sources_tree.indexOfTopLevelItem(item)
        if index >= 0:
            self.sources_tree.takeTopLevelItem(index)
        if self._current_document is document:
            self._clear_session()
            if self.sources_tree.topLevelItemCount() > 0:
                first = self.sources_tree.topLevelItem(0)
                if first.childCount() > 0:
                    self.sources_tree.setCurrentItem(first.child(0))
        self._refresh_ui()

    def _clear_session(self) -> None:
        self._scene_residency.deactivate()
        self._session = None
        self._current_document = None
        self._current_entry = None
        self._status_override = None
        self.viewport.clear_session()

    def _request_viewport_redraw(self, reason: str) -> None:
        logger.debug("Requesting viewport redraw reason=%s", reason)
        self.viewport.update()

    def _on_viewport_ready(self) -> None:
        logger.debug(
            "Viewport ready pending_entry=%s",
            self._pending_entry is not None,
        )
        if self._pending_entry is not None:
            document, entry = self._pending_entry
            self._pending_entry = None
            self._activate_entry(document, entry)

    def _on_tree_selection_changed(self) -> None:
        item = self.sources_tree.currentItem()
        if item is None:
            return
        payload = item.data(0, QtCore.Qt.ItemDataRole.UserRole)
        if not payload or payload[0] != "state":
            return
        _kind, document, entry = payload
        logger.info(
            "Selected state document=%s entry=%s entry_index=%s",
            document.path,
            entry.label,
            entry.index,
        )
        self._activate_entry(document, entry)

    def _preferred_scene_for_system(self, system) -> str:
        preferred_names = [self._preferred_scene_name, "Layer", "Vectors", "Ball"]
        seen = set()
        for name in preferred_names:
            if name in seen or name not in SCENES:
                continue
            seen.add(name)
            if _scene_supports_system(SCENES[name], system):
                return name
        for name, scene_cls in SCENES.items():
            if _scene_supports_system(scene_cls, system):
                return name
        raise RuntimeError("No built-in scenes are compatible with this system.")

    def _activate_entry(self, document: ViewerDocument, entry: StateEntry) -> None:
        if not self.viewport.is_ready:
            logger.debug(
                "Viewport is not ready yet; deferring activation document=%s entry=%s",
                document.path,
                entry.label,
            )
            self._pending_entry = (document, entry)
            return
        logger.debug(
            "Activating entry begin document=%s format=%s entry=%s entry_index=%s system_size=%s viewport_ready=%s has_previous_session=%s",
            document.path,
            document.format_name,
            entry.label,
            entry.index,
            tuple(int(v) for v in getattr(document.system, "size", ())),
            self.viewport.is_ready,
            self._session is not None,
        )
        previous_session = self._session
        previous_scene = self.viewport.host.scene
        previous_document = self._current_document
        previous_entry = self._current_entry
        logger.debug(
            "Activation residency start max_live_scenes=%s previous_scene=%s target_system_type=%s",
            self._max_live_scenes,
            None if previous_session is None else previous_session.selected_scene,
            None if document.system is None else type(document.system).__name__,
        )
        explicit_redraw_requests = 0
        try:
            state = entry.load_state()
            logger.debug(
                "Loaded state for activation document=%s entry=%s shape=%s dtype=%s",
                document.path,
                entry.label,
                tuple(int(v) for v in getattr(state, "shape", ())),
                getattr(state, "dtype", None),
            )
            preferred_scene = self._preferred_scene_for_system(document.system)
            session = self._scene_residency.activate(
                system=document.system,
                state=state,
                preferred_scene_name=preferred_scene,
            )
            scene_name = session.selected_scene or preferred_scene
            logger.debug(
                "Activated residency-managed session mode=%s scene=%s document=%s entry=%s",
                self._scene_residency.last_activation_mode,
                scene_name,
                document.path,
                entry.label,
            )
        except Exception:
            logger.exception(
                "Failed to activate entry document=%s entry=%s entry_index=%s mode=%s",
                document.path,
                entry.label,
                entry.index,
                self._scene_residency.last_activation_mode,
            )
            self._restore_previous_session(
                previous_session,
                previous_scene,
                previous_document,
                previous_entry,
            )
            self._set_status_override(
                f"Failed to activate {document.label} :: {entry.label}. See Log for details."
            )
            self._refresh_ui(full=self._session is not None)
            return

        self._session = session
        self.viewport.set_session(self._session)
        self._current_document = document
        self._current_entry = entry
        self._preferred_scene_name = self._session.selected_scene or scene_name
        self._status_override = None
        logger.info(
            "Activated entry document=%s entry=%s scene=%s",
            document.path,
            entry.label,
            self._preferred_scene_name,
        )
        self._refresh_ui(full=True)
        explicit_redraw_requests += 1
        self._request_viewport_redraw("activate_entry")
        logger.debug(
            "Activation explicit viewport redraw_requests=%s document=%s entry=%s",
            explicit_redraw_requests,
            document.path,
            entry.label,
        )

    def _restore_previous_session(
        self,
        session: ViewerSession | None,
        scene: object,
        document: ViewerDocument | None,
        entry: StateEntry | None,
    ) -> None:
        if session is None or scene is None:
            self._clear_session()
            return
        try:
            self.viewport.host.attach_scene(scene)
            self._session = session
            self.viewport.set_session(session)
            self._current_document = document
            self._current_entry = entry
            self._request_viewport_redraw("restore_previous_session")
        except Exception:
            logger.exception("Failed to restore the previous viewer session after an activation error")
            self._clear_session()

    def _refresh_scene_combo(self) -> None:
        self._scene_combo_updating = True
        try:
            self.scene_combo.clear()
            if self._session is None:
                self.scene_combo.setEnabled(False)
                return
            self.scene_combo.addItems(self._session.available_scenes)
            current = self._session.selected_scene
            if current is not None:
                index = self.scene_combo.findText(current)
                if index >= 0:
                    self.scene_combo.setCurrentIndex(index)
            self.scene_combo.setEnabled(bool(self._session.available_scenes))
        finally:
            self._scene_combo_updating = False

    def _status_text(self) -> str:
        if self._status_override:
            return self._status_override
        if self._session is None:
            return "No state selected."
        projection = self._session.camera_snapshot()["projection_label"]
        scene = self._session.selected_scene or "Unknown scene"
        source = (
            f"{self._current_document.label} :: {self._current_entry.label}"
            if self._current_document is not None and self._current_entry is not None
            else "Unknown source"
        )
        return f"{source} :: {scene} :: {projection}"

    def _refresh_status_and_actions(self) -> None:
        self.statusBar().showMessage(self._status_text())
        has_session = self._session is not None
        self.save_image_action.setEnabled(has_session)
        self.close_source_action.setEnabled(self.sources_tree.currentItem() is not None)
        self.reset_camera_action.setEnabled(has_session)
        self.snap_camera_action.setEnabled(has_session)
        self.toggle_projection_action.setEnabled(has_session)

    def _refresh_summary(self) -> None:
        if self._session is None:
            self.snippet_panel.set_text("")
            return
        if self.snippet_dock.isVisible():
            self.snippet_panel.set_text(self._session.render_snippet())

    def _refresh_properties_panel(self, *, rebuild: bool) -> None:
        if self._session is None:
            self.properties_panel.set_snapshot(None, rebuild=True)
            return
        self.properties_panel.set_snapshot(
            self._session.properties_snapshot(),
            rebuild=bool(rebuild),
        )

    def _refresh_ui(self, *, full: bool = False) -> None:
        self._refresh_scene_combo()
        if full and self._session is not None:
            self._refresh_properties_panel(rebuild=True)
            self._refresh_summary()
        elif self._session is None:
            self._refresh_properties_panel(rebuild=True)
            self._refresh_summary()
        self._refresh_status_and_actions()

    def _on_session_state_changed(self, refresh_summary: bool) -> None:
        self._refresh_status_and_actions()
        if refresh_summary:
            self._refresh_summary()

    def _set_status_override(self, message: str | None) -> None:
        self._status_override = None if not message else str(message)
        self.statusBar().showMessage(self._status_text())

    def _on_viewport_runtime_error(self, message: str) -> None:
        self._set_status_override(message)

    def _on_snippet_visibility_changed(self, visible: bool) -> None:
        if visible and self._session is not None:
            self.snippet_panel.set_text(self._session.render_snippet())

    def _on_scene_changed(self, scene_name: str) -> None:
        if self._scene_combo_updating or self._session is None or not scene_name:
            return
        logger.info("Switching scene scene=%s", scene_name)
        try:
            changed = self._scene_residency.switch_scene(scene_name)
        except Exception:
            logger.exception("Failed to switch scene scene=%s", scene_name)
            self._set_status_override(
                f"Failed to switch to scene {scene_name}. See Log for details."
            )
            self._refresh_ui(full=True)
            return
        if not changed:
            return
        self._preferred_scene_name = scene_name
        self._status_override = None
        self._refresh_ui(full=True)
        self._request_viewport_redraw("switch_scene")

    def _on_property_edited(
        self,
        group_id: str,
        prop_index: int,
        value: object,
        expected_field: object,
        expected_title: object,
    ) -> None:
        if self._session is None:
            return
        logger.debug(
            "Applying property edit group=%s index=%s field=%s title=%s value=%r",
            group_id,
            prop_index,
            expected_field,
            expected_title,
            value,
        )
        try:
            update_mask = self._session.apply_property_input(
                group_id,
                prop_index,
                value,
                expected_field=None if expected_field is None else str(expected_field),
                expected_title=None if expected_title is None else str(expected_title),
            )
        except Exception:
            logger.exception(
                "Failed to apply property edit group=%s index=%s",
                group_id,
                prop_index,
            )
            self._set_status_override("Failed to apply property change. See Log for details.")
            self._refresh_ui(full=True)
            return
        self._status_override = None
        self._refresh_properties_panel(
            rebuild=bool(update_mask & UpdateMask.REBUILD_PROPERTY_SCHEMA)
        )
        self._refresh_summary()
        self._refresh_status_and_actions()
        self._request_viewport_redraw("apply_property_change")

    def save_image(self) -> None:
        if self._session is None:
            return
        current_size = self.viewport.host.size
        stored_size = self._settings.value("export_size")
        if isinstance(stored_size, (list, tuple)) and len(stored_size) == 2:
            try:
                current_size = int(stored_size[0]), int(stored_size[1])
            except Exception:
                current_size = self.viewport.host.size
        dialog = ExportImageDialog(
            width=current_size[0],
            height=current_size[1],
            parent=self,
        )
        if dialog.exec() != QtWidgets.QDialog.DialogCode.Accepted:
            return
        export_size = dialog.selected_size()
        filename, _filter = QtWidgets.QFileDialog.getSaveFileName(
            self,
            "Save Image",
            str(Path.home() / "render.png"),
            "PNG files (*.png);;JPEG files (*.jpg *.jpeg);;All files (*)",
        )
        if not filename:
            return
        logger.info("Saving image path=%s size=%s", filename, export_size)
        try:
            image = self._session.export_image(size=export_size)
            image.save(filename)
        except Exception as exc:
            logger.exception("Failed to save image path=%s size=%s", filename, export_size)
            self._set_status_override("Failed to save image. See Log for details.")
            QtWidgets.QMessageBox.warning(
                self,
                "Save Failed",
                f"Failed to save image to {filename!r}.\n\n{exc}",
            )
            return
        self._settings.setValue("export_size", list(export_size))
        self._status_override = None
        self.statusBar().showMessage(f"Saved image to {filename}", 5000)

    def _on_log_record_appended(self, levelno: int) -> None:
        if levelno < logging.WARNING:
            return
        self.log_dock.show()
        self.log_dock.raise_()
        self._sync_dock_area_actions()
