"""YAML シーン定義スキーマ（Pydantic モデル）．

SPEC_02_シーン定義仕様 に準拠したモデル群と，YAML ファイルローダを提供する．
"""

from __future__ import annotations

from pathlib import Path
from typing import Annotated, Literal

import yaml
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator

# ---------------------------------------------------------------------------
# 共通基底・型エイリアス
# ---------------------------------------------------------------------------


# 全モデルで frozen=True を共有するため基底クラスに集約する
class _FrozenBase(BaseModel):
    model_config = ConfigDict(frozen=True)


# 3D ベクトル（mm 座標・方向ベクトル・RGB 輝度）の共通型
Vec3 = tuple[float, float, float]


# ---------------------------------------------------------------------------
# Film / Sensor
# ---------------------------------------------------------------------------


class FilmConfig(_FrozenBase):
    """レンダリングフィルム（出力解像度・ピクセルフィルタ）設定．"""

    width: int = Field(..., ge=1, description="フィルム幅 [px]")
    height: int = Field(..., ge=1, description="フィルム高さ [px]")
    rfilter: Literal["gaussian", "box", "tent"] = Field(
        default="gaussian", description="ピクセル再構成フィルタ"
    )


class SensorConfig(_FrozenBase):
    """カメラセンサ設定．perspective または orthographic に対応．

    - perspective: 通常のカメラ．fov_deg を必須．
    - orthographic: 平行投影．ortho_scale (mm) を必須（撮影範囲の半幅 (sx, sy)）．
      画像座標は X 軸が ±sx，Y 軸が ±sy にマップされる．
    """

    type: Literal["perspective", "orthographic"]
    position: Vec3 = Field(..., description="カメラ位置 [mm]")
    target: Vec3 = Field(..., description="注視点 [mm]")
    up: Vec3 = Field(..., description="上方向ベクトル")
    fov_deg: float | None = Field(
        default=None, description="水平方向 FOV [deg]．perspective のみ必須"
    )
    ortho_scale: tuple[float, float] | None = Field(
        default=None,
        description="撮影範囲の半幅 (sx, sy) [mm]．orthographic のみ必須",
    )
    film: FilmConfig

    @field_validator("fov_deg")
    @classmethod
    def _validate_fov(cls, v: float | None) -> float | None:
        """FOV が 0 < fov_deg < 180 の範囲内であることを検証する．"""
        if v is None:
            return v
        if not (0.0 < v < 180.0):
            msg = f"fov_deg must be in range (0, 180), got {v}"
            raise ValueError(msg)
        return v

    @model_validator(mode="after")
    def _validate_type_specific_fields(self) -> SensorConfig:
        """type に応じた必須フィールドの有無を検証する."""
        if self.type == "perspective" and self.fov_deg is None:
            msg = "perspective sensor requires fov_deg"
            raise ValueError(msg)
        if self.type == "orthographic" and self.ortho_scale is None:
            msg = "orthographic sensor requires ortho_scale (sx, sy) in mm"
            raise ValueError(msg)
        return self


# ---------------------------------------------------------------------------
# Render
# ---------------------------------------------------------------------------


class RenderConfig(_FrozenBase):
    """レンダリングパラメータ設定．"""

    variant: str = Field(default="llvm_ad_rgb", description="Mitsuba variant 文字列")
    integrator: Literal["path", "direct", "volpath", "ptracer"] = Field(
        default="path", description="積分器の種類．ptracer はフォワード光線追跡（光源→センサ）"
    )
    max_depth: int = Field(default=8, ge=1, description="最大光線追跡深度")
    spp: int = Field(default=256, ge=1, description="ピクセルあたりのサンプル数")


# ---------------------------------------------------------------------------
# Materials（discriminated union）
# ---------------------------------------------------------------------------


class DiffuseMaterial(_FrozenBase):
    """Lambertian 拡散反射材料．"""

    type: Literal["diffuse"]
    reflectance: float | Vec3 = Field(..., description="反射率（スカラまたは RGB タプル）")


class _IorBase(_FrozenBase):
    """屈折率フィールドを持つ誘電体材料の共通基底．"""

    int_ior: float = Field(..., description="内側の屈折率")
    ext_ior: float = Field(..., description="外側の屈折率")


class DielectricMaterial(_IorBase):
    """誘電体材料（厚みあり）．"""

    type: Literal["dielectric"]


class ThinDielectricMaterial(_IorBase):
    """薄膜誘電体材料（ディフューザー薄板向け）．"""

    type: Literal["thindielectric"]


class RoughConductorBSDFConfig(_FrozenBase):
    """マイクロファセット鏡面反射材料（完全導体近似）．

    Mitsuba 3 の ``roughconductor`` BSDF に対応する．
    ``material="none"`` で eta/k なし（完全反射体）として扱い，
    内壁の光沢成分を表現する感度分析に使用する．
    """

    type: Literal["roughconductor"]
    alpha: float = Field(
        ..., ge=0.0, le=1.0, description="マイクロファセット粗さ（0=鏡面，1=マット）"
    )
    distribution: Literal["ggx", "beckmann"] = Field(
        default="ggx", description="マイクロファセット法線分布（ggx または beckmann）"
    )


class BlendBSDFConfig(_FrozenBase):
    """2 つの BSDF を線形補間するブレンド材料．

    Mitsuba 3 の ``blendbsdf`` に対応する．
    ``weight=0`` で component[0] のみ，``weight=1`` で component[1] のみ有効．
    内壁の diffuse（拡散）と roughconductor（光沢）を weight でブレンドする
    感度分析に使用する．

    再帰的な入れ子も可能だが，感度分析では 2 要素のフラット構造を想定する．
    """

    type: Literal["blendbsdf"]
    weight: float = Field(
        ...,
        ge=0.0,
        le=1.0,
        description="component[1] の混合比（0=全 component[0]，1=全 component[1]）",
    )
    components: list[
        Annotated[
            DiffuseMaterial
            | DielectricMaterial
            | ThinDielectricMaterial
            | RoughConductorBSDFConfig
            | BlendBSDFConfig,
            Field(discriminator="type"),
        ]
    ] = Field(
        ...,
        min_length=2,
        max_length=2,
        description="ブレンドする 2 つの BSDF（[component0, component1]）",
    )


# 再帰型の forward reference を解決する
BlendBSDFConfig.model_rebuild()


# type フィールドで識別される discriminated union
MaterialConfig = Annotated[
    DiffuseMaterial
    | DielectricMaterial
    | ThinDielectricMaterial
    | RoughConductorBSDFConfig
    | BlendBSDFConfig,
    Field(discriminator="type"),
]


# ---------------------------------------------------------------------------
# Lights（discriminated union）
# ---------------------------------------------------------------------------


class PointLight(_FrozenBase):
    """点光源（LED 単体モデル等）．"""

    id: str = Field(..., description="シーン内ユニーク ID")
    type: Literal["point"]
    position: Vec3 = Field(..., description="位置 [mm]")
    intensity: float | Vec3 = Field(..., description="強度（スカラまたは RGB タプル）")
    profile: Literal["lambertian", "isotropic"] = Field(..., description="配光プロファイル")


class AreaLight(_FrozenBase):
    """面光源（矩形）．"""

    id: str = Field(..., description="シーン内ユニーク ID")
    type: Literal["area"]
    shape: Literal["rectangle"] = Field(..., description="面形状")
    center: Vec3 = Field(..., description="中心位置 [mm]")
    size: tuple[float, float] = Field(..., description="サイズ [mm, mm]")
    direction: Vec3 = Field(..., description="放射方向ベクトル")
    radiance: float | Vec3 = Field(..., description="放射輝度（スカラまたは RGB タプル）")


class DirectionalLight(_FrozenBase):
    """平行光源（無限遠光源）．"""

    id: str = Field(..., description="シーン内ユニーク ID")
    type: Literal["directional"]
    direction: Vec3 = Field(..., description="光の進行方向ベクトル")
    irradiance: float | Vec3 = Field(..., description="照度（スカラまたは RGB タプル）")


class SpotLight(_FrozenBase):
    """スポット光源（円錐配光・点光源ベース）．

    Mitsuba 3 の `spot` emitter に対応．LED の指向性をデータシートの配光角に
    合わせて表現するために使用する．`cutoff_angle`（光が 0 になる角度）と
    `beam_width`（中央フラット領域）の組み合わせで smoothstep 補間の配光を
    定義する．半値全幅（FWHM） = `(cutoff_angle + beam_width) / 2 × 2`．
    """

    id: str = Field(..., description="シーン内ユニーク ID")
    type: Literal["spot"]
    position: Vec3 = Field(..., description="位置 [mm]")
    direction: Vec3 = Field(..., description="光軸方向ベクトル")
    cutoff_angle: float = Field(..., description="光が完全に 0 になる角度（度，0 < cutoff ≤ 90）")
    beam_width: float = Field(
        ..., description="中央フラット領域の半角（度，0 ≤ beam_width ≤ cutoff_angle）"
    )
    intensity: float | Vec3 = Field(
        ..., description="軸上強度 I_0 [W/sr]（スカラまたは RGB タプル）"
    )


# type フィールドで識別される discriminated union
LightConfig = Annotated[
    PointLight | AreaLight | DirectionalLight | SpotLight,
    Field(discriminator="type"),
]


# ---------------------------------------------------------------------------
# Geometry
# ---------------------------------------------------------------------------


class MeshConfig(_FrozenBase):
    """メッシュ（STL）定義．"""

    id: str = Field(..., description="シーン内ユニーク ID")
    path: str = Field(..., description="プロジェクトルートからの相対パス（存在チェックは行わない）")
    unit: Literal["mm", "m"] = Field(..., description="モデルの単位系")
    material: str = Field(..., description="materials の dict キー参照")


class GeometryConfig(_FrozenBase):
    """ジオメトリ設定（メッシュ一覧）．"""

    meshes: list[MeshConfig] = Field(..., min_length=1, description="メッシュ定義リスト")


# ---------------------------------------------------------------------------
# OETF（光電変換関数）設定
# ---------------------------------------------------------------------------


class OetfExposureConfig(_FrozenBase):
    """OETF 適用時の露出（線形ゲイン）設定．"""

    mode: Literal["anchor_mean"] = Field(
        default="anchor_mean",
        description=(
            "露出決定モード．anchor_mean: OETF 後 ROI mean が target_mean に"
            "一致するよう線形ゲインを対数二分で決定する（実測 AE のアンカー）．"
        ),
    )
    target_mean: float = Field(
        ...,
        description=(
            "目標 ROI mean（uint8 スケール [0, 255]）．実測 ROI mean（例: 146.7146）を指定する．"
        ),
    )


class OetfConfig(_FrozenBase):
    """OETF（光電変換関数）順方向適用設定．

    enabled=True のとき sm-evaluate は ROI 抽出後・指標算出前に
    sRGB OETF を適用し，実測 sRGB PNG と同じドメインで評価する．

    後方互換: EvaluationConfig.oetf を省略すると enabled=False（無効）扱いで
    既存の linear 評価が継続される．
    """

    enabled: bool = Field(default=False, description="OETF 順方向適用の有効/無効．")
    transfer: Literal["srgb"] = Field(
        default="srgb",
        description="変換カーブ種別．現状 srgb（IEC 61966-2-1）のみサポート．",
    )
    exposure: OetfExposureConfig | None = Field(
        default=None,
        description="露出設定．enabled=True かつ anchor_mean モードを使う場合は必須．",
    )
    quantize_uint8: bool = Field(
        default=True,
        description=(
            "True のとき OETF 後を np.clip(np.round(x*255),0,255) で"
            "uint8 量子化する（実測 PNG 同等）．"
        ),
    )


# ---------------------------------------------------------------------------
# Normalization（linear mean anchor 正規化）設定
# ---------------------------------------------------------------------------


class NormalizationConfig(_FrozenBase):
    """linear ドメイン mean anchor 正規化設定．

    enabled=True のとき sm-evaluate は ROI 抽出後・指標算出前に
    ``apply_mean_anchor`` で mean を target_mean に揃える線形ゲインを適用する．

    線形ゲインは形状指標（CoV / 勾配 / C/P 比 / max-min 比）に不変なので，
    絶対値スケールだけを実測と合わせた上で形状の公平比較が可能になる．

    OETF との併用禁止: oetf.enabled=True かつ normalization.enabled=True は
    EvaluationConfig の model_validator でバリデーションエラーになる．
    （OETF が内部で露出アンカーを持つため，二重アンカーになるのを防ぐ）

    後方互換: EvaluationConfig.normalization を省略すると無効扱いで
    既存の linear 評価が継続される．
    """

    enabled: bool = Field(default=False, description="mean anchor 正規化の有効/無効．")
    type: Literal["mean_anchor"] = Field(
        default="mean_anchor",
        description=(
            "正規化種別．mean_anchor: ROI mean を target_mean に揃える線形ゲイン適用．"
            "将来の拡張（max_anchor / range_anchor 等）に備えた Literal 設計．"
        ),
    )
    target_mean: float = Field(
        ...,
        description=(
            "目標 ROI mean（uint8 スケール [0, 255] 想定だが任意 float 可）．"
            "実測 ROI mean（例: 146.7146）を指定する．"
        ),
    )


# ---------------------------------------------------------------------------
# Evaluation
# ---------------------------------------------------------------------------


class RoiConfig(_FrozenBase):
    """評価 ROI 設定（image_pixel 型のみ対応）．

    scene_face 型は将来実装予定．
    """

    type: Literal["image_pixel"]
    x: int = Field(..., ge=0, description="ROI 左上 X 座標 [px]")
    y: int = Field(..., ge=0, description="ROI 左上 Y 座標 [px]")
    width: int = Field(..., ge=1, description="ROI 幅 [px]")
    height: int = Field(..., ge=1, description="ROI 高さ [px]")


class ZonesConfig(_FrozenBase):
    """3 ゾーン分析設定（中心 / 中間 / 周辺）．"""

    enabled: bool = Field(default=True, description="ゾーン分析の有効/無効")
    boundaries: tuple[float, float] = Field(
        default=(0.33, 0.66),
        description="境界値 (b1, b2)．0 < b1 < b2 < 1 を満たす正規化距離",
    )

    @field_validator("boundaries")
    @classmethod
    def _validate_boundaries(cls, v: tuple[float, float]) -> tuple[float, float]:
        """境界値が 0 < b1 < b2 < 1 を満たすことを検証する．"""
        b1, b2 = v
        if not (0.0 < b1 < b2 < 1.0):
            msg = f"boundaries must satisfy 0 < b1 < b2 < 1, got ({b1}, {b2})"
            raise ValueError(msg)
        return v


class MetricsConfig(_FrozenBase):
    """算出する均一性指標フラグ設定．"""

    cov: bool = Field(default=True, description="変動係数 (CoV)")
    std: bool = Field(default=True, description="標準偏差")
    max_min: bool = Field(default=True, description="最大値/最小値比")
    radial_profile: bool = Field(default=True, description="放射状プロファイル")
    radial_min_max: bool = Field(
        default=False,
        description=(
            "動径 min/max 比（radial_min_max_ratio / radial_min_distance / "
            "radial_min / radial_max の 4 列を metrics.csv に追加）．"
            "True のとき radial_profile の有無に関わらず内部で radial_profile を算出する．"
        ),
    )
    shape_metrics: bool = Field(
        default=False,
        description=(
            "形状指標を算出する（輝度重心 / 角度-放射分解 / PCA 主軸）．"
            "SSIM / NCC は shape_reference が指定されたときのみ算出する．"
            "True のとき metrics.csv に 10〜12 列を末尾に追加する．"
        ),
    )
    shape_reference: str | None = Field(
        default=None,
        description=(
            "SSIM / NCC 算出用の参照画像パス（.npy または .png）．"
            "shape_metrics=True のときのみ有効．"
            "省略時（null）は SSIM / NCC の列は空欄になる．"
        ),
    )


class EvaluationConfig(_FrozenBase):
    """評価設定（ROI・ゾーン・指標・OETF・正規化）．

    oetf フィールドを省略した既存 YAML は後方互換（OETF 無効扱い）で動作する．
    normalization フィールドを省略した既存 YAML も後方互換（正規化無効扱い）で動作する．
    """

    roi: RoiConfig
    zones: ZonesConfig | None = None
    metrics: MetricsConfig | None = None
    oetf: OetfConfig | None = Field(
        default=None,
        description=(
            "OETF 順方向適用設定．省略時は OETF 無効（linear のまま評価）．"
            "eval_real.yaml（実測 PNG）には設定しないこと"
            "（実測は既に sRGB 符号化済みのため二重適用になる）．"
        ),
    )
    normalization: NormalizationConfig | None = Field(
        default=None,
        description=(
            "linear ドメイン mean anchor 正規化設定．省略時は正規化無効．"
            "oetf.enabled=True との併用は禁止（バリデーションエラー）．"
        ),
    )

    @model_validator(mode="after")
    def _validate_oetf_normalization_exclusive(self) -> EvaluationConfig:
        """OETF と normalization の同時有効化を禁止する．

        OETF は内部で露出アンカー（mean ベースの線形ゲイン）を持つため，
        normalization との併用は二重アンカーになり意味をなさない．

        Returns:
            検証済みの EvaluationConfig 自身．

        Raises:
            ValueError: oetf.enabled=True かつ normalization.enabled=True の場合．
        """
        oetf_on = self.oetf is not None and self.oetf.enabled
        norm_on = self.normalization is not None and self.normalization.enabled
        if oetf_on and norm_on:
            msg = (
                "oetf.enabled=True と normalization.enabled=True は同時に設定できません．"
                "OETF が内部で露出アンカーを持つため，linear mean anchor との併用は"
                "二重アンカーになります．どちらか一方のみ有効にしてください．"
            )
            raise ValueError(msg)
        return self


# ---------------------------------------------------------------------------
# Top-level SceneConfig
# ---------------------------------------------------------------------------


class SceneConfig(_FrozenBase):
    """シーン定義全体を表すトップレベルモデル．

    SPEC_02_シーン定義仕様 に準拠した構造を持つ．
    model_validator でメッシュ・光源 ID の一意性，および材料参照整合性を検証する．
    """

    name: str = Field(..., description="設計識別名（出力ディレクトリ名に使用）")
    description: str | None = Field(default=None, description="自由記述")
    geometry: GeometryConfig
    materials: dict[str, MaterialConfig]
    lights: list[LightConfig] = Field(..., min_length=1, description="光源リスト（1 個以上）")
    sensor: SensorConfig
    render: RenderConfig = Field(default_factory=RenderConfig)
    evaluation: EvaluationConfig | None = None

    @model_validator(mode="after")
    def _validate_cross_references(self) -> SceneConfig:
        """メッシュ/光源 ID の一意性と材料参照整合性を検証する．

        検証内容:
            1. geometry.meshes の各 id が重複していないこと．
            2. lights の各 id が重複していないこと．
            3. 各 mesh.material が materials の dict キーに存在すること．

        Returns:
            検証済みの SceneConfig 自身．

        Raises:
            ValueError: 重複 ID または未定義材料参照が見つかった場合．
        """
        # 1. mesh.id ユニーク性チェック
        seen_mesh_ids: set[str] = set()
        for mesh in self.geometry.meshes:
            if mesh.id in seen_mesh_ids:
                msg = f"Duplicate mesh id: '{mesh.id}'"
                raise ValueError(msg)
            seen_mesh_ids.add(mesh.id)

        # 2. light.id ユニーク性チェック
        # LightConfig は discriminated union なので各エントリは必ず id を持つ
        seen_light_ids: set[str] = set()
        for light in self.lights:
            if light.id in seen_light_ids:
                msg = f"Duplicate light id: '{light.id}'"
                raise ValueError(msg)
            seen_light_ids.add(light.id)

        # 3. mesh.material 参照整合性チェック
        material_keys = set(self.materials.keys())
        for mesh in self.geometry.meshes:
            if mesh.material not in material_keys:
                msg = f"mesh '{mesh.id}' references unknown material '{mesh.material}'"
                raise ValueError(msg)

        return self


# ---------------------------------------------------------------------------
# YAML Loader
# ---------------------------------------------------------------------------


def load_scene_yaml(path: str | Path) -> SceneConfig:
    """YAML ファイルを読み込み，検証済み SceneConfig を返す．

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

    Returns:
        検証済みの SceneConfig インスタンス．

    Raises:
        FileNotFoundError: ファイルが存在しない場合．
        pydantic.ValidationError: スキーマ違反．
        yaml.YAMLError: YAML 構文エラー．
    """
    resolved = Path(path)
    if not resolved.exists():
        msg = f"Scene YAML file not found: {resolved}"
        raise FileNotFoundError(msg)

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

    return SceneConfig.model_validate(data)
