"""`core/roi.py` の検証（SPEC_02 (0)「ROI 平均色」・PLAN_02 ステップ 4）．

仕様上の要件は以下．

- クリック点ごとに 51×51 px の矩形 ROI の平均 BGR を算出する
- 複数点クリック時は各 ROI 平均の平均（全クリックの合算 ÷ クリック数）を診断に使用する
- クリック可能な最大点数は 4 点（SPEC_01「ROI 指定」）
- ROI が画像外にはみ出す等の不正入力は `core` が例外で通知する（error-handling.md）

テストは実装ではなく仕様を基準とするため，期待値は解析的に手計算できる画像
（単色・座標に比例するグラデーション・単一点のみ明るい画像）で与える．
"""

import numpy as np
import pytest

from tiasjudge.core.roi import (
    MAX_ROI_POINTS,
    ROI_OFFSET,
    ROI_SIZE,
    mean_bgr,
    mean_rgb,
    roi_mean_bgr,
)

ROI_PIXEL_COUNT = ROI_SIZE * ROI_SIZE


# --- テスト用画像の生成ヘルパー ---


def uniform_image(bgr: tuple[int, int, int], width: int = 200, height: int = 200) -> np.ndarray:
    """全画素が同じ BGR の画像を作る．"""
    image = np.empty((height, width, 3), dtype=np.uint8)
    image[:, :] = bgr
    return image


def ramp_image(width: int = 200, height: int = 200, red: int = 7) -> np.ndarray:
    """B = x座標，G = y座標，R = 定数のグラデーション画像を作る．

    ROI（一辺 51 px の奇数サイズ）の平均は中心座標に一致するため，
    「クリック点を中心とする 51×51」であることを解析的に検証できる．
    """
    image = np.zeros((height, width, 3), dtype=np.uint8)
    image[:, :, 0] = np.arange(width, dtype=np.uint8)[np.newaxis, :]
    image[:, :, 1] = np.arange(height, dtype=np.uint8)[:, np.newaxis]
    image[:, :, 2] = red
    return image


# --- 定数（SPEC_02 (0)・SPEC_01） ---


def test_roi_size_is_51_px() -> None:
    """ROI の一辺は 51 px（SPEC_02 (0)）．"""
    assert ROI_SIZE == 51


def test_roi_offset_centers_the_roi_on_the_click_point() -> None:
    """オフセットは ROI をクリック点中心に置く値（(51 - 1) / 2 = 25）である．"""
    assert ROI_OFFSET == (ROI_SIZE - 1) // 2 == 25


def test_max_roi_points_is_four() -> None:
    """クリック可能な最大点数は 4 点（SPEC_01「ROI 指定」）．"""
    assert MAX_ROI_POINTS == 4


# --- 単一 ROI の平均（SPEC_02 (0)） ---


def test_uniform_image_returns_the_same_bgr() -> None:
    """単色画像の ROI 平均は，その色そのものになる．"""
    image = uniform_image((10, 20, 30))

    assert roi_mean_bgr(image, (100, 100)) == (10.0, 20.0, 30.0)


@pytest.mark.parametrize("point", [(25, 25), (100, 60), (174, 174)])
def test_ramp_image_mean_equals_the_click_point(point: tuple[int, int]) -> None:
    """B = x・G = y のグラデーションでは，51×51 の平均が中心（クリック点）と一致する．

    ROI が「クリック点中心・一辺 51 px」でなければ平均は中心座標からずれる．
    """
    image = ramp_image()

    blue, green, red = roi_mean_bgr(image, point)

    assert blue == pytest.approx(float(point[0]))
    assert green == pytest.approx(float(point[1]))
    assert red == pytest.approx(7.0)


def test_mean_is_not_rounded_to_integer() -> None:
    """平均は整数に丸めず実数で返す（後段の色変換の精度を落とさない）．"""
    image = np.zeros((200, 200, 3), dtype=np.uint8)
    # ROI 内の 1 画素だけ 1 にすると，平均は 1/2601 の非整数になる
    image[100, 100, 0] = 1

    blue, _, _ = roi_mean_bgr(image, (100, 100))

    assert blue == pytest.approx(1.0 / ROI_PIXEL_COUNT)


@pytest.mark.parametrize(
    "offset",
    [(-ROI_OFFSET, -ROI_OFFSET), (ROI_OFFSET, ROI_OFFSET), (-ROI_OFFSET, ROI_OFFSET), (0, 0)],
)
def test_pixels_inside_the_51x51_window_are_counted(offset: tuple[int, int]) -> None:
    """中心から ±25 px（= 51×51 の四隅・中央）の画素は ROI に含まれる．"""
    center = (100, 100)
    image = np.zeros((200, 200, 3), dtype=np.uint8)
    image[center[1] + offset[1], center[0] + offset[0], 0] = 255

    blue, _, _ = roi_mean_bgr(image, center)

    assert blue == pytest.approx(255.0 / ROI_PIXEL_COUNT)


@pytest.mark.parametrize(
    "offset",
    [(-ROI_OFFSET - 1, 0), (ROI_OFFSET + 1, 0), (0, -ROI_OFFSET - 1), (0, ROI_OFFSET + 1)],
)
def test_pixels_outside_the_51x51_window_are_ignored(offset: tuple[int, int]) -> None:
    """中心から 26 px 離れた画素は ROI に含まれない（ROI は 51×51 に収まる）．"""
    center = (100, 100)
    image = np.zeros((200, 200, 3), dtype=np.uint8)
    image[center[1] + offset[1], center[0] + offset[0], 0] = 255

    assert roi_mean_bgr(image, center) == (0.0, 0.0, 0.0)


def test_mean_matches_an_independent_computation() -> None:
    """任意の画像でも ROI 内 2601 画素の単純平均になる（`cv::mean` 相当）．

    期待値は実装とは別経路（float64 での全画素平均）で計算する．
    """
    rng = np.random.default_rng(seed=20240401)
    image = rng.integers(0, 256, size=(200, 200, 3), dtype=np.uint8)
    center = (100, 80)
    window = image[
        center[1] - ROI_OFFSET : center[1] + ROI_OFFSET + 1,
        center[0] - ROI_OFFSET : center[0] + ROI_OFFSET + 1,
    ].astype(np.float64)
    expected = tuple(window.reshape(-1, 3).mean(axis=0))

    assert roi_mean_bgr(image, center) == pytest.approx(expected)


def test_accepts_a_cropped_view_of_an_image() -> None:
    """クロップ後の部分ビュー（非連続配列）でも同じ結果になる．

    SPEC_01 の操作フローではクロップ後の画像上でクリックするため，
    ビューを渡しても座標どおりに ROI を切り出せる必要がある．
    """
    full = ramp_image(width=400, height=400)
    cropped = full[100:300, 100:300]  # 元画像の (100, 100) が原点になる

    blue, green, red = roi_mean_bgr(cropped, (60, 40))

    assert blue == pytest.approx(160.0)
    assert green == pytest.approx(140.0)
    assert red == pytest.approx(7.0)


def test_high_bit_depth_image_is_not_truncated() -> None:
    """8 bit を超える値の画像でも平均が桁あふれ・切り捨てされない．"""
    image = np.full((200, 200, 3), 1000, dtype=np.uint16)

    assert roi_mean_bgr(image, (100, 100)) == (1000.0, 1000.0, 1000.0)


# --- 複数点の平均（SPEC_02 (0)「合算 ÷ クリック数」） ---


def test_single_point_mean_matches_roi_mean() -> None:
    """1 点だけのときは，その ROI 平均がそのまま診断に使われる．"""
    image = ramp_image()

    assert mean_bgr(image, [(60, 80)]) == roi_mean_bgr(image, (60, 80))


def test_two_points_are_averaged() -> None:
    """2 点クリック時は 2 つの ROI 平均の平均になる（端数も保持する）．"""
    image = ramp_image()

    blue, green, red = mean_bgr(image, [(40, 30), (101, 90)])

    assert blue == pytest.approx((40.0 + 101.0) / 2.0)
    assert green == pytest.approx((30.0 + 90.0) / 2.0)
    assert red == pytest.approx(7.0)


def test_four_points_are_averaged() -> None:
    """最大クリック数（4 点）でも合算 ÷ クリック数になる．"""
    image = ramp_image()
    points = [(30, 30), (60, 40), (90, 50), (120, 61)]

    blue, green, _ = mean_bgr(image, points)

    assert blue == pytest.approx(sum(x for x, _ in points) / len(points))
    assert green == pytest.approx(sum(y for _, y in points) / len(points))


def test_mean_of_max_points_equals_the_average_of_each_roi_mean() -> None:
    """最大クリック数（4 点）の平均は，各 ROI 平均の合算 ÷ クリック数と一致する．"""
    image = ramp_image()
    points = [(30, 31), (60, 42), (91, 50), (120, 64)][:MAX_ROI_POINTS]
    per_point = [roi_mean_bgr(image, point) for point in points]
    expected = tuple(sum(values) / MAX_ROI_POINTS for values in zip(*per_point, strict=True))

    assert len(points) == MAX_ROI_POINTS
    assert mean_bgr(image, points) == pytest.approx(expected)


def test_repeated_point_is_counted_each_time() -> None:
    """同じ点を複数回クリックした場合もクリック数で割る（重複を除去しない）．"""
    image = ramp_image()

    assert mean_bgr(image, [(60, 80), (60, 80)]) == pytest.approx(roi_mean_bgr(image, (60, 80)))


def test_average_is_over_roi_means_not_over_all_pixels() -> None:
    """複数点の平均は「各 ROI 平均の平均」である（重なりも含めた画素平均ではない）．"""
    image = uniform_image((0, 0, 0))
    # 左側の ROI だけ 100，右側は 0 のままにする
    image[75:126, 75:126] = (100, 100, 100)

    assert mean_bgr(image, [(100, 100), (160, 100)]) == pytest.approx((50.0, 50.0, 50.0))


def test_mean_rgb_reorders_channels() -> None:
    """`mean_rgb` は (R, G, B) の順で返す（`color.rgb_to_lab` の入力並び）．"""
    image = uniform_image((10, 20, 30))
    points = [(100, 100)]

    assert mean_rgb(image, points) == (30.0, 20.0, 10.0)
    assert mean_rgb(image, points) == tuple(reversed(mean_bgr(image, points)))


# --- 異常系（error-handling.md） ---


@pytest.mark.parametrize(
    "point",
    [
        (ROI_OFFSET - 1, 100),  # 左にはみ出す
        (100, ROI_OFFSET - 1),  # 上にはみ出す
        (200 - ROI_OFFSET, 100),  # 右にはみ出す
        (100, 200 - ROI_OFFSET),  # 下にはみ出す
        (-5, -5),
    ],
)
def test_out_of_range_point_raises(point: tuple[int, int]) -> None:
    """ROI が画像外にはみ出す場合はエラーにする（旧実装のクラッシュを防ぐ）．"""
    image = ramp_image(width=200, height=200)

    with pytest.raises(ValueError):
        roi_mean_bgr(image, point)


def test_roi_touching_the_image_edge_is_valid() -> None:
    """ROI が画像端にちょうど収まる位置は有効である（境界値）．"""
    image = uniform_image((1, 2, 3), width=51, height=51)

    assert roi_mean_bgr(image, (ROI_OFFSET, ROI_OFFSET)) == (1.0, 2.0, 3.0)


def test_grayscale_image_raises() -> None:
    """BGR 3 チャンネル以外の画像は受け付けない．"""
    with pytest.raises(ValueError):
        roi_mean_bgr(np.zeros((200, 200), dtype=np.uint8), (100, 100))


def test_empty_image_raises() -> None:
    """空の画像は受け付けない．"""
    with pytest.raises(ValueError):
        roi_mean_bgr(np.zeros((0, 0, 3), dtype=np.uint8), (100, 100))


def test_no_points_raises() -> None:
    """クリック点が 0 個のときは診断に使う平均色を作れない．"""
    with pytest.raises(ValueError):
        mean_bgr(ramp_image(), [])


def test_out_of_range_point_in_multi_point_raises() -> None:
    """複数点のうち 1 点でも範囲外ならエラーにする．"""
    image = ramp_image()

    with pytest.raises(ValueError):
        mean_bgr(image, [(100, 100), (5, 5)])
