"""輝度分布「形」評価指標モジュール．

現状の均一性指標（CoV / 勾配 / C/P / 動径 min/max）は不均一性の絶対量しか測れず，
Y 軸単調勾配と中央ピーク楕円のように「形が構造的に異なる」パターンを区別できない．
本モジュールは「形」を直接評価する 5 つの純関数を提供する．

References:
    [1] TECH_11_形状指標の見直しと新指標導入
        docs/05_TECH/TECH_11_形状指標の見直しと新指標導入.md
"""

from __future__ import annotations

import numpy


def calc_intensity_centroid(
    image: numpy.ndarray,
    mask: numpy.ndarray | None = None,
) -> dict[str, float]:
    """輝度重心を計算する．

    各ピクセルの輝度値を重みとして ROI 内の重心座標を算出する．
    重心が ROI 中心から大きくずれていれば，均一でない輝度分布や
    非対称な光源配置を示す．

    Args:
        image: 2D (H, W) 輝度マップ．負値は ReLU で 0 に．
        mask: 2D bool マスク（image と同 shape）．True のピクセルのみ計算対象．
            None なら全画素を対象とする．

    Returns:
        以下のキーを持つ辞書:
        - ``"centroid_x_px"`` (float): ROI 座標系での重心 X（列方向，左端 = 0）．
        - ``"centroid_y_px"`` (float): ROI 座標系での重心 Y（行方向，上端 = 0）．
        - ``"centroid_x_norm"`` (float): ROI 中心を原点とした正規化 X（-1〜+1）．
        - ``"centroid_y_norm"`` (float): ROI 中心を原点とした正規化 Y（-1〜+1）．
        - ``"centroid_offset_norm"`` (float): 正規化座標での中心からの距離 √(x²+y²)．

    Raises:
        ValueError: mask の shape が image と異なる場合．
        ValueError: 有効ピクセルの輝度総和が 0 の場合（重心計算不能）．
    """
    if image.ndim != 2:
        msg = f"image は 2D 配列を期待します（ndim={image.ndim}）"
        raise ValueError(msg)

    if mask is not None:
        if mask.shape != image.shape:
            msg = (
                f"mask の shape {mask.shape} が image の shape {image.shape} と異なります"
            )
            raise ValueError(msg)

    h, w = image.shape

    # 負値は 0 に（ReLU）
    img = numpy.maximum(image.astype(numpy.float64), 0.0)

    if mask is not None:
        img = numpy.where(mask, img, 0.0)

    total_weight = float(img.sum())
    if total_weight == 0.0:
        msg = "有効ピクセルの輝度総和が 0 のため重心を計算できません"
        raise ValueError(msg)

    # グリッド座標（ピクセル中心）
    ys = numpy.arange(h, dtype=numpy.float64).reshape(-1, 1)
    xs = numpy.arange(w, dtype=numpy.float64).reshape(1, -1)

    centroid_x_px = float((img * xs).sum() / total_weight)
    centroid_y_px = float((img * ys).sum() / total_weight)

    # ROI 中心を原点とした正規化座標
    cx = (w - 1) / 2.0
    cy = (h - 1) / 2.0
    # 正規化: ±1 が ROI の端になるよう cx/cy で割る
    centroid_x_norm = (centroid_x_px - cx) / (cx if cx > 0.0 else 1.0)
    centroid_y_norm = (centroid_y_px - cy) / (cy if cy > 0.0 else 1.0)
    centroid_offset_norm = float(numpy.sqrt(centroid_x_norm**2 + centroid_y_norm**2))

    return {
        "centroid_x_px": centroid_x_px,
        "centroid_y_px": centroid_y_px,
        "centroid_x_norm": centroid_x_norm,
        "centroid_y_norm": centroid_y_norm,
        "centroid_offset_norm": centroid_offset_norm,
    }


def calc_angular_radial_decomposition(
    image: numpy.ndarray,
    n_radial_bins: int = 20,
    n_angular_bins: int = 36,
    mask: numpy.ndarray | None = None,
) -> dict[str, float]:
    """輝度マップを極座標で角度/半径方向に分解する．

    画像を画像中心（ROI 中心）を原点とする極座標 (r, θ) に変換し，
    放射方向の分散と角度方向の分散を分離することで「形」の方向性を数値化する．

    アルゴリズム:
        1. 画像中心を原点とする極座標に変換
        2. (r_bin, θ_bin) のグリッドで輝度を分類 → I[r_bin, θ_bin]
        3. angular_variance = mean_r[ Var_θ[I | r] ]  各 r での角度方向 var の半径平均
        4. radial_variance  = Var_r[ mean_θ[I | r] ]  角度平均した放射プロファイルの var
        5. angular_ratio = angular_variance / (angular_variance + radial_variance)

    物理的意味:
        - ``angular_ratio ≈ 0``: 完全同心円（各 r での角度変化が小 → 中央ピーク楕円に近い）
        - ``angular_ratio ≈ 0.5``: 方向性ありの典型的非均一パターン
        - ``angular_ratio ≈ 1``: 完全角度依存（強い方向性ムラ）
        - 中央ピーク楕円: angular_ratio が小さい（0.1〜0.3）
        - Y 単調勾配: angular_ratio が中程度（0.3〜0.5）

    Notes:
        TECH_11 で示した「Y 単調勾配 → angular_ratio 0.3〜0.5」は
        実シミュ画像（ノイズ込み・有限解像度）での典型値である．
        純粋な Y 軸方向均一勾配（横縞画像）を合成すると，極座標のビン集計上
        角度方向変動が支配的になるため angular_ratio は 0.5 を超える場合がある．
        「Y 軸勾配は同心円より angular_ratio が高い」という方向性は
        理論・合成画像ともに成立する．

    Args:
        image: 2D (H, W) 輝度マップ．負値は 0 として扱う．
        n_radial_bins: 半径方向のビン数．デフォルト 20．
        n_angular_bins: 角度方向のビン数（360° を等分）．デフォルト 36（各 10°）．
        mask: 2D bool マスク（image と同 shape）．True のピクセルのみ計算対象．
            None なら全画素を対象とする．

    Returns:
        以下のキーを持つ辞書:
        - ``"angular_variance"`` (float): 角度方向分散の平均
        - ``"radial_variance"`` (float): 放射方向分散
        - ``"angular_ratio"`` (float): 0〜1 の形指標（0=同心円，1=角度依存）．
          完全均一画像（全 variance = 0）では NaN を返す．

    Raises:
        ValueError: mask の shape が image と異なる場合．
    """
    if image.ndim != 2:
        msg = f"image は 2D 配列を期待します（ndim={image.ndim}）"
        raise ValueError(msg)

    if mask is not None:
        if mask.shape != image.shape:
            msg = (
                f"mask の shape {mask.shape} が image の shape {image.shape} と異なります"
            )
            raise ValueError(msg)

    h, w = image.shape
    img = numpy.maximum(image.astype(numpy.float64), 0.0)

    if mask is not None:
        img = numpy.where(mask, img, numpy.nan)

    # 画像中心を原点とした座標グリッド
    cy = (h - 1) / 2.0
    cx = (w - 1) / 2.0
    ys = numpy.arange(h, dtype=numpy.float64).reshape(-1, 1) - cy
    xs = numpy.arange(w, dtype=numpy.float64).reshape(1, -1) - cx

    # 極座標変換
    r = numpy.sqrt(xs**2 + ys**2)
    theta = numpy.arctan2(ys, xs)  # [-π, +π]

    # r を [0, r_max] の n_radial_bins ビンに割り当て
    r_max = float(r.max())
    if r_max == 0.0:
        return {
            "angular_variance": 0.0,
            "radial_variance": 0.0,
            "angular_ratio": float("nan"),
        }

    r_norm = r / r_max  # [0, 1]

    # θ を [-π, π] → [0, 2π] へシフトして [0, n_angular_bins) に bin 化
    theta_shifted = theta + numpy.pi  # [0, 2π]
    r_bin_indices = numpy.minimum(
        (r_norm * n_radial_bins).astype(int), n_radial_bins - 1
    )
    theta_bin_indices = numpy.minimum(
        (theta_shifted / (2.0 * numpy.pi) * n_angular_bins).astype(int),
        n_angular_bins - 1,
    )

    # 各 (r_bin, θ_bin) の輝度平均を集計
    # grid[r_bin, theta_bin] = 輝度平均（空セルは nan）
    grid = numpy.full((n_radial_bins, n_angular_bins), numpy.nan, dtype=numpy.float64)
    r_flat = r_bin_indices.ravel()
    t_flat = theta_bin_indices.ravel()
    v_flat = img.ravel()

    # 有効なピクセルだけを集計する
    valid_mask_flat = ~numpy.isnan(v_flat)
    valid_r = r_flat[valid_mask_flat]
    valid_t = t_flat[valid_mask_flat]
    valid_v = v_flat[valid_mask_flat]

    # numpy.add.at で各セルに値を累積し，カウントで割って平均を得る
    sum_grid = numpy.zeros((n_radial_bins, n_angular_bins), dtype=numpy.float64)
    count_grid = numpy.zeros((n_radial_bins, n_angular_bins), dtype=numpy.int64)
    numpy.add.at(sum_grid, (valid_r, valid_t), valid_v)
    numpy.add.at(count_grid, (valid_r, valid_t), 1)

    valid_cells = count_grid > 0
    grid[valid_cells] = sum_grid[valid_cells] / count_grid[valid_cells]

    # angular_variance: 各 r_bin について角度方向の分散を算出し，r 方向に平均
    angular_var_per_r = numpy.full(n_radial_bins, numpy.nan, dtype=numpy.float64)
    for r_idx in range(n_radial_bins):
        row = grid[r_idx, :]
        valid_vals = row[~numpy.isnan(row)]
        if valid_vals.size >= 2:
            angular_var_per_r[r_idx] = float(numpy.var(valid_vals, ddof=0))
        elif valid_vals.size == 1:
            angular_var_per_r[r_idx] = 0.0
        # else: 有効セルなし → nan のまま

    valid_ang_var = angular_var_per_r[~numpy.isnan(angular_var_per_r)]
    angular_variance = float(numpy.mean(valid_ang_var)) if valid_ang_var.size > 0 else 0.0

    # radial_variance: 各 r_bin について角度方向の平均を算出し，その r 方向の分散
    radial_mean_per_r = numpy.full(n_radial_bins, numpy.nan, dtype=numpy.float64)
    for r_idx in range(n_radial_bins):
        row = grid[r_idx, :]
        valid_vals = row[~numpy.isnan(row)]
        if valid_vals.size > 0:
            radial_mean_per_r[r_idx] = float(numpy.mean(valid_vals))

    valid_radial_means = radial_mean_per_r[~numpy.isnan(radial_mean_per_r)]
    radial_variance = (
        float(numpy.var(valid_radial_means, ddof=0)) if valid_radial_means.size >= 2 else 0.0
    )

    total_variance = angular_variance + radial_variance
    if total_variance == 0.0:
        angular_ratio = float("nan")
    else:
        angular_ratio = angular_variance / total_variance

    return {
        "angular_variance": angular_variance,
        "radial_variance": radial_variance,
        "angular_ratio": angular_ratio,
    }


def calc_ssim(
    image: numpy.ndarray,
    reference: numpy.ndarray,
    mask: numpy.ndarray | None = None,
    data_range: float | None = None,
) -> float:
    """構造的類似度 (Structural Similarity Index) を算出する．

    ``skimage.metrics.structural_similarity`` を使用する．
    形（空間構造）の類似性を 0〜1 で数値化する．1.0 で完全一致．

    Args:
        image: 評価対象 2D 輝度マップ（H, W）．
        reference: 参照画像 2D 輝度マップ（image と同 shape）．
        mask: 2D bool マスク．True ピクセルのみを計算対象とするため，
            mask 外を 0 で埋めた上で full=True モードの SSIM 全体値を用いる．
        data_range: 輝度値の最大レンジ（``image.max() - reference.max()`` の max）．
            None のとき ``max(image.max(), reference.max())`` を使う．

    Returns:
        SSIM 値（float, 0〜1）．

    Raises:
        ValueError: image と reference の shape が異なる場合．
        ValueError: mask の shape が image と異なる場合．
        ImportError: scikit-image がインストールされていない場合．
    """
    try:
        from skimage.metrics import structural_similarity  # noqa: PLC0415
    except ImportError as exc:
        msg = (
            "calc_ssim には scikit-image が必要です．"
            "pip install scikit-image>=0.21.0 でインストールしてください．"
        )
        raise ImportError(msg) from exc

    if image.ndim != 2 or reference.ndim != 2:
        msg = "image と reference は 2D 配列を期待します"
        raise ValueError(msg)

    if image.shape != reference.shape:
        msg = (
            f"image の shape {image.shape} と reference の shape {reference.shape} が異なります．"
            "事前に resize してください．"
        )
        raise ValueError(msg)

    if mask is not None:
        if mask.shape != image.shape:
            msg = (
                f"mask の shape {mask.shape} が image の shape {image.shape} と異なります"
            )
            raise ValueError(msg)

    img = image.astype(numpy.float64)
    ref = reference.astype(numpy.float64)

    if mask is not None:
        img = numpy.where(mask, img, 0.0)
        ref = numpy.where(mask, ref, 0.0)

    if data_range is None:
        data_range = float(max(img.max(), ref.max()))
        if data_range == 0.0:
            data_range = 1.0  # 全ゼロ画像でのゼロ除算を防ぐ

    ssim_val = structural_similarity(img, ref, data_range=data_range)
    return float(ssim_val)


def calc_ncc(
    image: numpy.ndarray,
    reference: numpy.ndarray,
    mask: numpy.ndarray | None = None,
) -> float:
    """正規化相関係数 (Normalized Cross-Correlation) を算出する．

    NCC = Σ((I - mean(I)) * (R - mean(R))) / sqrt(Σ(I - mean(I))² * Σ(R - mean(R))²)

    絶対輝度スケールに依存しない形の類似性指標．1.0 で完全相関（形が一致），
    0 で無相関，-1 で逆相関．SSIM より単純だが照明変動に対しロバスト．

    Args:
        image: 評価対象 2D 輝度マップ（H, W）．
        reference: 参照画像 2D 輝度マップ（image と同 shape）．
        mask: 2D bool マスク（image と同 shape）．True のピクセルのみ計算対象．
            None なら全画素を対象とする．

    Returns:
        NCC 値（float, -1〜+1）．
        分散がゼロの場合（均一画像）は NaN を返す．

    Raises:
        ValueError: image と reference の shape が異なる場合．
        ValueError: mask の shape が image と異なる場合．
    """
    if image.ndim != 2 or reference.ndim != 2:
        msg = "image と reference は 2D 配列を期待します"
        raise ValueError(msg)

    if image.shape != reference.shape:
        msg = (
            f"image の shape {image.shape} と reference の shape {reference.shape} が異なります"
        )
        raise ValueError(msg)

    if mask is not None:
        if mask.shape != image.shape:
            msg = (
                f"mask の shape {mask.shape} が image の shape {image.shape} と異なります"
            )
            raise ValueError(msg)

    img = image.astype(numpy.float64).ravel()
    ref = reference.astype(numpy.float64).ravel()

    if mask is not None:
        mask_flat = mask.ravel()
        img = img[mask_flat]
        ref = ref[mask_flat]

    if img.size == 0:
        return float("nan")

    img_centered = img - img.mean()
    ref_centered = ref - ref.mean()

    denom = float(
        numpy.sqrt(numpy.sum(img_centered**2) * numpy.sum(ref_centered**2))
    )
    if denom == 0.0:
        return float("nan")

    ncc_val = float(numpy.sum(img_centered * ref_centered) / denom)
    # 数値誤差で [-1, 1] の範囲をわずかに超える可能性があるためクリップ
    return float(numpy.clip(ncc_val, -1.0, 1.0))


def calc_intensity_pca(
    image: numpy.ndarray,
    mask: numpy.ndarray | None = None,
) -> dict[str, float]:
    """輝度分布の主成分分析で主軸方向と楕円率を計算する．

    各ピクセルを (x, y, weight=I(x,y)) とした weighted PCA で
    輝度分布の「伸び方向」と「偏心度」を数値化する．

    アルゴリズム:
        1. 輝度を重みとして重心座標 (cx, cy) を算出
        2. 重心からのオフセット (Δx, Δy) に輝度重みを掛けて共分散行列を構築
        3. 固有値分解 → 主軸方向（第 1 固有ベクトルの角度）と固有値

    物理的意味:
        - ``primary_axis_angle_deg ≈ 90°``: 主軸が Y 軸方向（Y 単調勾配の典型）
        - ``eccentricity ≈ 0``: 完全円形分布（等方的中央ピーク）
        - ``eccentricity ≈ 1``: 極端な楕円（強い方向性の輝度分布）

    Args:
        image: 2D (H, W) 輝度マップ．負値は 0 として扱う．
        mask: 2D bool マスク（image と同 shape）．True のピクセルのみ計算対象．
            None なら全画素を対象とする．

    Returns:
        以下のキーを持つ辞書:
        - ``"primary_axis_angle_deg"`` (float): 主軸の角度 [deg]（画像 x 軸基準，-90〜+90）．
        - ``"primary_eigenvalue"`` (float): 第 1 固有値（主軸方向の分散の大きさ）．
        - ``"secondary_eigenvalue"`` (float): 第 2 固有値（副軸方向の分散の大きさ）．
        - ``"eccentricity"`` (float): √(1 - secondary/primary)，0=完全円，1=極端な楕円．
          primary_eigenvalue が 0 の場合は 0 を返す．

    Raises:
        ValueError: mask の shape が image と異なる場合．
        ValueError: 有効ピクセルの輝度総和が 0 の場合（PCA 計算不能）．
    """
    if image.ndim != 2:
        msg = f"image は 2D 配列を期待します（ndim={image.ndim}）"
        raise ValueError(msg)

    if mask is not None:
        if mask.shape != image.shape:
            msg = (
                f"mask の shape {mask.shape} が image の shape {image.shape} と異なります"
            )
            raise ValueError(msg)

    h, w = image.shape
    img = numpy.maximum(image.astype(numpy.float64), 0.0)

    if mask is not None:
        img = numpy.where(mask, img, 0.0)

    # グリッド座標（ピクセル中心）
    ys = numpy.arange(h, dtype=numpy.float64).reshape(-1, 1)
    xs = numpy.arange(w, dtype=numpy.float64).reshape(1, -1)

    total_weight = float(img.sum())
    if total_weight == 0.0:
        msg = "有効ピクセルの輝度総和が 0 のため PCA を計算できません"
        raise ValueError(msg)

    # 重心
    cx = float((img * xs).sum() / total_weight)
    cy = float((img * ys).sum() / total_weight)

    # 重心からのオフセット
    dx = xs - cx
    dy = ys - cy

    # weighted 共分散行列の要素
    cov_xx = float((img * dx * dx).sum() / total_weight)
    cov_xy = float((img * dx * dy).sum() / total_weight)
    cov_yy = float((img * dy * dy).sum() / total_weight)

    cov_matrix = numpy.array([[cov_xx, cov_xy], [cov_xy, cov_yy]], dtype=numpy.float64)

    # 固有値分解（numpy.linalg.eigh は対称行列向けで安定かつ固有値昇順保証）
    eigenvalues, eigenvectors = numpy.linalg.eigh(cov_matrix)
    # eigh は昇順なので，最大固有値は eigenvalues[1]
    primary_eigenvalue = float(eigenvalues[1])
    secondary_eigenvalue = float(eigenvalues[0])

    # 主軸ベクトル（第 1 固有値に対応する固有ベクトル）
    primary_vec = eigenvectors[:, 1]  # shape (2,): [x_component, y_component]
    angle_rad = float(numpy.arctan2(primary_vec[1], primary_vec[0]))
    angle_deg = float(numpy.degrees(angle_rad))
    # -90〜+90 の範囲に折り畳む（主軸は方向性を持たないため）
    if angle_deg > 90.0:
        angle_deg -= 180.0
    elif angle_deg < -90.0:
        angle_deg += 180.0

    # 離心率 eccentricity = √(1 - secondary/primary)
    # primary が 0 の場合（全画素同一値）は 0 を返す
    if primary_eigenvalue <= 0.0:
        eccentricity = 0.0
    else:
        ratio = secondary_eigenvalue / primary_eigenvalue
        # ratio は理論上 [0, 1] だが数値誤差で負になることがあるためクリップ
        eccentricity = float(numpy.sqrt(max(0.0, 1.0 - ratio)))

    return {
        "primary_axis_angle_deg": angle_deg,
        "primary_eigenvalue": primary_eigenvalue,
        "secondary_eigenvalue": secondary_eigenvalue,
        "eccentricity": eccentricity,
    }
