"""visualize/viewer.py の仕様ベーステスト．

仕様出典:
- Phase 1 実装サマリー（show_scene 仕様）
- docs/01_GUIDE/GUIDE_08_テスト方針.md（可視化テストは smoke テスト）

テスト方針:
- PyVista は必ず off_screen=True で実行する（GUI を開かない）
- 「ファイルが生成されたか」程度の smoke テストで十分（GUIDE_08）
- Mitsuba をモックしない（GUIDE_08）

テスト対象:
    show_scene(scene_config, project_root, irradiance=None, *, off_screen=False, screenshot=None)
"""

from __future__ import annotations

from pathlib import Path

import numpy as np
import pytest

from sm_tias_lightsim.scene.schema import SceneConfig
from sm_tias_lightsim.visualize.baker import bake_irradiance
from sm_tias_lightsim.visualize.viewer import show_scene

# ---------------------------------------------------------------------------
# フィクスチャ：minimal.yaml をそのまま使う
# ---------------------------------------------------------------------------

MINIMAL_YAML = Path(__file__).parent.parent / "fixtures" / "minimal.yaml"


@pytest.fixture(scope="module")
def minimal_scene_config() -> tuple[SceneConfig, Path]:
    """tests/fixtures/minimal.yaml から SceneConfig と project_root を返す．

    module スコープで 1 回だけロード（YAML パース・Pydantic 検証は速いため）．
    """
    from sm_tias_lightsim.scene.schema import load_scene_yaml

    config = load_scene_yaml(MINIMAL_YAML)
    project_root = MINIMAL_YAML.parent
    return config, project_root


# ---------------------------------------------------------------------------
# 1. irradiance なしの smoke テスト
# ---------------------------------------------------------------------------


@pytest.mark.slow
def test_show_scene_without_irradiance_creates_screenshot(
    tmp_path: Path,
    minimal_scene_config: tuple[SceneConfig, Path],
):
    """irradiance=None のとき screenshot が生成されること（no-bake モード）．

    仕様: irradiance なし → light gray の半透明メッシュで表示
    screenshot 指定時は off_screen=True を強制し，screenshot を保存する
    """
    config, project_root = minimal_scene_config
    screenshot_path = tmp_path / "out_no_irr.png"

    show_scene(
        config,
        project_root,
        irradiance=None,
        screenshot=screenshot_path,
    )

    assert screenshot_path.exists(), "irradiance なしで screenshot ファイルが生成されること"
    assert screenshot_path.stat().st_size > 0, "screenshot ファイルが空でないこと"


# ---------------------------------------------------------------------------
# 2. irradiance ありの smoke テスト
# ---------------------------------------------------------------------------


@pytest.mark.slow
def test_show_scene_with_irradiance_creates_screenshot(
    tmp_path: Path,
    minimal_scene_config: tuple[SceneConfig, Path],
):
    """irradiance を渡したとき screenshot が生成されること（bake モード）．

    仕様: irradiance あり → 各メッシュを cell_data["irradiance"] で着色（viridis）
    """
    config, project_root = minimal_scene_config

    # ベイク結果を取得
    irradiance = bake_irradiance(config, project_root)

    screenshot_path = tmp_path / "out_with_irr.png"
    show_scene(
        config,
        project_root,
        irradiance=irradiance,
        screenshot=screenshot_path,
    )

    assert screenshot_path.exists(), "irradiance ありで screenshot ファイルが生成されること"
    assert screenshot_path.stat().st_size > 0, "screenshot ファイルが空でないこと"


# ---------------------------------------------------------------------------
# 3. screenshot=None, off_screen=True は no-op であること（例外を投げない）
# ---------------------------------------------------------------------------


@pytest.mark.slow
def test_show_scene_off_screen_without_screenshot_is_noop(
    tmp_path: Path,
    minimal_scene_config: tuple[SceneConfig, Path],
):
    """screenshot=None かつ off_screen=True のとき例外を投げないこと（no-op）．

    仕様: screenshot=None かつ off_screen=True のときは no-op
    （viewer.py show_scene の末尾条件分岐）
    """
    config, project_root = minimal_scene_config

    # 例外が発生しないこと（戻り値は None）
    result = show_scene(
        config,
        project_root,
        irradiance=None,
        off_screen=True,
        screenshot=None,
    )

    assert result is None, "show_scene は None を返すこと"


# ---------------------------------------------------------------------------
# 4. screenshot 指定時に off_screen が強制されること（例外なく完了する）
# ---------------------------------------------------------------------------


@pytest.mark.slow
def test_show_scene_screenshot_forces_off_screen(
    tmp_path: Path,
    minimal_scene_config: tuple[SceneConfig, Path],
):
    """screenshot を指定すると off_screen=False でも off_screen=True に強制されること．

    仕様: screenshot 指定時は off_screen=True を強制し，screenshot を保存する
    GUI ウィンドウが開かないことを間接的に確認する（off_screen 強制は CI/CD 環境で必須）．
    """
    config, project_root = minimal_scene_config
    screenshot_path = tmp_path / "forced_offscreen.png"

    # off_screen=False でも screenshot 指定なら強制的に off_screen になるはず
    show_scene(
        config,
        project_root,
        irradiance=None,
        off_screen=False,  # これは screenshot 指定で上書きされる
        screenshot=screenshot_path,
    )

    assert screenshot_path.exists(), "off_screen=False でも screenshot 指定なら保存されること"


# ---------------------------------------------------------------------------
# 5. irradiance の cell_data がメッシュに紐付けられていること（smoke）
# ---------------------------------------------------------------------------


@pytest.mark.slow
def test_show_scene_irradiance_with_wrong_mesh_id_does_not_crash(
    tmp_path: Path,
    minimal_scene_config: tuple[SceneConfig, Path],
):
    """存在しない mesh_id のキーを irradiance に渡しても例外を投げないこと．

    仕様: irradiance が None でないが mesh_id が辞書に含まれない場合，
    そのメッシュは light gray の半透明で表示される（else ブランチ）．
    """
    config, project_root = minimal_scene_config
    screenshot_path = tmp_path / "wrong_id.png"

    # 存在しない mesh_id を持つ irradiance を渡す
    fake_irradiance = {"nonexistent_mesh": np.zeros(10, dtype=np.float32)}

    show_scene(
        config,
        project_root,
        irradiance=fake_irradiance,
        screenshot=screenshot_path,
    )

    assert screenshot_path.exists(), "不正な mesh_id でも例外を投げず screenshot が生成されること"


# ---------------------------------------------------------------------------
# 6. bake_opacity クランプ — [0, 1] に制限されること（純ロジック）
# ---------------------------------------------------------------------------


class TestBakeOpacityClamp:
    """show_scene の bake_opacity クランプ仕様を検証する（純ロジック・引数チェック）．

    仕様（viewer.py show_scene）:
        bake_opacity = max(0.0, min(1.0, bake_opacity))
        0.0 より小さいと 0.0 に、1.0 より大きいと 1.0 にクランプする．
    """

    @pytest.mark.slow
    def test_bake_opacity_above_1_is_clamped(
        self,
        tmp_path: Path,
        minimal_scene_config: tuple[SceneConfig, Path],
    ):
        """bake_opacity > 1.0 を渡しても例外なく正常終了すること（1.0 にクランプ）．

        仕様: bake_opacity = max(0.0, min(1.0, bake_opacity))
        """
        config, project_root = minimal_scene_config
        screenshot_path = tmp_path / "opacity_clamp.png"
        show_scene(
            config,
            project_root,
            irradiance=None,
            screenshot=screenshot_path,
            bake_opacity=2.5,  # 1.0 にクランプされること
        )
        assert screenshot_path.exists()

    @pytest.mark.slow
    def test_bake_opacity_below_0_is_clamped(
        self,
        tmp_path: Path,
        minimal_scene_config: tuple[SceneConfig, Path],
    ):
        """bake_opacity < 0.0 を渡しても例外なく正常終了すること（0.0 にクランプ）．

        仕様: bake_opacity = max(0.0, min(1.0, bake_opacity))
        """
        config, project_root = minimal_scene_config
        screenshot_path = tmp_path / "opacity_below0.png"
        show_scene(
            config,
            project_root,
            irradiance=None,
            screenshot=screenshot_path,
            bake_opacity=-0.5,  # 0.0 にクランプされること
        )
        assert screenshot_path.exists()


# ---------------------------------------------------------------------------
# 7. bake_smooth クランプ — 負値が 0 にクランプされること（純ロジック）
# ---------------------------------------------------------------------------


class TestBakeSmoothClamp:
    """show_scene の bake_smooth クランプ仕様を検証する．

    仕様（viewer.py show_scene）:
        bake_smooth = max(0, bake_smooth)
        負値は 0 にクランプする
    """

    @pytest.mark.slow
    def test_bake_smooth_negative_is_clamped_to_0(
        self,
        tmp_path: Path,
        minimal_scene_config: tuple[SceneConfig, Path],
    ):
        """bake_smooth < 0 を渡しても例外なく正常終了すること（0 にクランプ）．

        仕様: bake_smooth = max(0, bake_smooth)
        """
        config, project_root = minimal_scene_config
        screenshot_path = tmp_path / "smooth_neg.png"
        show_scene(
            config,
            project_root,
            irradiance=None,
            screenshot=screenshot_path,
            bake_smooth=-3,  # 0 にクランプされること
        )
        assert screenshot_path.exists()


# ---------------------------------------------------------------------------
# 8. bake_scale 不正値フォールバック（"log" にフォールバック）
# ---------------------------------------------------------------------------


class TestBakeScaleFallback:
    """show_scene の bake_scale 不正値フォールバック仕様を検証する．

    仕様（viewer.py show_scene）:
        if bake_scale not in ("linear", "log"):
            bake_scale = "log"
        未知の bake_scale は "log" にフォールバックする
    """

    @pytest.mark.slow
    def test_unknown_bake_scale_falls_back_to_log(
        self,
        tmp_path: Path,
        minimal_scene_config: tuple[SceneConfig, Path],
    ):
        """bake_scale に不正値を渡しても例外なく正常終了すること（"log" フォールバック）．

        仕様: bake_scale not in ("linear", "log") → "log" にフォールバック
        """
        config, project_root = minimal_scene_config
        screenshot_path = tmp_path / "scale_fallback.png"
        show_scene(
            config,
            project_root,
            irradiance=None,
            screenshot=screenshot_path,
            bake_scale="gamma",  # 不正値 → "log" フォールバック
        )
        assert screenshot_path.exists()


# ---------------------------------------------------------------------------
# 9. E = π·L/ρ 換算ロジック（viewer._get_eval_board_reflectance）
# ---------------------------------------------------------------------------


class TestEvalBoardIrradianceConversion:
    """評価板の照度換算 E = π * L / ρ の仕様を検証する（純関数テスト）．

    仕様（SPEC_03「筐体（材料）」）:
        Lambertian 拡散面: L = ρ * E / π  →  E = π * L / ρ
        ρ: 評価板材料の拡散反射率（既定 0.85、SPEC_03 printer_white）

    viewer._get_eval_board_reflectance が:
        - 正常時: 評価板 material の reflectance をスカラ値で返す
        - material が DiffuseMaterial でない時: 0.85 フォールバック
        - reflectance が範囲外（<=0 or >1）: 0.85 フォールバック
    """

    def _make_config_with_eval_board_material(
        self, reflectance
    ) -> tuple[SceneConfig, str]:
        """評価板メッシュを含む SceneConfig と eval_board_id を返す．"""
        from sm_tias_lightsim.scene.schema import SceneConfig

        data = {
            "name": "test_e_board",
            "geometry": {"meshes": [
                {"id": "evaluation_board", "path": "board.stl", "unit": "mm", "material": "printer_white"},
                {"id": "housing", "path": "housing.stl", "unit": "mm", "material": "printer_white"},
            ]},
            "materials": {
                "printer_white": {"type": "diffuse", "reflectance": reflectance},
            },
            "lights": [{"id": "led", "type": "point", "position": [0.0, 0.0, 100.0],
                        "intensity": 1.0, "profile": "lambertian"}],
            "sensor": {
                "type": "orthographic",
                "position": [0.0, 0.0, -50.0],
                "target": [0.0, 0.0, 0.0],
                "up": [0.0, 1.0, 0.0],
                "ortho_scale": [50.0, 52.0],
                "film": {"width": 64, "height": 64},
            },
            "render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 2, "spp": 1},
        }
        return SceneConfig.model_validate(data), "evaluation_board"

    def test_get_eval_board_reflectance_scalar(self):
        """scalar reflectance が正しくスカラ値で返ること．

        仕様（viewer._get_eval_board_reflectance）:
            DiffuseMaterial の reflectance がスカラのとき float で返す
        """
        from sm_tias_lightsim.visualize.viewer import _get_eval_board_reflectance

        config, eval_id = self._make_config_with_eval_board_material(0.85)
        rho = _get_eval_board_reflectance(config, eval_id)
        assert rho == pytest.approx(0.85, abs=1e-6)

    def test_get_eval_board_reflectance_baseline_spec(self):
        """SPEC_03 基準値 ρ=0.85 が正しく取得されること．"""
        from sm_tias_lightsim.visualize.viewer import _get_eval_board_reflectance

        config, eval_id = self._make_config_with_eval_board_material(0.85)
        rho = _get_eval_board_reflectance(config, eval_id)
        assert rho == pytest.approx(0.85, abs=1e-6), "SPEC_03 基準設計値 ρ=0.85"

    def test_get_eval_board_reflectance_fallback_for_missing_mesh(self):
        """評価板メッシュが存在しない場合に ρ=0.85 にフォールバックすること．

        仕様: 評価板メッシュが見つからない場合は 0.85 をフォールバック値とする
        """
        from sm_tias_lightsim.visualize.viewer import _get_eval_board_reflectance

        config, _ = self._make_config_with_eval_board_material(0.90)
        # 存在しない id を渡す
        rho = _get_eval_board_reflectance(config, "nonexistent_board")
        assert rho == pytest.approx(0.85, abs=1e-6), "存在しないメッシュでは 0.85 フォールバック"

    def test_e_board_conversion_formula(self):
        """E = π * L / ρ の換算式が正しいこと（数値検証）．

        仕様（SPEC_03）: Lambertian 拡散面 E = π * L / ρ
        L=1.0, ρ=0.85 のとき E = π / 0.85 ≈ 3.6934
        """
        L = np.ones((10, 10), dtype=np.float64)
        rho = 0.85
        E_board = np.pi * L / rho
        expected = np.pi / 0.85

        np.testing.assert_allclose(
            E_board, expected,
            rtol=1e-9,
            err_msg="E = π * L / ρ の換算式が正しいこと"
        )

    def test_e_board_larger_than_luminance_for_rho_less_than_pi(self):
        """ρ < π のとき E > L であること（Lambertian の物理的性質）．

        仕様: ρ=0.85 < π なので E = π * L / ρ > L（照度は輝度より大きい）
        """
        L = np.full((5, 5), 100.0)
        rho = 0.85
        E_board = np.pi * L / rho
        assert np.all(E_board > L), "E = π*L/ρ は ρ < π のとき E > L"

    def test_get_eval_board_reflectance_rgb_tuple_uses_rec709(self):
        """RGB タプルの reflectance が Rec.709 輝度でスカラ化されること．

        仕様（viewer._get_eval_board_reflectance）:
            RGB タプル → Rec.709 輝度でスカラ化: Y = 0.2126R + 0.7152G + 0.0722B
        """
        from sm_tias_lightsim.scene.schema import SceneConfig
        from sm_tias_lightsim.visualize.viewer import _get_eval_board_reflectance

        r, g, b = 0.85, 0.85, 0.85
        expected_luma = 0.2126 * r + 0.7152 * g + 0.0722 * b

        data = {
            "name": "test_rgb_rho",
            "geometry": {"meshes": [
                {"id": "evaluation_board", "path": "board.stl", "unit": "mm", "material": "rgb_white"},
            ]},
            "materials": {
                "rgb_white": {"type": "diffuse", "reflectance": [r, g, b]},
            },
            "lights": [{"id": "led", "type": "point", "position": [0.0, 0.0, 100.0],
                        "intensity": 1.0, "profile": "lambertian"}],
            "sensor": {
                "type": "orthographic",
                "position": [0.0, 0.0, -50.0],
                "target": [0.0, 0.0, 0.0],
                "up": [0.0, 1.0, 0.0],
                "ortho_scale": [50.0, 52.0],
                "film": {"width": 64, "height": 64},
            },
            "render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 2, "spp": 1},
        }
        config = SceneConfig.model_validate(data)
        rho = _get_eval_board_reflectance(config, "evaluation_board")
        assert rho == pytest.approx(expected_luma, abs=1e-6), (
            f"RGB (r,g,b)=({r},{g},{b}) の Rec.709 輝度 {expected_luma:.6f} と一致すること"
        )
