"""Backend-neutral viewer session and host abstractions.

This module owns the reusable controller logic shared by notebook and desktop
frontends. It manages scene switching, camera state, property snapshots, and
Python snippet generation without depending on marimo, Qt, or browser
transport.
"""

from __future__ import annotations

import copy
from contextlib import nullcontext
from dataclasses import dataclass
from typing import Any, Callable, Protocol, runtime_checkable

import numpy as np
from PIL import Image

from ..core import ListProperty, NumericProperty, Property, System, UpdateMask
from ..mgl.camera import Camera

PROJECTION_LABELS = ("Perspective", "Orthographic")
DEFAULT_NUMERIC_STEP = 0.001


@dataclass(slots=True)
class ViewerFrame:
    """One rendered frame plus viewer metadata."""

    image: Image.Image
    status_text: str
    projection_mode: str
    camera_state: dict[str, Any]
    width: int
    height: int


@dataclass(slots=True)
class ViewerSessionResidencySnapshot:
    """Lightweight state retained across live-scene eviction or session rebuild."""

    shared_state: dict[str, Any]
    selected_scene: str | None
    default_property_values_by_scene: dict[str, dict[tuple[str, str, str], Any]]


@runtime_checkable
class ViewerHost(Protocol):
    """Minimal backend interface required by :class:`ViewerSession`."""

    @property
    def scene(self) -> Any: ...

    @property
    def size(self) -> tuple[int, int]: ...

    def create_scene(self, scene_cls: Any, system: Any) -> Any: ...

    def attach_scene(self, scene: Any) -> Any: ...

    def upload(self, state: np.ndarray | None) -> Any: ...

    def scene_mutation_scope(self, reason: str): ...

    def render_image(
        self,
        *,
        size: tuple[int, int] | None = None,
    ) -> Image.Image: ...


class ImageContainerHost:
    """Adapt :class:`topovec.mgl.image.ImageContainer` to :class:`ViewerHost`."""

    def __init__(self, image_container: Any):
        self._image_container = image_container

    @property
    def raw(self) -> Any:
        return self._image_container

    @property
    def scene(self) -> Any:
        return self._image_container.scene

    @property
    def size(self) -> tuple[int, int]:
        size = getattr(
            self._image_container,
            "size",
            getattr(self._image_container, "_size", (512, 512)),
        )
        return int(size[0]), int(size[1])

    def create_scene(self, scene_cls: Any, system: Any) -> Any:
        context = getattr(self._image_container, "_ctx", None)
        return scene_cls(mglctx=context, system=system)

    def attach_scene(self, scene: Any) -> Any:
        self._image_container.attach_scene(scene)
        return self

    def upload(self, state: np.ndarray | None) -> Any:
        self._image_container.upload(state)
        return self

    def scene_mutation_scope(self, reason: str):
        return nullcontext()

    def render_image(
        self,
        *,
        size: tuple[int, int] | None = None,
    ) -> Image.Image:
        requested = None if size is None else (int(size[0]), int(size[1]))
        if requested is not None and requested != self.size:
            raise NotImplementedError(
                "ImageContainerHost only renders at the container framebuffer size."
            )
        return self._image_container.save()


def adapt_viewer_host(host: Any) -> ViewerHost:
    """Normalize a raw image container or host-like object."""
    if isinstance(host, ViewerHost):
        return host
    return ImageContainerHost(host)


def serialize_camera(camera: Camera) -> dict[str, Any]:
    """Convert a live camera into a JSON-friendly backend-neutral snapshot."""
    return {
        "look_at": [float(v) for v in np.asarray(camera.look_at, dtype=float)],
        "look_direction": [
            float(v) for v in np.asarray(camera.look_direction, dtype=float)
        ],
        "look_up": [float(v) for v in np.asarray(camera.look_up, dtype=float)],
        "look_scale": float(camera.look_scale),
        "perspective": bool(camera.perspective_is_on),
    }


def restore_camera(camera: Camera, state: dict[str, Any]) -> Camera:
    """Apply a serialized snapshot back to an existing camera instance."""
    camera.look_at = np.asarray(state["look_at"], dtype=np.float32)
    camera.look_direction = np.asarray(state["look_direction"], dtype=np.float32)
    camera.look_up = np.asarray(state["look_up"], dtype=np.float32)
    camera.look_scale = float(state["look_scale"])
    camera.perspective_is_on = bool(state["perspective"])
    return camera.normalize()


def _normalize_camera_state(camera_state: dict[str, Any]) -> dict[str, Any]:
    camera = Camera(perspective=bool(camera_state["perspective"]))
    return serialize_camera(restore_camera(camera, camera_state))


def camera_state_signature(camera_state: dict[str, Any]) -> tuple[Any, ...]:
    """Create a rounded signature used to compare camera states."""
    state = _normalize_camera_state(camera_state)

    def rounded(values: list[float]) -> tuple[float, ...]:
        return tuple(round(float(v), 6) for v in values)

    return (
        rounded(state["look_at"]),
        rounded(state["look_direction"]),
        rounded(state["look_up"]),
        round(float(state["look_scale"]), 6),
        bool(state["perspective"]),
    )


def _format_python_float(value: float) -> str:
    return format(float(value), ".6g")


def _coerce_snapshot_value(value: Any) -> Any:
    if isinstance(value, np.generic):
        return value.item()
    if isinstance(value, np.ndarray):
        return value.tolist()
    if isinstance(value, tuple):
        return [_coerce_snapshot_value(v) for v in value]
    if isinstance(value, list):
        return [_coerce_snapshot_value(v) for v in value]
    return value


def _format_python_value(value: Any) -> str:
    value = _coerce_snapshot_value(value)
    if isinstance(value, bool):
        return "True" if value else "False"
    if isinstance(value, (int, np.integer)) and not isinstance(value, bool):
        return str(int(value))
    if isinstance(value, (float, np.floating)):
        return _format_python_float(float(value))
    if isinstance(value, str):
        return repr(value)
    if isinstance(value, list):
        return "[" + ", ".join(_format_python_value(item) for item in value) + "]"
    if isinstance(value, tuple):
        inner = ", ".join(_format_python_value(item) for item in value)
        if len(value) == 1:
            inner += ","
        return f"({inner})"
    if value is None:
        return "None"
    return repr(value)


def _projection_label(perspective: bool) -> str:
    return PROJECTION_LABELS[0] if perspective else PROJECTION_LABELS[1]


def format_camera_summary(camera: Camera, *, topovec_name: str = "tv") -> str:
    """Render the current camera as executable Python code."""
    theta, phi, alpha = camera.get_orientation_angles()
    look_at = ", ".join(
        _format_python_value(v)
        for v in np.asarray(camera.look_at, dtype=float)
    )
    return (
        f"{topovec_name}.mgl.Camera.from_angles("
        f"theta={_format_python_value(theta)}, "
        f"phi={_format_python_value(phi)}, "
        f"alpha={_format_python_value(alpha)}, "
        f"scale={_format_python_value(camera.look_scale)}, "
        f"perspective={camera.perspective_is_on}, "
        f"look_at=[{look_at}])"
    )


def apply_camera_command(
    camera: Camera,
    *,
    event_type: str,
    dx: float = 0.0,
    dy: float = 0.0,
    wheel: float = 0.0,
    rotate_rate: float = 2 * np.pi,
    roll_rate: float = 0.02 * np.pi,
    pan_rate: float = 2.0,
    zoom_rate: float = 0.12,
) -> Camera:
    """Apply one normalized interaction event to a camera."""
    dx = float(dx)
    dy = float(dy)
    wheel = float(wheel)

    if event_type == "rotate":
        camera.rotate_field_of_view(x=-dx, y=dy, rate=rotate_rate)
    elif event_type == "pan":
        camera.move_field_of_view(x=-dx, y=dy, rate=pan_rate)
    elif event_type == "roll":
        camera.rotate_around(z=-1.0, rate=dx * roll_rate)
    elif event_type == "zoom":
        camera.look_scale *= float(np.exp(np.clip(wheel, -8.0, 8.0) * zoom_rate))
    elif event_type in {"", "noop"}:
        return camera
    else:
        raise ValueError(f"Unsupported camera event: {event_type!r}")
    return camera


def _copy_shared_value(value: Any) -> Any:
    return copy.deepcopy(_coerce_snapshot_value(value))


def _shared_values_equal(left: Any, right: Any) -> bool:
    if isinstance(left, np.ndarray) or isinstance(right, np.ndarray):
        return np.array_equal(np.asarray(left), np.asarray(right))
    try:
        return bool(left == right)
    except Exception:
        return False


def _coerce_director_field_state(state: Any) -> np.ndarray:
    array = np.asarray(state)
    if array.ndim < 4:
        raise ValueError(
            "`inspect` expects a director field with at least three spatial "
            f"dimensions and a trailing vector axis, got shape {array.shape!r}."
        )
    if array.shape[-1] != 3:
        raise ValueError(
            "`inspect` expects the last state axis to have length 3, "
            f"got shape {array.shape!r}."
        )
    return array


def infer_cubic_system_from_state(state: np.ndarray) -> System:
    """Infer a trivial cubic system from a vector field shape."""
    return System.cubic(size=tuple(int(v) for v in state.shape[:3]))


def _load_builtin_scene_registry() -> dict[str, Any]:
    try:
        from ..mgl import SCENES
    except Exception:
        return {}
    return dict(SCENES)


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


def _resolve_builtin_scene_label(
    scene: Any,
    registry: dict[str, Any],
) -> str | None:
    scene_cls = getattr(scene, "__class__", None)
    for raw_label, candidate in registry.items():
        if scene_cls is candidate:
            return str(raw_label)
    return None


def _property_snapshot(prop: Property, index: int) -> dict[str, Any]:
    spec = dict(prop.spec())
    snapshot = {
        "index": int(index),
        "kind": str(spec["kind"]),
        "field": str(spec["field"]),
        "title": str(spec["title"]),
        "value": _coerce_snapshot_value(spec["value"]),
        "invalidates": bool(spec["invalidates"]),
        "param_key": spec.get("param_key"),
    }
    if isinstance(prop, NumericProperty):
        snapshot.update(
            {
                "min": float(spec["min"]),
                "max": float(spec["max"]),
                "count": int(spec["count"]),
            }
        )
    elif isinstance(prop, ListProperty):
        snapshot.update(
            {
                "values": [_coerce_snapshot_value(v) for v in spec["values"]],
                "selected_index": int(spec["selected_index"]),
            }
        )
    return snapshot


def _numeric_step(prop_snapshot: dict[str, Any]) -> float | None:
    count = int(prop_snapshot.get("count", 0))
    minimum = float(prop_snapshot.get("min", 0.0))
    maximum = float(prop_snapshot.get("max", 0.0))
    if count <= 1:
        return None
    delta = maximum - minimum
    if not np.isfinite(delta) or delta <= 0:
        return None
    step = delta / float(count - 1)
    if not np.isfinite(step) or step <= 0:
        return None
    return float(step)


def _default_numeric_step() -> float:
    return DEFAULT_NUMERIC_STEP


def _effective_numeric_step(prop_snapshot: dict[str, Any]) -> float:
    step = _numeric_step(prop_snapshot)
    if step is not None:
        return step
    return _default_numeric_step()


def _normalized_numeric_ui_step(
    step: float | None,
    *,
    significant_digits: int = 2,
) -> float | None:
    """Round a raw numeric step to a short terminating decimal for UI widgets."""
    if step is None:
        return None
    step = float(abs(step))
    if not np.isfinite(step) or step <= 0:
        return None

    precision = max(
        int(significant_digits - np.floor(np.log10(step)) - 1),
        0,
    )
    normalized = round(step, precision)
    while normalized == 0.0:
        precision += 1
        normalized = round(step, precision)
    return float(normalized)


def _numeric_bounds(
    prop_snapshot: dict[str, Any],
) -> tuple[float, float, float, bool]:
    """Normalize numeric metadata and report whether the current value fits it."""
    minimum = float(prop_snapshot.get("min", 0.0))
    maximum = float(prop_snapshot.get("max", 0.0))
    value = float(prop_snapshot.get("value", 0.0))

    if np.isfinite(minimum) and np.isfinite(maximum) and minimum > maximum:
        minimum, maximum = maximum, minimum

    value_in_bounds = (
        np.isfinite(minimum)
        and np.isfinite(maximum)
        and minimum <= value <= maximum
    )
    return minimum, maximum, value, value_in_bounds


def _numeric_step_decimals(step: float | None) -> int | None:
    if step is None:
        return None
    step = float(abs(step))
    if not np.isfinite(step) or step <= 0:
        return None
    text = f"{step:.12f}".rstrip("0").rstrip(".")
    if "." not in text:
        return 0
    return len(text.split(".", 1)[1])


def _quantize_numeric_value(value: float, prop_snapshot: dict[str, Any]) -> float:
    """Snap numeric input to either count-based or fallback resolution."""
    step = _effective_numeric_step(prop_snapshot)
    minimum, maximum, _, value_in_bounds = _numeric_bounds(prop_snapshot)
    snapped = float(value)

    if value_in_bounds:
        snapped = minimum + round((snapped - minimum) / step) * step
        snapped = float(np.clip(snapped, minimum, maximum))
    else:
        snapped = round(snapped / step) * step

    decimals = _numeric_step_decimals(step)
    if decimals is not None:
        snapped = round(snapped, decimals)

    return float(snapped)


def _clamp_numeric_value(value: float, prop_snapshot: dict[str, Any]) -> float:
    """Clamp numeric input to declared bounds without snapping to the step grid."""
    minimum, maximum, _current, _value_in_bounds = _numeric_bounds(prop_snapshot)
    clamped = float(value)
    if np.isfinite(minimum):
        clamped = max(float(minimum), clamped)
    if np.isfinite(maximum):
        clamped = min(float(maximum), clamped)
    return float(clamped)


def _decorate_property_snapshot(prop_snapshot: dict[str, Any]) -> dict[str, Any]:
    snapshot = dict(prop_snapshot)
    kind = str(snapshot.get("kind", ""))

    if kind == "numeric":
        minimum, maximum, value, value_in_bounds = _numeric_bounds(snapshot)
        step = _normalized_numeric_ui_step(_effective_numeric_step(snapshot))
        snapshot["value"] = float(value)
        snapshot["step"] = step
        snapshot["value_in_bounds"] = bool(value_in_bounds)
        snapshot["input_kind"] = "number"
    elif kind == "boolean":
        snapshot["input_kind"] = "switch"
    elif kind == "list":
        snapshot["input_kind"] = "dropdown"
    else:
        snapshot["input_kind"] = "display"

    snapshot["row_kind"] = "inline"

    return snapshot


class ViewerSession:
    """Python-side source of truth for one live render session."""

    def __init__(
        self,
        host: Any,
        state: np.ndarray | None = None,
        *,
        controls_text: str = "",
        rotate_rate: float = 2 * np.pi,
        roll_rate: float = 0.02 * np.pi,
        pan_rate: float = 2.0,
        zoom_rate: float = 0.12,
    ):
        self._host = adapt_viewer_host(host)
        self._scene = self._host.scene
        self._camera = self._scene.camera
        self.controls_text = controls_text
        self.rotate_rate = float(rotate_rate)
        self.roll_rate = float(roll_rate)
        self.pan_rate = float(pan_rate)
        self.zoom_rate = float(zoom_rate)
        self._revision = 0
        self._state = state
        self._committed_camera_state = serialize_camera(self._camera)
        self._shared_state: dict[str, Any] = {}
        self._scene_cache: dict[str, Any] = {}
        self._default_property_values_by_scene: dict[
            str, dict[tuple[str, str, str], Any]
        ] = {}
        self._selected_scene: str | None = None
        self._available_scenes: list[str] = []
        self._property_groups: list[dict[str, Any]] = []
        self._property_group_index: dict[str, int] = {}
        self._properties_by_group: dict[str, list[Property]] = {}

        if state is not None:
            self._host.upload(state)

        self._refresh_scene_metadata()
        self._cache_current_scene()
        self._capture_current_scene_default_baseline()
        self._sync_active_scene_from_shared_state()
        self._restore_or_register_current_camera_slot()
        self._committed_camera_state = self._current_camera_state()
        self._refresh_property_cache()

    @property
    def host(self) -> ViewerHost:
        return self._host

    @property
    def image_container(self) -> ViewerHost:
        """Backward-compatible alias used by the marimo adapter."""
        return self._host

    @property
    def scene(self) -> Any:
        return self._scene

    @property
    def camera(self) -> Camera:
        return self._camera

    @property
    def revision(self) -> int:
        return self._revision

    @property
    def can_undo(self) -> bool:
        return False

    @property
    def can_redo(self) -> bool:
        return False

    @property
    def selected_scene(self) -> str | None:
        return self._selected_scene

    @property
    def available_scenes(self) -> list[str]:
        return list(self._available_scenes)

    def _scene_system(self) -> Any:
        return getattr(self._scene, "system", None)

    def _scene_camera_slot(self) -> str:
        slot = getattr(
            self._scene,
            "camera_slot_key",
            getattr(self._scene, "camera_profile", "general"),
        )
        return str(slot or "general")

    def _camera_key(self, slot: str) -> str:
        return f"camera:{slot}"

    def _home_camera_key(self, slot: str) -> str:
        return f"camera-home:{slot}"

    def _current_camera_state(self) -> dict[str, Any]:
        return serialize_camera(self._camera)

    def _store_camera_state(
        self,
        slot: str,
        camera_state: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        state = _normalize_camera_state(
            self._current_camera_state() if camera_state is None else camera_state
        )
        self._shared_state[self._camera_key(str(slot))] = dict(state)
        return dict(state)

    def _ensure_home_state(
        self,
        slot: str,
        camera_state: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        slot = str(slot)
        key = self._home_camera_key(slot)
        if key not in self._shared_state:
            state = _normalize_camera_state(
                self._current_camera_state() if camera_state is None else camera_state
            )
            self._shared_state[key] = dict(state)
        return dict(self._shared_state[key])

    def _register_current_camera_slot(self) -> dict[str, Any]:
        slot = self._scene_camera_slot()
        self._activate_current_scene_camera_slot()
        state = self._store_camera_state(slot)
        self._ensure_home_state(slot, state)
        return state

    def _restore_or_register_current_camera_slot(self) -> bool:
        slot = self._scene_camera_slot()
        self._activate_current_scene_camera_slot()
        cached = self._shared_state.get(self._camera_key(slot))
        if cached is None:
            self._register_current_camera_slot()
            return False
        restore_camera(self._camera, cached)
        self._ensure_home_state(slot, cached)
        return True

    def _set_committed_camera_state(
        self,
        camera_state: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        state = _normalize_camera_state(
            self._current_camera_state() if camera_state is None else camera_state
        )
        self._committed_camera_state = dict(state)
        slot = self._scene_camera_slot()
        self._store_camera_state(slot, state)
        self._ensure_home_state(slot, state)
        return dict(state)

    def _cache_current_scene(self) -> None:
        if self._selected_scene is not None:
            self._scene_cache[self._selected_scene] = self._scene

    def _property_baseline_key(
        self,
        group_id: str,
        prop_snapshot: dict[str, Any],
    ) -> tuple[str, str, str]:
        return (
            str(group_id),
            str(prop_snapshot.get("field", "")),
            str(prop_snapshot.get("title", "")),
        )

    def _builtin_scene_registry(self) -> dict[str, Any]:
        return _load_builtin_scene_registry()

    def _refresh_scene_metadata(self) -> None:
        registry = self._builtin_scene_registry()
        self._selected_scene = _resolve_builtin_scene_label(self._scene, registry)
        self._cache_current_scene()

        system = self._scene_system()
        if system is None:
            self._available_scenes = []
            return

        self._available_scenes = [
            str(raw_label)
            for raw_label, scene_cls in registry.items()
            if _scene_class_supports_system(scene_cls, system)
        ]

    def _create_scene(self, scene_cls: Any) -> Any:
        system = self._scene_system()
        if system is None:
            raise RuntimeError("Scene switching requires `scene.system`.")
        return self._host.create_scene(scene_cls, system)

    def _attach_scene(self, scene: Any) -> None:
        self._host.attach_scene(scene)
        self._scene = self._host.scene
        self._camera = self._scene.camera

    def _activate_current_scene_camera_slot(self) -> None:
        activate = getattr(self._scene, "activate_camera_slot", None)
        if callable(activate):
            activate()
        self._camera = self._scene.camera

    def _register_property_shared_value(self, prop: Property) -> None:
        if prop.param_key is None:
            return
        self._shared_state[prop.param_key] = _copy_shared_value(prop.get())

    def _sync_active_scene_from_shared_state(self) -> UpdateMask:
        aggregate = UpdateMask.NONE
        with self._host.scene_mutation_scope("sync_active_scene_from_shared_state"):
            for _ in range(4):
                props_by_group = self._scene.list_properties()
                iteration = UpdateMask.NONE
                changed = False
                previous_slot = self._scene_camera_slot()
                previous_camera = self._current_camera_state()
                for props in props_by_group.values():
                    for prop in props:
                        key = prop.param_key
                        if key is None:
                            continue
                        if key not in self._shared_state:
                            self._register_property_shared_value(prop)
                            continue
                        target = _copy_shared_value(self._shared_state[key])
                        if _shared_values_equal(prop.get(), target):
                            continue
                        iteration |= UpdateMask(prop.let(target))
                        changed = True
                if iteration & UpdateMask.CAMERA_SLOT_CHANGED:
                    self._store_camera_state(previous_slot, previous_camera)
                    self._restore_or_register_current_camera_slot()
                aggregate |= iteration
                if not changed or not (iteration & UpdateMask.REBUILD_PROPERTIES):
                    break
        return aggregate

    def has_pending_camera_delta(self) -> bool:
        return camera_state_signature(
            self._current_camera_state()
        ) != camera_state_signature(self._committed_camera_state)

    def _increment_revision(self) -> None:
        self._revision += 1

    def set_home(self) -> None:
        slot = self._scene_camera_slot()
        state = self._current_camera_state()
        self._shared_state[self._home_camera_key(slot)] = _normalize_camera_state(state)
        self._store_camera_state(slot, state)

    def frame_snapshot(self) -> ViewerFrame:
        """Render the current frame into a transport-neutral snapshot."""
        image = self._host.render_image()
        size = self._host.size
        return ViewerFrame(
            image=image,
            status_text=format_camera_summary(self._camera),
            projection_mode=_projection_label(self._camera.perspective_is_on),
            camera_state=self._current_camera_state(),
            width=int(size[0]),
            height=int(size[1]),
        )

    def frame_payload(
        self,
        *,
        image_encoder: Callable[[Image.Image], Any] | None = None,
    ) -> dict[str, Any]:
        """Render the current frame and package it for frontend transport."""
        frame = self.frame_snapshot()
        payload = {
            "status_text": frame.status_text,
            "projection_mode": frame.projection_mode,
            "camera_state": dict(frame.camera_state),
            "width": frame.width,
            "height": frame.height,
        }
        if image_encoder is None:
            payload["image"] = frame.image
        else:
            payload["image_data"] = image_encoder(frame.image)
        return payload

    def export_image(
        self,
        *,
        size: tuple[int, int] | None = None,
    ) -> Image.Image:
        """Render the current scene to a PIL image, optionally at a custom size."""
        return self._host.render_image(size=size)

    def sync_viewer(
        self,
        viewer: Any,
        *,
        image_encoder: Callable[[Image.Image], Any] | None = None,
    ) -> dict[str, Any]:
        """Render the current frame and push it into a viewport-like object."""
        payload = self.frame_payload(image_encoder=image_encoder)
        target = getattr(viewer, "widget", viewer)
        target.set_frame_payload(payload)
        return payload

    def _refresh_property_cache(self) -> list[dict[str, Any]]:
        self._refresh_scene_metadata()
        props_by_group = self._scene.list_properties()
        groups: list[dict[str, Any]] = []
        group_index: dict[str, int] = {}
        properties_by_group: dict[str, list[Property]] = {}

        for raw_group_id, props in props_by_group.items():
            group_id = str(raw_group_id)
            prop_list = list(props)
            group_index[group_id] = len(groups)
            properties_by_group[group_id] = prop_list
            for prop in prop_list:
                if prop.param_key is not None and prop.param_key not in self._shared_state:
                    self._register_property_shared_value(prop)
            groups.append(
                {
                    "id": group_id,
                    "title": group_id,
                    "properties": [
                        self._decorate_property_snapshot(group_id, prop, index)
                        for index, prop in enumerate(prop_list)
                    ],
                }
            )

        self._property_groups = groups
        self._property_group_index = group_index
        self._properties_by_group = properties_by_group
        return self._property_groups

    def _capture_current_scene_default_baseline(
        self,
        scene_name: str | None = None,
    ) -> dict[tuple[str, str, str], Any]:
        resolved_scene = self._selected_scene if scene_name is None else scene_name
        if resolved_scene is None:
            return {}
        if resolved_scene in self._default_property_values_by_scene:
            return self._default_property_values_by_scene[resolved_scene]
        baseline: dict[tuple[str, str, str], Any] = {}
        for raw_group_id, props in self._scene.list_properties().items():
            group_id = str(raw_group_id)
            for prop_index, prop in enumerate(list(props)):
                snapshot = _decorate_property_snapshot(
                    _property_snapshot(prop, prop_index)
                )
                baseline[self._property_baseline_key(group_id, snapshot)] = (
                    _copy_shared_value(snapshot.get("value"))
                )
        self._default_property_values_by_scene[resolved_scene] = baseline
        return baseline

    def _default_property_baseline(
        self,
        scene_name: str | None = None,
    ) -> dict[tuple[str, str, str], Any]:
        resolved_scene = self._selected_scene if scene_name is None else scene_name
        if resolved_scene is None:
            return {}
        if (
            resolved_scene not in self._default_property_values_by_scene
            and resolved_scene == self._selected_scene
        ):
            self._capture_current_scene_default_baseline(resolved_scene)
        return self._default_property_values_by_scene.get(resolved_scene, {})

    def _decorate_property_snapshot(
        self,
        group_id: str,
        prop: Property,
        index: int,
    ) -> dict[str, Any]:
        snapshot = _decorate_property_snapshot(_property_snapshot(prop, index))
        baseline = self._default_property_baseline()
        key = self._property_baseline_key(group_id, snapshot)
        default_value = _copy_shared_value(baseline.get(key, snapshot.get("value")))
        snapshot["default_value"] = default_value
        snapshot["is_default"] = bool(
            _shared_values_equal(snapshot.get("value"), default_value)
        )
        snapshot["can_reset"] = bool(key in baseline)
        return snapshot

    def export_residency_snapshot(self) -> ViewerSessionResidencySnapshot:
        """Export lightweight state that survives scene eviction or session rebuild."""
        return ViewerSessionResidencySnapshot(
            shared_state=copy.deepcopy(self._shared_state),
            selected_scene=(
                None if self._selected_scene is None else str(self._selected_scene)
            ),
            default_property_values_by_scene=copy.deepcopy(
                self._default_property_values_by_scene
            ),
        )

    def restore_residency_snapshot(
        self,
        snapshot: ViewerSessionResidencySnapshot | dict[str, Any] | None,
    ) -> "ViewerSession":
        """Restore lightweight persistent viewer state onto the current live scene."""
        if snapshot is None:
            return self
        if isinstance(snapshot, dict):
            snapshot = ViewerSessionResidencySnapshot(
                shared_state=copy.deepcopy(snapshot.get("shared_state", {})),
                selected_scene=(
                    None
                    if snapshot.get("selected_scene") is None
                    else str(snapshot.get("selected_scene"))
                ),
                default_property_values_by_scene=copy.deepcopy(
                    snapshot.get("default_property_values_by_scene", {})
                ),
            )

        self._shared_state = copy.deepcopy(snapshot.shared_state)
        self._default_property_values_by_scene = copy.deepcopy(
            snapshot.default_property_values_by_scene
        )
        self._refresh_scene_metadata()
        self._sync_active_scene_from_shared_state()
        self._restore_or_register_current_camera_slot()
        self._set_committed_camera_state()
        self._refresh_property_cache()
        return self

    def export_live_scenes(self) -> dict[str, Any]:
        """Return all currently live scene objects owned by this session."""
        live_scenes = dict(self._scene_cache)
        if self._selected_scene is not None and self._scene is not None:
            live_scenes[str(self._selected_scene)] = self._scene
        return live_scenes

    def live_scene_names(self) -> list[str]:
        """List scene labels currently backed by live scene objects."""
        return list(self.export_live_scenes().keys())

    def seed_live_scenes(self, scenes: dict[str, Any] | None) -> "ViewerSession":
        """Install pre-created live scenes into this session cache."""
        if not scenes:
            return self
        for scene_name, scene in scenes.items():
            if scene is None:
                continue
            self._scene_cache[str(scene_name)] = scene
        self._cache_current_scene()
        return self

    def evict_live_scene(self, scene_name: str) -> bool:
        """Drop one inactive live scene and release its GL resources."""
        resolved = str(scene_name)
        if resolved == self._selected_scene:
            return False
        scene = self._scene_cache.pop(resolved, None)
        if scene is None:
            return False
        release = getattr(scene, "release", None)
        if callable(release):
            release()
        return True

    def release_live_scenes(self) -> None:
        """Release all live scenes currently owned by this session."""
        seen: set[int] = set()
        for scene in self.export_live_scenes().values():
            if scene is None or id(scene) in seen:
                continue
            seen.add(id(scene))
            release = getattr(scene, "release", None)
            if callable(release):
                release()
        self._scene_cache.clear()

    def _patch_property_cache(
        self,
        group_id: str,
        prop_index: int,
        prop: Property,
    ) -> dict[str, Any]:
        if group_id not in self._property_group_index:
            raise KeyError(f"Unknown property group: {group_id!r}")
        group = self._property_groups[self._property_group_index[group_id]]
        props = group["properties"]
        if not (0 <= prop_index < len(props)):
            raise KeyError(
                f"Unknown property index {prop_index} for group {group_id!r}."
            )
        snapshot = self._decorate_property_snapshot(group_id, prop, prop_index)
        props[prop_index] = snapshot
        return snapshot

    def camera_snapshot(self) -> dict[str, Any]:
        """Collect camera state for frontend adapters."""
        camera_state = self._current_camera_state()
        return {
            "revision": self._revision,
            **camera_state,
            "projection_label": _projection_label(self._camera.perspective_is_on),
            "can_undo": self.can_undo,
            "can_redo": self.can_redo,
            "history_index": 0,
            "history_size": 1,
            "has_pending_delta": self.has_pending_camera_delta(),
        }

    def properties_snapshot(self) -> dict[str, Any]:
        """Collect current scene/property metadata for frontend adapters."""
        return {
            "revision": self._revision,
            "property_groups": copy.deepcopy(self._property_groups),
            "selected_scene": self._selected_scene,
            "available_scenes": list(self._available_scenes),
        }

    def snapshot(self) -> dict[str, Any]:
        """Collect a combined session snapshot for tests and diagnostics."""
        camera_snapshot = self.camera_snapshot()
        properties_snapshot = self.properties_snapshot()
        return {
            "revision": self._revision,
            "camera": camera_snapshot,
            "property_groups": properties_snapshot["property_groups"],
            "selected_scene": properties_snapshot["selected_scene"],
            "available_scenes": properties_snapshot["available_scenes"],
            "summary_text": self.render_snippet(),
        }

    def render_snippet(
        self,
        field_name: str = "field",
        system_name: str = "system",
        topovec_name: str = "tv",
    ) -> str:
        """Generate runnable Python code reproducing the visible render."""
        scene_name = self._selected_scene
        if scene_name is None:
            return (
                "# Current scene is not a built-in topovec.mgl.SCENES scene.\n"
                "# Reproduction snippet is only available for built-in scenes."
            )

        lines = [
            f"ic = {topovec_name}.mgl.render_prepare("
            f"{_format_python_value(scene_name)}, {system_name})",
            f"ic.upload({field_name})",
        ]

        baseline = self._default_property_baseline(scene_name)
        for group in self._property_groups:
            group_id = str(group["id"])
            for prop_snapshot in group["properties"]:
                key = self._property_baseline_key(group_id, prop_snapshot)
                current_value = _copy_shared_value(prop_snapshot.get("value"))
                default_value = baseline.get(key, current_value)
                if _shared_values_equal(current_value, default_value):
                    continue
                lines.append(
                    f"ic.scene[{_format_python_value(group_id)}]"
                    f"[{_format_python_value(prop_snapshot['title'])}] = "
                    f"{_format_python_value(current_value)}"
                )

        lines.append(
            "ic.scene.set_camera("
            f"{format_camera_summary(self._camera, topovec_name=topovec_name)})"
        )
        lines.append("ic.save()")
        return "\n".join(lines)

    def _resolve_property(
        self,
        group_id: str,
        prop_index: int,
        *,
        expected_field: str | None = None,
        expected_title: str | None = None,
    ) -> Property:
        if group_id not in self._properties_by_group:
            raise KeyError(f"Unknown property group: {group_id!r}")
        props = self._properties_by_group[group_id]
        if not (0 <= prop_index < len(props)):
            raise KeyError(
                f"Unknown property index {prop_index} for group {group_id!r}."
            )
        prop = props[prop_index]
        if expected_field is not None and prop.field != expected_field:
            raise RuntimeError(
                f"Property slot mismatch for {group_id!r}[{prop_index}]: "
                f"expected field {expected_field!r}, got {prop.field!r}."
            )
        if expected_title is not None and prop.title != expected_title:
            raise RuntimeError(
                f"Property slot mismatch for {group_id!r}[{prop_index}]: "
                f"expected title {expected_title!r}, got {prop.title!r}."
            )
        return prop

    def apply_camera_delta(
        self,
        *,
        event_type: str,
        dx: float = 0.0,
        dy: float = 0.0,
        wheel: float = 0.0,
    ) -> bool:
        """Apply a live camera delta without mutating explicit-save history."""
        if event_type in {
            "",
            "noop",
            "reset",
            "snap",
            "toggle_perspective",
            "camera_commit",
        }:
            return False

        previous = self._current_camera_state()
        apply_camera_command(
            self._camera,
            event_type=event_type,
            dx=dx,
            dy=dy,
            wheel=wheel,
            rotate_rate=self.rotate_rate,
            roll_rate=self.roll_rate,
            pan_rate=self.pan_rate,
            zoom_rate=self.zoom_rate,
        )
        current = self._current_camera_state()
        return camera_state_signature(current) != camera_state_signature(previous)

    def apply_camera_event(
        self,
        *,
        event_type: str,
        dx: float = 0.0,
        dy: float = 0.0,
        wheel: float = 0.0,
    ) -> bool:
        """Backward-compatible alias for live camera deltas."""
        return self.apply_camera_delta(
            event_type=event_type,
            dx=dx,
            dy=dy,
            wheel=wheel,
        )

    def commit_camera_interaction(self) -> bool:
        """Publish a committed camera pose without tracking history."""
        changed = self.has_pending_camera_delta()
        if changed:
            self._set_committed_camera_state()
            self._increment_revision()
        return changed

    def reset_camera(self) -> bool:
        """Restore the saved home camera for the active profile."""
        previous = self._current_camera_state()
        slot = self._scene_camera_slot()
        home_state = self._shared_state.get(self._home_camera_key(slot))
        if home_state is None:
            home_state = self._ensure_home_state(slot)
        restore_camera(self._camera, home_state)
        current = self._current_camera_state()
        if camera_state_signature(current) == camera_state_signature(previous):
            return False
        self._set_committed_camera_state(current)
        self._increment_revision()
        return True

    def apply_camera_button(self, action: str) -> bool:
        """Apply explicit camera actions."""
        if action in {"undo", "redo"}:
            return False

        if action == "reset":
            return self.reset_camera()

        if action == "snap":
            previous = self._current_camera_state()
            self._camera.snap()
            current = self._current_camera_state()
            if camera_state_signature(current) == camera_state_signature(previous):
                return False
            self._set_committed_camera_state(current)
            self._increment_revision()
            return True

        if action != "toggle_perspective":
            raise ValueError(f"Unsupported camera button action: {action!r}")

        previous = self._current_camera_state()
        self._camera.toggle_perspective()
        current = self._current_camera_state()
        if camera_state_signature(current) == camera_state_signature(previous):
            return False
        self._set_committed_camera_state(current)
        self._increment_revision()
        return True

    def switch_scene(self, scene_name: str) -> bool:
        registry = self._builtin_scene_registry()
        if scene_name not in registry:
            raise KeyError(f"Unknown built-in scene: {scene_name!r}")

        scene_cls = registry[scene_name]
        system = self._scene_system()
        if system is None:
            raise RuntimeError("Scene switching requires `scene.system`.")
        if not _scene_class_supports_system(scene_cls, system):
            raise ValueError(
                f"Scene {scene_name!r} is incompatible with the current system."
            )
        if scene_name == self._selected_scene:
            return False

        previous_slot = self._scene_camera_slot()
        self._store_camera_state(previous_slot)
        self._cache_current_scene()

        scene = self._scene_cache.get(scene_name)
        if scene is None:
            scene = self._create_scene(scene_cls)
            self._scene_cache[scene_name] = scene

        self._attach_scene(scene)
        if self._state is not None:
            self._host.upload(self._state)
        if scene_name not in self._default_property_values_by_scene:
            self._capture_current_scene_default_baseline(scene_name)

        self._sync_active_scene_from_shared_state()
        self._restore_or_register_current_camera_slot()
        self._set_committed_camera_state()
        self._refresh_property_cache()
        self._increment_revision()
        return True

    def set_render_state(self, state: np.ndarray | None) -> "ViewerSession":
        if state is not None:
            self._host.upload(state)
        self._state = state
        self._refresh_property_cache()
        self._increment_revision()
        return self

    def apply_property_input(
        self,
        group_id: str,
        prop_index: int,
        raw_value: Any,
        *,
        expected_field: str | None = None,
        expected_title: str | None = None,
    ) -> UpdateMask:
        prop = self._resolve_property(
            group_id,
            prop_index,
            expected_field=expected_field,
            expected_title=expected_title,
        )
        kind = getattr(prop, "kind", None)
        value = raw_value
        if kind == "numeric":
            snapshot = _decorate_property_snapshot(
                _property_snapshot(prop, prop_index)
            )
            if raw_value is None:
                raise ValueError("Numeric property update requires a value.")
            value = _clamp_numeric_value(float(raw_value), snapshot)
        elif kind == "boolean":
            value = bool(raw_value)
        return self.apply_property_change(
            group_id,
            prop_index,
            value,
            expected_field=expected_field,
            expected_title=expected_title,
        )

    def apply_property_change(
        self,
        group_id: str,
        prop_index: int,
        value: Any,
        *,
        expected_field: str | None = None,
        expected_title: str | None = None,
    ) -> UpdateMask:
        """Apply a scene property update and reconcile any camera side effects."""
        previous_slot = self._scene_camera_slot()
        previous_camera = self._current_camera_state()
        prop = self._resolve_property(
            group_id,
            prop_index,
            expected_field=expected_field,
            expected_title=expected_title,
        )
        with self._host.scene_mutation_scope("apply_property_change"):
            update_mask = UpdateMask(prop.let(value))
        if prop.param_key is not None:
            self._register_property_shared_value(prop)

        if update_mask & UpdateMask.CAMERA_SLOT_CHANGED:
            self._store_camera_state(previous_slot, previous_camera)
            self._restore_or_register_current_camera_slot()

        refresh_mask = UpdateMask(update_mask)
        if update_mask & (
            UpdateMask.REBUILD_PROPERTIES | UpdateMask.REBUILD_PROPERTY_SCHEMA
        ):
            refresh_mask |= self._sync_active_scene_from_shared_state()
            self._refresh_property_cache()
        else:
            self._patch_property_cache(group_id, prop_index, prop)

        self._set_committed_camera_state()
        self._increment_revision()
        return UpdateMask(refresh_mask)


__all__ = [
    "DEFAULT_NUMERIC_STEP",
    "PROJECTION_LABELS",
    "ImageContainerHost",
    "ViewerFrame",
    "ViewerSessionResidencySnapshot",
    "ViewerHost",
    "ViewerSession",
    "_coerce_director_field_state",
    "_decorate_property_snapshot",
    "_effective_numeric_step",
    "_property_snapshot",
    "_quantize_numeric_value",
    "adapt_viewer_host",
    "apply_camera_command",
    "camera_state_signature",
    "format_camera_summary",
    "infer_cubic_system_from_state",
    "restore_camera",
    "serialize_camera",
]
