"""scripts/sweep_led_placement.py（照明配置スイープ）の仕様ベーステスト．

仕様出典:
- docs/06_TEST/TEST_17_オフ軸LED配置の影響とサイズ依存.md（オフ軸配置の定量．本スイープの前段）
- docs/04_SPEC/SPEC_02_シーン定義仕様.md（シーン YAML スキーマ・render.seed）
- docs/02_ENV/ENV_04_開発コマンド.md「研究室サーバでの実行」
  （_tmp YAML のメッシュパスは YAML からの相対パスでなければサーバで読めない．
  同じ seed で読み直すと指標がほぼ一致するので，再現性チェックには seed を振る）
- スクリプト冒頭の条件設計（位置 θ グリッド / 向き / 個数 / 再現性の 4 グループ）

検証する仕様:
- conditions() が設計どおりの条件集合（198 条件・名前が一意・重要な順）を返す
- --list で条件名の一覧だけを表示して終了する（レンダしない）
- prepare_condition() が条件ごとのシーン YAML を作り，再レンダ条件（rep>1）は
  幾何を共有したまま render.seed = rep-1 の別 YAML になる
- YAML に書くメッシュパスは YAML からの相対パス（POSIX 区切り・ドライブ無し）

テスト方針（testing.md）:
- sm-render / sm-evaluate は呼ばない（レンダはしない）．幾何生成と YAML だけを見る
- TMP_DIR / MESH_DIR を tmp_path に差し替える
  （tests/scripts/test_sweep_relative_mesh_paths.py と同じ流儀）
- 筐体メッシュ生成は球で十数秒かかるので，生成を伴うテストは立方体条件のみを使い，
  モジュールスコープのフィクスチャで 1 度だけ生成する
"""

from __future__ import annotations

import importlib.util
import json
import sys
from pathlib import Path, PurePosixPath, PureWindowsPath
from types import ModuleType
from unittest import mock

import pytest
import yaml

_ROOT = Path(__file__).resolve().parents[2]
_SCRIPTS = _ROOT / "scripts"

# 再現性グループで使う立方体条件（スクリプトの SIZE_PAIRS[REPEAT_PAIR_INDEX] と対応）
_CUBE_SIZE = 234.0


def _load(name: str) -> ModuleType:
    """scripts/<name>.py を import する（既に読み込み済みならそれを使う）．"""
    path = (_SCRIPTS / f"{name}.py").resolve()
    cached = sys.modules.get(name)
    if cached is not None and Path(getattr(cached, "__file__", "")).resolve() == path:
        return cached
    spec = importlib.util.spec_from_file_location(name, path)
    assert spec is not None and spec.loader is not None
    module = importlib.util.module_from_spec(spec)
    sys.modules[name] = module
    spec.loader.exec_module(module)
    return module


@pytest.fixture(scope="module")
def sweep() -> ModuleType:
    return _load("sweep_led_placement")


@pytest.fixture(autouse=True)
def _same_drive(tmp_path):
    if tmp_path.resolve().drive.upper() != _ROOT.drive.upper():
        pytest.skip("tmp_path とリポジトリが別ドライブのため相対パスで表せない環境")


# ---------------------------------------------------------------------------
# 条件集合
# ---------------------------------------------------------------------------


def test_conditions_total_count(sweep):
    """全条件数が 198 であること．

    仕様: 位置 2 形状 × 4 サイズ × 13θ = 104，個数 2 × 4 × (3 + 2) = 40，
         向き 2 × 4 × 3θ × 3 向き = 72 のうち θ=60/90/120 の板向き 1 灯 24 条件は
         位置グループと重複するので 48，再現性 2 形状 × 3 回 = 6．合計 198．
    """
    assert len(sweep.conditions()) == 198


def test_condition_names_are_unique(sweep):
    """条件名が一意であること（出力ディレクトリ名として使われるため）．"""
    names = [c.name for c in sweep.conditions()]
    assert len(names) == len(set(names))


def test_conditions_cover_all_groups(sweep):
    """4 つのグループがすべて含まれること．"""
    groups = {c.group for c in sweep.conditions()}
    assert groups == set(sweep.GROUPS)


def test_group_counts_match_design(sweep):
    """グループごとの条件数が設計どおりであること．"""
    from collections import Counter

    counts = Counter(c.group for c in sweep.conditions())
    assert counts["position"] == 104
    assert counts["count"] == 40
    assert counts["direction"] == 48
    assert counts["repeat"] == 6


def test_shapes_are_balanced(sweep):
    """球と立方体が同数であること（同内面積ペアでの比較が目的）．"""
    conds = sweep.conditions()
    assert sum(c.shape == "sphere" for c in conds) == sum(c.shape == "cube" for c in conds)


def test_position_group_comes_first(sweep):
    """主実験（位置）が先頭に並ぶこと（途中で止めても主実験が揃うようにする）．"""
    groups = [c.group for c in sweep.conditions()]
    assert groups[0] == "position"
    assert set(groups[:104]) == {"position"}


def test_repeat_conditions_share_base_name_and_differ_by_rep(sweep):
    """再現性条件は同じ base_name で rep だけが違うこと（幾何は同一）．"""
    repeats = [c for c in sweep.conditions() if c.group == "repeat"]
    assert repeats, "再現性グループの条件が存在すること"
    for cond in repeats:
        assert cond.rep > 1
        assert cond.name == f"{cond.base_name}_rep{cond.rep}"


def test_position_conditions_use_rep_one(sweep):
    """再現性以外の条件は rep=1（名前に rep が付かない）であること．"""
    for cond in sweep.conditions():
        if cond.group != "repeat":
            assert cond.rep == 1
            assert cond.name == cond.base_name


def test_azimuths_are_evenly_spaced(sweep):
    """多灯条件の方位角が等間隔で，1 灯目が +X 側であること．"""
    cond = sweep.Condition("count", "cube", _CUBE_SIZE, 90.0, "board", 4)
    assert cond.azimuths_deg == [0.0, 90.0, 180.0, 270.0]


# ---------------------------------------------------------------------------
# --list（レンダせず一覧のみ）
# ---------------------------------------------------------------------------


def test_list_prints_all_condition_names(sweep, monkeypatch, capsys):
    """--list が全条件名と件数を表示して 0 を返すこと（レンダしない）．"""
    monkeypatch.setattr(sys, "argv", ["sweep_led_placement.py", "--list"])
    assert sweep.main() == 0
    out = capsys.readouterr().out
    assert "198 条件" in out
    assert out.count("\n") == 199  # 条件名 198 行 + 件数 1 行


def test_list_respects_shape_and_group_filters(sweep, monkeypatch, capsys):
    """--shapes / --groups で条件を絞れること．"""
    monkeypatch.setattr(
        sys,
        "argv",
        ["sweep_led_placement.py", "--list", "--shapes", "cube", "--groups", "position"],
    )
    assert sweep.main() == 0
    lines = [ln for ln in capsys.readouterr().out.splitlines() if ln]
    names = lines[:-1]
    assert lines[-1] == "52 条件"  # 4 サイズ × 13θ
    assert all(name.startswith("cube_") for name in names)


# ---------------------------------------------------------------------------
# prepare_condition（幾何生成 + シーン YAML）
# ---------------------------------------------------------------------------


@pytest.fixture(scope="module")
def prepared(sweep, tmp_path_factory) -> dict[str, tuple[Path, dict]]:
    """立方体条件の rep1 / rep2 / rep3 を 1 度だけ生成する．

    筐体メッシュは同じファイルを共有するので，2 件目以降の生成は速い．
    """
    base = tmp_path_factory.mktemp("led_placement")
    if base.resolve().drive.upper() != _ROOT.drive.upper():
        pytest.skip("tmp_path とリポジトリが別ドライブのため相対パスで表せない環境")
    conds = [
        sweep.Condition("repeat", "cube", _CUBE_SIZE, 90.0, "board", 1, rep)
        for rep in (1, 2, 3)
    ]
    with (
        mock.patch.object(sweep, "TMP_DIR", base),
        mock.patch.object(sweep, "MESH_DIR", base / "meshes"),
    ):
        return {cond.name: sweep.prepare_condition(cond) for cond in conds}


def _load_yaml(path: Path) -> dict:
    return yaml.safe_load(path.read_text(encoding="utf-8"))


def test_prepare_writes_scene_yaml_named_after_condition(sweep, prepared):
    """条件ごとに scene_<name>.yaml が作られること．"""
    for name, (scene_yaml, _) in prepared.items():
        assert scene_yaml.name == f"scene_{name}.yaml"
        assert scene_yaml.is_file()


def test_repeat_conditions_get_separate_yaml_files(sweep, prepared):
    """再レンダ条件（rep>1）は元の条件とは別の YAML になること．"""
    paths = {str(scene_yaml) for scene_yaml, _ in prepared.values()}
    assert len(paths) == len(prepared)


def test_seed_equals_rep_minus_one(sweep, prepared):
    """render.seed が rep-1 になること．

    仕様: seed が同じだとシーンを読み直しても指標が 4 桁まで一致してノイズフロアにならない
         （ENV_04「研究室サーバでの実行」の実測）．再レンダは seed を振って別実現にする．
    """
    for name, (scene_yaml, _) in prepared.items():
        rep = int(name.split("_rep")[1]) if "_rep" in name else 1
        assert _load_yaml(scene_yaml)["render"]["seed"] == rep - 1, name


def test_first_rep_uses_default_seed_zero(sweep, prepared):
    """rep=1（通常条件）の seed が 0 であること（既定値と同じ）．"""
    scene_yaml, _ = prepared["cube_L234_th090_board_n1"]
    assert _load_yaml(scene_yaml)["render"]["seed"] == 0


def test_repeat_yaml_differs_only_in_seed_and_name(sweep, prepared):
    """再レンダ条件の YAML は seed・name・description 以外が元の条件と同一であること．

    仕様: 幾何（筐体・LED・バッフル）は共有し，変えるのは乱数シードだけ．
    """
    base = _load_yaml(prepared["cube_L234_th090_board_n1"][0])
    rep2 = _load_yaml(prepared["cube_L234_th090_board_n1_rep2"][0])
    for key in ("name", "description"):
        base.pop(key, None)
        rep2.pop(key, None)
    assert base["render"].pop("seed") == 0
    assert rep2["render"].pop("seed") == 1
    assert base == rep2


def test_scene_yaml_is_valid_scene_config(sweep, prepared):
    """生成した YAML が SceneConfig として検証を通ること（SPEC_02 スキーマ）．"""
    from sm_tias_lightsim.scene.schema import SceneConfig

    scene_yaml, _ = prepared["cube_L234_th090_board_n1"]
    config = SceneConfig.model_validate(_load_yaml(scene_yaml))
    assert config.render.seed == 0


def test_mesh_paths_are_relative_to_yaml(sweep, prepared):
    """メッシュパスが YAML からの相対パス（POSIX 区切り・ドライブ無し）であること．

    仕様: Windows 絶対パスの YAML は研究室サーバで読めない（ENV_04 注意）．
    """
    from sm_tias_lightsim.remote.files import PushEntry, find_yaml_with_windows_paths

    scene_yaml, _ = prepared["cube_L234_th090_board_n1"]
    meshes = _load_yaml(scene_yaml)["geometry"]["meshes"]
    assert meshes, "メッシュが 1 つ以上書かれていること"
    for mesh in meshes:
        path = mesh["path"]
        assert "\\" not in path, f"{mesh['id']}: POSIX 区切りでない: {path}"
        assert not PurePosixPath(path).is_absolute(), f"{mesh['id']}: 絶対パス: {path}"
        assert not PureWindowsPath(path).drive, f"{mesh['id']}: ドライブ付き: {path}"
        assert (scene_yaml.parent / path).resolve().is_file(), f"{mesh['id']}: 実体が無い: {path}"

    entry = PushEntry(
        path=scene_yaml, arcname=f"configs/_tmp_led_placement/{scene_yaml.name}", size=0
    )
    assert find_yaml_with_windows_paths([entry]) == []


def test_housing_mesh_is_shared_between_repeats(sweep, prepared):
    """再レンダ条件が同じ筐体メッシュを参照すること（幾何を作り直さない）．"""
    base_yaml, _ = prepared["cube_L234_th090_board_n1"]
    rep_yaml, _ = prepared["cube_L234_th090_board_n1_rep2"]

    def housing(path: Path) -> Path:
        meshes = _load_yaml(path)["geometry"]["meshes"]
        entry = next(m for m in meshes if m["id"] == "cube_housing")
        return (path.parent / entry["path"]).resolve()

    assert housing(base_yaml) == housing(rep_yaml)


def test_baffled_condition_records_baffle(sweep, prepared):
    """板の前方に発光面がある条件はバッフルを生成し，記録に残すこと．"""
    _, record = prepared["cube_L234_th090_board_n1"]
    assert record["baffled"] == 1
    assert record["baffle_radius_mm"] > 0.0


# ---------------------------------------------------------------------------
# variant の解決
# ---------------------------------------------------------------------------


def test_render_variant_prefers_env(sweep, monkeypatch):
    """SM_RENDER_VARIANT がテンプレート YAML の variant より優先されること．"""
    monkeypatch.setattr(sweep, "_render_variant", None)
    monkeypatch.setenv("SM_RENDER_VARIANT", "cuda_ad_rgb")
    assert sweep.render_variant() == "cuda_ad_rgb"


def test_render_variant_falls_back_to_template(sweep, monkeypatch):
    """環境変数が無ければテンプレート YAML の render.variant を使うこと．"""
    monkeypatch.setattr(sweep, "_render_variant", None)
    monkeypatch.delenv("SM_RENDER_VARIANT", raising=False)
    template = yaml.safe_load(sweep.TEMPLATE_YAML.read_text(encoding="utf-8"))
    assert sweep.render_variant() == template["render"]["variant"]


def test_render_variant_is_cached(sweep, monkeypatch):
    """variant は 1 度だけ解決して使い回すこと（条件ごとの YAML 読み直しを避ける）．"""
    monkeypatch.setattr(sweep, "_render_variant", None)
    monkeypatch.setenv("SM_RENDER_VARIANT", "cuda_ad_rgb")
    first = sweep.render_variant()
    monkeypatch.setenv("SM_RENDER_VARIANT", "scalar_rgb")
    assert sweep.render_variant() == first


# ---------------------------------------------------------------------------
# run_job（レンダ + 評価をプロセス内で実行する）
# ---------------------------------------------------------------------------


def _fake_metrics_csv(path: Path) -> None:
    """read_metrics_csv が読む列を持つ metrics.csv を書く．"""
    columns = [
        "cov", "center_periphery_ratio", "gradient_magnitude", "radial_min_max_ratio",
        "angular_ratio", "pca_eccentricity", "intensity_centroid_offset_norm",
        "max_min_ratio", "intensity_centroid_x_norm", "intensity_centroid_y_norm",
    ]
    header = ",".join(columns)
    values = ",".join("0.5" for _ in columns)
    path.write_text(f"{header}\n{values}\n", encoding="utf-8")


def test_run_job_reuses_existing_row_json(sweep, tmp_path, monkeypatch):
    """既に row.json がある条件は再レンダせず，その内容をそのまま返すこと．

    仕様: スイープは途中で止めて再開できる（既存結果の再利用）．
    """
    def _must_not_render(*args, **kwargs):
        raise AssertionError("既存結果があるときはレンダしないこと")

    monkeypatch.setattr(sweep, "OUTPUT_BASE", tmp_path / "output")
    monkeypatch.setattr(sweep, "render_yaml", _must_not_render)
    monkeypatch.setattr(sweep, "ensure_render_variant", _must_not_render)

    cond = sweep.Condition("position", "cube", _CUBE_SIZE, 90.0, "board", 1)
    out_dir = tmp_path / "output" / cond.name
    out_dir.mkdir(parents=True)
    (out_dir / "row.json").write_text(
        json.dumps({"name": cond.name, "cov": 0.01}), encoding="utf-8"
    )

    row, _ = sweep.run_job(cond, tmp_path / "scene.yaml", {"baffled": 0})
    assert row == {"name": cond.name, "cov": 0.01}


def test_run_job_renders_and_evaluates_without_figures(sweep, tmp_path, monkeypatch):
    """レンダ → 評価をプロセス内で呼び，評価は figures 無しで行うこと．

    仕様: スイープでは図が不要なので save_figures=False で描画時間を省く．
    """
    import numpy as np

    calls: dict = {}

    def fake_render_yaml(scene_yaml, out_dir, variant):
        calls["render"] = {"scene": scene_yaml, "out_dir": out_dir, "variant": variant}
        np.save(Path(out_dir) / "luminance.npy", np.full((512, 512), 100.0))
        return Path(out_dir) / "render.png", Path(out_dir) / "luminance.npy"

    def fake_evaluate_image(image, config, out_dir, save_figures=True):
        calls["evaluate"] = {"image": image, "config": config, "save_figures": save_figures}
        _fake_metrics_csv(Path(out_dir) / "metrics.csv")
        return {}

    monkeypatch.setattr(sweep, "OUTPUT_BASE", tmp_path / "output")
    monkeypatch.setattr(sweep, "render_yaml", fake_render_yaml)
    monkeypatch.setattr(sweep, "evaluate_image", fake_evaluate_image)
    monkeypatch.setattr(sweep, "ensure_render_variant", lambda: "scalar_rgb")

    cond = sweep.Condition("position", "cube", _CUBE_SIZE, 90.0, "board", 1)
    row, _ = sweep.run_job(cond, tmp_path / "scene.yaml", {"baffled": 0})

    assert calls["evaluate"]["save_figures"] is False
    assert calls["render"]["variant"] == "scalar_rgb"
    assert row["render_variant"] == "scalar_rgb"
    assert row["name"] == cond.name
    assert (tmp_path / "output" / cond.name / "row.json").is_file()


def test_run_job_returns_none_on_failure(sweep, tmp_path, monkeypatch):
    """1 条件が失敗しても例外を投げず None を返すこと（スイープ全体を止めない）．"""
    def boom(*args, **kwargs):
        raise RuntimeError("テスト用レンダ失敗")

    monkeypatch.setattr(sweep, "OUTPUT_BASE", tmp_path / "output")
    monkeypatch.setattr(sweep, "render_yaml", boom)
    monkeypatch.setattr(sweep, "ensure_render_variant", lambda: "scalar_rgb")

    cond = sweep.Condition("position", "cube", _CUBE_SIZE, 90.0, "board", 1)
    row, messages = sweep.run_job(cond, tmp_path / "scene.yaml", {"baffled": 0})
    assert row is None
    assert any("失敗" in m for m in messages)
    assert not (tmp_path / "output" / cond.name / "row.json").exists()
