import re
import importlib
import importlib.resources as importlib_resources
from types import SimpleNamespace

import numpy as np
import pytest
from PIL import Image

import topovec.marimo as tvm
camera_viewer_module = importlib.import_module("topovec.marimo.camera_viewer")
from topovec.core.property import BooleanProperty, ListProperty, NumericProperty
from topovec.core import System, UpdateMask
from topovec.marimo.camera_viewer import (
    DirectorFieldView,
    RenderSession,
    _camera_viewport_widget_class,
    _effective_numeric_step,
    _quantize_numeric_value,
    camera_state_signature,
    inspect as inspect_layout,
    inspect_view,
    restore_camera,
    serialize_camera,
)
from topovec.mgl.camera import Camera, UnitCamera


class DummySystem:
    def __init__(self, dim=3):
        self.dim = dim


class DummySceneBase:
    def __init__(self, mglctx=None, system=None, camera=None):
        self._mglctx = mglctx
        self._system = DummySystem() if system is None else system
        self.camera = Camera() if camera is None else camera
        self._cameras = {str(self.camera_slot_key): self.camera}
        self.uploaded_state = None
        self.list_properties_calls = 0
        self.activate_camera_slot()

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

    @property
    def camera_slot_key(self):
        return self.camera_profile

    @property
    def camera_profile(self):
        return "general"

    def _make_default_camera(self, slot):
        return Camera()

    def activate_camera_slot(self):
        slot = str(self.camera_slot_key)
        if slot not in self._cameras:
            self._cameras[slot] = self._make_default_camera(slot)
        self.camera = self._cameras[slot]
        return self.camera

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

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


class DummyScene(DummySceneBase):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.flag = False
        self.level = 0.5
        self._follow_view = False
        self._mode = "alpha"
        self._min_level = 0.0
        self._max_level = 1.0
        self._axis = 2
        self.axis = 2

    @property
    def camera_profile(self):
        return f"layer-{('x', 'y', 'z')[getattr(self, '_axis', 2)]}"

    def _make_default_camera(self, slot):
        camera = Camera()
        if slot == "layer-x":
            camera.set_basis(depth=[1, 0, 0], up=[0, 0, 1])
        elif slot == "layer-y":
            camera.set_basis(depth=[0, 1, 0], up=[1, 0, 0])
        else:
            camera.set_basis(depth=[0, 0, 1], up=[1, 0, 0])
        return camera

    def get_mode(self):
        return self._mode

    def set_mode(self, value):
        self._mode = value
        if value == "alpha":
            self._min_level = 0.0
            self._max_level = 1.0
        else:
            self._min_level = -2.0
            self._max_level = 2.0
        self.level = float(np.clip(self.level, self._min_level, self._max_level))
        return UpdateMask.REDRAW | UpdateMask.REBUILD_PROPERTIES
    mode = property(get_mode, set_mode)

    def get_axis(self):
        return self._axis

    def set_axis(self, value):
        self._axis = int(value)
        return (
            UpdateMask.REDRAW
            | UpdateMask.REBUILD_PROPERTIES
            | UpdateMask.CAMERA_SLOT_CHANGED
        )
    axis = property(get_axis, set_axis)

    def get_follow_view(self):
        return self._follow_view

    def set_follow_view(self, value):
        self._follow_view = bool(value)
        if self._follow_view:
            self.camera.set_basis(depth=[1, 1, 0], up=[0, 0, 1])
        else:
            self.camera.set_basis(depth=[0, 0, 1], up=[1, 0, 0])
        return UpdateMask.REDRAW
    follow_view = property(get_follow_view, set_follow_view)

    def list_properties(self):
        self.list_properties_calls += 1
        return {
            "Render": [
                ListProperty(
                    self,
                    "mode",
                    title="Mode",
                    values=["alpha", "beta"],
                    reset=True,
                ),
                NumericProperty(
                    self,
                    "level",
                    title="Level",
                    min=self._min_level,
                    max=self._max_level,
                    count=5,
                    param_key="shared.level",
                ),
                BooleanProperty(
                    self,
                    "flag",
                    title="Flag",
                    param_key="shared.flag",
                ),
                NumericProperty(
                    self,
                    "axis",
                    title="Axis",
                    min=0,
                    max=2,
                    count=3,
                    reset=True,
                    param_key="layer.axis",
                ),
                BooleanProperty(self, "follow_view", title="Follow view"),
            ]
        }


class DummyGeneralScene(DummySceneBase):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.level = 0.25
        self.flag = False

    def list_properties(self):
        self.list_properties_calls += 1
        return {
            "General": [
                NumericProperty(
                    self,
                    "level",
                    title="Level",
                    min=0.0,
                    max=1.0,
                    count=5,
                    param_key="shared.level",
                ),
                BooleanProperty(
                    self,
                    "flag",
                    title="Flag",
                    param_key="shared.flag",
                )
            ]
        }


class DummyBlochScene(DummySceneBase):
    @property
    def camera_profile(self):
        return "bloch"

    def _make_default_camera(self, slot):
        camera = Camera()
        camera.set_basis(depth=[0, 1, 1], up=[1, 0, 0])
        return camera

    def list_properties(self):
        self.list_properties_calls += 1
        return {"Ball": []}


class DummyUglyStepScene(DummySceneBase):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.hue = 0.0

    def list_properties(self):
        self.list_properties_calls += 1
        return {
            "Render": [
                NumericProperty(
                    self,
                    "hue",
                    title="Hue",
                    min=0.0,
                    max=1.0,
                    count=360,
                )
            ]
        }


class UnsupportedScene(DummySceneBase):
    @classmethod
    def supports_system(cls, system):
        return False


class DummyImageContainer:
    def __init__(self, size=(32, 24), scene_factory=None):
        self._size = size
        self._scene_factory = scene_factory
        self.scene = None
        self.uploaded_state = None
        self.saved_frames = 0
        self.attach_scene(scene_factory or (lambda _ctx: DummyScene()))

    @property
    def size(self):
        return self._size

    def attach_scene(self, scene_factory):
        self._scene_factory = scene_factory
        if callable(scene_factory):
            self.scene = scene_factory(None)
        else:
            self.scene = scene_factory
        return self

    def upload(self, state):
        self.uploaded_state = state
        if hasattr(self.scene, "upload"):
            self.scene.upload(state)
        return self

    def save(self):
        self.saved_frames += 1
        return Image.new(
            "RGB",
            self._size,
            color=((self.saved_frames * 23) % 255, 34, 56),
        )


def install_dummy_scene_registry(monkeypatch):
    registry = {
        "Layer": DummyScene,
        "General": DummyGeneralScene,
        "Ball": DummyBlochScene,
        "Unsupported": UnsupportedScene,
    }
    monkeypatch.setattr(
        camera_viewer_module,
        "_load_builtin_scene_registry",
        lambda: dict(registry),
    )
    return registry


def test_switch_scene_message_prefers_residency_manager():
    calls = []

    class DummyResidency:
        def switch_scene(self, scene_name):
            calls.append(scene_name)
            return True

    session = SimpleNamespace(
        _scene_residency_manager=DummyResidency(),
        switch_scene=lambda _scene_name: (_ for _ in ()).throw(
            AssertionError("session.switch_scene should not run")
        ),
    )

    outcome = camera_viewer_module._apply_bound_session_message(
        session,
        "switch_scene",
        {"scene_name": "Ball"},
    )

    assert outcome.handled is True
    assert outcome.publish_frame is True
    assert calls == ["Ball"]


def test_camera_commit_message_does_not_publish_properties():
    session = SimpleNamespace(
        commit_camera_interaction=lambda: True,
    )

    outcome = camera_viewer_module._apply_bound_session_message(
        session,
        "camera_commit",
        {},
    )

    assert outcome.handled is True
    assert outcome.publish_frame is True
    assert outcome.publish_camera is True
    assert outcome.publish_summary is True
    assert outcome.publish_properties is False


def test_apply_property_change_message_publishes_properties():
    calls = []

    session = SimpleNamespace(
        apply_property_input=lambda *args, **kwargs: calls.append((args, kwargs))
        or UpdateMask.REDRAW
    )

    outcome = camera_viewer_module._apply_bound_session_message(
        session,
        "apply_property_change",
        {
            "group_id": "Render",
            "prop_index": 2,
            "value": True,
            "expected_field": "flag",
            "expected_title": "Flag",
        },
    )

    assert outcome.handled is True
    assert outcome.publish_frame is True
    assert outcome.publish_camera is True
    assert outcome.publish_summary is True
    assert outcome.publish_properties is True
    assert calls == [
        (
            ("Render", 2, True),
            {
                "expected_field": "flag",
                "expected_title": "Flag",
            },
        )
    ]


def test_mgl_camera_is_importable_without_moderngl():
    import topovec.mgl as mgl

    assert mgl.Camera is Camera


def test_unit_camera_initializes_without_system_argument():
    camera = UnitCamera()

    assert np.allclose(camera.look_at, [0.0, 0.0, 0.0])
    assert np.allclose(camera.look_direction, [0.0, 0.0, -1.0])
    assert np.allclose(camera.look_up, [0.0, 1.0, 0.0])
    assert camera.look_scale == 1
    assert camera.perspective_is_on is True


def test_camera_state_roundtrip():
    camera = Camera.from_angles(
        theta=1.1,
        phi=0.7,
        alpha=0.2,
        scale=3.5,
        perspective=False,
        look_at=[1.0, 2.0, 3.0],
    )

    state = tvm.serialize_camera(camera)
    clone = Camera()
    tvm.restore_camera(clone, state)

    assert np.allclose(clone.look_at, camera.look_at)
    assert np.allclose(clone.look_direction, camera.look_direction)
    assert np.allclose(clone.look_up, camera.look_up)
    assert clone.look_scale == camera.look_scale
    assert clone.perspective_is_on is camera.perspective_is_on


def test_property_specs_expose_kind_field_and_invalidates():
    owner = SimpleNamespace(flag=True, value=0.25, mode="alpha")

    numeric = NumericProperty(
        owner,
        "value",
        title="Value",
        min=0.0,
        max=1.0,
        count=5,
        reset=True,
    )
    boolean = BooleanProperty(owner, "flag", title="Flag")
    listed = ListProperty(owner, "mode", title="Mode", values=["alpha", "beta"])

    assert numeric.spec() == {
        "kind": "numeric",
        "field": "value",
        "title": "Value",
        "value": 0.25,
        "invalidates": True,
        "param_key": None,
        "min": 0.0,
        "max": 1.0,
        "count": 5,
    }
    assert boolean.spec() == {
        "kind": "boolean",
        "field": "flag",
        "title": "Flag",
        "value": True,
        "invalidates": False,
        "param_key": None,
    }
    assert listed.spec() == {
        "kind": "list",
        "field": "mode",
        "title": "Mode",
        "value": "alpha",
        "invalidates": False,
        "param_key": None,
        "values": ["alpha", "beta"],
        "selected_index": 0,
    }


def test_numeric_property_rejects_count_one_for_non_degenerate_range():
    owner = SimpleNamespace(value=0.25)

    with pytest.raises(
        ValueError,
        match="count must be at least 2",
    ):
        NumericProperty(
            owner,
            "value",
            title="Value",
            min=0.0,
            max=1.0,
            count=1,
        )


def test_render_session_snapshot_contains_property_groups(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = RenderSession(DummyImageContainer())

    snapshot = session.snapshot()

    assert snapshot["revision"] == 0
    assert snapshot["camera"]["can_undo"] is False
    assert snapshot["camera"]["can_redo"] is False
    assert "import topovec" not in snapshot["summary_text"]
    assert "ic = tv.mgl.render_prepare('Layer', system)" in snapshot["summary_text"]
    assert "ic.upload(field)" in snapshot["summary_text"]
    assert "ic.scene.set_camera(tv.mgl.Camera.from_angles(" in snapshot["summary_text"]
    assert snapshot["summary_text"].rstrip().endswith("ic.save()")
    assert snapshot["selected_scene"] == "Layer"
    assert snapshot["available_scenes"] == ["Layer", "General", "Ball"]

    group = snapshot["property_groups"][0]
    assert group["id"] == "Render"
    assert group["title"] == "Render"

    mode_prop = group["properties"][0]
    assert mode_prop["kind"] == "list"
    assert mode_prop["field"] == "mode"
    assert mode_prop["invalidates"] is True
    assert mode_prop["values"] == ["alpha", "beta"]

    level_prop = group["properties"][1]
    assert level_prop["kind"] == "numeric"
    assert level_prop["min"] == 0.0
    assert level_prop["max"] == 1.0
    assert level_prop["input_kind"] == "number"
    assert level_prop["row_kind"] == "inline"
    assert level_prop["default_value"] == 0.5
    assert level_prop["is_default"] is True
    assert level_prop["can_reset"] is True

    flag_prop = group["properties"][2]
    assert flag_prop["input_kind"] == "switch"
    assert flag_prop["row_kind"] == "inline"

    mode_prop = group["properties"][0]
    assert mode_prop["input_kind"] == "dropdown"
    assert mode_prop["row_kind"] == "inline"


def test_render_session_render_snippet_omits_default_properties(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = RenderSession(DummyImageContainer())

    snippet = session.render_snippet()

    assert "ic.scene['Render']['Mode']" not in snippet
    assert "ic.scene['Render']['Level']" not in snippet
    assert "ic.scene['Render']['Flag']" not in snippet
    assert "ic.scene['Render']['Axis']" not in snippet
    assert "ic.scene['Render']['Follow view']" not in snippet


def test_render_session_render_snippet_includes_only_changed_properties(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = RenderSession(DummyImageContainer())

    session.apply_property_change(
        "Render",
        2,
        True,
        expected_field="flag",
        expected_title="Flag",
    )
    snippet = session.render_snippet()

    assert "ic.scene['Render']['Flag'] = True" in snippet
    assert "ic.scene['Render']['Level']" not in snippet


def test_render_session_render_snippet_drops_line_when_property_returns_to_default(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = RenderSession(DummyImageContainer())

    session.apply_property_change(
        "Render",
        2,
        True,
        expected_field="flag",
        expected_title="Flag",
    )
    session.apply_property_change(
        "Render",
        2,
        False,
        expected_field="flag",
        expected_title="Flag",
    )
    snippet = session.render_snippet()

    assert "ic.scene['Render']['Flag'] = True" not in snippet
    assert "ic.scene['Render']['Flag'] = False" not in snippet


def test_render_session_render_snippet_uses_compact_numeric_formatting(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = RenderSession(DummyImageContainer())

    restore_camera(
        session.camera,
        serialize_camera(
            Camera.from_angles(
                theta=0.123456789,
                phi=1.23456789,
                alpha=-1.987654321,
                scale=12.3456789,
                perspective=False,
                look_at=[0.000123456789, -9876.54321, 0.0],
            )
        ),
    )

    snippet = session.render_snippet()

    assert "theta=0.123457" in snippet
    assert "phi=1.23457" in snippet
    assert "alpha=-1.98765" in snippet
    assert "scale=12.3457" in snippet
    assert "look_at=[0.000123457, -9876.54, 0]" in snippet


def test_render_session_summary_and_snippet_do_not_create_extra_scenes(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    create_calls = []
    original_create_scene = camera_viewer_module._viewer_session_core.ViewerSession._create_scene

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

    monkeypatch.setattr(
        camera_viewer_module._viewer_session_core.ViewerSession,
        "_create_scene",
        counted_create_scene,
    )
    session = RenderSession(DummyImageContainer())

    snapshot = session.snapshot()
    snippet = session.render_snippet()

    assert "render_prepare('Layer', system)" in snapshot["summary_text"]
    assert "render_prepare('Layer', system)" in snippet
    assert create_calls == []


def test_render_session_camera_event_does_not_reread_property_groups(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = RenderSession(DummyImageContainer())
    calls = session.scene.list_properties_calls

    changed = session.apply_camera_event(event_type="rotate", dx=0.25, dy=0.1)
    camera = session.camera_snapshot()
    properties = session.properties_snapshot()

    assert changed is True
    assert session.scene.list_properties_calls == calls
    assert camera["revision"] == 0
    assert properties["revision"] == 0
    assert camera["history_size"] == 1
    assert camera["has_pending_delta"] is True


def test_render_session_property_change_rereads_dynamic_metadata(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = RenderSession(DummyImageContainer())
    calls = session.scene.list_properties_calls

    session.apply_property_change(
        "Render",
        0,
        "beta",
        expected_field="mode",
        expected_title="Mode",
    )
    snapshot = session.snapshot()
    level_prop = snapshot["property_groups"][0]["properties"][1]

    assert snapshot["revision"] == 1
    assert session.scene.list_properties_calls >= calls + 1
    assert level_prop["min"] == -2.0
    assert level_prop["max"] == 2.0
    assert snapshot["property_groups"][0]["properties"][0]["value"] == "beta"


def test_render_session_non_reset_property_change_patches_cache_locally(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = RenderSession(DummyImageContainer())
    calls = session.scene.list_properties_calls

    session.apply_property_change(
        "Render",
        2,
        True,
        expected_field="flag",
        expected_title="Flag",
    )
    snapshot = session.properties_snapshot()

    assert session.scene.list_properties_calls == calls
    assert snapshot["property_groups"][0]["properties"][2]["value"] is True


def test_render_session_axis_switch_restores_cached_layer_camera(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = RenderSession(DummyImageContainer())
    session.camera.set_basis(depth=[1, 1, 1], up=[0, 0, 1])
    before = session.snapshot()["camera"]

    session.apply_property_change(
        "Render",
        3,
        0.0,
        expected_field="axis",
        expected_title="Axis",
    )
    after = session.snapshot()["camera"]

    assert session.scene.camera_profile == "layer-x"
    assert camera_state_signature(before) != camera_state_signature(after)

    session.camera.set_basis(depth=[1, 1, 0], up=[0, 0, 1])
    layer_x_custom = session.snapshot()["camera"]
    session.apply_property_change(
        "Render",
        3,
        2.0,
        expected_field="axis",
        expected_title="Axis",
    )
    restored_z = session.snapshot()["camera"]
    assert session.scene.camera_profile == "layer-z"
    assert camera_state_signature(restored_z) == camera_state_signature(before)

    session.apply_property_change(
        "Render",
        3,
        0.0,
        expected_field="axis",
        expected_title="Axis",
    )
    restored_x = session.snapshot()["camera"]
    assert camera_state_signature(restored_x) == camera_state_signature(layer_x_custom)


def test_render_session_non_reset_property_camera_change_commits_current_profile(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = RenderSession(DummyImageContainer())
    before = session.snapshot()["camera"]

    session.apply_property_change(
        "Render",
        4,
        True,
        expected_field="follow_view",
        expected_title="Follow view",
    )
    after = session.snapshot()["camera"]

    assert after["history_size"] == 1
    assert after["has_pending_delta"] is False
    assert camera_state_signature(before) != camera_state_signature(after)


def test_render_session_camera_commit_and_toggle_use_history_stubs(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = RenderSession(DummyImageContainer())
    base = session.snapshot()["camera"]

    session.apply_camera_event(event_type="rotate", dx=0.25, dy=0.1)
    rotated = session.snapshot()["camera"]
    assert rotated["history_size"] == 1
    assert rotated["revision"] == 0
    assert rotated["has_pending_delta"] is True
    assert camera_state_signature(base) != camera_state_signature(rotated)

    session.commit_camera_interaction()
    committed = session.snapshot()["camera"]
    assert committed["history_size"] == 1
    assert committed["revision"] == 1
    assert committed["has_pending_delta"] is False
    assert camera_state_signature(committed) == camera_state_signature(rotated)

    session.apply_camera_button("toggle_perspective")
    toggled = session.snapshot()["camera"]
    assert toggled["history_size"] == 1
    assert toggled["can_undo"] is False
    assert toggled["perspective"] is False
    assert toggled["has_pending_delta"] is False

    assert camera_state_signature(base) != camera_state_signature(toggled)


def test_render_session_snap_button_snaps_camera(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = RenderSession(DummyImageContainer())
    session.camera.set_basis(depth=[0.1, 0.0, 0.995], up=[1.0, 0.0, 0.0])

    changed = session.apply_camera_button("snap")
    snapped = session.snapshot()["camera"]

    assert changed is True
    assert snapped["revision"] == 1
    assert np.allclose(session.camera.look_direction, [0.0, 0.0, 1.0])
    assert snapped["has_pending_delta"] is False


def test_render_session_undo_redo_are_disabled(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = RenderSession(DummyImageContainer())
    base = session.snapshot()["camera"]

    session.apply_camera_event(event_type="rotate", dx=0.25, dy=0.1)
    assert session.apply_camera_button("undo") is False
    assert session.apply_camera_button("redo") is False

    current = session.snapshot()["camera"]
    assert current["history_size"] == 1
    assert current["can_undo"] is False
    assert current["can_redo"] is False
    assert camera_state_signature(current) != camera_state_signature(base)


def test_render_session_switch_scene_reuses_state_and_restores_profile_camera(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    state = np.arange(6, dtype=np.float32).reshape((2, 3))
    session = RenderSession(
        DummyImageContainer(scene_factory=lambda _ctx: DummyGeneralScene()),
        state=state,
    )

    assert session.selected_scene == "General"
    general_initial_upload = session.scene.uploaded_state
    assert general_initial_upload is state

    session.camera.set_basis(depth=[1, 1, 1], up=[0, 0, 1])
    general_camera = session.snapshot()["camera"]

    assert session.switch_scene("Ball") is True
    assert session.selected_scene == "Ball"
    assert session.scene.uploaded_state is state
    ball_scene = session.scene

    session.camera.set_basis(depth=[0, 1, 1], up=[1, 0, 0])
    bloch_camera = session.snapshot()["camera"]

    assert session.switch_scene("General") is True
    restored_general = session.snapshot()["camera"]
    assert camera_state_signature(restored_general) == camera_state_signature(general_camera)

    assert session.switch_scene("Ball") is True
    assert session.scene is ball_scene
    restored_bloch = session.snapshot()["camera"]
    assert camera_state_signature(restored_bloch) == camera_state_signature(bloch_camera)


def test_render_session_render_snippet_updates_after_scene_switch(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = RenderSession(DummyImageContainer())

    assert "render_prepare('Layer', system)" in session.render_snippet()

    session.switch_scene("Ball")
    snippet = session.render_snippet()

    assert "render_prepare('Ball', system)" in snippet
    assert "ic.scene['Render']" not in snippet


def test_render_session_shared_param_key_persists_across_scene_switch(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = RenderSession(DummyImageContainer())

    session.apply_property_change(
        "Render",
        2,
        True,
        expected_field="flag",
        expected_title="Flag",
    )
    assert session.switch_scene("General") is True

    snapshot = session.properties_snapshot()
    general_props = snapshot["property_groups"][0]["properties"]

    assert general_props[1]["field"] == "flag"
    assert general_props[1]["value"] is True


def test_render_session_switch_scene_rejects_incompatible_builtin_scene(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = RenderSession(DummyImageContainer())

    with pytest.raises(ValueError, match="incompatible"):
        session.switch_scene("Unsupported")


def test_custom_transport_queue_drops_stale_camera_commit_before_later_delta():
    esm = _camera_viewport_widget_class()._esm

    assert "const dropPendingCameraCommits" in esm
    assert "dropPendingCameraCommits();" in esm
    assert re.search(
        r"const onPointerDown = .*?dropPendingCameraCommits\(\);",
        esm,
        re.DOTALL,
    )
    assert re.search(
        r"const onWheel = .*?dropPendingCameraCommits\(\);",
        esm,
        re.DOTALL,
    )
    assert 'queueEvent("camera_commit");' in esm


def test_viewport_widgets_offer_snap_action():
    viewport_esm = _camera_viewport_widget_class()._esm
    composite_esm = camera_viewer_module._composite_director_field_widget_class()._esm

    assert "Snap" in viewport_esm
    assert '"snap_camera"' in viewport_esm
    assert 'queueEvent("snap")' in viewport_esm
    assert "Snap" in composite_esm
    assert '"snap_camera"' in composite_esm


def test_marimo_widget_assets_are_available_as_package_resources():
    assets_module = importlib.import_module("topovec.marimo._assets")
    assets_root = importlib_resources.files("topovec.marimo.assets")

    viewport_js = assets_root.joinpath("camera_viewport.js").read_text(
        encoding="utf-8"
    )
    viewport_css = assets_root.joinpath("camera_viewport.css").read_text(
        encoding="utf-8"
    )
    inspect_js = assets_root.joinpath("director_field_inspect.js").read_text(
        encoding="utf-8"
    )
    inspect_css = assets_root.joinpath("director_field_inspect.css").read_text(
        encoding="utf-8"
    )

    assert "topovec-camera-viewer" in viewport_js
    assert ".topovec-camera-viewer" in viewport_css
    assert "topovec-composite-widget" in inspect_js
    assert ".topovec-composite-widget" in inspect_css
    assert viewport_js == assets_module.camera_viewport_esm()
    assert viewport_css == assets_module.camera_viewport_css()
    assert inspect_js == assets_module.director_field_inspect_esm()
    assert inspect_css == assets_module.director_field_inspect_css()


def test_anywidget_classes_load_assets_from_resource_loader():
    assets_module = importlib.import_module("topovec.marimo._assets")
    viewport_cls = _camera_viewport_widget_class()
    composite_cls = camera_viewer_module._composite_director_field_widget_class()

    assert viewport_cls._esm == assets_module.camera_viewport_esm()
    assert viewport_cls._css == assets_module.camera_viewport_css()
    assert composite_cls._esm == assets_module.director_field_inspect_esm()
    assert composite_cls._css == assets_module.director_field_inspect_css()


def test_composite_widget_uses_compact_panel_contract():
    widget_cls = camera_viewer_module._composite_director_field_widget_class()
    css = widget_cls._css
    esm = widget_cls._esm

    assert "grid-template-columns: minmax(0, 3fr) minmax(0, 1fr);" in css
    assert ".topovec-composite-rows" in css
    assert ".topovec-composite-row" in css
    assert ".topovec-composite-grid" not in css
    assert "buildCompactRow" in esm
    assert 'buildCompactRow("Reset"' not in esm
    assert 'buildCompactRow("Projection"' not in esm
    assert "other.open = false;" in esm
    assert 'summaryLabel.textContent = "Reproduce render";' in esm


def test_quantize_numeric_value_uses_count_resolution():
    prop_snapshot = {
        "min": 0.0,
        "max": 1.0,
        "count": 5,
        "value": 0.0,
    }

    assert _quantize_numeric_value(0.33, prop_snapshot) == 0.25
    assert _quantize_numeric_value(0.87, prop_snapshot) == 0.75


def test_quantize_numeric_value_falls_back_to_three_decimals():
    prop_snapshot = {
        "min": 1.0,
        "max": 0.5,
        "count": 2,
        "value": 0.75,
    }

    assert _effective_numeric_step(prop_snapshot) == 0.001
    assert _quantize_numeric_value(0.7334, prop_snapshot) == 0.733
    assert _quantize_numeric_value(0.7336, prop_snapshot) == 0.734


def test_inspect_view_prepares_container_from_state(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    import topovec.mgl as mgl

    captured = {}

    def fake_render_prepare(
        scenecls,
        system,
        camera_preset=2,
        scalex=False,
        imgsize=1024,
    ):
        captured["scenecls"] = scenecls
        captured["system"] = system
        captured["camera_preset"] = camera_preset
        captured["scalex"] = scalex
        captured["imgsize"] = imgsize
        return DummyImageContainer()

    monkeypatch.setattr(mgl, "render_prepare", fake_render_prepare)
    state = np.zeros((2, 3, 4, 3), dtype=np.float32)

    view = inspect_view(state)
    raw_widget = getattr(view.widget, "widget", view.widget)

    assert isinstance(view, DirectorFieldView)
    assert captured["scenecls"] == "Layer"
    assert tuple(int(v) for v in captured["system"].size) == (2, 3, 4)
    assert captured["camera_preset"] is None
    assert captured["scalex"] is False
    assert captured["imgsize"] == 512
    assert view.layout is view.widget
    assert hasattr(view.widget, "_mime_")
    assert raw_widget.properties_snapshot["selected_scene"] == "Layer"
    assert raw_widget.summary_rows == 8
    assert "render_prepare('Layer', system)" in raw_widget.summary_text
    assert view.session.selected_scene == "Layer"


def test_inspect_view_uses_explicit_system(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    import topovec.mgl as mgl

    captured = {}
    explicit_system = DummySystem(dim=3)

    def fake_render_prepare(
        scenecls,
        system,
        camera_preset=2,
        scalex=False,
        imgsize=1024,
    ):
        captured["scenecls"] = scenecls
        captured["system"] = system
        return DummyImageContainer()

    monkeypatch.setattr(mgl, "render_prepare", fake_render_prepare)

    view = inspect_view(
        np.zeros((2, 3, 4, 1, 3), dtype=np.float32),
        system=explicit_system,
        scene="Ball",
    )

    assert isinstance(view, DirectorFieldView)
    assert captured["scenecls"] == "Ball"
    assert captured["system"] is explicit_system
    assert view.session.selected_scene == "Ball"


def test_inspect_returns_layout(monkeypatch):
    sentinel = SimpleNamespace()
    view = SimpleNamespace(widget=sentinel, layout=sentinel)
    monkeypatch.setattr(
        camera_viewer_module,
        "inspect_view",
        lambda *args, **kwargs: view,
    )

    layout = inspect_layout(np.zeros((2, 3, 4, 3), dtype=np.float32))

    assert layout is sentinel
    assert layout._topovec_director_field_view is view


def test_inspect_widget_switch_scene_updates_properties_snapshot(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    import topovec.mgl as mgl

    monkeypatch.setattr(mgl, "render_prepare", lambda *args, **kwargs: DummyImageContainer())
    view = inspect_view(np.zeros((2, 3, 4, 3), dtype=np.float32))
    raw_widget = getattr(view.widget, "widget", view.widget)

    assert raw_widget.properties_snapshot["selected_scene"] == "Layer"

    raw_widget._handle_custom_message(
        None,
        {"type": "switch_scene", "scene_name": "Ball"},
        None,
    )

    assert view.session.selected_scene == "Ball"
    assert raw_widget.properties_snapshot["selected_scene"] == "Ball"
    assert raw_widget.properties_snapshot["property_groups"][0]["title"] == "Ball"
    assert raw_widget.camera_snapshot["revision"] == 1
    assert "render_prepare('Ball', system)" in raw_widget.summary_text


def test_render_session_scene_residency_eviction_preserves_gl_context():
    import topovec.mgl as mgl

    system = System.cubic((4, 4, 4))
    state = np.zeros((4, 4, 4, 3), dtype=np.float32)
    state[..., 2] = 1.0
    image_container = mgl.render_prepare(
        scenecls="Layer",
        system=system,
        imgsize=32,
    )
    manager = camera_viewer_module.SceneResidencyManager(
        image_container,
        scene_registry_loader=lambda: {
            "Layer": mgl.SCENES["Layer"],
            "Ball": mgl.SCENES["Ball"],
            "Level surface": mgl.SCENES["Level surface"],
        },
        session_factory=RenderSession,
        max_live_scenes=1,
    )

    try:
        session = manager.activate(
            system=system,
            state=state,
            preferred_scene_name="Layer",
        )

        assert manager.switch_scene("Ball") is True
        assert manager.switch_scene("Level surface") is True

        payload = session.frame_payload(image_encoder=lambda image: image.size)

        assert session.selected_scene == "Level surface"
        assert payload["image_data"] == image_container.size
        assert payload["projection_mode"] == "Perspective"
    finally:
        manager.release()


def test_inspect_widget_exposes_normalized_numeric_step(monkeypatch):
    import topovec.mgl as mgl

    monkeypatch.setattr(
        camera_viewer_module,
        "_load_builtin_scene_registry",
        lambda: {"Layer": DummyUglyStepScene},
    )
    monkeypatch.setattr(
        mgl,
        "render_prepare",
        lambda *args, **kwargs: DummyImageContainer(
            scene_factory=lambda _ctx: DummyUglyStepScene()
        ),
    )

    view = inspect_view(np.zeros((2, 3, 4, 3), dtype=np.float32))
    raw_widget = getattr(view.widget, "widget", view.widget)
    prop = raw_widget.properties_snapshot["property_groups"][0]["properties"][0]

    assert prop["title"] == "Hue"
    assert prop["step"] == pytest.approx(0.0028)


def test_inspect_widget_property_message_rebuilds_snapshot(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    import topovec.mgl as mgl

    monkeypatch.setattr(mgl, "render_prepare", lambda *args, **kwargs: DummyImageContainer())
    view = inspect_view(np.zeros((2, 3, 4, 3), dtype=np.float32))
    raw_widget = getattr(view.widget, "widget", view.widget)

    raw_widget._handle_custom_message(
        None,
        {
            "type": "apply_property_change",
            "group_id": "Render",
            "prop_index": 0,
            "value": "beta",
            "expected_field": "mode",
            "expected_title": "Mode",
        },
        None,
    )

    level_prop = raw_widget.properties_snapshot["property_groups"][0]["properties"][1]
    assert view.session.scene.get_mode() == "beta"
    assert level_prop["min"] == -2.0
    assert level_prop["max"] == 2.0
    assert "ic.scene['Render']['Mode'] = 'beta'" in raw_widget.summary_text


def test_inspect_widget_numeric_input_is_not_quantized(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    import topovec.mgl as mgl

    monkeypatch.setattr(mgl, "render_prepare", lambda *args, **kwargs: DummyImageContainer())
    view = inspect_view(np.zeros((2, 3, 4, 3), dtype=np.float32))
    raw_widget = getattr(view.widget, "widget", view.widget)

    raw_widget._handle_custom_message(
        None,
        {
            "type": "apply_property_change",
            "group_id": "Render",
            "prop_index": 1,
            "value": 0.26,
            "expected_field": "level",
            "expected_title": "Level",
        },
        None,
    )

    level_prop = raw_widget.properties_snapshot["property_groups"][0]["properties"][1]
    assert view.session.scene.level == 0.26
    assert level_prop["value"] == 0.26
    assert "ic.scene['Render']['Level'] = 0.26" in raw_widget.summary_text


def test_inspect_view_rejects_invalid_state_shape():
    with pytest.raises(ValueError, match="at least three spatial dimensions"):
        inspect_view(np.zeros((2, 3, 4), dtype=np.float32))

    with pytest.raises(ValueError, match="last state axis to have length 3"):
        inspect_view(np.zeros((2, 3, 4, 2), dtype=np.float32))


def test_marimo_namespace_exports_inspect_helpers():
    assert tvm.inspect is camera_viewer_module.inspect
    assert tvm.inspect_view is camera_viewer_module.inspect_view
    assert tvm.DirectorFieldView is camera_viewer_module.DirectorFieldView
    assert tvm.camera_viewer is camera_viewer_module.camera_viewer
    assert tvm.CameraViewportWidget is camera_viewer_module.CameraViewportWidget
    assert not hasattr(tvm, "CompositeDirectorFieldView")
    assert not hasattr(tvm, "render_director_field_view")
    assert not hasattr(tvm, "render_viewer")
    assert not hasattr(tvm, "render_layout")
    assert not hasattr(tvm, "sync_viewer_commands")


def test_camera_viewer_smoke():
    widget = tvm.camera_viewer(
        DummyImageContainer(),
        state=np.arange(6, dtype=np.float32).reshape((2, 3)),
    )
    raw_widget = getattr(widget, "widget", widget)

    assert hasattr(widget, "_mime_")
    assert raw_widget.use_custom_transport is False
    assert raw_widget.show_toolbar is True
    assert raw_widget.projection_mode == "Perspective"


def test_camera_viewport_widget_factory_returns_public_widget():
    widget = tvm.CameraViewportWidget(DummyImageContainer())

    assert widget.__class__.__name__ == "CameraViewportWidget"
    assert hasattr(widget, "set_frame_payload")
    assert widget.use_custom_transport is False


def test_pil_image_to_data_url_uses_png_data_uri():
    image = Image.new("RGB", (2, 2), color=(12, 34, 56))
    data_url = tvm.pil_image_to_data_url(image)

    assert data_url.startswith("data:image/png;base64,")
    assert len(data_url) > len("data:image/png;base64,")


def test_camera_summary_is_python_constructor_call():
    camera = Camera()
    summary = tvm.format_camera_summary(camera)

    assert summary == (
        "tv.mgl.Camera.from_angles("
        "theta=0, phi=0, alpha=-1.5708, scale=1, "
        "perspective=True, look_at=[0, 0, 0])"
    )
    assert "theta=0" in summary
    assert "phi=0" in summary
    assert "alpha=-1.5708" in summary
    assert "scale=1" in summary
    assert "perspective=True" in summary
    assert "look_at=[0, 0, 0]" in summary
