"""オフ軸 LED スイープ: 光源位置ずれの影響とキャビティサイズによる減衰（TEST_17）.

TEST_15/16 の board_baffle 構成は LED・光軸・センサがすべて z 軸上にあり，
輝度分布は同心円対称になる．本スイープでは **LED を軸から外して側壁に置き**，
その非対称が評価板にどれだけ写るか，キャビティが大きいほど多重反射の混合で
薄まるか（＝配置誤差へのロバスト性）を球・立方体の両方で測る．

幾何（ユーザー指定）:
    - LED は深さ方向の軸（板中心〜キャビティ底）の**中点の高さ**で側壁と交わる点の
      **+X 側 1 箇所**に置く
        立方体: 側壁 x=+L/2・z=-L/2
        球    : z_mid = pole_z/2 = -0.96r の高さで球面と交わる点（ほぼ赤道）
    - 発光方向は**評価板の中心** (0, 0, -4.5) へ向ける．壁法線ではないので円板は
      壁に対して斜めになり，傾いた縁が壁を貫かない分だけ浮かせる
    - バッフルは LED→板中心の視線上 10mm 先に置き，板の全点 × LED の全点の直線を
      遮る最小半径（軸対称でないため数値で求める）× 安全率とする．遮蔽はこれまで
      どおりレイキャストで直接検証する（verify_baffle_occlusion --scene）

固定条件は TEST_15/16 と同一（材質・レンダ・評価・評価板）．LED の面積・radiance も
据え置き（全光束保存）．球は similar_radial キャビティ（TEST_15 最良）を使う．

実行方法:
    uv run python scripts/sweep_offaxis_led.py --prepare-only   # メッシュ + シーン YAML のみ
    uv run python scripts/sweep_offaxis_led.py                  # 上記 + レンダ + 評価
"""

from __future__ import annotations

import argparse
import copy
import csv
import subprocess
import sys
import time
from pathlib import Path

import numpy as np
import trimesh
import yaml

sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))

from generate_cube_housing import build_cube_housing  # noqa: E402
from sweep_sphere_radius import (  # noqa: E402
    BAFFLE_GAP,
    BAFFLE_MARGIN,
    BAFFLE_THICKNESS,
    BOARD_HALF,
    BOARD_SURFACE_Z,
    EVAL_CONFIG_YAML,
    LED_RADIUS,
    RIM_INSET,
    RIM_Z,
    TEMPLATE_YAML,
    THICKNESS,
    raw_stats,
    read_metrics_csv,
)
from sweep_sphere_radius import (
    build_mesh as build_sphere_mesh,
)
from sweep_sphere_radius import (
    center_z as sphere_center_z,
)

from sm_tias_lightsim.scene.paths import relative_mesh_path  # noqa: E402

ROOT = Path(__file__).resolve().parents[1]

TMP_DIR = ROOT / "configs" / "_tmp_offaxis"
MESH_DIR = TMP_DIR / "meshes"
OUTPUT_BASE = ROOT / "output" / "offaxis_led_sweep"

SPHERE_CAVITY = "similar_radial"  # TEST_15 最良の球設計を比較対象にする
SPHERE_RADII = [88.0, 100.0, 135.0, 170.0, 250.0]
CUBE_SIZES = [120.0, 160.0, 200.0, 280.0, 360.0]

# 評価板の中心（キャビティ側の面）＝ LED の照準点
BOARD_CENTER = np.array([0.0, 0.0, BOARD_SURFACE_Z])
# 傾いた円板の縁が壁に触れないための追加マージン（mm）．球面のサジッタも吸収する
STANDOFF_MARGIN = 0.5

SUMMARY_COLUMNS = [
    "shape", "size_mm", "led_x_mm", "led_z_mm", "led_standoff_mm",
    "tilt_deg", "baffle_radius_mm", "cavity_area_mm2", "elapsed_s",
    "raw_mean_roi", "raw_mean_full", "raw_rel_std_roi",
    "cov", "center_periphery_ratio", "gradient_magnitude", "radial_min_max_ratio",
    "angular_ratio", "pca_eccentricity", "intensity_centroid_offset_norm",
    "max_min_ratio",
]


def job_name(shape: str, size: float) -> str:
    """条件名（ディレクトリ名）を作る."""
    return f"{shape}_{'r' if shape == 'sphere' else 'L'}{size:g}"


def wall_anchor(shape: str, size: float) -> tuple[np.ndarray, np.ndarray]:
    """LED を貼り付ける側壁上の点と，そこでの内向き法線を返す（mm）.

    深さ方向の軸（板中心 z=0 付近〜キャビティ最深部）の中点の高さで，+X 側の壁面．
    """
    if shape == "cube":
        z_mid = RIM_Z - size / 2.0
        return np.array([size / 2.0, 0.0, z_mid]), np.array([-1.0, 0.0, 0.0])
    c_z = sphere_center_z(size, SPHERE_CAVITY)
    pole_z = c_z - size
    z_mid = (RIM_Z + pole_z) / 2.0
    dz = z_mid - c_z
    rho = float(np.sqrt(size**2 - dz**2))
    point = np.array([rho, 0.0, z_mid])
    normal = (np.array([0.0, 0.0, c_z]) - point) / size
    return point, normal


def led_geometry(shape: str, size: float) -> dict[str, np.ndarray | float]:
    """LED 円板の中心・発光方向・浮かせ量・傾き角を返す.

    浮かせ量は「傾いた円板の縁が壁面へ食い込む量（LED_RADIUS×sinθ）+ マージン」．
    方向は浮かせた後の中心から板中心へ向け直す（1 回の反復で十分収束する）．
    """
    wall, normal = wall_anchor(shape, size)
    center = wall.copy()
    for _ in range(2):
        direction = BOARD_CENTER - center
        direction = direction / np.linalg.norm(direction)
        sin_tilt = float(np.linalg.norm(np.cross(direction, normal)))
        standoff = LED_RADIUS * sin_tilt + STANDOFF_MARGIN
        center = wall + standoff * normal
    tilt_deg = float(np.degrees(np.arcsin(np.clip(sin_tilt, -1.0, 1.0))))
    return {
        "center": center, "direction": direction,
        "standoff": standoff, "tilt_deg": tilt_deg,
    }


def _disk_points(center: np.ndarray, direction: np.ndarray, radius: float, n: int) -> np.ndarray:
    """direction を法線とする円板の外周点 n 個 + 中心を返す."""
    axis = np.array([0.0, 1.0, 0.0]) if abs(direction[1]) < 0.9 else np.array([0.0, 0.0, 1.0])
    u = np.cross(direction, axis)
    u /= np.linalg.norm(u)
    v = np.cross(direction, u)
    # 位相をずらして対称面（y=0）上にサンプル点を置かない（球版と同じ配慮）
    angles = 0.1234 + np.linspace(0.0, 2.0 * np.pi, n, endpoint=False)
    ring = center + radius * (np.outer(np.cos(angles), u) + np.outer(np.sin(angles), v))
    return np.vstack([center[None, :], ring])


def _board_points(grid: int = 41) -> np.ndarray:
    """評価板のキャビティ側の面の格子点（四隅を含む）を返す."""
    gx = np.linspace(-BOARD_HALF[0], BOARD_HALF[0], grid)
    gy = np.linspace(-BOARD_HALF[1], BOARD_HALF[1], grid)
    mesh_x, mesh_y = np.meshgrid(gx, gy)
    z = np.full(mesh_x.size, BOARD_SURFACE_Z)
    return np.stack([mesh_x.ravel(), mesh_y.ravel(), z], axis=1)


def baffle_radius(led: dict) -> float:
    """板の全点から LED 全点を隠すのに必要なバッフル半径（mm）を数値的に求める.

    軸対称でないため閉形式は使えない．LED 円板上の点 Q × 板上の点 P の全直線が
    バッフル平面（LED から BAFFLE_GAP 先・法線 = 発光方向）を横切る位置を計算し，
    バッフル中心からの面内距離の最大値に安全率を掛ける．サンプルは遮蔽検証
    （verify_baffle_occlusion）と同じ密度（板 41×41・LED 外周 64+1）を使う．
    """
    center, direction = led["center"], led["direction"]
    b_center = center + BAFFLE_GAP * direction
    q = _disk_points(center, direction, LED_RADIUS, 64)
    p = _board_points()
    # 全組合せ: Q (M,3) × P (N,3)
    delta = p[None, :, :] - q[:, None, :]  # (M, N, 3)
    denom = delta @ direction
    t = ((b_center - q) @ direction)[:, None] / denom
    x = q[:, None, :] + t[..., None] * delta
    offset = x - b_center
    radial = offset - (offset @ direction)[..., None] * direction[None, None, :]
    return float(BAFFLE_MARGIN * np.linalg.norm(radial, axis=-1).max())


def _wall_distance(shape: str, size: float, points: np.ndarray) -> float:
    """点群からキャビティ内壁までの最小距離（mm）を返す（負なら壁の外）."""
    if shape == "cube":
        margins = np.concatenate([
            (size / 2.0 - np.abs(points[:, 0:2])).ravel(),
            (points[:, 2] - (RIM_Z - size)),
            (RIM_Z - points[:, 2]),
        ])
        return float(margins.min())
    c = np.array([0.0, 0.0, sphere_center_z(size, SPHERE_CAVITY)])
    return float((size - np.linalg.norm(points - c, axis=1)).min())


def build_baffle_mesh(
    shape: str, size: float, led: dict, dest: Path
) -> tuple[trimesh.Trimesh, float]:
    """LED の視線上に置く傾いたバッフル円板を生成して書き出す.

    半セクタ回転（対称面エッジ回避）→ Z 軸を発光方向へ向ける回転 → 平行移動の順．

    円板が内壁に食い込むのは許容する（実物の積分球バッフルと同じ「壁に取り付けた板」の
    解釈．LED が壁際にあるため，板の手前側の縁への視線＝壁とほぼ平行な直線を遮るには
    円板が壁まで届く必要があり，円形ゆえ反対側の張り出しが壁に埋まる）．ただし外殻
    （壁厚 THICKNESS）を貫通してはならない．

    Returns:
        (メッシュ, 内壁への食い込み深さ mm．正なら食い込みあり・0 なら非接触)．

    Raises:
        ValueError: バッフルが外殻の外にはみ出す場合．
    """
    sections = 128
    radius = baffle_radius(led)
    mesh = trimesh.creation.cylinder(radius=radius, height=BAFFLE_THICKNESS, sections=sections)
    mesh.apply_transform(trimesh.transformations.rotation_matrix(np.pi / sections, [0, 0, 1]))
    direction = led["direction"]
    rot = trimesh.geometry.align_vectors([0.0, 0.0, 1.0], direction)
    mesh.apply_transform(rot)
    mesh.apply_translation(led["center"] + BAFFLE_GAP * direction)
    embed = max(0.0, -_wall_distance(shape, size, mesh.vertices))
    if embed > THICKNESS:
        raise ValueError(
            f"{job_name(shape, size)}: バッフルが外殻を貫通します（食い込み {embed:.2f}mm > "
            f"壁厚 {THICKNESS:g}mm）"
        )
    dest.parent.mkdir(parents=True, exist_ok=True)
    mesh.export(dest)
    return mesh, embed


def build_housing_mesh(shape: str, size: float, dest: Path) -> trimesh.Trimesh:
    """筐体メッシュを生成して書き出す（軸上スイープと同一のビルダを使う）."""
    if shape == "sphere":
        return build_sphere_mesh(size, dest, SPHERE_CAVITY)
    mesh = build_cube_housing(
        size=size, thickness=THICKNESS,
        board_half_x=BOARD_HALF[0], board_half_y=BOARD_HALF[1],
        rim_inset=RIM_INSET, rim_z=RIM_Z,
    )
    dest.parent.mkdir(parents=True, exist_ok=True)
    mesh.export(dest)
    return mesh


def cavity_area(shape: str, size: float) -> float:
    """比較の横軸に使う内面積（mm²）を返す（各スイープと同じ定義）."""
    if shape == "cube":
        from sweep_cube_size import cavity_geometry as cube_geo

        return cube_geo(size)["cavity_area_mm2"]
    from sweep_sphere_radius import cavity_geometry as sphere_geo

    return sphere_geo(size, SPHERE_CAVITY)["cavity_area_mm2"]


def build_scene_yaml(
    shape: str, size: float, mesh_path: Path, baffle_path: Path, led: dict, dest: Path
) -> None:
    """テンプレートから当該条件のシーン YAML を書き出す.

    材質・センサ・レンダ設定・LED の半径/radiance は一切変更しない．変えるのは
    筐体メッシュ・LED の位置と向き・バッフルだけ．
    """
    config = copy.deepcopy(yaml.safe_load(TEMPLATE_YAML.read_text(encoding="utf-8")))
    name = job_name(shape, size)
    config["name"] = f"offaxis_{name}"
    config["description"] = (
        f"オフ軸 LED スイープ [{shape} / {size:g}mm]: LED を側壁（軸中点の高さ・+X 側）に置き，"
        f"評価板中心 (0,0,{BOARD_SURFACE_Z:g}) へ向ける（傾き {led['tilt_deg']:.1f}°）．"
        f"バッフルで直接光を遮断．"
    )
    # メッシュは書き出し先 YAML からの相対パスで書く（絶対パスだと Windows で生成した
    # YAML を研究室サーバで読めない）
    yaml_dir = dest.parent
    for mesh in config["geometry"]["meshes"]:
        if mesh["id"] == "sphere_housing":
            if shape == "cube":
                mesh["id"] = "cube_housing"
            mesh["path"] = relative_mesh_path(mesh_path, yaml_dir)
        else:
            mesh["path"] = relative_mesh_path(TEMPLATE_YAML.parent / mesh["path"], yaml_dir)
    config["geometry"]["meshes"].append({
        "id": "baffle",
        "path": relative_mesh_path(baffle_path, yaml_dir),
        "unit": "mm",
        "material": "printer_white",
    })
    for light in config["lights"]:
        light["center"] = [round(float(c), 4) for c in led["center"]]
        light["direction"] = [round(float(d), 6) for d in led["direction"]]
    dest.parent.mkdir(parents=True, exist_ok=True)
    dest.write_text(yaml.safe_dump(config, allow_unicode=True, sort_keys=False), encoding="utf-8")


def jobs() -> list[tuple[str, float]]:
    """実行する（形状, サイズ）の一覧を返す."""
    return [("sphere", r) for r in SPHERE_RADII] + [("cube", size) for size in CUBE_SIZES]


def prepare(log) -> dict[tuple[str, float], Path]:
    """全条件のメッシュとシーン YAML を生成し，watertight とクリアランスを検証する."""
    scenes: dict[tuple[str, float], Path] = {}
    log("=== 幾何生成（オフ軸 LED / 球 similar_radial + 立方体）===")
    for shape, size in jobs():
        name = job_name(shape, size)
        led = led_geometry(shape, size)
        mesh_path = MESH_DIR / f"筐体_{name}.stl"
        housing = build_housing_mesh(shape, size, mesh_path)
        if not housing.is_watertight:
            raise ValueError(f"{name}: watertight でないメッシュが生成されました")
        # LED 円板の縁が壁の内側にあることを数値で確認する
        led_rim = _disk_points(led["center"], led["direction"], LED_RADIUS, 64)
        rim_clearance = _wall_distance(shape, size, led_rim)
        if rim_clearance < 0.0:
            raise ValueError(f"{name}: LED 円板が壁を貫いています（余裕 {rim_clearance:.3f}mm）")
        baffle_path = MESH_DIR / f"バッフル_{name}.stl"
        _, embed = build_baffle_mesh(shape, size, led, baffle_path)
        scene_yaml = TMP_DIR / f"scene_offaxis_{name}.yaml"
        build_scene_yaml(shape, size, mesh_path, baffle_path, led, scene_yaml)
        scenes[(shape, size)] = scene_yaml
        log(f"  {name:>12} LED=({led['center'][0]:7.2f}, 0, {led['center'][2]:8.2f}) "
            f"傾き={led['tilt_deg']:5.1f}° 浮かせ={led['standoff']:4.2f}mm "
            f"バッフル R={baffle_radius(led):5.2f}mm 円板余裕={rim_clearance:5.2f}mm "
            f"壁食い込み={embed:4.2f}mm")
    return scenes


def run_job(shape: str, size: float, scene_yaml: Path, log) -> dict | None:
    """1 条件をレンダ→評価まで実行して集約行を返す（失敗時 None）."""
    name = job_name(shape, size)
    out_dir = OUTPUT_BASE / name
    led = led_geometry(shape, size)
    row: dict = {
        "shape": shape,
        "size_mm": size,
        "led_x_mm": round(float(led["center"][0]), 3),
        "led_z_mm": round(float(led["center"][2]), 3),
        "led_standoff_mm": round(float(led["standoff"]), 3),
        "tilt_deg": round(led["tilt_deg"], 2),
        "baffle_radius_mm": round(baffle_radius(led), 3),
        "cavity_area_mm2": cavity_area(shape, size),
        "elapsed_s": 0.0,
    }
    if (out_dir / "metrics.csv").exists() and (out_dir / "luminance.npy").exists():
        log(f"--- {name}: 既存結果を再利用")
    else:
        log(f"--- {name}: レンダ開始")
        out_dir.mkdir(parents=True, exist_ok=True)
        start = time.time()
        for cmd in (
            ["uv", "run", "sm-render", "--scene", str(scene_yaml), "--output", str(out_dir)],
            ["uv", "run", "sm-evaluate", "--image", str(out_dir / "luminance.npy"),
             "--config", str(EVAL_CONFIG_YAML), "--output", str(out_dir)],
        ):
            # encoding 明示は必須（Windows 既定 cp932 で日本語ログが読めなくなる．球版で実測）
            result = subprocess.run(
                cmd, cwd=ROOT, capture_output=True, text=True,
                encoding="utf-8", errors="replace",
            )
            if result.returncode != 0:
                log(f"  !! 失敗 ({cmd[2]}): {result.stderr[-800:]}")
                return None
        row["elapsed_s"] = round(time.time() - start, 1)
    row.update(read_metrics_csv(out_dir / "metrics.csv"))
    row.update(raw_stats(out_dir / "luminance.npy"))
    log(f"  -> {row['elapsed_s']:.0f}s CoV={row['cov']:.4f} "
        f"centroid_offset={row['intensity_centroid_offset_norm']:.5f} "
        f"angular_ratio={row['angular_ratio']:.4f} 生mean={row['raw_mean_roi']:.4f}")
    return row


def write_summary(rows: list[dict]) -> None:
    """集約 CSV を（形状, サイズ）順で書き直す（途中終了でも最新状態が残る）."""
    summary_csv = OUTPUT_BASE / "summary.csv"
    summary_csv.parent.mkdir(parents=True, exist_ok=True)
    with summary_csv.open("w", newline="", encoding="utf-8") as fh:
        writer = csv.DictWriter(fh, fieldnames=SUMMARY_COLUMNS)
        writer.writeheader()
        for row in sorted(rows, key=lambda r: (r["shape"], r["size_mm"])):
            writer.writerow({k: row.get(k, "") for k in SUMMARY_COLUMNS})


def main() -> int:
    """オフ軸 LED スイープを実行するメインエントリポイント."""
    parser = argparse.ArgumentParser(description="オフ軸 LED スイープ（球 + 立方体）")
    parser.add_argument("--prepare-only", action="store_true",
                        help="メッシュとシーン YAML の生成だけ行う（3D 配置確認用）")
    parser.add_argument("--shapes", choices=("sphere", "cube"), nargs="*", default=None,
                        help="実行する形状（省略時は両方）")
    args = parser.parse_args()

    log_path = ROOT / "output" / "_sweep_offaxis_led.log"
    log_path.parent.mkdir(parents=True, exist_ok=True)
    log_file = log_path.open("a", encoding="utf-8")

    def log(message: str) -> None:
        print(message, flush=True)
        log_file.write(message + "\n")
        log_file.flush()

    log("=== オフ軸 LED スイープ ===")
    scenes = prepare(log)
    if args.prepare_only:
        log(f"--prepare-only のため終了．シーン YAML: {TMP_DIR}")
        log_file.close()
        return 0

    rows: list[dict] = []
    for shape, size in jobs():
        if args.shapes and shape not in args.shapes:
            continue
        row = run_job(shape, size, scenes[(shape, size)], log)
        if row is not None:
            rows.append(row)
            write_summary(rows)

    log(f"\n=== 完了: {len(rows)} 条件 ===")
    log(f"  集約 CSV: {OUTPUT_BASE / 'summary.csv'}")
    log_file.close()
    return 0


if __name__ == "__main__":
    sys.exit(main())
