"""sm-evaluate コマンドラインエントリポイント．

PNG または luminance.npy を入力として均一性評価を実行し，
metrics.csv と可視化 PNG を出力ディレクトリに保存する．

matplotlib のバックエンドは Agg を強制する（headless 環境対応）．
evaluate は Mitsuba を使わないため，render.py と異なり遅延 import は不要．
ただし Agg バックエンドの設定順序を保つため，matplotlib.use() は pyplot import より前に置く．
"""

from __future__ import annotations

import argparse
import sys
from pathlib import Path

import matplotlib

# headless 環境（サーバ・CI 等）でも動くよう Agg バックエンドを強制する．
# visualize.py より先に設定することで，pyplot import 時に Agg が有効になる．
matplotlib.use("Agg")

import numpy  # noqa: E402
import pandas  # noqa: E402
from PIL import Image  # noqa: E402

from sm_tias_lightsim.evaluation.oetf import apply_srgb_oetf, solve_exposure  # noqa: E402
from sm_tias_lightsim.evaluation.radial import calc_radial_profile  # noqa: E402
from sm_tias_lightsim.evaluation.roi import extract_roi  # noqa: E402
from sm_tias_lightsim.evaluation.visualize import (  # noqa: E402
    save_histogram,
    save_luminance_map,
    save_radial_profile,
    save_zone_map,
)
from sm_tias_lightsim.evaluation.zones import (  # noqa: E402
    calc_zone_stats,
    classify_zones,
    compute_normalized_distance_map,
)
from sm_tias_lightsim.scene.schema import EvaluationConfig, OetfConfig  # noqa: E402
from sm_tias_lightsim.uniformity import calc_uniformity, to_grayscale  # noqa: E402

# 出力ファイル名を一元管理する（変更時の影響範囲を最小化するため）
_METRICS_FILENAME = "metrics.csv"
_FIGURES_DIRNAME = "figures"
_LUMINANCE_MAP_FILENAME = "luminance_map.png"
_HISTOGRAM_FILENAME = "histogram.png"
_RADIAL_PROFILE_FILENAME = "radial_profile.png"
_ZONE_MAP_FILENAME = "zone_map.png"


def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    """コマンドライン引数をパースして返す．

    Args:
        argv: 引数リスト．None のとき sys.argv[1:] を使用．

    Returns:
        パース済み Namespace．
    """
    parser = argparse.ArgumentParser(
        prog="sm-evaluate",
        description=(
            "PNG または luminance.npy を読み込み，均一性指標 CSV と可視化 PNG を出力する．"
        ),
    )
    parser.add_argument(
        "--image",
        "-i",
        required=True,
        type=Path,
        metavar="IMAGE_PATH",
        help="評価対象の画像ファイル（PNG または .npy）．",
    )
    parser.add_argument(
        "--config",
        "-c",
        required=True,
        type=Path,
        metavar="YAML_PATH",
        help="評価設定 YAML ファイルのパス（SceneConfig または EvaluationConfig 互換）．",
    )
    parser.add_argument(
        "--output",
        "-o",
        required=True,
        type=Path,
        metavar="OUTPUT_DIR",
        help="出力先ディレクトリ（存在しない場合は親ごと作成する）．",
    )
    return parser.parse_args(argv)


def _apply_oetf_to_luminance(
    roi_luminance: numpy.ndarray,
    oetf_cfg: OetfConfig,
) -> numpy.ndarray:
    """OETF 順方向適用：ROI linear luminance を sRGB 符号化空間へ変換する．

    無彩色シーン（シミュ LED・材料が RGB 等価）では luminance への直接 OETF 適用は
    「チャネル別 sRGB OETF → Rec.709 luma 加重和」と厳密一致する（SPEC_03 参照）．

    露出ソルバは ROI 上で解く（target_mean は実測 ROI mean のため ROI 上で収束させる）．

    Args:
        roi_luminance: ROI 切り出し済みの linear 輝度配列（H' x W', float64）．
        oetf_cfg: OetfConfig（enabled=True が前提）．

    Returns:
        OETF 適用後の ROI 輝度配列（H' x W', float64, quantize_uint8=True なら [0,255]）．

    Raises:
        ValueError: oetf_cfg.exposure が None の場合（anchor_mean モードで必須）．
    """
    if oetf_cfg.exposure is None:
        msg = "oetf.enabled=True かつ anchor_mean モードでは exposure 設定が必須です．"
        raise ValueError(msg)

    roi_lin = roi_luminance.astype(numpy.float64)
    target_mean = oetf_cfg.exposure.target_mean
    # ROI linear 値で露出スケールを決定（実測 mean が ROI mean のため ROI 上で解く）
    scale, clipped = solve_exposure(roi_lin, target_mean)
    clip_pct = clipped * 100.0
    print(f"  OETF exposure  : scale={scale:.6g}  clip={clip_pct:.1f}%")

    # ROI に対してのみ OETF を適用する（指標算出は ROI 上で行うため）
    return apply_srgb_oetf(roi_lin, scale, quantize_uint8=oetf_cfg.quantize_uint8)


def _load_luminance(image_path: Path) -> numpy.ndarray:
    """画像ファイルを読み込み，輝度配列（2D float64）を返す．

    拡張子が .npy の場合は numpy.load で読み込む．
    それ以外（PNG 等）は PIL で RGB 画像として読み込み，to_grayscale で変換する．
    .npy が 3D（H, W, 3）だった場合も to_grayscale を通す．

    Args:
        image_path: 読み込む画像ファイルのパス．

    Returns:
        輝度配列（H x W, float64）．

    Raises:
        FileNotFoundError: ファイルが存在しない場合．
        ValueError: ファイルを輝度配列に変換できない場合．
    """
    if not image_path.exists():
        msg = f"画像ファイルが見つかりません: {image_path}"
        raise FileNotFoundError(msg)

    if image_path.suffix.lower() == ".npy":
        data = numpy.load(image_path)
        if data.ndim == 3:
            return to_grayscale(data.astype(numpy.float64))
        if data.ndim == 2:
            return data.astype(numpy.float64)
        msg = f".npy の次元数が不正です（ndim={data.ndim}）．2D または 3D を期待します．"
        raise ValueError(msg)

    pil_image = Image.open(image_path).convert("RGB")
    return to_grayscale(numpy.array(pil_image, dtype=numpy.float64))


def _load_eval_config(config_path: Path) -> EvaluationConfig:
    """YAML を読み込んで EvaluationConfig を返す．

    SceneConfig の evaluation サブツリー，または EvaluationConfig 互換の
    トップレベル YAML の両方をサポートする．

    Args:
        config_path: YAML ファイルのパス．

    Returns:
        EvaluationConfig インスタンス．

    Raises:
        FileNotFoundError: ファイルが存在しない場合．
        pydantic.ValidationError: スキーマ違反．
        yaml.YAMLError: YAML 構文エラー．
    """
    import yaml

    if not config_path.exists():
        msg = f"設定 YAML ファイルが見つかりません: {config_path}"
        raise FileNotFoundError(msg)

    with config_path.open(encoding="utf-8") as f:
        data = yaml.safe_load(f)

    # "evaluation" キーがあれば SceneConfig 埋め込みとみなしてサブツリーを取得する
    if isinstance(data, dict) and "evaluation" in data:
        eval_data = data["evaluation"]
    else:
        eval_data = data

    return EvaluationConfig.model_validate(eval_data)


def _run_zone_analysis(
    roi_luminance: numpy.ndarray,
    zones_config: object,
) -> tuple[numpy.ndarray, dict] | tuple[None, dict]:
    """ゾーン分析を実行し，zone_map とゾーン統計を返す．

    Args:
        roi_luminance: ROI 輝度配列（H x W, float64）．
        zones_config: ZonesConfig インスタンス（enabled / boundaries を持つ）．

    Returns:
        (zone_map, zone_stats) のタプル．
        zones が無効なら (None, {}) を返す．

    Raises:
        ValueError: ゾーン分析の計算に失敗した場合．
    """
    if zones_config is None or not zones_config.enabled:
        return None, {}

    h, w = roi_luminance.shape
    distance_map = compute_normalized_distance_map(h, w)
    zone_map = classify_zones(distance_map, zones_config.boundaries)
    zone_stats = calc_zone_stats(roi_luminance, zone_map)
    return zone_map, zone_stats


def _run_radial_profile(
    roi_luminance: numpy.ndarray,
    metrics_config: object,
) -> numpy.ndarray | None:
    """放射状プロファイルを算出して返す．

    Args:
        roi_luminance: ROI 輝度配列（H x W, float64）．
        metrics_config: MetricsConfig インスタンス（radial_profile フラグを持つ）．

    Returns:
        放射状プロファイル配列（(n_bins, 2)），無効時は None．
    """
    if metrics_config is None or not metrics_config.radial_profile:
        return None
    return calc_radial_profile(roi_luminance)


def _save_metrics_csv(metrics: dict, csv_path: Path) -> None:
    """均一性指標を CSV ファイルとして保存する．

    Args:
        metrics: 指標名 → 値の辞書．
        csv_path: 保存先 CSV パス．
    """
    df = pandas.DataFrame([metrics])
    df.to_csv(csv_path, index=False)


def _save_figures(
    roi_luminance: numpy.ndarray,
    figures_dir: Path,
    radial_profile: numpy.ndarray | None,
    zone_map: numpy.ndarray | None,
) -> None:
    """可視化 PNG を figures/ ディレクトリに保存する．

    Args:
        roi_luminance: ROI 輝度配列（H x W, float64）．
        figures_dir: 保存先ディレクトリ（事前に mkdir 済みであること）．
        radial_profile: 放射状プロファイル配列，None の場合は保存しない．
        zone_map: ゾーンマップ配列，None の場合は保存しない．
    """
    save_luminance_map(roi_luminance, figures_dir / _LUMINANCE_MAP_FILENAME)
    save_histogram(roi_luminance, figures_dir / _HISTOGRAM_FILENAME)

    if radial_profile is not None:
        save_radial_profile(radial_profile, figures_dir / _RADIAL_PROFILE_FILENAME)

    if zone_map is not None:
        save_zone_map(zone_map, figures_dir / _ZONE_MAP_FILENAME, luminance=roi_luminance)


def _print_summary(metrics: dict, csv_path: Path, figures_dir: Path) -> None:
    """評価サマリーを標準出力に表示する．

    Args:
        metrics: 指標名 → 値の辞書．
        csv_path: metrics.csv の保存先パス（表示用）．
        figures_dir: figures/ ディレクトリのパス（表示用）．
    """
    print("=== 均一性評価サマリー ===")
    print(f"  CoV            : {metrics['cov']:.6f}")
    print(f"  std            : {metrics['std']:.4f}")
    print(f"  max/min ratio  : {metrics['max_min_ratio']:.4f}")
    print(f"  mean           : {metrics['mean']:.4f}")
    print(f"  max            : {metrics['max']:.4f}")
    print(f"  min            : {metrics['min']:.4f}")

    if "center_periphery_ratio" in metrics:
        print(f"  C/P ratio      : {metrics['center_periphery_ratio']:.4f}")
        print(f"  gradient [%]   : {metrics['gradient_magnitude']:.2f}")

    print(f"\nmetrics.csv     : {csv_path}")
    print(f"figures/        : {figures_dir}")


def main(argv: list[str] | None = None) -> int:
    """sm-evaluate コマンドのメインエントリポイント．

    処理フロー:
        1. コマンドライン引数をパースする．
        2. 画像ファイルを読み込み，輝度配列を得る．
        3. YAML 設定ファイルを読み込み，EvaluationConfig を得る．
        4. 出力ディレクトリと figures/ サブディレクトリを作成する．
        5. extract_roi で ROI を切り出す．
        6. calc_uniformity で均一性指標を算出する．
        7. zones.enabled のときゾーン分析を実行する．
        8. metrics.radial_profile のとき放射状プロファイルを算出する．
        9. metrics.csv を保存する．
        10. 可視化 PNG を figures/ に保存する．
        11. 算出指標のサマリーを標準出力に表示する．

    Args:
        argv: 引数リスト．None のとき sys.argv[1:] を使用．

    Returns:
        終了コード（成功時 0，失敗時 1）．
    """
    # argparse 自体のエラー（必須引数欠落等）は ArgumentParser が直接 sys.exit するため
    # ここでは ValueError / FileNotFoundError 等の実行時エラーのみ捕捉する
    args = _parse_args(argv)

    image_path: Path = args.image.resolve()
    config_path: Path = args.config.resolve()
    output_dir: Path = args.output.resolve()

    try:
        luminance = _load_luminance(image_path)
    except FileNotFoundError as exc:
        print(f"エラー: {exc}", file=sys.stderr)
        return 1
    except Exception as exc:  # noqa: BLE001
        print(f"エラー: 画像の読み込みに失敗しました — {exc}", file=sys.stderr)
        return 1

    try:
        eval_config = _load_eval_config(config_path)
    except FileNotFoundError as exc:
        print(f"エラー: {exc}", file=sys.stderr)
        return 1
    except Exception as exc:  # noqa: BLE001
        print(f"エラー: 設定 YAML の読み込みに失敗しました — {exc}", file=sys.stderr)
        return 1

    figures_dir = output_dir / _FIGURES_DIRNAME
    try:
        output_dir.mkdir(parents=True, exist_ok=True)
        figures_dir.mkdir(parents=True, exist_ok=True)
    except OSError as exc:
        print(f"エラー: 出力ディレクトリを作成できません — {exc}", file=sys.stderr)
        return 1

    try:
        roi_luminance = extract_roi(luminance, eval_config.roi)
    except (ValueError, NotImplementedError) as exc:
        print(f"エラー: ROI 抽出に失敗しました — {exc}", file=sys.stderr)
        return 1

    # OETF 順方向適用（enabled のとき ROI 抽出後・指標算出前に挟む）．
    # eval_real.yaml（実測 PNG）は oetf を設定しないこと：
    # 実測 PNG は既に sRGB 符号化済みのため，ここで OETF を掛けると二重適用になる．
    oetf_cfg = eval_config.oetf
    if oetf_cfg is not None and oetf_cfg.enabled:
        try:
            roi_luminance = _apply_oetf_to_luminance(roi_luminance, oetf_cfg)
        except ValueError as exc:
            print(f"エラー: OETF 適用に失敗しました — {exc}", file=sys.stderr)
            return 1

    try:
        uniformity_stats = calc_uniformity(roi_luminance)
    except ValueError as exc:
        print(f"エラー: 均一性指標の算出に失敗しました — {exc}", file=sys.stderr)
        return 1

    metrics: dict = dict(uniformity_stats)

    try:
        zone_map, zone_stats = _run_zone_analysis(roi_luminance, eval_config.zones)
        metrics.update(zone_stats)
    except ValueError as exc:
        print(f"エラー: ゾーン分析に失敗しました — {exc}", file=sys.stderr)
        return 1

    try:
        radial_profile = _run_radial_profile(roi_luminance, eval_config.metrics)
    except Exception as exc:  # noqa: BLE001
        print(f"エラー: 放射状プロファイルの算出に失敗しました — {exc}", file=sys.stderr)
        return 1

    csv_path = output_dir / _METRICS_FILENAME
    try:
        _save_metrics_csv(metrics, csv_path)
    except Exception as exc:  # noqa: BLE001
        print(f"エラー: metrics.csv の保存に失敗しました — {exc}", file=sys.stderr)
        return 1

    try:
        _save_figures(roi_luminance, figures_dir, radial_profile, zone_map)
    except Exception as exc:  # noqa: BLE001
        print(f"エラー: 可視化 PNG の保存に失敗しました — {exc}", file=sys.stderr)
        return 1

    _print_summary(metrics, csv_path, figures_dir)
    return 0
