#!python
"""OpenGate asset/blueprint editor.

Thin wiring (``sys.path``, pygame env flag) mirroring the ``og``/``og-server``
launchers, then the full Asset Viewer application, so it can be started with
``uv run ./og-editor`` (or ``./og-editor``) from the repository root.
"""

import sys
import os

sys.path.append("./")
os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "1"

import argparse
import signal
import time
from pathlib import Path

import numpy as np

import Opengate as og
import Ogre
import Ogre.Bites
import Ogre.Bullet as Bullet
import Ogre.ImGui as ImGui

import Opengate.client.gui as gui

from Opengate.common.resources import ResourceManager
from Opengate.client.gameManagement import GameStateListener, OpengateClient
from Opengate.effects.spacescape import SpaceScape

from Opengate.common.ogreCommons import (
    createCamera,
    createLight,
    createSceneNode,
    cloneMaterial,
)
from Opengate.common.ogreMeshHelper import (
    Line,
)
from Opengate.common.ssao import (
    attachSSAO,
    detachSSAO,
    setSSAODebugMode,
    SSAODebugMode,
    requestSSAOResync,
    processSSAOResync,
)

from Opengate.editor.viewerItems import (
    AssetViewerItem,
    GPUProgramViewer,
    GPUProgramViewerCache,
    SectorViewer,
    MeshViewer,
    LightsViewer,
    SSAOViewer,
)

import Opengate.editor.testScenes as testScenes
from Opengate.editor.utils import (
    reloadMaterialsFromEntity,
    findResourceSources,
    findMaterial,
    preciseRayTest,
)
from Opengate.editor.capture import takeScreenshot, GifRecorder
from Opengate.editor.dbEditor import DBEditorOverlay
from Opengate.editor.playerEditor import PlayerEditorOverlay
from Opengate.editor.blueprintEditor import BluePrintEditorOverlay
from Opengate.editor.shaderGraphEditor import ShaderGraphEditorOverlay
from Opengate.editor.inputGuards import imguiWantsKeyboard
from Opengate.editor.renderProperties import PropertiesOverlay
from Opengate.editor import materialWriter


VIEWER_GRPNAME = "AssetViewer"


def signal_handler(sig, frame):
    """Handle the Ctrl+C signal to shutdown the client."""
    print("You pressed Ctrl+C!")
    ResourceManager.game.shutdown()


class CameraController(Ogre.Bites.CameraMan):
    """Camera controller for the asset viewer."""

    def __init__(self, sm: Ogre.SceneManager):
        self.sm = sm
        self.cam = createCamera(self.sm, "Main Camera")
        self.cam.setAutoAspectRatio(True)
        self.camNode = self.cam.getParentSceneNode()

        super().__init__(self.camNode)

        # #self.camMgr.setStyle(Ogre.Bites.CS_FREELOOK)
        # #self.camMgr.setStyle(Ogre.Bites.CS_MANUAL)
        self.setStyle(Ogre.Bites.CS_ORBIT)

        self.camNode.setPosition(0, 8, 15)
        self.camNode.lookAt((0, 0, 0), Ogre.Node.TS_PARENT)

        self.pivotNode = createSceneNode(self.sm, "Camera.PivotNode")
        self._pivotVisible = True

        Line(
            self.sm,
            pnts=[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]],
            mat="#FF0000FF",
            name="Camera.PivotLine_X",
            node=self.pivotNode,
        )
        Line(
            self.sm,
            pnts=[[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
            mat="#00FF00FF",
            name="Camera.PivotLine_Y",
            node=self.pivotNode,
        )
        Line(
            self.sm,
            pnts=[[0.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
            mat="#0000FFFF",
            name="Camera.PivotLine_Z",
            node=self.pivotNode,
        )

        self._pause = False

    def isPaused(self):
        """Return whether the camera controller is paused."""
        return self._pause

    def togglePause(self):
        """Toggle the pause state of the camera controller."""
        self._pause = not self._pause

    def setPause(self, pause):
        """Set the pause state of the camera controller."""
        self._pause = pause

    def mouseMoved(self, evt):
        """Handle mouse movement events."""
        if self._pause is False:
            super().mouseMoved(evt)

    def mousePressed(self, evt):
        """Handle mouse press events."""
        if self._pause is False:
            super().mousePressed(evt)

    def mouseReleased(self, evt):
        """Handle mouse release events."""
        if self._pause is False:
            super().mouseReleased(evt)

    def fitView(self, ent):
        """Set camera to fill 50% of screen with the given entity."""
        eSize = (
            ent.getBoundingBox().getMaximum()
            - ent.getBoundingBox().getMinimum()
        )

        maxSize = np.sqrt(eSize.x**2 + eSize.y**2 + eSize.z**2)
        # print(f"Entity size: {eSize}, max size: {maxSize}")

        self.camNode.setPosition(0, 0.9 * maxSize, 1.2 * maxSize)

        entPos = ent.getParentSceneNode().getPosition()
        self.camNode.lookAt(entPos, Ogre.Node.TS_WORLD)

    def setPivotOffset(self, offset):
        """Set the pivot offset for orbiting around a point."""
        super().setPivotOffset(offset)
        # self.pivotIcon.position = offset
        self.pivotNode.setPosition(offset)

    def _togglePivotVisible(self):
        self._pivotVisible = not self._pivotVisible
        self.pivotNode.setVisible(self._pivotVisible)

    def renderToolbarControls(self, toolbar):
        """Render some controls for the camera."""
        gui.imGuiRenderButton(
            "Pivot",
            toggle=self._pivotVisible,
            cb=lambda: self._togglePivotVisible(),
        )


class GridFloor:
    """Grid floor for the asset viewer.

    Taken and modified from
    https://github.com/OGRECave/ogre-meshviewer/tree/master.
    """

    def __init__(self, scale, N, parentNode, app):
        """Initialize the grid floor.

        Arguments:
        ----------
        scale:
            Scale of the grid floor. Base size is 1.0 (m),
            so scale = 2.0 will make the grid twice as large.
        N:
            Number of grid lines in each direction.
        parentNode:
            Parent scene node to attach the grid floor to.
        """
        self.material = Ogre.MaterialManager.getSingleton().create(
            "VertexColour", VIEWER_GRPNAME
        )
        p = self.material.getTechnique(0).getPass(0)
        p.setLightingEnabled(False)
        p.setVertexColourTracking(Ogre.TVC_AMBIENT)

        self.planeNode = parentNode.createChildSceneNode("GridFloor")
        self.scale = scale
        self.app = app

        self.N = N
        self.planes = [self._createPlane(i) for i in range(3)]
        self.plane = "XZ"  # Defaults to showing the XZ plane
        self.visible = False

    @property
    def scale(self):
        """Scale the grid floor."""
        return self._scale

    @scale.setter
    def scale(self, scale):
        """Set the scale of the grid floor."""
        self._scale = scale
        self.planeNode.setScale(scale * 50, scale * 50, scale * 50)

    @property
    def visible(self):
        """Return whether the grid floor is visible."""
        return self._visible

    @visible.setter
    def visible(self, visible):
        """Set the visibility of the grid floor."""
        self._visible = visible
        self.planeNode.setVisible(visible)
        if self.visible is True:
            self.showPlane(self.plane)

    def toggleVisibility(self):
        """Toggle the visibility of the grid floor."""
        self.visible = not self.visible

    def showPlane(self, plane):
        """Show the specified plane ("XY", "XZ", "YZ") or hide if None."""
        self.plane = plane
        if self.visible is True:
            for i, grid in enumerate(self.planes):
                grid.setVisible(self.plane == ["XY", "XZ", "YZ"][i])

    def _createPlane(self, plane):
        """Create a grid plane along the specified axis (0, 1, 2)."""
        normal = [0, 0, 0]
        normal[plane] = 1

        axis_color = [[0, 0, 0], [0, 0, 0]]
        axis_color[0][(plane + 2) % 3] = 1
        axis_color[1][(plane + 1) % 3] = 1

        grid_color1 = (0.4, 0.4, 0.4)
        grid_color5 = (0.2, 0.2, 0.2)
        grid_color10 = (0.0, 0.0, 0.0)

        # Compute the other axes based on the normal vector
        axis = [
            Ogre.Vector3(normal[1], normal[2], normal[0]),
            Ogre.Vector3(normal[2], normal[0], normal[1]),
        ]

        o = self.planeNode.getCreator().createManualObject(
            f"Viewer/plane{plane}"
        )
        o.begin(self.material, Ogre.RenderOperation.OT_LINE_LIST)
        o.setQueryFlags(0)
        o.setVisible(False)

        nX = self.N // 2
        for i in range(2):
            for j in range(-nX, nX + 1):
                cl = (
                    axis_color[i]
                    if j == 0
                    else grid_color10
                    if j % 10 == 0
                    else grid_color5
                    if j % 5 == 0
                    else grid_color1
                )
                o.position(-axis[i] + axis[1 - i] * j / nX)
                o.colour(cl)
                o.position(axis[i] + axis[1 - i] * j / nX)
                o.colour(cl)

        o.end()

        self.planeNode.attachObject(o)
        return o

    def decSize(self):
        """Decrease the grid size."""
        self.scale = max(0.1, self.app.grid.scale / 10)

    def incSize(self):
        """Increase the grid size."""
        self.scale = min(1000, int(self.app.grid.scale * 10))

    def _renderPlaneControls(self, plane):
        """Render the controls for the specified plane."""
        active = False
        if self.plane == plane:
            active = True
            ImGui.PushStyleColor(
                ImGui.Col_Button,
                ImGui.GetStyleColorVec4(ImGui.Col_ButtonHovered),
            )
            ImGui.PushStyleColor(
                ImGui.Col_ButtonHovered,
                ImGui.GetStyleColorVec4(ImGui.Col_ButtonActive),
            )
            ImGui.PushStyleColor(
                ImGui.Col_ButtonActive,
                ImGui.GetStyleColorVec4(ImGui.Col_ButtonHovered),
            )

        if ImGui.Button(plane):
            self.showPlane(plane)

        if active is True:
            ImGui.PopStyleColor(3)

    def renderToolbarControls(self, toolbar):
        """Render the toolbar controls for the grid floor."""
        ImGui.Text("Grid:")
        ImGui.SameLine()
        label = "ON" if self.visible else "OFF"
        if ImGui.Button(label):
            self.toggleVisibility()

        ImGui.SameLine()
        self._renderPlaneControls("XY")
        ImGui.SameLine()
        self._renderPlaneControls("XZ")
        ImGui.SameLine()
        self._renderPlaneControls("YZ")

        ImGui.SameLine()
        if ImGui.Button("-"):
            self.decSize()

        ImGui.SameLine()
        ImGui.Text(f"{self.app.grid.scale}")

        ImGui.SameLine()
        if ImGui.Button("+"):
            self.incSize()


class GUIOverlay(gui.ImGuiOverlay):
    """GUI main overlay for the asset viewer."""

    def __init__(self, name, app, **kwargs):
        super().__init__(name, **kwargs)
        self.app = app
        self.showMetrics = False
        self.showAbout = False
        self.showSceneManager = False
        self.showProperties = False
        self.showDBEditor = False
        self.errLog = []
        # Capture / recording dialog state.
        self.showRecordDialog = False
        self.sectorViewer = None
        self.meshViewer = None
        self._recUseDuration = True
        self._recDuration = 3.0
        self._recFrames = 60
        self._recFps = 20
        self.toolbarHeight = 34.0
        self.menuBarHeight = -1
        self.footerHeight = 24.0
        self.statusMessage = ""
        self.statusMessageTime = 0.0

    def setStatusMessage(self, message):
        """Show *message* in the footer status bar (e.g. editor state changes)."""
        self.statusMessage = message
        self.statusMessageTime = time.time()

    def drawFooter(self):
        """Draw a fixed footer bar along the bottom of the window for state messages."""
        if not self.statusMessage:
            return
        flags = 0
        flags |= ImGui.WindowFlags_NoCollapse
        flags |= ImGui.WindowFlags_NoTitleBar
        flags |= ImGui.WindowFlags_NoScrollWithMouse
        flags |= ImGui.WindowFlags_NoScrollbar
        flags |= ImGui.WindowFlags_NoResize
        flags |= ImGui.WindowFlags_NoSavedSettings
        flags |= ImGui.WindowFlags_NoMove
        flags |= ImGui.WindowFlags_NoFocusOnAppearing
        flags |= ImGui.WindowFlags_NoNav

        with gui.ImGuiWindow(
            "Footer",
            flags=flags,
            area=(
                (0, 0),
                (1, -self.footerHeight),
                (1, 0),
                (0, self.footerHeight),
            ),
            cond=ImGui.Cond_Always,
            StyleColor={
                ImGui.Col_WindowBg: ImGui.GetStyleColorVec4(ImGui.Col_MenuBarBg)
            },
        ):
            ImGui.Text(self.statusMessage)

    def drawToolbar(self):
        """Draw a fixed toolbar below the main menu bar."""
        flags = 0
        flags |= ImGui.WindowFlags_NoCollapse
        flags |= ImGui.WindowFlags_NoTitleBar
        flags |= ImGui.WindowFlags_NoScrollWithMouse
        flags |= ImGui.WindowFlags_NoScrollbar
        flags |= ImGui.WindowFlags_NoResize
        flags |= ImGui.WindowFlags_NoSavedSettings
        flags |= ImGui.WindowFlags_NoMove
        flags |= ImGui.WindowFlags_NoFocusOnAppearing
        flags |= ImGui.WindowFlags_NoNav

        with gui.ImGuiWindow(
            "Toolbar",
            flags=flags,
            area=(
                (0, 0),
                (
                    0.0,
                    self.menuBarHeight / ResourceManager.guiDispatcher.guiScale
                    - 1,
                ),
                (1.0, 0),
                (0, self.toolbarHeight),
            ),
            cond=ImGui.Cond_Always,
            StyleColor={
                ImGui.Col_WindowBg: ImGui.GetStyleColorVec4(ImGui.Col_MenuBarBg)
            },
        ) as toolbar:
            # TODO refactor me when more toolbar controls are added
            self.app.grid.renderToolbarControls(toolbar)
            gui.imGuiSeparatorV()
            self.app.camMgr.renderToolbarControls(toolbar)
            gui.imGuiSeparatorV()

    def renderImGui(self):
        """Render the ImGui login dialog."""
        if ImGui.BeginMainMenuBar():
            self.menuBarHeight = ImGui.GetWindowHeight()
            if ImGui.BeginMenu("File"):
                if ImGui.MenuItem("Quit", "Ctrl-q"):
                    ResourceManager.app.stop()
                ImGui.EndMenu()

            if ImGui.BeginMenu("View"):
                hasEntity = self.app.activeEntity is not None

                if ImGui.MenuItem(
                    "Axes", "A", hasEntity and self.app.axesVisible, hasEntity
                ):
                    self.app.toggleAxes()

                if ImGui.MenuItem(
                    "Bounding Box",
                    "B",
                    hasEntity
                    and self.app.activeEntity.getParentSceneNode().getShowBoundingBox(),
                    hasEntity,
                ):
                    self.app.toggleBoundingBox()

                if ImGui.MenuItem("Grid", "G", self.app.grid.visible):
                    self.app.grid.toggleVisibility()

                if ImGui.MenuItem("Metrics", "M", self.showMetrics):
                    self.showMetrics = not self.showMetrics

                if ImGui.MenuItem(
                    "Wireframe Mode",
                    "W",
                    self.app.camMgr.cam.getPolygonMode() == Ogre.PM_WIREFRAME,
                ):
                    self.app.toggleWireframe()

                if ImGui.MenuItem(
                    "Scene Manager", "Ctrl-S", self.showSceneManager
                ):
                    self.app.toggleSceneManager()

                if ImGui.MenuItem("Properties", "P", self.showProperties):
                    self.app.propOverlay.toggle()
                ImGui.EndMenu()

            if ImGui.BeginMenu("Capture"):
                if ImGui.MenuItem("Screenshot", "F12"):
                    self.app.takeScreenshot()

                if not self.app.isRecording():
                    if ImGui.MenuItem("Record GIF..."):
                        self.showRecordDialog = True
                else:
                    if ImGui.MenuItem("Stop Recording"):
                        self.app.stopRecording()
                ImGui.EndMenu()

            if ImGui.BeginMenu("Open"):
                if ImGui.MenuItem(
                    "SpaceScape", "", False, self.app.spaceScapeViewer is None
                ):
                    self.app.addSpaceScape()
                if ImGui.MenuItem(
                    "Mesh", "", False, self.app.meshViewer is None
                ):
                    self.app.addMeshViewer()
                if ImGui.MenuItem(
                    "Sector", "", False, self.app.sectorViewer is None
                ):
                    self.app.addSectorViewer()
                ImGui.EndMenu()

            if ImGui.BeginMenu("Tools"):
                dbOverlay = self.app.dbEditorOverlay
                if ImGui.MenuItem(
                    "Station DB Editor",
                    "D",
                    dbOverlay is not None and dbOverlay.isVisible(),
                ):
                    if dbOverlay is not None:
                        dbOverlay.toggle()

                plrOverlay = self.app.playerEditorOverlay
                if ImGui.MenuItem(
                    "Player Editor",
                    "E",
                    plrOverlay is not None and plrOverlay.isVisible(),
                ):
                    if plrOverlay is not None:
                        plrOverlay.toggle()

                bpeOverlay = self.app.bpEditorOverlay
                if ImGui.MenuItem(
                    "Blueprint Editor",
                    "Ctrl-B",
                    bpeOverlay is not None and bpeOverlay.isVisible(),
                ):
                    if bpeOverlay is not None:
                        bpeOverlay.toggle()

                sgeOverlay = self.app.sgEditorOverlay
                if ImGui.MenuItem(
                    "Shader Graph Editor",
                    "N",
                    sgeOverlay is not None and sgeOverlay.isVisible(),
                ):
                    if sgeOverlay is not None:
                        sgeOverlay.toggle()
                ImGui.EndMenu()

            if ImGui.BeginMenu("Help"):
                if ImGui.MenuItem("About"):
                    self.showAbout = not self.showAbout
                ImGui.EndMenu()

            ImGui.EndMainMenuBar()

        self.drawToolbar()

        if self.showMetrics is True:
            self.drawMetrics()

        if self.showAbout is True:
            self.drawAbout()

        if self.showRecordDialog is True:
            self.drawRecordDialog()

        if len(self.errLog) > 0:
            self.drawErrorLog()

        self.drawFooter()

    def drawMetrics(self):
        """Draw the metrics overlay."""
        stats = ResourceManager.app.win.getStatistics()

        ImGui.SetNextWindowBgAlpha(0.3)
        flags = 0
        flags |= ImGui.WindowFlags_NoCollapse
        flags |= ImGui.WindowFlags_NoTitleBar
        flags |= ImGui.WindowFlags_NoScrollWithMouse
        flags |= ImGui.WindowFlags_NoScrollbar
        flags |= ImGui.WindowFlags_NoResize
        flags |= ImGui.WindowFlags_NoSavedSettings
        flags |= ImGui.WindowFlags_NoMove
        flags |= ImGui.WindowFlags_NoFocusOnAppearing
        flags |= ImGui.WindowFlags_NoNav

        ImGui.SetNextWindowBgAlpha(0.3)

        with gui.ImGuiWindow(
            "Metrics",
            flags=flags,
            area=((1, -180), (1, -100), (0, 180), (0, 100)),
            cond=ImGui.Cond_Always,
        ):
            stats_dict = {
                "Average FPS": f"{stats.avgFPS:.2f}",
                "Batches": f"{stats.batchCount}",
                "Triangles": f"{stats.triangleCount}",
            }

            ImGui.Text("Metrics")
            ImGui.Separator()
            if ImGui.BeginTable("Metrics", 2):
                for stat, value in stats_dict.items():
                    ImGui.TableNextRow()
                    ImGui.TableSetColumnIndex(0)
                    ImGui.Text(stat)
                    ImGui.TableSetColumnIndex(1)
                    ImGui.Text(value)
                ImGui.EndTable()

    def drawAbout(self):
        """Draw the about overlay."""
        flags = 0
        flags |= ImGui.WindowFlags_AlwaysAutoResize
        flags |= ImGui.WindowFlags_NoCollapse
        flags |= ImGui.WindowFlags_NoResize

        ImGui.SetNextWindowBgAlpha(0.8)
        with gui.ImGuiWindow(
            "About",
            flags=flags,
            open=self.showAbout,
            area=((0.5, -150), (0.5, -100), (0, 300), (0, 200)),
        ) as window:
            ImGui.Text("Opengate asset viewer")
            ImGui.Separator()
            ImGui.Text("Version 0.0.1")
            ImGui.Text("Copyright (c) 2026 OpenGate Team")
            ImGui.Separator()
            ImGui.BulletText(f"Ogre:  {Ogre.__version__}")
            ImGui.BulletText(f"ImGui: {ImGui.GetVersion()}")
            self.showAbout = window.open

    def drawRecordDialog(self):
        """Draw the GIF recording configuration dialog."""
        flags = 0
        flags |= ImGui.WindowFlags_AlwaysAutoResize
        flags |= ImGui.WindowFlags_NoCollapse

        ImGui.SetNextWindowBgAlpha(0.9)
        with gui.ImGuiWindow(
            "Record GIF",
            flags=flags,
            open=self.showRecordDialog,
            area=((0.5, -170), (0.5, -110), (0, 340), (0, 220)),
        ) as window:
            self.ensureCameraFocus()

            ImGui.Text("Capture the render window to an animated GIF.")
            ImGui.Separator()

            # Choose duration- or frame-based length.
            if ImGui.RadioButton("By duration", self._recUseDuration):
                self._recUseDuration = True
            ImGui.SameLine()
            if ImGui.RadioButton("By frames", not self._recUseDuration):
                self._recUseDuration = False

            ImGui.PushItemWidth(160)
            if self._recUseDuration:
                changed, val = ImGui.DragFloat(
                    "Duration (s)", float(self._recDuration), 0.1, 0.1, 60.0
                )
                if changed:
                    self._recDuration = max(0.1, val)
            else:
                changed, val = ImGui.DragInt(
                    "Frames", int(self._recFrames), 1, 1, 100000
                )
                if changed:
                    self._recFrames = max(1, val)

            changed, val = ImGui.DragInt("FPS", int(self._recFps), 1, 1, 120)
            if changed:
                self._recFps = max(1, val)
            ImGui.PopItemWidth()

            ImGui.Separator()

            if ImGui.Button("Record", ImGui.ImVec2(150, 0)):
                self.app.startRecording(
                    duration=(
                        self._recDuration if self._recUseDuration else None
                    ),
                    frames=(None if self._recUseDuration else self._recFrames),
                    fps=self._recFps,
                )
                self.showRecordDialog = False

            ImGui.SameLine()

            if ImGui.Button("Cancel", ImGui.ImVec2(150, 0)):
                self.showRecordDialog = False

            self.showRecordDialog = window.open and self.showRecordDialog

    def drawErrorLog(self):
        """Draw the error log overlay."""
        flags = 0
        flags |= ImGui.WindowFlags_AlwaysAutoResize
        flags |= ImGui.WindowFlags_NoCollapse

        ImGui.SetNextWindowBgAlpha(0.8)
        with gui.ImGuiWindow(
            "Error Log",
            flags=flags,
            open=True,
            area=((0.5, -250), (0.5, -100), (0, 500), (0, 200)),
        ):
            for line in self.errLog:
                ImGui.Text(line)


class ResourceTreeOverlay(gui.ImGuiOverlay):
    """Resource tree overlay for the asset viewer."""

    def __init__(self, name, app, **kwargs):
        super().__init__(name, **kwargs)
        self.app = app
        self.resources = []
        self.sm = None
        self.positionSet = False
        self.unfoldNode = None  # select node to be unfolded
        self.unfoldRecursive = True
        self.unfoldSubtreeDepth = 0
        self.unfoldFramesRemaining = 0
        self.activeItemName = None
        self.hoveredSubEntity = None
        self._labelStack = []

        ## Use a custom pass for highlighting sub-entities,
        ## or use separate cloned materials for each sub-entity.
        ## Using a custom pass is more efficient,
        ## but work on material level and highlight all Sub-entities with
        ## same material names.
        self.usePassForHighlight = True

    def addResource(self, res):
        """Add a resource to the list of resources."""
        if isinstance(res, Ogre.SceneManager):
            self.sm = res
        self.insertResource(res, len(self.resources))

    def removeResource(self, res):
        """Remove a resource from the list of resources."""
        if isinstance(res, Ogre.SceneManager):
            self.sm = None
        if res in self.resources:
            self.resources.remove(res)

    def insertResource(self, res, index):
        """Insert a resource at the specified index in the list of resources."""
        if isinstance(res, Ogre.SceneManager):
            self.sm = res

        if res not in self.resources:
            self.resources.insert(index, res)
        else:
            og._y("Resource already in list:", res)

    def unfold(self, item, recursive=True):
        """Unfold the tree to the specified item."""
        self.unfoldNode = item
        self.unfoldRecursive = recursive
        self.unfoldSubtreeDepth = 0
        # Keep unfolding active for a few frames because some Ogre material
        # internals (techniques/passes) may appear lazily in subsequent frames.
        self.unfoldFramesRemaining = 8 if recursive else 1

    _HIGHLIGHT_PASS_NAME = "_SelectionHighlight"  # noqa: S105 -- Ogre pass name, not a credential
    _HIGHLIGHT_VP_NAME = "_SelectionHighlightVP"
    _HIGHLIGHT_FP_NAME = "_SelectionHighlightFP"

    _HIGHLIGHT_VP_SRC = (
        "#version 330 core\n"
        "in vec4 position;\n"
        "uniform mat4 worldViewProj;\n"
        "void main() { gl_Position = worldViewProj * position; }\n"
    )

    _HIGHLIGHT_FP_SRC = (
        "#version 330 core\n"
        "out vec4 fragColor;\n"
        "void main() { fragColor = vec4(0.0, 0.8, 1.0, 0.8); }\n"
    )

    def _ensureHighlightPrograms(self):
        """Create the highlight GPU programs the first time they are needed."""
        mgr = Ogre.GpuProgramManager.getSingleton()

        if not mgr.resourceExists(self._HIGHLIGHT_VP_NAME, VIEWER_GRPNAME):
            vp = mgr.createProgram(
                self._HIGHLIGHT_VP_NAME,
                VIEWER_GRPNAME,
                "glsl",
                Ogre.GPT_VERTEX_PROGRAM,
            )
            vp.setSource(self._HIGHLIGHT_VP_SRC)
            vp.load()

        if not mgr.resourceExists(self._HIGHLIGHT_FP_NAME, VIEWER_GRPNAME):
            fp = mgr.createProgram(
                self._HIGHLIGHT_FP_NAME,
                VIEWER_GRPNAME,
                "glsl",
                Ogre.GPT_FRAGMENT_PROGRAM,
            )
            fp.setSource(self._HIGHLIGHT_FP_SRC)
            fp.load()

    def _removeHighlightMaterial(self, subEntity):
        """Remove the selection highlight pass from a material."""
        if self.usePassForHighlight is True:
            mat = subEntity.getMaterial()

            tech = mat.getTechnique(mat.getNumTechniques() - 1)
            pass_obj = tech.getPass(self._HIGHLIGHT_PASS_NAME)
            if pass_obj is not None:
                tech.removePass(pass_obj.getIndex())
            mat.compile()
        else:
            subEntity.setMaterialName(subEntity.activeMaterialName)

        subEntity.removeHighlights = None
        self.highlightedSubEntity = None

    def _clearHighlightedSubEntity(self):
        """Clear the currently highlighted SubEntity, if any."""
        if (
            getattr(self, "highlightedSubEntity", None) is not None
            and hasattr(self.highlightedSubEntity, "removeHighlights")
            and self.highlightedSubEntity.removeHighlights is not None
        ):
            self.highlightedSubEntity.removeHighlights()

        self.highlightedSubEntity = None

    def _setHighlightMaterial(self, subEntity):
        """Clone material and add a glowing wireframe pass using inline GLSL."""
        subEntity.activeMaterialName = subEntity.getMaterialName()

        if self.usePassForHighlight is True:
            mat = subEntity.getMaterial()
        else:
            mat = cloneMaterial(
                subEntity.activeMaterialName,
                f"_{subEntity.activeMaterialName}_highlight",
            )

        self._ensureHighlightPrograms()
        if mat.getNumTechniques() == 0:
            return
        tech = mat.getTechnique(mat.getNumTechniques() - 1)
        p = tech.createPass()
        p.setName(self._HIGHLIGHT_PASS_NAME)
        p.setPolygonMode(Ogre.PM_WIREFRAME)
        p.setDepthWriteEnabled(False)
        p.setSceneBlending(Ogre.SBT_ADD)

        p.setVertexProgram(self._HIGHLIGHT_VP_NAME)
        vpParams = p.getVertexProgramParameters()
        vpParams.setNamedAutoConstant(
            "worldViewProj", Ogre.GpuProgramParameters.ACT_WORLDVIEWPROJ_MATRIX
        )

        p.setFragmentProgram(self._HIGHLIGHT_FP_NAME)
        mat.compile()
        if self.usePassForHighlight is False:
            subEntity.setMaterial(mat)

        subEntity.removeHighlights = lambda: self._removeHighlightMaterial(
            subEntity
        )

    def highlightSubEntity(self, subEntity):
        """Highlight the specified sub-entity in the tree."""
        if not hasattr(self, "highlightedSubEntity"):
            self.highlightedSubEntity = None

        if subEntity is None:
            self._clearHighlightedSubEntity()
            return

        if self.highlightedSubEntity is subEntity:
            return

        self._clearHighlightedSubEntity()
        self.highlightedSubEntity = subEntity
        self._setHighlightMaterial(self.highlightedSubEntity)

    def itemClicked(self, item):
        """Handle item click events without triggering hover highlight."""
        if item != self.app.propOverlay.item:
            self.app.propOverlay.item = item

    def addTreeItem(
        self, label, item, children=None, renderChildrenCB=None, dirty=False
    ):
        """Add a tree item with proper selection handling.

        Arguments:
        ----------
            label:
                Display label for the tree node.
            item:
                The item to associate with this node (for selection).
            children:
                Optional list of child items to render recursively.
            renderChildrenCB:
                Optional callback to render children (for custom rendering).
            dirty:
                Render this node's own label in yellow (unsaved changes).
        """
        flags = (
            ImGui.TreeNodeFlags_OpenOnArrow
            | ImGui.TreeNodeFlags_OpenOnDoubleClick
        )

        fullLabel = "/".join(self._labelStack + [label])

        # Mark as selected if this is the selected node
        if self.activeItemName == fullLabel:
            flags |= ImGui.TreeNodeFlags_Selected
            self.itemClicked(item)

        hasChildren = children is not None and len(children) > 0
        shouldForceOpen = hasChildren and (
            item == self.unfoldNode
            or (self.unfoldRecursive and self.unfoldSubtreeDepth > 0)
        )

        if shouldForceOpen:
            ImGui.SetNextItemOpen(True, ImGui.Cond_Always)

        # If no children, make it a leaf node
        hovered = False
        nodeOpen = False

        if dirty:
            ImGui.PushStyleColor(
                ImGui.Col_Text, ImGui.ImVec4(1.0, 1.0, 0.0, 1.0)
            )
        try:
            if not hasChildren:
                flags |= (
                    ImGui.TreeNodeFlags_Leaf
                    | ImGui.TreeNodeFlags_NoTreePushOnOpen
                )
                ImGui.TreeNodeEx(label, flags)
                hovered = ImGui.IsItemHovered()

                if ImGui.IsItemClicked():
                    self.activeItemName = fullLabel
            else:
                # Node with children
                nodeOpen = ImGui.TreeNodeEx(label, flags)
                hovered = ImGui.IsItemHovered()

                if ImGui.IsItemClicked():
                    self.activeItemName = fullLabel
        finally:
            if dirty:
                ImGui.PopStyleColor()

        if hovered and isinstance(item, Ogre.SubEntity):
            self.hoveredSubEntity = item

        if nodeOpen:
            enteredUnfoldSubtree = self.unfoldRecursive and (
                item == self.unfoldNode or self.unfoldSubtreeDepth > 0
            )
            if enteredUnfoldSubtree:
                self.unfoldSubtreeDepth += 1

            self._labelStack.append(label)
            try:
                # Recursively render children
                if renderChildrenCB is not None:
                    renderChildrenCB(children)
                else:
                    # Default: assume children are scene nodes
                    for i, child in enumerate(children):
                        self.renderTreeNodeItem(child, childID=i)
            finally:
                self._labelStack.pop()
                if enteredUnfoldSubtree:
                    self.unfoldSubtreeDepth -= 1
                ImGui.TreePop()

    def createLeafName(self, item, label=None):
        """Create a name for a leaf node based on the item type."""
        if hasattr(item, "getSchemeName") and item.getSchemeName() is not None:
            return f"Technique: {item.getSchemeName()}"
        if hasattr(item, "name") and item.name is not None:
            return f"{item.name}"
        else:
            return label or f"{type(item).__name__}" + (
                f": {item.getName()}" if hasattr(item, "getName") else "##xx"
            )

    def renderTreeNodeItem(self, item, label=None, childID=0):
        """Recursively render a scene node and its children as a tree."""
        if self.sm is None:
            self.sm = self.app.sm

        children = None
        dirty = False
        if isinstance(item, Ogre.Node):
            # Get children
            children = item.getChildren()

            n = None
            try:
                n = self.sm.getSceneNode(item.getName())
            except BaseException:
                pass

            if n is not None:
                children = [*children, *n.getAttachedObjects()]

        elif isinstance(item, Ogre.MovableObject):
            children = None
            if item.getMovableType() == "Entity":
                try:
                    item = self.sm.getEntity(item.getName())
                except BaseException:
                    pass
                children = [
                    item.getSubEntity(i)
                    for i in range(item.getNumSubEntities())
                ]

            elif item.getMovableType() == "Camera":
                item = self.sm.getCamera(item.getName())

            elif item.getMovableType() == "Light":
                item = self.sm.getLight(item.getName())

            elif item.getMovableType() == "ManualObject":
                try:
                    item = self.sm.getManualObject(item.getName())
                except BaseException:
                    pass
            else:
                pass

        elif isinstance(item, Ogre.SubEntity):
            mat = findMaterial(item.getMaterial())
            if mat.getName() != item.getMaterial().getName():
                item.setMaterialName(mat.getName())
            materialWriter.ensurePristineSnapshot(mat)
            dirty = materialWriter.isMaterialDirty(mat)

            if not hasattr(item, "origMaterialName"):
                item.origMaterialName = mat.getName()

            label = "SubEntity: " + item.origMaterialName
            children = [
                mat.getTechnique(i) for i in range(mat.getNumTechniques())
            ]

            if len(children) > 1:
                for c in children:
                    if c.getName() == "" or c.getName().startswith(
                        "_first_frame"
                    ):
                        c.setName(f"{c.getSchemeName()}")
            elif len(children) == 1:
                # RTTS shader materials are seen as 1 technique
                # for the first frame and then as 2 techniques for the second frame,
                # if the 2. technique got a name the second created by RTTS got the same
                # name, So this little hack is to rename the first technique to _first_frame to avoid name clashes

                if children[0].getName() == "":
                    children[0].setName(
                        f"_first_frame{children[0].getSchemeName()}"
                    )
                elif children[0].getName().startswith("_first_frame"):
                    children[0].setName(f"{children[0].getSchemeName()}")

            ## remove first technique if it is the default one and the second one is the rtts shader technique
            if len(children) == 2:
                if (
                    children[0].getName() == "Default"
                    and children[1].getName() == "ShaderGeneratorDefaultScheme"
                ):
                    del children[0]

        elif isinstance(item, Ogre.Technique):
            children = [item.getPass(i) for i in range(item.getNumPasses())]

            visiblePasses = []
            for c in children:
                if c.getName() == self._HIGHLIGHT_PASS_NAME:
                    continue
                if c.getName() == "":
                    c.setName(f"{len(visiblePasses)}")
                visiblePasses.append(c)
            children = visiblePasses

        elif isinstance(item, Ogre.Pass):
            children = [
                item.getTextureUnitState(i)
                for i in range(item.getNumTextureUnitStates())
            ]
            for i, c in enumerate(children):
                if c.getName() == "":
                    c.setName(f"{i}")

            gpuPrograms = [
                "ComputeProgram",
                "VertexProgram",
                "FragmentProgram",
                "GeometryProgram",
                "TessellationDomainProgram",
            ]
            for program in gpuPrograms:
                programOK = getattr(item, f"has{program}", None)()

                if programOK is True:
                    programObj = getattr(item, f"get{program}", None)()

                    matName = item.getParent().getParent().getName()
                    cacheName = f"{programObj.getName()}({matName})"

                    if 1 and cacheName in GPUProgramViewerCache:
                        progViewer = GPUProgramViewerCache[cacheName]
                    else:
                        progViewer = GPUProgramViewer(
                            self.app, programObj, item
                        )
                        if matName.startswith("OpenGate/SSAO/"):
                            # render_quad compositor passes run on a
                            # cloned material only resynced on recompile.
                            # Deferred: doing the remove/add compositor
                            # dance straight from this ImGui callback
                            # would run mid-frame, while the compositor
                            # chain may still be executing -- segfault.
                            progViewer.onParamChanged = requestSSAOResync
                        GPUProgramViewerCache[cacheName] = progViewer

                    children.append(progViewer)

        elif isinstance(item, Ogre.TextureUnitState):
            pass

        self.addTreeItem(
            f"{self.createLeafName(item, label)}##{childID}",
            item,
            children,
            dirty=dirty,
        )

    def renderImGui(self):
        """Render the resource tree overlay."""
        # Start each frame with a clean recursion depth counter.
        self.unfoldSubtreeDepth = 0
        self._labelStack = []
        self.hoveredSubEntity = None

        flags = 0
        flags |= ImGui.WindowFlags_NoCollapse
        # Keep startup focus on the main UI instead of this side panel.
        flags |= ImGui.WindowFlags_NoFocusOnAppearing
        ImGui.SetNextWindowBgAlpha(0.8)

        display_size = ImGui.GetIO().DisplaySize
        if display_size.x > 0 and display_size.y > 0 and not self.positionSet:
            cond = ImGui.Cond_Always
            self.positionSet = True

            pos, size = gui.toPosSize(
                area=((0, 1), (0, 22), (0, 300), (0.5, -22)),
                parentSize=ImGui.GetIO().DisplaySize,
            )

            ImGui.SetNextWindowSizeConstraints(
                ImGui.ImVec2(size.x, size.y), ImGui.ImVec2(size.x, size.y)
            )
        else:
            cond = ImGui.Cond_Once

        with gui.ImGuiWindow(
            "Resources",
            flags=flags,
            cond=cond,
            area=((0, 0), (0, 20 + 34), (0, 300), (0.5, -22)),
        ):
            self.ensureCameraFocus()

            for res in self.resources:
                if hasattr(res, "updateChildren"):
                    res.updateChildren()

                try:  # Recursively render children
                    if isinstance(res, Ogre.SceneManager):

                        def renderSMChilds(childs):
                            for child in childs:
                                if isinstance(child, Ogre.SceneNode):
                                    self.renderTreeNodeItem(child)
                                elif isinstance(child, Ogre.Camera):
                                    self.addTreeItem(
                                        f"Camera: {child.getName()}", child
                                    )
                                else:
                                    self.addTreeItem(
                                        f"{type(child).__name__}", child
                                    )

                        cams = [
                            res.getCamera(cStr) for cStr in res.getCameras()
                        ]

                        self.addTreeItem(
                            "SceneManager",
                            item=res,
                            children=[res.getRootSceneNode(), *cams],
                            renderChildrenCB=renderSMChilds,
                        )

                    elif isinstance(res, SpaceScape):
                        # Render SpaceScape with custom layer rendering
                        def render_layers(layers, res=res):
                            for i, (layerType, params) in enumerate(layers):
                                layer_name = params.get("name", f"Layer {i}")
                                layer_type_name = [
                                    "Points",
                                    "Billboards",
                                    "Noise",
                                ][layerType]
                                isHidden = params.get("_hidden", False)
                                hidden_marker = " [Hidden]" if isHidden else ""
                                label = f"{layer_name} ({layer_type_name}){hidden_marker}"

                                # Grey out hidden layers
                                if isHidden:
                                    ImGui.PushStyleColor(
                                        ImGui.Col_Text,
                                        ImGui.ImVec4(0.5, 0.5, 0.5, 1.0),
                                    )

                                self.addTreeItem(label, (res, i), children=None)

                                if isHidden:
                                    ImGui.PopStyleColor()

                        self.addTreeItem(
                            "SpaceScape",
                            item=res,
                            children=res.layers,
                            renderChildrenCB=render_layers,
                        )

                    elif isinstance(res, AssetViewerItem):
                        self.addTreeItem(
                            res.name,
                            item=res,
                            children=res.children,
                            renderChildrenCB=res.renderChildsTreeItemCB,
                        )

                    else:
                        if hasattr(res, "name"):
                            self.addTreeItem(f"{res.name}", item=res)
                        else:
                            self.addTreeItem(f"{type(res).__name__}", item=res)

                except Exception as e:
                    print(res, type(res))
                    print(f"Error rendering resource tree: {e}")
                    continue

            self.highlightSubEntity(self.hoveredSubEntity)

            if self.unfoldFramesRemaining > 0:
                self.unfoldFramesRemaining -= 1
                if self.unfoldFramesRemaining == 0:
                    self.unfoldNode = None


class AssetViewer(GameStateListener):
    """Manages the loading and unloading of assets for the viewer."""

    def __init__(self):
        super().__init__()
        ResourceManager.game = self
        self.sm = None
        self.camMgr = None
        self.rayQuery = None
        """For screen picking."""
        self.collisionWorld = None
        """Bullet world holding a trimesh collider for the active entity,
        used for per-triangle accurate raycasts (e.g. decal placement)."""
        self.activeEntity = None
        self.axesVisible = False
        self.spaceScapeViewer = None
        self.meshViewer = None
        self.sectorViewer = None
        self.dbEditorOverlay = None
        self.playerEditorOverlay = None
        self.bpEditorOverlay = None
        self.sgEditorOverlay = None
        self.axes = None
        self.grid = None
        self.skipInput = False
        self._watchedFiles = {}
        self._lastWatchTime = 0.0
        self._watchCheckInterval = 1.0
        self.recorder = None
        self._pendingScreenshot = False
        # CLI-driven capture: filled in from args in setupScene().
        self._cliCapture = None
        self._cliWarmupFrames = 30
        self.updateListener = []
        self._vp = None
        self._ssaoAttached = False

        self.omitMeshSelection = False
        """If True don't feed MeshViewer on click."""
        self._cameraPaused = False

    def pauseCamera(self, pause):
        """Pause or unpause the camera controller."""
        if pause == self._cameraPaused:
            return
        self._cameraPaused = pause
        self.skipInput = pause
        if pause is True:
            ResourceManager.app.removeInputListener(self.camMgr)
        else:
            ResourceManager.app.addInputListener(self.camMgr)

    def start(self, args, **kwargs):
        """Start the content manager."""
        self.guiOverlay = GUIOverlay("Asset Viewer", self)
        self.guiOverlay.show()

        self.resourceTreeOverlay = ResourceTreeOverlay("Resources", self)
        self.propOverlay = PropertiesOverlay("Properties", self)
        self.dbEditorOverlay = DBEditorOverlay("Station DB Editor", self)
        self.playerEditorOverlay = PlayerEditorOverlay("Player Editor", self)
        self.bpEditorOverlay = BluePrintEditorOverlay("Blueprint Editor", self)
        self.sgEditorOverlay = ShaderGraphEditorOverlay(
            "Shader Graph Editor",
            self,
            outputPath="./assetsTmp/shadergen",
            groupname="Opengate",
            extraGroups=("Opengate",),
        )

        ResourceManager.app.setWindowGrab(False)
        rgm = Ogre.ResourceGroupManager.getSingleton()
        rgm.createResourceGroup(VIEWER_GRPNAME, False)
        self.setupScene(args, **kwargs)

    def keyPressed(self, evt):
        """Handle key press events for the viewer."""
        # Ctrl-Q must quit even while an ImGui editor window/widget has
        # keyboard focus, so check it before the ImGui-capture guard below.
        if evt.keysym.sym == ord("q") and evt.keysym.mod & Ogre.Bites.KMOD_CTRL:
            ResourceManager.app.stop()
            return True

        # n must still close the shader graph editor while it has focus;
        # only a real text field (WantTextInput) should swallow it.
        if evt.keysym.sym == ord("n") and not ImGui.GetIO().WantTextInput:
            self.sgEditorOverlay.toggle()
            return True

        if self.skipInput is True:
            return False
        # Don't let single-letter viewer shortcuts (w/a/b/d/e/g/m/p/...) fire
        # while the user is typing into an ImGui text field: ImGui reports
        # WantTextInput/WantCaptureKeyboard when an input widget is active. In
        # that case swallow the key so it reaches the text box instead of
        # toggling wireframe, editors, the grid, etc.
        if imguiWantsKeyboard(ImGui.GetIO()):
            return False

        if evt.keysym.sym == ord("w"):
            self.toggleWireframe()
        elif evt.keysym.sym == ord("a"):
            self.toggleAxes()
        elif (
            evt.keysym.sym == ord("b") and evt.keysym.mod & Ogre.Bites.KMOD_CTRL
        ):
            self.bpEditorOverlay.toggle()
        elif evt.keysym.sym == ord("b"):
            self.toggleBoundingBox()
        elif evt.keysym.sym == ord("g"):
            self.grid.toggleVisibility()
        elif (
            evt.keysym.sym == ord("m")
            and evt.keysym.mod & Ogre.Bites.KMOD_SHIFT
        ):
            self.reloadActiveMaterial()
        elif (
            evt.keysym.sym == ord("r")
            and evt.keysym.mod & Ogre.Bites.KMOD_SHIFT
        ):
            self.reloadActiveMesh()
        elif evt.keysym.sym == ord("m"):
            self.guiOverlay.showMetrics = not self.guiOverlay.showMetrics
        elif (
            evt.keysym.sym == ord("s") and evt.keysym.mod & Ogre.Bites.KMOD_CTRL
        ):
            self.toggleSceneManager()
        elif evt.keysym.sym == ord("p"):
            self.guiOverlay.showProperties = not self.guiOverlay.showProperties
            self.propOverlay.toggle()
        elif evt.keysym.sym == ord("d") and not (
            evt.keysym.mod & Ogre.Bites.KMOD_SHIFT
        ):
            # Shift+D is reserved for DecalEditor's duplicate-decal binding.
            self.dbEditorOverlay.toggle()
        elif evt.keysym.sym == ord("e"):
            self.playerEditorOverlay.toggle()
        elif evt.keysym.sym == Ogre.Bites.SDLK_F12:
            self.takeScreenshot()
        elif evt.keysym.sym == Ogre.Bites.SDLK_F1:
            setSSAODebugMode(SSAODebugMode.NORMAL)
        elif evt.keysym.sym == Ogre.Bites.SDLK_F2:
            setSSAODebugMode(SSAODebugMode.DEPTH)
        elif evt.keysym.sym == Ogre.Bites.SDLK_F3:
            setSSAODebugMode(SSAODebugMode.OCCLUSION)
        elif evt.keysym.sym == Ogre.Bites.SDLK_F4:
            setSSAODebugMode(SSAODebugMode.DECAL_ALBEDO)
        elif evt.keysym.sym == Ogre.Bites.SDLK_F5:
            setSSAODebugMode(SSAODebugMode.DECAL_NORMAL)
        elif evt.keysym.sym == Ogre.Bites.SDLK_F6:
            setSSAODebugMode(SSAODebugMode.DECAL_ROUGH_METAL)
        elif evt.keysym.sym == Ogre.Bites.SDLK_F7:
            setSSAODebugMode(SSAODebugMode.DECAL_EMISSIVE)
        elif evt.keysym.sym == Ogre.Bites.SDLK_F8:
            setSSAODebugMode(SSAODebugMode.DECAL_AO_CURV_HEIGHT)
        elif evt.keysym.sym == Ogre.Bites.SDLK_F9:
            setSSAODebugMode(SSAODebugMode.OFF)
        elif evt.keysym.sym == Ogre.Bites.SDLK_F10:
            self.toggleSSAOAttached()

        return True

    def toggleSSAOAttached(self):
        """Toggle the SSAO attachment state."""
        if self._ssaoAttached:
            detachSSAO(self._vp)
            self._ssaoAttached = False
            og._b("SSAO fully detached (baseline, no compositor)")
        else:
            attachSSAO(self._vp)
            self._ssaoAttached = True
            og._b("SSAO reattached")

    def _toggleNoOpCompositor(self):
        vp = getattr(self, "_ssaoDebugViewport", None)
        if vp is None:
            return
        cm = Ogre.CompositorManager.getSingleton()
        attached = getattr(self, "_noOpAttached", False)
        if attached:
            cm.setCompositorEnabled(vp, "OpenGate/NoOp", False)
            cm.removeCompositor(vp, "OpenGate/NoOp")
            self._noOpAttached = False
            og._b("No-op compositor detached")
        else:
            if cm.addCompositor(vp, "OpenGate/NoOp"):
                cm.setCompositorEnabled(vp, "OpenGate/NoOp", True)
                self._noOpAttached = True
                og._b("No-op compositor attached")
            else:
                og._r("_toggleNoOpCompositor: failed to add compositor")

    def _dumpOcclusionTexture(self):
        """Dump the raw 'occlusion' compositor texture to disk.

        Bypasses the Modulate pass's uMode debug-view switching entirely,
        to tell whether the Occlusion pass itself is producing the wrong
        values vs. the debug-view wiring being stale/broken.
        """
        vp = getattr(self, "_ssaoDebugViewport", None)
        if vp is None:
            og._r("_dumpOcclusionTexture: no SSAO debug viewport")
            return
        chain = Ogre.CompositorManager.getSingleton().getCompositorChain(vp)
        inst = chain.getCompositor("OpenGate/SSAO")
        if inst is None:
            og._r("_dumpOcclusionTexture: 'OpenGate/SSAO' not in chain")
            return
        tex = inst.getTextureInstance("occlusion", 0)
        w, h = tex.getWidth(), tex.getHeight()
        # Target byte buffer -- blitToMemory does the float->byte
        # conversion, same proven pattern as capture.grabFrame().
        frame = np.empty((h, w, 3), dtype=np.uint8)
        dst = Ogre.PixelBox(Ogre.Box(0, 0, 0, w, h, 1), Ogre.PF_BYTE_RGB, frame)
        tex.getBuffer().blitToMemory(dst)
        img = Ogre.Image()
        img.loadDynamicImage(frame, w, h, Ogre.PF_BYTE_RGB)
        path = str(Path("occlusion_dump.png").resolve())
        img.save(path)
        og._g(f"Dumped raw occlusion texture: {path}")

    def _forwardMiddleDragToCamera(self, evt):
        """Forward MMB drag to CameraMan as rotate or pan.

        In this runtime path, CameraController mouse button callbacks may not
        be invoked from Python, but AssetViewer mouseMoved is. Injecting the
        remapped drag here adds Blender-like controls reliably.
        """
        if self.camMgr is None:
            return False

        if ImGui.IsMouseDown(ImGui.MouseButton_Middle) is False:
            return False

        io = ImGui.GetIO()
        remapButton = 3 if io.KeyShift else 1

        remappedEvt = Ogre.Bites.MouseButtonEvent()
        remappedEvt.x = getattr(evt, "x", 0)
        remappedEvt.y = getattr(evt, "y", 0)
        remappedEvt.button = remapButton

        self.camMgr.mousePressed(remappedEvt)
        self.camMgr.mouseMoved(evt)
        self.camMgr.mouseReleased(remappedEvt)
        return True

    def mouseMoved(self, evt):
        """Handle mouse move events for additional camera bindings."""
        if self.skipInput is True:
            return False

        if ImGui.GetIO().WantCaptureMouse:
            return False

        return bool(self._forwardMiddleDragToCamera(evt))

    def mousePressed(self, evt):
        """Handle mouse press events for the viewer."""
        # Keep MMB/Shift+MMB free for navigation without triggering picking.
        if self._cameraPaused:
            return True

        if getattr(evt, "button", None) != 1:
            return True

        vp = self.camMgr.cam.getViewport()
        ray = self.camMgr.cam.getCameraToViewportRay(
            evt.x / vp.getActualWidth(), evt.y / vp.getActualHeight()
        )
        self.rayQuery.setRay(ray)
        self.rayQuery.setSortByDistance(True)

        for hit in self.rayQuery.execute():
            if hit.movable.isVisible() is False:
                continue

            print(
                f"Mouse click hit: {hit.movable.getName()} at distance {hit.distance}"
            )
            if evt.clicks == 2:
                self.camMgr.setPivotOffset(ray.getPoint(hit.distance))
                return True

            hitEntity = hit.movable.castEntity()

            if hasattr(self.meshViewer, "onRaycastHitPoint"):
                # Bullet raycast hits the actual triangles, unlike the
                # AABB-only RaySceneQuery above; fall back if it misses.
                pos = preciseRayTest(self.collisionWorld, ray)
                if pos is None:
                    pos = ray.getPoint(hit.distance)
                self.meshViewer.onRaycastHitPoint(pos, ray)
                return True

            print(f"Clicked on entity: {self.omitMeshSelection}")
            if self.omitMeshSelection is False:
                if self.meshViewer is None:
                    self.addMeshViewer()

                self.meshViewer.setActive(hitEntity)

            break

        return True

    def initScene(self, bgc=(0.6, 0.6, 0.6)):
        """Initialize the scene.

        * create the scene manager
        * create the camera
        * create the viewport
        * create a default camera manager
        """
        self.sm = ResourceManager.app.root.createSceneManager()
        # important before anything else
        Ogre.RTShader.ShaderGenerator.getSingleton().addSceneManager(self.sm)
        self.sm.setAmbientLight(bgc)

        self.camMgr = CameraController(self.sm)
        ResourceManager.app.addInputListener(self.camMgr)

        self._vp = ResourceManager.app.win.addViewport(
            self.camMgr.cam, -1, 0.0, 0.0, 1.0, 1.0
        )
        self._vp.setBackgroundColour(bgc)
        self.toggleSSAOAttached()

        self.rayQuery = self.sm.createRayQuery(Ogre.Ray())
        self.collisionWorld = Bullet.DynamicsWorld(Ogre.Vector3(0, 0, 0))

        if self.grid is None:
            self.grid = GridFloor(
                scale=1, N=100, parentNode=self.sm.getRootSceneNode(), app=self
            )

        self.initImGUI()
        self.guiOverlay.show()

        for i in range(ResourceManager.app.win.getNumViewports()):
            self.resourceTreeOverlay.addResource(
                ResourceManager.app.win.getViewport(i)
            )

        self.resourceTreeOverlay.addResource(LightsViewer(self))
        self.resourceTreeOverlay.addResource(SSAOViewer(self))

    def initImGUI(self):
        """Initialize the ImGui overlay."""
        ResourceManager.app.win.addListener(ResourceManager.guiDispatcher)
        ResourceManager.app.overlay.show()  # without -> segfaults
        ResourceManager.app.addInputListener(
            ResourceManager.app.getImGuiInputListener()
        )
        self.sm.addRenderQueueListener(ResourceManager.overlaySystem)
        ResourceManager.app.scaleUIOnResize = False

    def setActiveEntity(self, entity):
        """Set the active entity for the viewer."""
        og._b(
            f"Setting active entity: {entity.getName()} "
            f"({type(entity).__name__}) "
            f"with {entity.getNumSubEntities()} sub-entities"
        )

        for sub in entity.getSubEntities():
            print(sub.getMaterialName(), sub.getMaterial().getNumTechniques())

        self.activeEntity = entity
        diam = self.activeEntity.getBoundingBox().getSize().length()
        self.camMgr.cam.setNearClipDistance(diam * 0.01)

        if self.collisionWorld is not None:
            try:
                # CollisionWorld.addCollisionObject() leaves a static
                # btCollisionObject at the identity transform (never synced
                # to the node) and also never sets its user pointer, which
                # segfaults rayTest() on the first hit. addKinematicRigidBody()
                # attaches a RigidBodyState that tracks the entity's node
                # transform and sets the user pointer correctly.
                # Re-adding for the same node replaces the previous
                # collider (Ogre::Bullet ties its lifetime to the node).
                self.collisionWorld.addKinematicRigidBody(
                    entity, Bullet.CT_TRIMESH
                )
            except Exception as e:
                og._y(f"Could not build collider for {entity.getName()}: {e}")

        # The previous self.axes, if still toggled on, stays registered as
        # an sm listener forever otherwise -- drop it before replacing it.
        if self.axesVisible is True and self.axes is not None:
            self.sm.removeListener(self.axes)

        self.axes = Ogre.DefaultDebugDrawer()
        self.axes.setStatic(True)
        self.axes.drawAxes(Ogre.Affine3.IDENTITY, diam / 4)
        self.axesVisible = False

        self._updateWatchedFiles(self.activeEntity)

    def clearActiveEntity(self):
        """Drop all references to the active entity before it is destroyed.

        Must be called before destroying a Sector (or anything else that owns
        entities) since activeEntity/watched files/propOverlay.item and
        meshViewer's own selection can otherwise keep pointing at a freed
        Ogre::Entity, segfaulting on the next frame's watch check.
        """
        self.activeEntity = None
        self._watchedFiles = {}
        self.propOverlay.item = None
        if self.meshViewer is not None:
            self.meshViewer.activeEntity = None
            self.meshViewer._children = None
            # meshViewer.update() runs every frame via updateListener and
            # dereferences node directly, regardless of activeEntity.
            self.meshViewer.node = None

        if self.axesVisible is True and self.axes is not None:
            self.sm.removeListener(self.axes)
        self.axes = None
        self.axesVisible = False

        # resourceTreeOverlay caches a hovered/highlighted SubEntity across
        # frames (to add/remove its highlight material pass). Clear it here,
        # while the SubEntity is still valid, or the next frame's
        # highlightSubEntity(None) call removeHighlights()'s a freed one.
        if self.resourceTreeOverlay is not None:
            self.resourceTreeOverlay.hoveredSubEntity = None
            self.resourceTreeOverlay._clearHighlightedSubEntity()

    def _updateWatchedFiles(self, entity=None):
        """Update the dict of watched files and their modification times."""
        if entity is None:
            entity = self.activeEntity
        if entity is None:
            self._watchedFiles = {}
            return
        self._watchedFiles = {}
        for path in findResourceSources(entity):
            if path and Path(path).is_file():
                try:
                    self._watchedFiles[path] = Path(path).stat().st_mtime
                except OSError:
                    pass

    def frameStarted(self, evt):
        """Check watched files for changes and reload material if needed."""
        # Before any rendering/compositor chain execution this frame, so
        # a pending SSAO resync (queued from an ImGui slider callback)
        # doesn't remove/re-add a compositor instance mid-render.
        processSSAOResync()

        if self.collisionWorld is not None:
            # Kinematic colliders only pick up node transform changes (e.g.
            # MeshViewer's yaw/pitch/roll) once the world steps.
            self.collisionWorld.stepSimulation(evt.timeSinceLastFrame)

        now = time.time()
        if (
            self._watchedFiles
            and now - self._lastWatchTime >= self._watchCheckInterval
        ):
            self._lastWatchTime = now
            for path, mtime in list(self._watchedFiles.items()):
                try:
                    newMtime = Path(path).stat().st_mtime
                except OSError:
                    continue
                if newMtime != mtime:
                    self._watchedFiles[path] = newMtime
                    self.reloadActiveMaterial()
                    break

        for listener in self.updateListener:
            listener.update(evt.timeSinceLastFrame)

        return True

    def frameEnded(self, evt):
        """Drive pending screenshot / GIF capture after the frame renders."""
        if self._pendingScreenshot:
            pending = self._pendingScreenshot
            self._pendingScreenshot = False
            takeScreenshot(pending if isinstance(pending, str) else None)

        if self.recorder is not None and self.recorder.recording:
            if not self.recorder.captureFrame():
                # Recording finished (and was encoded) this frame.
                self.recorder = None
                if self._cliCapture is not None:
                    self._cliCapture = None
                    ResourceManager.app.stop()

        self._driveCliCapture()
        return True

    def _driveCliCapture(self):
        """Handle headless CLI capture: warm up, capture, then quit."""
        req = self._cliCapture
        if req is None:
            return

        # Let a few frames render so the scene/materials are ready.
        if self._cliWarmupFrames > 0:
            self._cliWarmupFrames -= 1
            return

        if req["kind"] == "screenshot":
            takeScreenshot(req.get("file"))
            self._cliCapture = None
            ResourceManager.app.stop()
        elif req["kind"] == "gif":
            # Kick off recording; auto-quit happens when it finishes above.
            self.startRecording(
                fileName=req.get("file"),
                duration=req.get("duration"),
                frames=req.get("frames"),
                fps=req.get("fps", 20),
            )

    def takeScreenshot(self, fileName=None):
        """Request a screenshot.

        The actual capture happens in :meth:`frameEnded` so the fully
        rendered frame (including overlays) is written.

        Arguments
        ---------
        fileName: str, optional
            Destination PNG file. When ``None`` a timestamped name is used.
        """
        self._pendingScreenshot = fileName if fileName is not None else True

    def startRecording(self, fileName=None, duration=None, frames=None, fps=20):
        """Start recording an animated GIF of the render window.

        Arguments
        ---------
        fileName: str, optional
            Destination GIF file. When ``None`` a timestamped name is used.
        duration: float, optional
            Recording length in seconds.
        frames: int, optional
            Explicit number of frames to capture (overrides ``duration``).
        fps: int
            Playback / sampling frame rate of the GIF.
        """
        if self.recorder is not None and self.recorder.recording:
            return
        self.recorder = GifRecorder(
            fileName=fileName, duration=duration, frames=frames, fps=fps
        )
        self.recorder.start()

    def stopRecording(self):
        """Stop an in-progress GIF recording and write it to disk."""
        if self.recorder is not None:
            self.recorder.stop()
            self.recorder = None

    def isRecording(self):
        """Return whether a GIF recording is currently in progress."""
        return self.recorder is not None and self.recorder.recording

    def reloadActiveMaterial(self):
        """Reload the material of the active entity."""
        self.propOverlay.item = None
        # Cached GPUProgramViewer entries hold SWIG pointers to the
        # Pass/GpuProgram objects reloadMaterial() is about to destroy --
        # stale entries crash on next access (segfault), not just a
        # Python exception.
        GPUProgramViewerCache.clear()
        ent = self.activeEntity

        if ent is not None:
            errLog = reloadMaterialsFromEntity(ent)
            self.guiOverlay.errLog = errLog

    def reloadActiveMesh(self):
        """Reload the mesh geometry of the active entity from disk."""
        if self.meshViewer is not None:
            self.meshViewer.reloadMesh()

    def setupScene(self, args, **kwargs):
        """Set up the scene for the asset viewer."""
        self.initScene()

        self.light = createLight(
            self.sm,
            name="Sun",
            color=Ogre.ColourValue(1, 1, 1),
            typ=Ogre.Light.LT_POINT,
        )
        self.light.getParentSceneNode().setPosition(10000, 10000, 10000)

        available_scenes = [
            name.split("scene")[-1]
            for name in dir(testScenes)
            if name.startswith("scene")
        ]

        if args.scene not in available_scenes and args.scene != "og-stars":
            print(f"Unknown scene: {args.scene}, loading default sphere")
            print(f"Available scenes: {available_scenes}")

            testScenes.sceneMesh(self, "Sphere", "#334455aa")
        else:
            getattr(testScenes, f"scene{args.scene}")(
                self, *args.args, **kwargs
            )

        self.propOverlay.show()
        self.resourceTreeOverlay.show()

        self._setupCliCapture(args)

    def _setupCliCapture(self, args):
        """Configure headless CLI capture from parsed arguments."""
        if getattr(args, "screenshot", False):
            self._cliCapture = {
                "kind": "screenshot",
                "file": args.out,
            }
        elif getattr(args, "gif", False):
            self._cliCapture = {
                "kind": "gif",
                "file": args.out,
                "duration": args.duration,
                "frames": args.frames,
                "fps": args.fps,
            }

    def addSpaceScape(self, fileName=None):
        """Add a SpaceScape skybox to the scene."""
        if self.spaceScapeViewer is not None:
            return

        self.spaceScapeViewer = SpaceScape(self.sm)
        # Load a default spacescape configuration to make it visible in scene tree
        try:
            if fileName is not None:
                self.spaceScapeViewer.load(fileName)
            else:
                self.spaceScapeViewer.load("new_spacescape.toml")
        except Exception as e:
            print(f"Failed to load spacescape config: {e}")
            # Add a default layer if loading fails
            self.spaceScapeViewer.addLayer(0, {})
        self.resourceTreeOverlay.addResource(self.spaceScapeViewer)
        for i in range(ResourceManager.app.win.getNumViewports()):
            ResourceManager.app.win.getViewport(i).setBackgroundColour(
                (0.0, 0.0, 0.0)
            )

    def addMeshViewer(self):
        """Add a mesh viewer to the scene."""
        if self.meshViewer is not None:
            return

        self.meshViewer = MeshViewer(self)
        self.resourceTreeOverlay.addResource(self.meshViewer)

        self.updateListener.append(self.meshViewer)

    def addSectorViewer(self):
        """Add a sector viewer to the scene."""
        self.sm.destroyLight(self.light)
        self.light = None

        if self.sectorViewer is not None:
            return

        self.sectorViewer = SectorViewer(self)
        self.resourceTreeOverlay.addResource(self.sectorViewer)
        self.updateListener.append(self.sectorViewer)

    def toggleAxes(self):
        """Toggle the axes for the active entity."""
        if self.axes is None:
            return
        self.axesVisible = not self.axesVisible
        if self.axesVisible is True:
            self.sm.addListener(self.axes)
        else:
            self.sm.removeListener(self.axes)

    def toggleBoundingBox(self):
        """Toggle the bounding box for the active entity."""
        if self.activeEntity is not None:
            enode = self.activeEntity.getParentSceneNode()
            enode.showBoundingBox(not enode.getShowBoundingBox())

    def toggleWireframe(self):
        """Toggle wireframe mode for the active entity."""
        polygon_mode = self.camMgr.cam.getPolygonMode()

        if polygon_mode == Ogre.PM_SOLID:
            self.camMgr.cam.setPolygonMode(Ogre.PM_WIREFRAME)
        if polygon_mode == Ogre.PM_WIREFRAME:
            self.camMgr.cam.setPolygonMode(Ogre.PM_SOLID)

    def toggleSceneManager(self):
        """Toggle the visibility of the scene manager overlay."""
        self.guiOverlay.showSceneManager = not self.guiOverlay.showSceneManager

        print(
            f"SceneManager overlay visibility: {self.guiOverlay.showSceneManager}"
        )
        if self.guiOverlay.showSceneManager is True:
            self.resourceTreeOverlay.insertResource(self.sm, 1)
        else:
            self.resourceTreeOverlay.removeResource(self.sm)


if __name__ == "__main__":
    signal.signal(signal.SIGINT, signal_handler)
    parser = argparse.ArgumentParser(description="Opengate Asset Viewer")
    parser.add_argument(
        "-l", "--log", dest="log", type=int, default=3, help="log level"
    )
    parser.add_argument(
        "-d",
        "--debug",
        dest="debug",
        action="store_true",
        help="enable debug overlay",
    )
    parser.add_argument(
        "-s",
        "--scene",
        dest="scene",
        type=str,
        default="default",
        help="select debug scene",
    )
    parser.add_argument(
        "--screenshot",
        dest="screenshot",
        action="store_true",
        help="capture a single screenshot then exit",
    )
    parser.add_argument(
        "--gif",
        dest="gif",
        action="store_true",
        help="record an animated GIF then exit",
    )
    parser.add_argument(
        "-o",
        "--out",
        dest="out",
        type=str,
        default=None,
        help="output file for --screenshot / --gif",
    )
    parser.add_argument(
        "--duration",
        dest="duration",
        type=float,
        default=None,
        help="GIF recording length in seconds (--gif)",
    )
    parser.add_argument(
        "--frames",
        dest="frames",
        type=int,
        default=None,
        help="explicit number of frames to record (--gif)",
    )
    parser.add_argument(
        "--fps",
        dest="fps",
        type=int,
        default=20,
        help="GIF playback / sampling frame rate (--gif)",
    )
    parser.add_argument(
        "args",
        nargs="*",
        default=[],
        help="additional positional args forwarded to the scene",
    )

    args = parser.parse_args()

    if not os.environ.get("OGRE_MIN_LOGLEVEL"):
        os.environ["OGRE_MIN_LOGLEVEL"] = f"{args.log}"

    ResourceManager.addResourceLocations(
        Ogre.Bites.ApplicationContextBase.getDefaultMediaDir() + "/RTShaderLib",
        "FileSystem",
        "Opengate",
    )
    ResourceManager.addResourceLocations(
        "./assetsTmp", "FileSystem", "Opengate"
    )
    ResourceManager.addResourceLocations(
        "./assetsTmp/textures", "FileSystem", "Opengate"
    )
    ResourceManager.addResourceLocations(
        "./assetsTmp/monkey", "FileSystem", "Opengate"
    )
    ResourceManager.addResourceLocations(
        "./assetsTmp/shadergen", "FileSystem", "Opengate"
    )
    ResourceManager.addResourceLocations(
        "./tools/ogre-glslwright/src/glslwright/material",
        "FileSystem",
        "Opengate",
    )
    ResourceManager.addResourceLocations(
        "./assetsTmp/decals", "FileSystem", "Opengate"
    )

    if (
        args.args
        and Path(args.args[-1]).is_file()
        and not args.args[-1].endswith(".toml")
    ):
        fileDirs = set()
        fileNames = set()

        for a in list(args.args):
            fileDirs.add(str(Path(a).parent))
            fileNames.add(Path(a).name)
            args.args.remove(a)

        args.args.append(sorted(fileNames))

        for fd in fileDirs:
            ResourceManager.addResourceLocations(fd, "FileSystem", "Opengate")

    elif args.args and Path(args.args[-1]).is_dir():
        ResourceManager.addResourceLocations(
            args.args[-1], "FileSystem", "Opengate"
        )

    viewer = OpengateClient(
        name="Opengate Asset Viewer",
        contentManager=AssetViewer(),
        logFile=".assertViewer.log",
        args=args,
        cacheRTShader=False,
    )

    viewer.start(args)
