import json
from types import SimpleNamespace

import numpy as np
import pytest
from PIL import Image

from topovec.core import BooleanProperty, ListProperty, NumericProperty, System, UpdateMask
from topovec.mgl.camera import Camera
from topovec.viewer.residency import SceneResidencyManager
from topovec.viewer.session import (
    ViewerSession,
    ViewerSessionResidencySnapshot,
    _decorate_property_snapshot,
    _effective_numeric_step,
    camera_state_signature,
)


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.release_count = 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

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


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

    @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 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",
                ),
            ]
        }


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 DummyVectorScene(DummySceneBase):
    SHAPES = ["Cone", "Double cone", "Cylinder", "Arrow"]

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._mesh_shape = "Cone"

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

    def get_mesh_shape(self):
        return self._mesh_shape

    def set_mesh_shape(self, value):
        if value not in self.SHAPES:
            raise ValueError(f"Unsupported mesh shape: {value!r}")
        self._mesh_shape = str(value)
        return UpdateMask.REDRAW

    mesh_shape = property(get_mesh_shape, set_mesh_shape)

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


class DummyScopedVectorScene(DummySceneBase):
    SHAPES = ["Cone", "Double cone", "Cylinder", "Arrow"]

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

    def get_mesh_shape(self):
        return self._mesh_shape

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

    mesh_shape = property(get_mesh_shape, set_mesh_shape)

    def list_properties(self):
        self.list_properties_calls += 1
        return {
            "Vector": [
                ListProperty(
                    self,
                    "mesh_shape",
                    title="Shape",
                    values=list(self.SHAPES),
                    reset=True,
                    param_key="layer.mesh_shape",
                )
            ]
        }


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

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


class DummyStyleScene(DummySceneBase):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.white_background = True

    def list_properties(self):
        self.list_properties_calls += 1
        return {
            "Style": [
                BooleanProperty(
                    self,
                    "white_background",
                    title="White background",
                    param_key="style.white_background",
                )
            ]
        }


class DummyStyleVariantScene(DummySceneBase):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.white_background = True
        self.flag = False

    def list_properties(self):
        self.list_properties_calls += 1
        return {
            "Style": [
                BooleanProperty(
                    self,
                    "white_background",
                    title="White background",
                    param_key="style.white_background",
                ),
                BooleanProperty(
                    self,
                    "flag",
                    title="Flag",
                ),
            ]
        }


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),
        )


class ScopedDummyHost:
    def __init__(self, scene):
        self._scene = scene
        self.scope_calls = []

    @property
    def scene(self):
        return self._scene

    @property
    def size(self):
        return (32, 24)

    def create_scene(self, scene_cls, system):
        return scene_cls(system=system)

    def attach_scene(self, scene):
        self._scene = scene
        return self

    def upload(self, state):
        if hasattr(self._scene, "upload"):
            self._scene.upload(state)
        return self

    def render_image(self, *, size=None):
        return Image.new("RGB", self.size, color=(12, 34, 56))

    def scene_mutation_scope(self, reason: str):
        self.scope_calls.append(f"enter:{reason}")
        self._scene._mutation_state["active"] = True

        class _Scope:
            def __enter__(inner_self):
                return self

            def __exit__(inner_self, exc_type, exc, tb):
                self._scene._mutation_state["active"] = False
                self.scope_calls.append(f"exit:{reason}")
                return False

        return _Scope()


def install_dummy_scene_registry(monkeypatch):
    registry = {
        "Layer": DummyScene,
        "Ball": DummyBlochScene,
    }
    monkeypatch.setattr(
        "topovec.viewer.session._load_builtin_scene_registry",
        lambda: dict(registry),
    )
    return registry


def test_viewer_session_snapshot_contains_property_groups(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = ViewerSession(DummyImageContainer())

    snapshot = session.snapshot()

    assert snapshot["revision"] == 0
    assert snapshot["selected_scene"] == "Layer"
    assert snapshot["available_scenes"] == ["Layer", "Ball"]
    assert snapshot["property_groups"][0]["id"] == "Render"
    assert "ic = tv.mgl.render_prepare('Layer', system)" in snapshot["summary_text"]


def test_render_snippet_does_not_create_extra_baseline_scene(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    create_calls = []
    original_create_scene = 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(ViewerSession, "_create_scene", counted_create_scene)
    session = ViewerSession(DummyImageContainer())

    assert create_calls == []

    snippet = session.render_snippet()

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


def test_viewer_session_frame_payload_accepts_encoder(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = ViewerSession(DummyImageContainer())

    payload = session.frame_payload(image_encoder=lambda image: f"img:{image.size}")

    assert payload["image_data"] == "img:(32, 24)"
    assert payload["projection_mode"] == "Perspective"
    assert payload["width"] == 32
    assert payload["height"] == 24


def test_viewer_session_switch_scene_restores_camera_slots(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = ViewerSession(DummyImageContainer())

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

    assert camera_state_signature(layer_camera) != camera_state_signature(layer_x_camera)

    assert session.switch_scene("Ball") is True
    ball_camera = session.snapshot()["camera"]
    assert session.switch_scene("Layer") is True
    restored_x = session.snapshot()["camera"]

    assert camera_state_signature(ball_camera) != camera_state_signature(restored_x)


def test_switch_scene_creates_only_live_scene_and_reuses_cache(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    create_calls = []
    original_create_scene = 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(ViewerSession, "_create_scene", counted_create_scene)
    session = ViewerSession(DummyImageContainer())

    assert session.switch_scene("Ball") is True
    assert create_calls == ["DummyBlochScene"]

    session.render_snippet()
    assert create_calls == ["DummyBlochScene"]

    assert session.switch_scene("Layer") is True
    assert create_calls == ["DummyBlochScene"]

    assert session.switch_scene("Ball") is True
    assert create_calls == ["DummyBlochScene"]


def test_viewer_session_residency_snapshot_roundtrip(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = ViewerSession(DummyImageContainer())

    assert session.switch_scene("Ball") is True
    snapshot = session.export_residency_snapshot()

    assert isinstance(snapshot, ViewerSessionResidencySnapshot)
    assert snapshot.selected_scene == "Ball"
    assert "Layer" in snapshot.default_property_values_by_scene
    assert "camera:bloch" in snapshot.shared_state

    restored = ViewerSession(
        DummyImageContainer(scene_factory=lambda _ctx: DummyBlochScene()),
    )
    restored.restore_residency_snapshot(snapshot)

    assert restored.export_residency_snapshot() == snapshot


def test_scene_residency_manager_reuses_live_selected_scene_and_preserves_state(
    monkeypatch,
):
    install_dummy_scene_registry(monkeypatch)
    host = DummyImageContainer()
    manager = SceneResidencyManager(
        host,
        scene_registry_loader=lambda: {
            "Layer": DummyScene,
            "Ball": DummyBlochScene,
        },
        session_factory=ViewerSession,
        max_live_scenes=2,
    )
    state_a0 = np.zeros((4, 4, 4, 1, 3), dtype=np.float32)
    state_a1 = np.ones((4, 4, 4, 1, 3), dtype=np.float32)
    state_b0 = np.full((5, 4, 4, 1, 3), 2.0, dtype=np.float32)

    session_a = manager.activate(
        system=System.cubic((4, 4, 4)),
        state=state_a0,
        preferred_scene_name="Layer",
    )
    assert session_a.switch_scene("Ball") is True
    session_a.camera.look_scale *= 1.75
    session_a.commit_camera_interaction()
    ball_scene = session_a.scene
    camera_a = camera_state_signature(session_a.camera_snapshot())

    session_b = manager.activate(
        system=System.cubic((5, 4, 4)),
        state=state_b0,
        preferred_scene_name="Layer",
    )
    assert session_b is not session_a

    session_a_reloaded = manager.activate(
        system=System.cubic((4, 4, 4)),
        state=state_a1,
        preferred_scene_name="Layer",
    )

    assert session_a_reloaded is not session_a
    assert session_a_reloaded.selected_scene == "Ball"
    assert session_a_reloaded.scene is ball_scene
    assert camera_state_signature(session_a_reloaded.camera_snapshot()) == camera_a


def test_scene_residency_manager_evicts_lru_non_active_scene(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    host = DummyImageContainer()
    manager = SceneResidencyManager(
        host,
        scene_registry_loader=lambda: {
            "Layer": DummyScene,
            "Ball": DummyBlochScene,
        },
        session_factory=ViewerSession,
        max_live_scenes=2,
    )

    session_a = manager.activate(
        system=System.cubic((4, 4, 4)),
        state=np.zeros((4, 4, 4, 1, 3), dtype=np.float32),
        preferred_scene_name="Layer",
    )
    layer_scene = session_a.scene
    assert manager.switch_scene("Ball") is True

    manager.activate(
        system=System.cubic((5, 4, 4)),
        state=np.ones((5, 4, 4, 1, 3), dtype=np.float32),
        preferred_scene_name="Layer",
    )

    assert layer_scene.release_count == 1


def test_scene_residency_manager_applies_vector_shape_property_change(monkeypatch):
    monkeypatch.setattr(
        "topovec.viewer.session._load_builtin_scene_registry",
        lambda: {"Layer": DummyVectorScene},
    )
    host = DummyImageContainer()
    manager = SceneResidencyManager(
        host,
        scene_registry_loader=lambda: {"Layer": DummyVectorScene},
        session_factory=ViewerSession,
        max_live_scenes=1,
    )

    session = manager.activate(
        system=System.cubic((4, 4, 4)),
        state=np.zeros((4, 4, 4, 1, 3), dtype=np.float32),
        preferred_scene_name="Layer",
    )

    session.apply_property_change(
        "Vector",
        0,
        "Arrow",
        expected_field="mesh_shape",
        expected_title="Shape",
    )

    assert manager.session is session
    assert session.scene.mesh_shape == "Arrow"
    assert (
        session.properties_snapshot()["property_groups"][0]["properties"][0]["value"]
        == "Arrow"
    )
    payload = session.frame_payload(image_encoder=lambda image: image.size)
    assert payload["image_data"] == (32, 24)


def test_apply_property_change_returns_redraw_mask_for_simple_property(monkeypatch):
    monkeypatch.setattr(
        "topovec.viewer.session._load_builtin_scene_registry",
        lambda: {"Layer": DummyVectorScene},
    )
    session = ViewerSession(DummyImageContainer(scene_factory=lambda _ctx: DummyVectorScene()))

    mask = session.apply_property_change(
        "Vector",
        0,
        "Arrow",
        expected_field="mesh_shape",
        expected_title="Shape",
    )

    assert mask == UpdateMask.REDRAW


def test_properties_snapshot_includes_default_metadata(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = ViewerSession(DummyImageContainer())

    props = session.properties_snapshot()["property_groups"][0]["properties"]
    mode_prop = props[0]
    level_prop = props[1]

    assert mode_prop["default_value"] == "alpha"
    assert mode_prop["is_default"] is True
    assert mode_prop["can_reset"] is True
    assert level_prop["default_value"] == 0.5
    assert level_prop["is_default"] is True
    assert level_prop["can_reset"] is True


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

    assert _effective_numeric_step(prop_snapshot) == pytest.approx(1.0 / 359.0)


@pytest.mark.parametrize(
    ("minimum", "maximum", "count", "expected_step"),
    [
        (0.0, 2.0, 1000, 0.002),
        (0.0, 1.0, 360, 0.0028),
        (0.01, 2.0, 100, 0.02),
        (0.0, 2.0, 30, 0.069),
        (0.0, 0.02, 1000, 0.00002),
    ],
)
def test_decorate_property_snapshot_normalizes_numeric_ui_step(
    minimum,
    maximum,
    count,
    expected_step,
):
    snapshot = _decorate_property_snapshot(
        {
            "kind": "numeric",
            "min": minimum,
            "max": maximum,
            "count": count,
            "value": minimum,
        }
    )

    assert snapshot["step"] == pytest.approx(expected_step)


def test_apply_property_input_clamps_numeric_without_quantizing(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = ViewerSession(DummyImageContainer())

    mask = session.apply_property_input(
        "Render",
        1,
        0.26,
        expected_field="level",
        expected_title="Level",
    )

    assert mask == UpdateMask.REDRAW
    assert session.scene.level == 0.26
    assert (
        session.properties_snapshot()["property_groups"][0]["properties"][1]["value"]
        == 0.26
    )


def test_apply_property_change_restores_default_value(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = ViewerSession(DummyImageContainer())

    session.apply_property_change(
        "Render",
        2,
        True,
        expected_field="flag",
        expected_title="Flag",
    )
    snapshot = session.properties_snapshot()["property_groups"][0]["properties"][2]

    assert snapshot["is_default"] is False

    session.apply_property_change(
        "Render",
        2,
        snapshot["default_value"],
        expected_field="flag",
        expected_title="Flag",
    )

    restored = session.properties_snapshot()["property_groups"][0]["properties"][2]
    assert session.scene.flag is False
    assert restored["is_default"] is True


def test_apply_property_change_returns_rebuild_mask_without_schema(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = ViewerSession(DummyImageContainer())

    mask = session.apply_property_change(
        "Render",
        0,
        "beta",
        expected_field="mode",
        expected_title="Mode",
    )

    assert mask & UpdateMask.REBUILD_PROPERTIES
    assert not (mask & UpdateMask.REBUILD_PROPERTY_SCHEMA)


def test_apply_property_change_returns_schema_mask_for_reset_property(monkeypatch):
    monkeypatch.setattr(
        "topovec.viewer.session._load_builtin_scene_registry",
        lambda: {"Layer": DummySchemaScene},
    )
    session = ViewerSession(
        DummyImageContainer(scene_factory=lambda _ctx: DummySchemaScene())
    )

    mask = session.apply_property_change(
        "Render",
        0,
        "beta",
        expected_field="mode",
        expected_title="Mode",
    )

    assert mask & UpdateMask.REBUILD_PROPERTIES
    assert mask & UpdateMask.REBUILD_PROPERTY_SCHEMA


def test_white_background_snapshot_and_shared_state_persist_across_scene_switch(
    monkeypatch,
):
    monkeypatch.setattr(
        "topovec.viewer.session._load_builtin_scene_registry",
        lambda: {
            "Layer": DummyStyleScene,
            "Variant": DummyStyleVariantScene,
        },
    )
    session = ViewerSession(DummyImageContainer(scene_factory=lambda _ctx: DummyStyleScene()))

    mask = session.apply_property_change(
        "Style",
        0,
        False,
        expected_field="white_background",
        expected_title="White background",
    )
    snapshot = session.properties_snapshot()

    assert mask == UpdateMask.REDRAW
    assert snapshot["property_groups"][0]["id"] == "Style"
    assert snapshot["property_groups"][0]["properties"][0]["param_key"] == "style.white_background"
    assert snapshot["property_groups"][0]["properties"][0]["value"] is False

    assert session.switch_scene("Variant") is True
    variant_props = session.properties_snapshot()["property_groups"][0]["properties"]
    fields = {prop["field"] for prop in variant_props}

    assert "white_background" in fields
    assert "inverted" not in fields
    assert variant_props[0]["value"] is False


def test_apply_property_change_preserves_camera_slot_changed_mask(monkeypatch):
    install_dummy_scene_registry(monkeypatch)
    session = ViewerSession(DummyImageContainer())

    mask = session.apply_property_change(
        "Render",
        3,
        0.0,
        expected_field="axis",
        expected_title="Axis",
    )

    assert mask & UpdateMask.CAMERA_SLOT_CHANGED


def test_viewer_session_applies_context_sensitive_property_under_mutation_scope():
    mutation_state = {"active": False}
    scene = DummyScopedVectorScene(mutation_state=mutation_state)
    host = ScopedDummyHost(scene)
    session = ViewerSession(host)

    session.apply_property_change(
        "Vector",
        0,
        "Arrow",
        expected_field="mesh_shape",
        expected_title="Shape",
    )

    assert session.scene.mesh_shape == "Arrow"
    assert host.scope_calls == [
        "enter:sync_active_scene_from_shared_state",
        "exit:sync_active_scene_from_shared_state",
        "enter:apply_property_change",
        "exit:apply_property_change",
    ]


def test_viewer_session_syncs_context_sensitive_shared_property_under_mutation_scope():
    mutation_state = {"active": False}
    scene = DummyScopedVectorScene(mutation_state=mutation_state)
    host = ScopedDummyHost(scene)
    session = ViewerSession(host)
    session._shared_state["layer.mesh_shape"] = "Cylinder"

    session._sync_active_scene_from_shared_state()

    assert session.scene.mesh_shape == "Cylinder"
    assert host.scope_calls[-2:] == [
        "enter:sync_active_scene_from_shared_state",
        "exit:sync_active_scene_from_shared_state",
    ]
