"""evaluation/pipeline.py（evaluate_image）の仕様ベーステスト．

仕様出典:
- docs/04_SPEC/SPEC_04_評価仕様.md
  「出力ファイル仕様」: <dir>/metrics.csv と <dir>/figures/{luminance_map, histogram,
  radial_profile, zone_map}.png
  「metrics.csv カラム定義」: cov, std, max_min_ratio, mean, max, min,
  center_*, middle_*, periphery_*, center_periphery_ratio, gradient_magnitude
- docs/04_SPEC/SPEC_02_シーン定義仕様.md「evaluation」セクション（ROI / zones / metrics）
- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md（evaluate パイプラインの責務分離）

検証する仕様:
- evaluate_image() が CLI と同じ評価を行い，指標の辞書を返す
- save_figures=False のとき figures/ を作らず描画もしない（指標は変わらない）
- 段階ごとの失敗は EvaluationStepError として，どの段階かが分かる文言で送出される
  （CLI はこれを「エラー: …」+ 終了コード 1 に変換するだけ）

テスト方針（testing.md）:
- ファイル I/O はモックせず tmp_path で実ファイルを使う
- 合成輝度マップは seed を固定する
"""

from __future__ import annotations

import matplotlib  # noqa: I001

# headless 環境でも動くよう Agg バックエンドを pyplot より前に設定する
matplotlib.use("Agg")

from pathlib import Path

import numpy
import pytest

from sm_tias_lightsim.evaluation.pipeline import EvaluationStepError, evaluate_image

# SPEC_04「metrics.csv カラム定義（ヘッダ順）」の必須列
_BASE_COLUMNS = ("cov", "std", "max_min_ratio", "mean", "max", "min")
_ZONE_COLUMNS = (
    "center_mean",
    "center_std",
    "middle_mean",
    "middle_std",
    "periphery_mean",
    "periphery_std",
    "center_periphery_ratio",
    "gradient_magnitude",
)
# SPEC_04「可視化規約」の 4 種
_FIGURE_FILES = ("luminance_map.png", "histogram.png", "radial_profile.png", "zone_map.png")


def _make_luminance_array(seed: int = 0, size: int = 128) -> numpy.ndarray:
    """ゼロを含まない合成輝度配列を返す（seed 固定）．"""
    rng = numpy.random.RandomState(seed)
    return numpy.full((size, size), 100.0) + rng.normal(0, 5, (size, size))


def _write_npy(tmp_path: Path, seed: int = 0, size: int = 128) -> Path:
    path = tmp_path / "luminance.npy"
    numpy.save(path, _make_luminance_array(seed=seed, size=size))
    return path


def _write_eval_yaml(
    tmp_path: Path,
    x: int = 16,
    y: int = 16,
    width: int = 96,
    height: int = 96,
    zones_enabled: bool = True,
    radial_profile: bool = True,
) -> Path:
    """評価専用 YAML（トップレベルが EvaluationConfig 互換）を書き出す．"""
    content = f"""\
roi:
  type: image_pixel
  x: {x}
  y: {y}
  width: {width}
  height: {height}
zones:
  enabled: {"true" if zones_enabled else "false"}
  boundaries: [0.33, 0.66]
metrics:
  cov: true
  std: true
  max_min: true
  radial_profile: {"true" if radial_profile else "false"}
"""
    path = tmp_path / "eval_config.yaml"
    path.write_text(content, encoding="utf-8")
    return path


# ---------------------------------------------------------------------------
# 正常系: 既定（save_figures=True）
# ---------------------------------------------------------------------------


def test_returns_metrics_dict(tmp_path: Path):
    """evaluate_image が指標の辞書を返すこと．

    仕様: SPEC_04「metrics.csv カラム定義」の基本 6 指標を含む．
    """
    metrics = evaluate_image(
        _write_npy(tmp_path, seed=0), _write_eval_yaml(tmp_path), tmp_path / "out"
    )
    assert isinstance(metrics, dict)
    for key in _BASE_COLUMNS:
        assert key in metrics, key
        assert isinstance(metrics[key], float)


def test_returned_metrics_are_physically_consistent(tmp_path: Path):
    """返された指標が互いに整合すること（min <= mean <= max・CoV = std/mean）．"""
    metrics = evaluate_image(
        _write_npy(tmp_path, seed=1), _write_eval_yaml(tmp_path), tmp_path / "out"
    )
    assert metrics["min"] <= metrics["mean"] <= metrics["max"]
    assert metrics["cov"] == pytest.approx(metrics["std"] / metrics["mean"], rel=1e-9)
    assert metrics["max_min_ratio"] == pytest.approx(metrics["max"] / metrics["min"], rel=1e-9)


def test_zone_metrics_included_when_zones_enabled(tmp_path: Path):
    """zones.enabled のときゾーン指標が辞書に含まれること（SPEC_04 3 ゾーン分析）．"""
    metrics = evaluate_image(
        _write_npy(tmp_path, seed=2), _write_eval_yaml(tmp_path), tmp_path / "out"
    )
    for key in _ZONE_COLUMNS:
        assert key in metrics, key


def test_creates_metrics_csv(tmp_path: Path):
    """metrics.csv が出力ディレクトリ直下に作られること（SPEC_04 出力ファイル仕様）．"""
    out_dir = tmp_path / "out"
    evaluate_image(_write_npy(tmp_path, seed=3), _write_eval_yaml(tmp_path), out_dir)
    assert (out_dir / "metrics.csv").is_file()


def test_metrics_csv_matches_returned_dict(tmp_path: Path):
    """metrics.csv の内容が戻り値の辞書と一致すること．"""
    import csv

    out_dir = tmp_path / "out"
    metrics = evaluate_image(_write_npy(tmp_path, seed=4), _write_eval_yaml(tmp_path), out_dir)
    with (out_dir / "metrics.csv").open(encoding="utf-8") as fh:
        row = next(csv.DictReader(fh))
    assert set(row) == set(metrics)
    for key, value in row.items():
        assert float(value) == pytest.approx(metrics[key], rel=1e-6)


def test_creates_four_figures_by_default(tmp_path: Path):
    """既定（save_figures 省略）で figures/ に 4 種の PNG が出ること（SPEC_04 可視化規約）．"""
    out_dir = tmp_path / "out"
    evaluate_image(_write_npy(tmp_path, seed=5), _write_eval_yaml(tmp_path), out_dir)
    for name in _FIGURE_FILES:
        assert (out_dir / "figures" / name).is_file(), name


def test_creates_deep_nested_output_dir(tmp_path: Path):
    """出力ディレクトリが親ごと作られること．"""
    out_dir = tmp_path / "a" / "b" / "c"
    evaluate_image(_write_npy(tmp_path, seed=6), _write_eval_yaml(tmp_path), out_dir)
    assert (out_dir / "metrics.csv").is_file()


# ---------------------------------------------------------------------------
# save_figures=False（スイープ用の図なしモード）
# ---------------------------------------------------------------------------


def test_no_figures_dir_when_save_figures_false(tmp_path: Path):
    """save_figures=False のとき figures/ が作られないこと．"""
    out_dir = tmp_path / "out"
    evaluate_image(
        _write_npy(tmp_path, seed=7), _write_eval_yaml(tmp_path), out_dir, save_figures=False
    )
    assert not (out_dir / "figures").exists()


def test_metrics_csv_still_created_when_save_figures_false(tmp_path: Path):
    """save_figures=False でも metrics.csv は出ること（指標だけ欲しい用途）．"""
    out_dir = tmp_path / "out"
    evaluate_image(
        _write_npy(tmp_path, seed=8), _write_eval_yaml(tmp_path), out_dir, save_figures=False
    )
    assert (out_dir / "metrics.csv").is_file()


def test_metrics_identical_with_and_without_figures(tmp_path: Path):
    """図の有無で指標が変わらないこと（描画は評価結果に影響しない）．"""
    npy = _write_npy(tmp_path, seed=9)
    config = _write_eval_yaml(tmp_path)
    with_figs = evaluate_image(npy, config, tmp_path / "with", save_figures=True)
    without_figs = evaluate_image(npy, config, tmp_path / "without", save_figures=False)
    assert set(with_figs) == set(without_figs)
    for key in with_figs:
        assert with_figs[key] == pytest.approx(without_figs[key], rel=1e-12)


def test_summary_omits_figures_line_when_save_figures_false(tmp_path: Path, capsys):
    """save_figures=False のときサマリーに figures/ 行を出さないこと．"""
    evaluate_image(
        _write_npy(tmp_path, seed=10),
        _write_eval_yaml(tmp_path),
        tmp_path / "out",
        save_figures=False,
    )
    out = capsys.readouterr().out
    assert "metrics.csv" in out
    assert "figures/" not in out


def test_summary_includes_figures_line_by_default(tmp_path: Path, capsys):
    """既定ではサマリーに figures/ 行が出ること（従来どおり）．"""
    evaluate_image(_write_npy(tmp_path, seed=11), _write_eval_yaml(tmp_path), tmp_path / "out")
    assert "figures/" in capsys.readouterr().out


def test_no_figure_files_written_anywhere_when_disabled(tmp_path: Path):
    """save_figures=False のとき出力ディレクトリ配下に PNG が 1 つも無いこと．"""
    out_dir = tmp_path / "out"
    evaluate_image(
        _write_npy(tmp_path, seed=12), _write_eval_yaml(tmp_path), out_dir, save_figures=False
    )
    assert list(out_dir.rglob("*.png")) == []


# ---------------------------------------------------------------------------
# 異常系: 段階別の EvaluationStepError
# ---------------------------------------------------------------------------


def test_missing_image_raises_step_error(tmp_path: Path):
    """画像が無いとき EvaluationStepError になること（見つからない旨を含む）．"""
    with pytest.raises(EvaluationStepError) as exc:
        evaluate_image(tmp_path / "nope.npy", _write_eval_yaml(tmp_path), tmp_path / "out")
    assert "画像ファイルが見つかりません" in str(exc.value)


def test_broken_image_raises_load_step_error(tmp_path: Path):
    """壊れた .npy は「画像の読み込みに失敗しました」段階の例外になること．"""
    bad = tmp_path / "broken.npy"
    bad.write_bytes(b"not a npy file")
    with pytest.raises(EvaluationStepError) as exc:
        evaluate_image(bad, _write_eval_yaml(tmp_path), tmp_path / "out")
    assert "画像の読み込みに失敗しました" in str(exc.value)


def test_missing_config_raises_step_error(tmp_path: Path):
    """設定 YAML が無いとき EvaluationStepError になること．"""
    with pytest.raises(EvaluationStepError) as exc:
        evaluate_image(_write_npy(tmp_path, seed=13), tmp_path / "nope.yaml", tmp_path / "out")
    assert "設定 YAML ファイルが見つかりません" in str(exc.value)


def test_invalid_config_raises_config_step_error(tmp_path: Path):
    """ROI 欠落の YAML は「設定 YAML の読み込みに失敗しました」段階の例外になること．"""
    config = tmp_path / "bad.yaml"
    config.write_text("zones:\n  enabled: true\n", encoding="utf-8")
    with pytest.raises(EvaluationStepError) as exc:
        evaluate_image(_write_npy(tmp_path, seed=14), config, tmp_path / "out")
    assert "設定 YAML の読み込みに失敗しました" in str(exc.value)


def test_roi_out_of_bounds_raises_roi_step_error(tmp_path: Path):
    """ROI が画像外のとき「ROI 抽出に失敗しました」段階の例外になること．"""
    config = _write_eval_yaml(tmp_path, x=0, y=0, width=9999, height=9999)
    with pytest.raises(EvaluationStepError) as exc:
        evaluate_image(_write_npy(tmp_path, seed=15), config, tmp_path / "out")
    assert "ROI 抽出に失敗しました" in str(exc.value)


def test_zero_luminance_raises_metrics_step_error(tmp_path: Path):
    """全ゼロ画像は「均一性指標の算出に失敗しました」段階の例外になること．"""
    npy = tmp_path / "zeros.npy"
    numpy.save(npy, numpy.zeros((128, 128), dtype=numpy.float64))
    with pytest.raises(EvaluationStepError) as exc:
        evaluate_image(npy, _write_eval_yaml(tmp_path), tmp_path / "out")
    assert "均一性指標の算出に失敗しました" in str(exc.value)


def test_step_error_is_runtime_error_subclass():
    """EvaluationStepError は RuntimeError 派生であること（呼び出し側の捕捉粒度）．"""
    assert issubclass(EvaluationStepError, RuntimeError)


def test_no_output_written_when_roi_fails(tmp_path: Path):
    """ROI 抽出で失敗したとき metrics.csv は作られないこと（中途半端な出力を残さない）．"""
    out_dir = tmp_path / "out"
    config = _write_eval_yaml(tmp_path, x=0, y=0, width=9999, height=9999)
    with pytest.raises(EvaluationStepError):
        evaluate_image(_write_npy(tmp_path, seed=16), config, out_dir)
    assert not (out_dir / "metrics.csv").exists()
