"""orthographic センサ投影モジュール．

sm-render が出力した 2D 輝度マップ (luminance.npy) を
3D メッシュの各頂点へ射影し、per-vertex scalar 配列を返す純関数群を提供する．

## 投影規約（orthographic センサとの整合）

SPEC_03 と scene/sensors.py の look_at + scale 変換を厳密に再現する．

センサ設定（scene_baseline.yaml）:
  - position = (0, 0, -50) mm → m では (0, 0, -0.05)
  - target   = (0, 0, 0)
  - up       = (0, 1, 0)
  - ortho_scale = (50, 52) mm → m では sx=0.05, sy=0.052

look_at 基底の導出:
  - forward d = normalize(target - origin) = (0, 0, 1) (+Z 向き)
  - right   r = normalize(cross(up, d))    = normalize((0,1,0)×(0,0,1)) = (1, 0, 0) (+X 向き)
    ※ Mitsuba look_at は right = cross(up, forward) で計算する
  - 実 up   u = cross(d, r)               = (0,0,1)×(1,0,0) = (0,-1,0)
    ※ ただし Mitsuba は渡した up ベクトルを直接使うため (0,1,0) のまま

to_world = look_at @ scale(sx, sy, 1):
  [sx  0   0   0  ]
  [0   sy  0   0  ]
  [0   0   1  -0.05]
  [0   0   0   1  ]

clip_space → world_space（Z=-0.05 平面）:
  world_x = clip_x * sx_m
  world_y = clip_y * sy_m
  world_z = -0.05 (センサ位置、投影面ではなくレイ起点)

画像座標 (row, col) と clip_space の対応（Mitsuba/標準慣例）:
  clip_x = (2 * col / W) - 1  … col=0 → -1 (物理 X-), col=W → +1 (物理 X+)
  clip_y = 1 - (2 * row / H)  … row=0 → +1 (物理 Y+), row=H → -1 (物理 Y-)

実データ検証: luminance.npy (spp=1024) で確認済み
  - LED Y-方向 (y=-70.2 mm) → 下部 (row > H/2) が明るい
  - row 大 → clip_y 小 → world_y 小 → 物理 Y- = LED 方向 ✓
  - 左右対称 (LED は X+/-対称配置) ✓

逆変換（world → 画像ピクセル）:
  clip_x = world_x / sx_m
  clip_y = world_y / sy_m
  col = (clip_x + 1) / 2 * W
  row = (1 - clip_y) / 2 * H

評価板メッシュの特定:
  scene_config 中で id="evaluation_board" のメッシュを優先的に使用する．
  そのような id がない場合は、全メッシュの Z 座標範囲中心が sensor.target に最も
  近いメッシュを選択する（docstring に記載した決定論的フォールバック）．
"""

from __future__ import annotations

import logging

import numpy as np

from sm_tias_lightsim.scene.schema import SceneConfig, SensorConfig

_logger = logging.getLogger(__name__)

# 評価板メッシュのデフォルト id（scene_baseline.yaml に準拠）
_EVAL_BOARD_ID = "evaluation_board"

# YAML mm → m 変換係数
_MM_TO_M = 0.001


def _compute_camera_basis_and_col_row(
    vertices_m: np.ndarray,
    sensor: SensorConfig,
) -> tuple[np.ndarray, np.ndarray, int, int]:
    """orthographic センサの look_at 基底を計算し、頂点の浮動小数点ピクセル座標を返す内部ヘルパ．

    投影規約（本モジュール冒頭 docstring・SPEC_03「画像↔物理の向き」）に準拠し、
    `project_luminance_to_vertices` と `compute_orthographic_uv` が共有する
    カメラ基底計算〜(col_f, row_f) 算出を一か所に集約する（DRY）．

    Args:
        vertices_m: 頂点配列 (N_v, 3)，m 単位．
        sensor: SceneConfig.sensor（position / target / up / ortho_scale を使用）．

    Returns:
        (col_f, row_f, n_cols, n_rows) のタプル．
            col_f: float 列座標 (N_v,)，範囲外頂点もクランプ前の生値を返す．
            row_f: float 行座標 (N_v,)，同上．
            n_cols: 輝度マップの列数（ortho_scale[0] の比率を表す整数代替ではなく
                この関数内部では不使用；呼び出し側で形状から求める）．
            n_rows: 同上．
        実際には (col_f, row_f, clip_x, clip_y) を返す（クランプ前 clip 値は
        範囲外チェックに使うため）．

    Raises:
        ValueError: sensor.type が 'orthographic' でない場合，ortho_scale が None の場合，
            または position=target / up ∥ forward のとき．

    Note:
        n_cols / n_rows はこの関数の返り値には含まれない（輝度マップのサイズは
        呼び出し側が持つため）．代わりに clip_x, clip_y を返し、呼び出し側で
        画像サイズを掛けてピクセル座標に変換する．
    """
    if sensor.type != "orthographic":
        msg = f"投影は orthographic センサのみサポートします (type={sensor.type!r})"
        raise ValueError(msg)
    if sensor.ortho_scale is None:
        msg = "sensor.ortho_scale が未設定です"
        raise ValueError(msg)

    # ortho_scale mm → m
    sx_m = sensor.ortho_scale[0] * _MM_TO_M
    sy_m = sensor.ortho_scale[1] * _MM_TO_M

    # カメラ look_at 基底の計算（Mitsuba 規約）
    pos_m = np.array(sensor.position, dtype=np.float64) * _MM_TO_M
    target_m = np.array(sensor.target, dtype=np.float64) * _MM_TO_M
    up = np.array(sensor.up, dtype=np.float64)

    forward = target_m - pos_m
    forward_len = np.linalg.norm(forward)
    if forward_len < 1e-9:
        msg = "sensor position と target が一致しています"
        raise ValueError(msg)
    forward = forward / forward_len

    right = np.cross(up, forward)
    right_len = np.linalg.norm(right)
    if right_len < 1e-9:
        msg = "sensor up ベクトルが forward と平行です"
        raise ValueError(msg)
    right = right / right_len

    cam_up = np.cross(forward, right)

    rot_cam_to_world = np.stack([right, cam_up, forward], axis=1)  # (3, 3)

    v_rel = vertices_m - pos_m  # (N_v, 3)
    cam_coords = v_rel @ rot_cam_to_world  # (N_v, 3)

    clip_x = cam_coords[:, 0] / sx_m
    clip_y = cam_coords[:, 1] / sy_m

    return clip_x, clip_y


def find_eval_board_mesh_id(scene_config: SceneConfig) -> str:
    """評価板メッシュの id を scene_config から決定する．

    優先順位:
      1. id == "evaluation_board" のメッシュ
      2. 全メッシュ中で Z 座標範囲中心が sensor.target に最も近いもの
         （評価板は sensor の正面にある）

    Args:
        scene_config: 検証済み SceneConfig．

    Returns:
        評価板として使用するメッシュの id 文字列．

    Raises:
        ValueError: geometry.meshes が空の場合．
    """
    if not scene_config.geometry.meshes:
        msg = "geometry.meshes が空です"
        raise ValueError(msg)

    # 優先 1: id="evaluation_board"
    for mesh_cfg in scene_config.geometry.meshes:
        if mesh_cfg.id == _EVAL_BOARD_ID:
            _logger.debug("evaluation_board mesh found by id")
            return mesh_cfg.id

    # フォールバック: メッシュリストの先頭を使用（合理的デフォルト）
    # ジオメトリ情報なしで Z 中心を計算するには STL 読み込みが必要で、
    # ここでは project_root が不明なため、先頭メッシュを返す．
    # 呼び出し側が project_root を持つ場合は個別に最近傍メッシュを選択可能．
    _logger.warning(
        "id='evaluation_board' が見つかりません．先頭メッシュ '%s' を使用します．",
        scene_config.geometry.meshes[0].id,
    )
    return scene_config.geometry.meshes[0].id


def compute_face_camera_mask(
    vertices_m: np.ndarray,
    faces: np.ndarray,
    sensor: SensorConfig,
    *,
    tol_m: float = 0.001,
) -> np.ndarray:
    """各 face が評価板の「カメラ側（センサ側）平坦評価プレート面」かを判定する bool マスクを返す．

    ## 判定基準（幾何位置ベース・巻き順非依存）

    STL の巻き順は作者依存で信頼できないため、face 法線の内積による判定をやめ、
    face centroid の「カメラ向き主軸方向座標」が評価板メッシュのその端に集中しているか
    で判定する．

    具体的な手順:
      1. sensor の view_dir = normalize(target - position) を計算する．
      2. view_dir の各頂点への射影成分 proj = dot(vertex, view_dir) を求め、
         メッシュ全体での proj_min（センサ側端）と proj_max（反センサ側端）を得る．
         baseline: sensor が -Z から +Z を向く → view_dir ≈ (0,0,1)、
                   proj ≈ world_z → proj_min が評価板の最小 Z 端（Z≈-4.5mm）．
      3. 各 face の centroid の proj 値が「proj_min + tol_m 以下」の face を
         「カメラ側平坦評価プレート面」（内側面）として True にする．
         - tol_m（既定 1mm）: 平坦面の数値誤差とわずかな厚みを吸収する余裕幅．
         - 評価板の評価プレートは単一平面（SPEC_03: Z=-4.5mm）のため tol_m=1mm は十分大きく、
           かつ外側 AR マーカー等（Z≈-2.5mm 以上）を取り込まない適切な値．
      4. その他の face（外側の楕円窓・AR マーカー・+Z 構造）は False（ニュートラルグレー描画）．

    view_dir から向きを決めるため sensor.position / sensor.target のみに依存し、
    STL の巻き順・法線符号には依存しない．

    Args:
        vertices_m: 頂点配列 (N_v, 3)，m 単位．
        faces: 三角形面配列 (N_f, 3)，頂点インデックス（0-based）．
        sensor: SceneConfig.sensor（position / target を使用）．
        tol_m: センサ側端から数えた許容幅（m 単位，既定 0.001m = 1mm）．
            評価プレートが単一平面なら数値誤差を吸収するだけの小さい値で十分．
            外側構造が近すぎる場合は小さくする（0.0005m = 0.5mm 等）．

    Returns:
        bool 配列 (N_f,)．True の face が評価対象（カメラ側平坦面・投影着色の対象）．
    """
    pos_m = np.array(sensor.position, dtype=np.float64) * _MM_TO_M
    target_m = np.array(sensor.target, dtype=np.float64) * _MM_TO_M

    # カメラ視線方向（センサが向いている方向）
    view_dir = target_m - pos_m
    view_len = np.linalg.norm(view_dir)
    if view_len < 1e-9:
        msg = "sensor position と target が一致しています"
        raise ValueError(msg)
    view_dir = view_dir / view_len  # (3,) 単位ベクトル

    # 全頂点を view_dir へ射影（スカラ値）
    # baseline: view_dir = (0,0,1) のとき proj ≈ world_z
    proj_v = vertices_m @ view_dir  # (N_v,) 各頂点の view_dir 方向成分

    # メッシュ全体の射影範囲
    proj_min = float(proj_v.min())  # センサ側端（baseline では最小 Z 端）

    # face centroid の射影値を計算
    # face centroid = (v0 + v1 + v2) / 3 の view_dir 方向成分 = 各頂点 proj の平均
    proj_centroids = proj_v[faces].mean(axis=1)  # (N_f,)

    # centroid の proj が「センサ側端 + tol_m 以内」= カメラ側平坦評価プレート面
    mask = proj_centroids <= proj_min + tol_m

    n_camera = int(mask.sum())
    n_total = len(faces)
    _logger.debug(
        "face カメラ側端判定 (幾何位置ベース): 評価面=%d / 全 face=%d "
        "(proj_min=%.5f m, tol_m=%.4f m, 閾値=%.5f m)",
        n_camera,
        n_total,
        proj_min,
        tol_m,
        proj_min + tol_m,
    )

    return mask


def compute_inward_face_mask(
    vertices_m: np.ndarray,
    faces: np.ndarray,
    scene_center: np.ndarray,
) -> np.ndarray:
    """各 face の法線がシーン中心側を向く（内向き）かを判定する bool マスクを返す．

    ## 判定基準（PoC poc_cull.py で妥当性確認済み）

    PoC での検証結果:
      - `dot(face_normal, scene_center - face_centroid) > 0` で内向き判定
      - 外向き（≈0 の外殻）をカリングすると濁り/暗殻が消え、穴も開かない
      - カリング後の共有 clim: log10 1.35〜3.42（外殻を含む全面より締まる）
      - 評価は全描画メッシュの頂点平均を scene_center として渡す（呼び出し側が計算）

    ## 巻き順依存への注記

    face 法線は STL の巻き順（vertex 順）から `cross(v1-v0, v2-v0)` で算出するため、
    STL 作成ツール依存の巻き順に影響を受ける．
    評価板 STL では過去に巻き順不整合が確認されているが（compute_face_camera_mask 参照）、
    PoC（poc_cull.py）で筐体・壁面 STL に対して妥当な内向き判定ができることを確認済み．
    巻き順が揃っていない STL では内向き/外向きが反転する可能性があることに注意．
    （過剰実装を避けるためヒューリスティックとして実装し、--no-cull-outward で無効化可能）

    Args:
        vertices_m: 頂点配列 (N_v, 3)，m 単位．
        faces: 三角形面配列 (N_f, 3)，頂点インデックス（0-based）．
        scene_center: シーン中心座標 (3,)，m 単位．
            全描画メッシュ頂点の平均を呼び出し側で算出して渡す．

    Returns:
        bool 配列 (N_f,)．True の face が内向き（描画対象）．
        False の face が外向き（カリング対象）．
    """
    v0 = vertices_m[faces[:, 0]]
    v1 = vertices_m[faces[:, 1]]
    v2 = vertices_m[faces[:, 2]]

    # face 重心
    centroids = (v0 + v1 + v2) / 3.0  # (N_f, 3)

    # face 法線（巻き順依存: cross(v1-v0, v2-v0)、STL 作者依存）
    raw_normals = np.cross(v1 - v0, v2 - v0)  # (N_f, 3)
    norms = np.linalg.norm(raw_normals, axis=1, keepdims=True)
    norms = np.where(norms == 0.0, 1.0, norms)
    normals = raw_normals / norms  # (N_f, 3) 単位ベクトル

    # scene_center → centroid ベクトル（face からシーン中心への方向）
    to_center = scene_center - centroids  # (N_f, 3)

    # dot(normal, to_center) > 0 → 法線がシーン中心側を向く = 内向き
    dot = np.einsum("ij,ij->i", normals, to_center)  # (N_f,)
    inward_mask = dot > 0.0

    n_inward = int(inward_mask.sum())
    n_total = len(faces)
    _logger.debug(
        "compute_inward_face_mask: inward(描画)=%d / 全 face=%d "
        "(outward(カリング)=%d, scene_center=[%.4f, %.4f, %.4f])",
        n_inward,
        n_total,
        n_total - n_inward,
        float(scene_center[0]),
        float(scene_center[1]),
        float(scene_center[2]),
    )

    return inward_mask


def project_luminance_to_vertices(
    vertices_m: np.ndarray,
    luminance_map: np.ndarray,
    sensor: SensorConfig,
    *,
    clip_low: float = 1.0,
    clip_high: float = 99.0,
) -> np.ndarray:
    """メッシュ頂点を orthographic センサで 2D 輝度マップに射影し per-vertex 輝度を返す．

    投影規約は本モジュール冒頭の docstring を参照（sensors.py の look_at + scale と
    厳密に整合している）．

    Args:
        vertices_m: 頂点配列 (N_v, 3)，m 単位．
        luminance_map: 2D 輝度マップ (H, W) または (H, W, C)．
            C チャネルある場合は最初のチャネルを使用する．
        sensor: SceneConfig.sensor（orthographic が前提）．
        clip_low: percentile クリップの下限（%）．既定 1．
        clip_high: percentile クリップの上限（%）．既定 99．

    Returns:
        per-vertex 輝度スカラ配列 (N_v,) float32．
        センサ外（範囲外）の頂点は最近傍クリップ（境界値で折り返す）で補完する．

    Raises:
        ValueError: sensor.type が 'orthographic' でない場合，または
            sensor.ortho_scale が None の場合．
    """
    # 2D マップの準備: チャネルがあれば最初のチャネルを使用
    if luminance_map.ndim == 3:
        lum2d = luminance_map[:, :, 0].astype(np.float64)
    else:
        lum2d = luminance_map.astype(np.float64)

    n_rows, n_cols = lum2d.shape

    # カメラ基底計算（_compute_camera_basis_and_col_row と同一ロジック、DRY）
    clip_x, clip_y = _compute_camera_basis_and_col_row(vertices_m, sensor)

    # clip → 画像ピクセル座標
    # clip_x=-1 → col=0 (左端), clip_x=+1 → col=n_cols (右端)
    # clip_y=+1 → row=0 (上端), clip_y=-1 → row=n_rows (下端)
    # ※ Mitsuba は Y-up のため、clip_y と画像 row が反転する
    col_f = (clip_x + 1.0) / 2.0 * n_cols  # float 列座標
    row_f = (1.0 - clip_y) / 2.0 * n_rows  # float 行座標（Y 軸反転）

    # bilinear サンプリング（範囲外は境界でクランプ）
    col_f_clamped = np.clip(col_f, 0.0, n_cols - 1)
    row_f_clamped = np.clip(row_f, 0.0, n_rows - 1)

    col0 = np.floor(col_f_clamped).astype(np.int32)
    row0 = np.floor(row_f_clamped).astype(np.int32)
    col1 = np.clip(col0 + 1, 0, n_cols - 1)
    row1 = np.clip(row0 + 1, 0, n_rows - 1)

    # bilinear 補間の重み
    wc = col_f_clamped - col0.astype(np.float64)
    wr = row_f_clamped - row0.astype(np.float64)

    # 2D → 1D インデックスでサンプリング（4 隣接ピクセル）
    lum_flat = lum2d.ravel()
    idx00 = row0 * n_cols + col0
    idx01 = row0 * n_cols + col1
    idx10 = row1 * n_cols + col0
    idx11 = row1 * n_cols + col1

    sampled = (
        lum_flat[idx00] * (1 - wr) * (1 - wc)
        + lum_flat[idx01] * (1 - wr) * wc
        + lum_flat[idx10] * wr * (1 - wc)
        + lum_flat[idx11] * wr * wc
    )

    # percentile クリップで高ダイナミックレンジ潰れを防止
    p_low = float(np.percentile(sampled, clip_low))
    p_high = float(np.percentile(sampled, clip_high))
    sampled_clipped = np.clip(sampled, p_low, p_high)

    # 0–1 正規化（scalar bar が物理単位でなく相対輝度を表示する場合に使用）
    # → 呼び出し側でカラーマップに渡す際はクリップ後の値そのまま使用する
    #   (scalar bar タイトルで単位を明示する)

    # 範囲外頂点の割合をログ出力
    out_of_range = np.sum((clip_x < -1) | (clip_x > 1) | (clip_y < -1) | (clip_y > 1))
    if out_of_range > 0:
        _logger.debug(
            "投影範囲外の頂点: %d / %d（境界クランプ済み）",
            int(out_of_range),
            len(clip_x),
        )

    return sampled_clipped.astype(np.float32)


def compute_orthographic_uv(
    vertices_m: np.ndarray,
    luminance_shape: tuple[int, int],
    sensor: SensorConfig,
) -> np.ndarray:
    """orthographic センサ投影で各頂点の UV テクスチャ座標 ∈ [0, 1] を返す純関数．

    `project_luminance_to_vertices` と**同一の投影規約**（look_at 基底 +
    ortho_scale mm→m + clip→col/row 変換）で各頂点の浮動小数点ピクセル座標を求め、
    VTK テクスチャ座標（原点: 左下）に変換する．

    ## UV 変換式

    ピクセル座標から UV への変換（_compute_camera_basis_and_col_row と共有）:

        col_f = (clip_x + 1) / 2 * W    # 画像座標: 左端=0, 右端=W
        row_f = (1 - clip_y) / 2 * H    # 画像座標: 上端=0, 下端=H（Y 反転）

        u = col_f / W                    # ∈ [0, 1]（画像左=u=0, 右=u=1）
        v = 1 - row_f / H               # ∈ [0, 1]（VTK 原点は左下=v=0, 上=v=1）

    ## 向きの根拠（SPEC_03「画像↔物理の向き（検証済み）」）

    - 画像右（col 大）= 物理 X+  → u 大 = X+ ✓
    - 画像上（row 小）= 物理 Y+  → v 大（v=1-row/H, row 小で v 大）= Y+ ✓
    - VTK テクスチャ座標は Y 軸が画像とは逆（原点が左下）であるため、
      v = 1 - row_f / H の反転で「画像上 = Y+」と整合する．

    Args:
        vertices_m: 頂点配列 (N_v, 3)，m 単位．
        luminance_shape: (H, W) 輝度マップの形状（UV 比率の計算に使用）．
        sensor: SceneConfig.sensor（orthographic が前提）．

    Returns:
        UV テクスチャ座標 (N_v, 2) float32，各行 (u, v) ∈ [0, 1]．
        センサ外（範囲外）の頂点は [0, 1] にクランプされる．

    Raises:
        ValueError: sensor.type が 'orthographic' でない場合，または
            sensor.ortho_scale が None の場合．
    """
    h, w = luminance_shape

    # 共通ヘルパで clip_x, clip_y を計算（DRY: project_luminance_to_vertices と同一基底）
    clip_x, clip_y = _compute_camera_basis_and_col_row(vertices_m, sensor)

    # clip → 正規化ピクセル座標（0〜1）
    # col_f / W = (clip_x + 1) / 2
    u = (clip_x + 1.0) / 2.0
    # row_f / H = (1 - clip_y) / 2  →  v = 1 - row_f/H = (1 + clip_y) / 2
    # VTK テクスチャ座標は原点が左下（画像は左上原点）なので v を反転する
    # v = 1 - (row_f / H) = 1 - (1 - clip_y) / 2 = (1 + clip_y) / 2
    v = (1.0 + clip_y) / 2.0

    # 範囲外をクランプ
    u = np.clip(u, 0.0, 1.0)
    v = np.clip(v, 0.0, 1.0)

    out_of_range = np.sum((clip_x < -1) | (clip_x > 1) | (clip_y < -1) | (clip_y > 1))
    if out_of_range > 0:
        _logger.debug(
            "UV 算出: 投影範囲外の頂点 %d / %d （[0,1] クランプ済み）",
            int(out_of_range),
            len(clip_x),
        )

    uv = np.stack([u, v], axis=1).astype(np.float32)
    _logger.debug(
        "compute_orthographic_uv: shape=%s  u=[%.4f, %.4f]  v=[%.4f, %.4f]",
        uv.shape,
        float(u.min()),
        float(u.max()),
        float(v.min()),
        float(v.max()),
    )
    return uv
