"""loader.py の単体テスト.

仕様 (SPEC_01_アーキテクチャ設計.md):
  - load_image: PNG を OpenCV で読み込み BGR→RGB 変換して返す
  - extract_roi: NumPy スライスで矩形 ROI を切り出す

テスト方針 (GUIDE_08_テスト方針.md):
  - pytest を使用
  - テスト用画像は tests/fixtures/ に配置するか numpy.zeros 等で合成する
  - extract_roi は純粋な NumPy 操作のためダミー配列でテスト可能
  - load_image はテスト用の小さな PNG を一時生成して使用する
"""

import re
import tempfile
from pathlib import Path

import cv2
import numpy
import pytest

from src.io.loader import extract_roi, load_image


# ---------------------------------------------------------------------------
# ヘルパー: テスト用 PNG を一時ファイルとして生成する
# ---------------------------------------------------------------------------


def _write_test_png(path: str, image: numpy.ndarray) -> None:
    """BGR の NumPy 配列をPNG として保存するヘルパー."""
    cv2.imwrite(path, image)


# ---------------------------------------------------------------------------
# load_image テスト
# ---------------------------------------------------------------------------


class TestLoadImage:
    """load_image 関数のテスト群."""

    def test_returns_rgb_ndarray_with_correct_shape(self, tmp_path: Path) -> None:
        """正常: PNG を読み込み H x W x 3 の uint8 配列が返ること."""
        # 8x6 の BGR 画像を生成して保存
        bgr_image = numpy.zeros((6, 8, 3), dtype=numpy.uint8)
        png_path = str(tmp_path / "test.png")
        _write_test_png(png_path, bgr_image)

        result = load_image(png_path)

        assert isinstance(result, numpy.ndarray)
        assert result.ndim == 3
        assert result.shape == (6, 8, 3)
        assert result.dtype == numpy.uint8

    def test_bgr_to_rgb_conversion_is_correct(self, tmp_path: Path) -> None:
        """正常: BGR→RGB 変換が正しく行われていること.

        OpenCV は BGR 順で保存・読み込みするため、変換後は R と B が入れ替わる.
        既知の BGR 値を持つ 1x1 画像で検証する.
        """
        # BGR = (100, 150, 200) の 1x1 画像を保存
        bgr_value = (100, 150, 200)  # B=100, G=150, R=200
        bgr_image = numpy.array([[[bgr_value[0], bgr_value[1], bgr_value[2]]]], dtype=numpy.uint8)
        png_path = str(tmp_path / "color_test.png")
        _write_test_png(png_path, bgr_image)

        result = load_image(png_path)

        # load_image 後は RGB 順になるはず: R=200, G=150, B=100
        assert result[0, 0, 0] == 200, f"R チャンネルが期待値と異なる: {result[0, 0, 0]}"
        assert result[0, 0, 1] == 150, f"G チャンネルが期待値と異なる: {result[0, 0, 1]}"
        assert result[0, 0, 2] == 100, f"B チャンネルが期待値と異なる: {result[0, 0, 2]}"

    def test_raises_file_not_found_for_nonexistent_path(self) -> None:
        """異常: 存在しないパスで FileNotFoundError が送出されること."""
        with pytest.raises(FileNotFoundError):
            load_image("/nonexistent/path/image.png")

    def test_raises_value_error_for_invalid_format(self, tmp_path: Path) -> None:
        """異常: 不正な形式のファイル（PNG ではないテキスト）で ValueError が送出されること."""
        invalid_path = tmp_path / "invalid.png"
        invalid_path.write_bytes(b"this is not a valid image file content")

        with pytest.raises(ValueError):
            load_image(str(invalid_path))

    def test_file_not_found_error_message_contains_path(self, tmp_path: Path) -> None:
        """異常: FileNotFoundError のメッセージにパスが含まれること."""
        missing = str(tmp_path / "missing.png")

        with pytest.raises(FileNotFoundError, match=re.escape(missing)):
            load_image(missing)

    def test_value_error_message_contains_path(self, tmp_path: Path) -> None:
        """異常: ValueError のメッセージにパスが含まれること."""
        invalid_path = tmp_path / "invalid.png"
        invalid_path.write_bytes(b"not an image")

        with pytest.raises(ValueError, match=re.escape(str(invalid_path))):
            load_image(str(invalid_path))

    def test_uniform_white_image_values(self, tmp_path: Path) -> None:
        """正常: 均一な白画像の全ピクセル値が 255 で返ること."""
        # BGR で全ピクセル (255, 255, 255) → RGB でも (255, 255, 255)
        white_bgr = numpy.full((8, 8, 3), 255, dtype=numpy.uint8)
        png_path = str(tmp_path / "white.png")
        _write_test_png(png_path, white_bgr)

        result = load_image(png_path)

        assert numpy.all(result == 255)


# ---------------------------------------------------------------------------
# extract_roi テスト
# ---------------------------------------------------------------------------


class TestExtractRoi:
    """extract_roi 関数のテスト群."""

    @pytest.fixture
    def sample_image(self) -> numpy.ndarray:
        """テスト用の 10x8 (H=10, W=8) RGB 画像を返す fixture.

        ピクセル値: row * 10 + col で一意にする（チャンネルは同値）.
        """
        image = numpy.zeros((10, 8, 3), dtype=numpy.uint8)
        for row in range(10):
            for col in range(8):
                image[row, col, :] = (row * 10 + col) % 256
        return image

    def test_correct_roi_is_extracted(self, sample_image: numpy.ndarray) -> None:
        """正常: 指定した座標・サイズの ROI が正しく切り出されること."""
        roi = extract_roi(sample_image, x=2, y=3, width=4, height=5)

        assert roi.shape == (5, 4, 3)
        # 切り出し後の左上ピクセルが元画像の (y=3, x=2) と一致すること
        numpy.testing.assert_array_equal(roi[0, 0], sample_image[3, 2])

    def test_roi_contents_match_original(self, sample_image: numpy.ndarray) -> None:
        """正常: 切り出した ROI の全ピクセル値が元画像と一致すること."""
        x, y, width, height = 1, 2, 3, 4
        roi = extract_roi(sample_image, x=x, y=y, width=width, height=height)

        expected = sample_image[y : y + height, x : x + width]
        numpy.testing.assert_array_equal(roi, expected)

    def test_full_image_roi(self, sample_image: numpy.ndarray) -> None:
        """正常: 画像全体を ROI として指定した場合、元画像と同一内容が返ること."""
        img_h, img_w = sample_image.shape[:2]
        roi = extract_roi(sample_image, x=0, y=0, width=img_w, height=img_h)

        assert roi.shape == sample_image.shape
        numpy.testing.assert_array_equal(roi, sample_image)

    def test_single_pixel_roi(self, sample_image: numpy.ndarray) -> None:
        """正常: 1x1 ピクセルの ROI を切り出せること."""
        roi = extract_roi(sample_image, x=3, y=4, width=1, height=1)

        assert roi.shape == (1, 1, 3)
        numpy.testing.assert_array_equal(roi[0, 0], sample_image[4, 3])

    def test_roi_at_bottom_right_corner(self, sample_image: numpy.ndarray) -> None:
        """正常: 右下隅ギリギリの ROI を切り出せること."""
        img_h, img_w = sample_image.shape[:2]
        # 右下 1x1 ピクセル
        roi = extract_roi(sample_image, x=img_w - 1, y=img_h - 1, width=1, height=1)

        assert roi.shape == (1, 1, 3)
        numpy.testing.assert_array_equal(roi[0, 0], sample_image[img_h - 1, img_w - 1])

    def test_raises_value_error_for_negative_x(self, sample_image: numpy.ndarray) -> None:
        """異常: x < 0 の場合に ValueError が送出されること."""
        with pytest.raises(ValueError):
            extract_roi(sample_image, x=-1, y=0, width=3, height=3)

    def test_raises_value_error_for_negative_y(self, sample_image: numpy.ndarray) -> None:
        """異常: y < 0 の場合に ValueError が送出されること."""
        with pytest.raises(ValueError):
            extract_roi(sample_image, x=0, y=-1, width=3, height=3)

    def test_raises_value_error_for_zero_width(self, sample_image: numpy.ndarray) -> None:
        """異常: width = 0 の場合に ValueError が送出されること."""
        with pytest.raises(ValueError):
            extract_roi(sample_image, x=0, y=0, width=0, height=3)

    def test_raises_value_error_for_zero_height(self, sample_image: numpy.ndarray) -> None:
        """異常: height = 0 の場合に ValueError が送出されること."""
        with pytest.raises(ValueError):
            extract_roi(sample_image, x=0, y=0, width=3, height=0)

    def test_raises_value_error_for_negative_width(self, sample_image: numpy.ndarray) -> None:
        """異常: width < 0 の場合に ValueError が送出されること."""
        with pytest.raises(ValueError):
            extract_roi(sample_image, x=0, y=0, width=-5, height=3)

    def test_raises_value_error_for_negative_height(self, sample_image: numpy.ndarray) -> None:
        """異常: height < 0 の場合に ValueError が送出されること."""
        with pytest.raises(ValueError):
            extract_roi(sample_image, x=0, y=0, width=3, height=-5)

    def test_raises_value_error_when_roi_exceeds_width(self, sample_image: numpy.ndarray) -> None:
        """異常: x + width が画像幅を超える場合に ValueError が送出されること."""
        img_h, img_w = sample_image.shape[:2]
        with pytest.raises(ValueError):
            extract_roi(sample_image, x=img_w - 1, y=0, width=2, height=3)

    def test_raises_value_error_when_roi_exceeds_height(self, sample_image: numpy.ndarray) -> None:
        """異常: y + height が画像高さを超える場合に ValueError が送出されること."""
        img_h, img_w = sample_image.shape[:2]
        with pytest.raises(ValueError):
            extract_roi(sample_image, x=0, y=img_h - 1, width=3, height=2)

    def test_raises_value_error_when_roi_starts_outside_image(
        self, sample_image: numpy.ndarray
    ) -> None:
        """異常: x が画像幅以上の場合に ValueError が送出されること."""
        img_h, img_w = sample_image.shape[:2]
        with pytest.raises(ValueError):
            extract_roi(sample_image, x=img_w, y=0, width=1, height=1)

    def test_return_type_is_ndarray(self, sample_image: numpy.ndarray) -> None:
        """正常: 戻り値が numpy.ndarray であること."""
        roi = extract_roi(sample_image, x=0, y=0, width=4, height=4)

        assert isinstance(roi, numpy.ndarray)

    def test_return_dtype_is_uint8(self, sample_image: numpy.ndarray) -> None:
        """正常: 戻り値の dtype が uint8 であること."""
        roi = extract_roi(sample_image, x=0, y=0, width=4, height=4)

        assert roi.dtype == numpy.uint8

    def test_return_shape_is_height_x_width_x_3(self, sample_image: numpy.ndarray) -> None:
        """正常: 戻り値の shape が (height, width, 3) であること."""
        roi = extract_roi(sample_image, x=1, y=2, width=5, height=3)

        assert roi.shape == (3, 5, 3)
