"""結果画面の QWidget 非依存ロジックの検証（SPEC_01「結果画面」・PLAN_02 ステップ 7）．

仕様上の要件は以下（SPEC_01「結果画面」）．

- 診断画像はクロップ後の元画像を 1/2 縮小表示する
- 結果パネルは 5 色の行（色名 + 説明）を一覧表示し，**上位 2 色のみ**スコア（%）と下線を描く
- 言語切替ボタンは日本語 ⇔ 英語を切り替え，ラベルは切替**先**の言語名
- 診断失敗時は「Diagnosis failed / Please try again.」を表示する
- 注記は「本結果は疾患の診断を行うものではない」「表示画像は明るさ補正されている」の 2 件

※ testing.md の方針により GUI（QWidget）そのものの自動テストは書かない．
本ファイルは `ResultWindow` を構築せず，画面部品に依存しない定数・関数のみを対象とする．
※ 実行環境にフォントが無い場合でも成立するよう，描画結果のピクセル比較は行わない．
"""

from __future__ import annotations

from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path

import numpy as np
import pytest
from PySide6.QtGui import QFont, QPainter, QPixmap
from PySide6.QtWidgets import QApplication

from tiasjudge.core.color import rgb_to_lab
from tiasjudge.core.csv_io import TrainingData, load_training_data
from tiasjudge.core.svm import COLOR_KEYS, DiagnosisResult, diagnose
from tiasjudge.gui.result_window import (
    AFFILIATION_FONT_SIZE,
    AFFILIATION_MAX_WIDTH,
    AFFILIATION_POSITION,
    COLOR_SWATCHES,
    DESCRIPTION_FONT_SIZE,
    DESCRIPTION_FONT_WEIGHT,
    DESCRIPTION_MAX_WIDTH,
    DESCRIPTION_MAX_WIDTH_HIGHLIGHTED,
    DESCRIPTION_MIN_FONT_SIZE,
    DESCRIPTION_X,
    DISPLAY_ORDER,
    FAILURE_DETAIL,
    FAILURE_DETAIL_FONT_SIZE,
    FAILURE_RECT,
    FAILURE_TITLE,
    FAILURE_TITLE_FONT_SIZE,
    HEADING_COLOR,
    HEADING_FONT_SIZE,
    HEADING_POSITION,
    HIGHLIGHT_COUNT,
    IMAGE_DISPLAY_DIVISOR,
    LANGUAGE_TEXTS,
    NAME_FONT_SIZE,
    NAME_MAX_WIDTH,
    NAME_OUTLINE_COLOR,
    NAME_OUTLINE_WIDTH,
    NAME_X,
    NOTE_FONT_SIZE,
    NOTE_POSITIONS,
    PANEL_SIZE,
    PERCENT_FONT_SIZE,
    PERCENT_X,
    ROW_FIRST_Y,
    ROW_MARGIN,
    SCORE_AREA_WIDTH,
    SCORE_FONT_SIZE,
    SCORE_RIGHT_X,
    TEXT_MARGIN,
    UNDERLINE_WIDTH,
    UNDERLINE_X_RANGE,
    Language,
    ResultRow,
    _fit_font,
    _set_font,
    build_result_rows,
    format_score,
    is_highlighted,
    next_language,
    render_result_panel,
    row_baseline,
    switch_button_label,
    texts_for,
    to_display_pixmap,
)

# --- ヘルパー ---


@pytest.fixture(scope="session")
def gui_app() -> QApplication:
    """QPixmap の生成に必要な QApplication を用意する（conftest で offscreen 指定済み）．

    ※ `QGuiApplication` ではなく `QApplication` を作る．他のテスト（`--check` 起動）が
    `QApplication.instance()` を再利用してウィジェットを生成するため，ここで
    ウィジェット非対応のインスタンスを残すとプロセスごと落ちる．
    """
    app = QApplication.instance()
    if app is None:
        app = QApplication([])
    assert isinstance(app, QApplication)
    return app


def make_result(
    percentages: dict[str, int],
    top_labels: tuple[str, str],
    *,
    failed: bool = False,
) -> DiagnosisResult:
    """表示検証用の `DiagnosisResult` を組み立てる（SVM は動かさない）．"""
    scores = {key: percentages[key] / 100.0 for key in COLOR_KEYS}
    return DiagnosisResult(
        lab=rgb_to_lab((180.0, 110.0, 110.0), _linear_coefficients()),
        scores=scores,
        percentages=dict(percentages),
        top_labels=top_labels,
        predictions=np.zeros(190, dtype=np.float32),
        failed=failed,
    )


def _linear_coefficients() -> np.ndarray:
    """1 次項のみの合成変換行列（SPEC_02 (1) の 17×3 形式）．"""
    coefficients = np.zeros((17, 3), dtype=np.float64)
    coefficients[0] = (0.4124, 0.2126, 0.0193)
    coefficients[1] = (0.3576, 0.7152, 0.1192)
    coefficients[2] = (0.1805, 0.0722, 0.9505)
    return coefficients * (100.0 / 255.0)


SAMPLE_PERCENTAGES = {"purple": 42, "dark_red": 0, "crimson": 5, "pink": 68, "pale": 3}


@pytest.fixture
def sample_result() -> DiagnosisResult:
    """上位 2 色が pink・purple の結果．"""
    return make_result(SAMPLE_PERCENTAGES, ("pink", "purple"))


@pytest.fixture(scope="module")
def training_data(data_csv_path: Path) -> TrainingData:
    """同梱学習データ（`data/data.csv`）を読み込む．"""
    return load_training_data(data_csv_path)


@contextmanager
def painter_on_panel() -> Iterator[QPainter]:
    """パネルと同じ大きさの QPixmap 上の `QPainter` を貸す（フォント設定の検証用）．"""
    pixmap = QPixmap(PANEL_SIZE, PANEL_SIZE)
    painter = QPainter(pixmap)
    try:
        yield painter
    finally:
        painter.end()


def make_image(width: int, height: int, bgr: tuple[int, int, int] = (10, 20, 30)) -> np.ndarray:
    """指定サイズの単色 BGR 画像を作る．"""
    image = np.empty((height, width, 3), dtype=np.uint8)
    image[:, :] = bgr
    return image


# --- 定数（SPEC_01・旧実装 `ResultDlg.cpp` 踏襲） ---


def test_display_order_is_pink_crimson_dark_red_purple_pale() -> None:
    """パネルの並びは上から 淡紅・紅・暗赤・紫・淡白（SPEC_01「5 色の表示内容」）．"""
    assert DISPLAY_ORDER == ("pink", "crimson", "dark_red", "purple", "pale")


def test_display_order_covers_all_color_keys_exactly_once() -> None:
    """5 色すべてを過不足なく含む（`core.svm.COLOR_KEYS` と同じ集合）．"""
    assert len(DISPLAY_ORDER) == len(set(DISPLAY_ORDER)) == len(COLOR_KEYS)
    assert set(DISPLAY_ORDER) == set(COLOR_KEYS)


def test_highlight_count_is_two() -> None:
    """スコア・下線を描くのは上位 2 色のみ（SPEC_01「結果パネル」）．"""
    assert HIGHLIGHT_COUNT == 2


def test_image_is_displayed_at_half_scale() -> None:
    """診断画像は 1/2 縮小表示（SPEC_01「診断画像」）．"""
    assert IMAGE_DISPLAY_DIVISOR == 2


def test_panel_size_follows_legacy() -> None:
    """結果パネルは旧実装の背景画像と同じ 644×644．"""
    assert PANEL_SIZE == 644


def test_row_layout_follows_legacy_coordinates() -> None:
    """行の基準座標は旧実装 `ShowResultImg()` の y=250 / margin=87．"""
    assert (ROW_FIRST_Y, ROW_MARGIN) == (250, 87)


def test_underline_follows_legacy_geometry() -> None:
    """下線は x=15〜635・太さ 8（旧実装 `cv::line(..., 8)`）．"""
    assert UNDERLINE_X_RANGE == (15, 635)
    assert UNDERLINE_WIDTH == 8


def test_failure_band_follows_legacy_rect() -> None:
    """失敗帯は (0, 250)-(550, 450)（旧実装 `cv::rectangle`）．x, y, 幅, 高さで保持する．"""
    x, y, width, height = FAILURE_RECT
    assert (x, y) == (0, 250)
    assert (x + width, y + height) == (550, 450)


def test_failure_message_follows_spec() -> None:
    """失敗表示の文言は「Diagnosis failed / Please try again.」（SPEC_01「診断失敗表示」）．"""
    assert FAILURE_TITLE == "Diagnosis failed"
    assert FAILURE_DETAIL == "Please try again."


# --- 言語リソース（SPEC_01「5 色の表示内容」） ---


class TestLanguageTexts:
    """日英 2 言語分の表示リソース．"""

    def test_both_languages_are_defined(self) -> None:
        assert set(LANGUAGE_TEXTS) == set(Language) == {Language.JAPANESE, Language.ENGLISH}

    @pytest.mark.parametrize("language", list(Language))
    def test_rows_cover_display_order_exactly(self, language: Language) -> None:
        """各言語の行は `DISPLAY_ORDER` の 5 キーを過不足なく持つ．"""
        keys = [row.key for row in texts_for(language).rows]
        assert len(keys) == len(set(keys)) == len(DISPLAY_ORDER)
        assert set(keys) == set(DISPLAY_ORDER)

    @pytest.mark.parametrize("language", list(Language))
    def test_has_two_notes(self, language: Language) -> None:
        """注記は 2 件（SPEC_01「注記」）．"""
        assert len(texts_for(language).notes) == 2

    @pytest.mark.parametrize("language", list(Language))
    def test_note_contents_follow_spec(self, language: Language) -> None:
        """注記は「疾患の診断を行うものではない」「明るさ補正されている」の 2 件．"""
        notes = texts_for(language).notes
        if language is Language.JAPANESE:
            assert "病気" in notes[0] or "疾患" in notes[0]
            assert "明るさ" in notes[1]
        else:
            assert "disease" in notes[0]
            assert "brightness" in notes[1]

    @pytest.mark.parametrize("language", list(Language))
    def test_labels_are_not_empty(self, language: Language) -> None:
        texts = texts_for(language)
        assert texts.window_title
        assert texts.heading
        assert texts.switch_button_label
        assert texts.close_button_label
        assert all(row.name and row.description for row in texts.rows)

    def test_english_names_and_descriptions_match_spec_table(self) -> None:
        """英語の色名・説明は SPEC_01「5 色の表示内容」の表どおり．"""
        expected = {
            "pink": ("Pink", "Normal"),
            "crimson": ("Red", "Hot flush / feverish"),
            "dark_red": ("Dark red", "Blood slightly thick"),
            "purple": ("Purple", "Blood is thick"),
            "pale": ("Pale red", "Cold / fatigue / anemia"),
        }
        actual = {row.key: (row.name, row.description) for row in texts_for(Language.ENGLISH).rows}
        assert actual == expected

    def test_japanese_names_match_spec_table(self) -> None:
        """日本語の色名は SPEC_01「5 色の表示内容」の表どおり．

        5 色目は結果画面では「淡白紅」（メイン画面の「淡白」とは表記が異なる）．
        旧実装の背景画像 `img_student2.bmp` の表記を現行踏襲したもので，
        SPEC_01 の注記に明記されている．
        """
        expected = {
            "pink": "淡紅",
            "crimson": "紅",
            "dark_red": "暗赤",
            "purple": "紫",
            "pale": "淡白紅",
        }
        actual = {row.key: row.name for row in texts_for(Language.JAPANESE).rows}
        assert actual == expected

    def test_swatch_is_defined_for_every_color(self) -> None:
        assert set(COLOR_SWATCHES) == set(DISPLAY_ORDER)
        for value in COLOR_SWATCHES.values():
            assert len(value) == 3
            assert all(0 <= channel <= 255 for channel in value)


# --- 文字色（旧背景画像 img_student2.bmp からの実測値．回帰固定） ---


class TestPanelColors:
    """見出し・色名の表示色．

    旧実装のソース上の `Scalar(...)` 指定は旧背景画像 `img/img_student2.bmp` と一致しない
    ため，画像から実測した値を正とする（result_window.py の定数コメント参照）．
    見た目の一致は人間が確認する項目なので，ここでは**実測値からの意図しない逸脱**を
    検出するための回帰固定のみ行う．
    """

    def test_heading_color_matches_the_measured_value(self) -> None:
        """見出しのオレンジは実測値 RGB(233, 113, 50)．"""
        assert HEADING_COLOR == (233, 113, 50)

    def test_color_swatches_match_the_measured_values(self) -> None:
        """5 色の色名の文字色は旧背景画像からの実測値．"""
        assert COLOR_SWATCHES == {
            "pink": (255, 187, 174),
            "crimson": (224, 112, 146),
            "dark_red": (214, 69, 65),
            "purple": (125, 94, 125),
            "pale": (255, 241, 167),
        }

    def test_swatches_are_distinct(self) -> None:
        """5 色は互いに異なる色で描く（同じ色だと行の区別が付かない）．"""
        assert len(set(COLOR_SWATCHES.values())) == len(DISPLAY_ORDER)

    def test_name_outline_is_a_dark_thin_stroke(self) -> None:
        """色名の縁取りは黒・1 px 以上（旧背景画像の色名には暗色の縁取りがある）．"""
        assert NAME_OUTLINE_COLOR == (0, 0, 0)
        assert NAME_OUTLINE_WIDTH >= 1


# --- レイアウト・フォントサイズの整合（はみ出し・重なりの予防） ---


class TestLayoutConstraints:
    """文字を拡大しても要素同士が重ならないための制約．"""

    def test_x_positions_are_in_left_to_right_order(self) -> None:
        """色名 → 説明 → スコア → % の順に左から並ぶ．"""
        assert NAME_X < DESCRIPTION_X < SCORE_RIGHT_X < PERCENT_X

    def test_all_max_widths_are_positive(self) -> None:
        widths = (
            NAME_MAX_WIDTH,
            DESCRIPTION_MAX_WIDTH,
            DESCRIPTION_MAX_WIDTH_HIGHLIGHTED,
            AFFILIATION_MAX_WIDTH,
        )
        assert all(width > 0 for width in widths)

    def test_name_and_description_do_not_overlap(self) -> None:
        """色名の描画上限は説明文の開始位置より手前で止まる．"""
        assert NAME_X + NAME_MAX_WIDTH <= DESCRIPTION_X

    def test_highlighted_description_stops_before_the_score_area(self) -> None:
        """強調行の説明文はスコア欄（`SCORE_AREA_WIDTH`）に食い込まない．"""
        assert DESCRIPTION_X + DESCRIPTION_MAX_WIDTH_HIGHLIGHTED == SCORE_RIGHT_X - SCORE_AREA_WIDTH
        assert DESCRIPTION_MAX_WIDTH_HIGHLIGHTED < DESCRIPTION_MAX_WIDTH

    def test_all_max_widths_fit_in_the_panel(self) -> None:
        assert DESCRIPTION_X + DESCRIPTION_MAX_WIDTH + TEXT_MARGIN <= PANEL_SIZE
        assert AFFILIATION_POSITION[0] + AFFILIATION_MAX_WIDTH + TEXT_MARGIN <= PANEL_SIZE

    @pytest.mark.parametrize(
        "pixel_size",
        [
            HEADING_FONT_SIZE,
            AFFILIATION_FONT_SIZE,
            NOTE_FONT_SIZE,
            NAME_FONT_SIZE,
            DESCRIPTION_FONT_SIZE,
            DESCRIPTION_MIN_FONT_SIZE,
            SCORE_FONT_SIZE,
            PERCENT_FONT_SIZE,
            FAILURE_TITLE_FONT_SIZE,
            FAILURE_DETAIL_FONT_SIZE,
        ],
    )
    def test_font_sizes_are_positive(self, pixel_size: int) -> None:
        assert pixel_size > 0

    def test_description_shrink_limit_is_below_the_base_size(self) -> None:
        """説明文は基準サイズから下限まで縮められる（下限 < 基準）．"""
        assert 0 < DESCRIPTION_MIN_FONT_SIZE < DESCRIPTION_FONT_SIZE

    def test_header_elements_are_stacked_top_down(self) -> None:
        """見出し → 注記 2 行 → 5 色の行 の順に上から並ぶ．"""
        assert HEADING_POSITION[1] < NOTE_POSITIONS[0][1] < NOTE_POSITIONS[1][1] < ROW_FIRST_Y

    def test_first_row_is_below_the_notes_area(self) -> None:
        """1 行目の色名は注記より下に来る（色名を拡大しても重ならない）．

        ※ 見出し自体の上端の収まりはフォント（字形）依存のため，ここでは検証しない
        （拡大後の見た目は人間が確認する．testing.md「GUI の自動テストは書かない」）．
        """
        assert NOTE_POSITIONS[1][1] < ROW_FIRST_Y - NAME_FONT_SIZE


# --- フォント設定（_set_font / _fit_font） ---


class TestSetFont:
    """`_set_font`（サイズ・太さの指定）．

    ※ 描画結果ではなく `QFont` の状態のみを見るため，インストール済みフォントに依存しない．
    """

    def test_sets_the_pixel_size(self, gui_app: QApplication) -> None:
        with painter_on_panel() as painter:
            _set_font(painter, 33)
            assert painter.font().pixelSize() == 33

    def test_default_is_not_bold(self, gui_app: QApplication) -> None:
        with painter_on_panel() as painter:
            _set_font(painter, 20)
            assert painter.font().bold() is False

    def test_bold_flag_is_applied(self, gui_app: QApplication) -> None:
        with painter_on_panel() as painter:
            _set_font(painter, 20, bold=True)
            assert painter.font().bold() is True

    def test_weight_selects_an_intermediate_thickness(self, gui_app: QApplication) -> None:
        """`weight` では Bold 以外の太さ（Medium 等）も選べる．"""
        with painter_on_panel() as painter:
            _set_font(painter, 20, weight=QFont.Weight.Medium)
            font = painter.font()
            assert font.weight() == QFont.Weight.Medium
            assert QFont.Weight.Normal < font.weight() < QFont.Weight.Bold

    def test_weight_takes_precedence_over_bold(self, gui_app: QApplication) -> None:
        with painter_on_panel() as painter:
            _set_font(painter, 20, bold=True, weight=QFont.Weight.Normal)
            assert painter.font().weight() == QFont.Weight.Normal

    def test_description_weight_is_between_normal_and_bold(self) -> None:
        """説明文は太字にせず，やや太い程度に留める（色名より目立たせない）．"""
        assert QFont.Weight.Normal < DESCRIPTION_FONT_WEIGHT < QFont.Weight.Bold


class TestFitFont:
    """`_fit_font`（省略の前に文字を縮めて全文を残す）．"""

    def test_keeps_the_base_size_when_it_already_fits(self, gui_app: QApplication) -> None:
        with painter_on_panel() as painter:
            _fit_font(painter, "abc", 40, PANEL_SIZE, 28)
            assert painter.font().pixelSize() == 40

    def test_shrinks_until_the_text_fits(self, gui_app: QApplication) -> None:
        """収まらない幅では，収まる最大サイズまで 1 px ずつ縮める．"""
        text = "Cold / fatigue / anemia"
        with painter_on_panel() as painter:
            _set_font(painter, 40)
            full_width = painter.fontMetrics().horizontalAdvance(text)
            if full_width == 0:  # フォントが無い環境（幅が測れない）では検証できない
                pytest.skip("フォントメトリクスが取得できない環境")
            target_width = full_width // 2

            _fit_font(painter, text, 40, target_width, 1)
            fitted = painter.font().pixelSize()
            assert fitted < 40
            assert painter.fontMetrics().horizontalAdvance(text) <= target_width

            # 1 px 大きくすると収まらない（＝収まる最大サイズを選んでいる）
            _set_font(painter, fitted + 1)
            assert painter.fontMetrics().horizontalAdvance(text) > target_width

    def test_never_goes_below_the_minimum_size(self, gui_app: QApplication) -> None:
        """下限まで縮めても収まらない場合は下限で止める（残りは呼び出し元が省略する）．"""
        with painter_on_panel() as painter:
            _fit_font(painter, "とても長い説明文" * 20, 40, 10, 28)
            assert painter.font().pixelSize() == 28

    @pytest.mark.parametrize("max_width", [1, 50, 200, 1000])
    def test_result_is_always_within_the_allowed_range(
        self, gui_app: QApplication, max_width: int
    ) -> None:
        with painter_on_panel() as painter:
            _fit_font(painter, "Blood slightly thick", 40, max_width, 28)
            assert 28 <= painter.font().pixelSize() <= 40

    def test_weight_is_preserved_while_shrinking(self, gui_app: QApplication) -> None:
        with painter_on_panel() as painter:
            _fit_font(painter, "abcdefghij" * 10, 40, 50, 28, weight=QFont.Weight.Medium)
            assert painter.font().weight() == QFont.Weight.Medium


# --- 言語切替（SPEC_01「言語切替ボタン」） ---


class TestLanguageSwitching:
    """日本語 ⇔ 英語の切替とボタンラベル．"""

    def test_next_language_toggles(self) -> None:
        assert next_language(Language.JAPANESE) is Language.ENGLISH
        assert next_language(Language.ENGLISH) is Language.JAPANESE

    @pytest.mark.parametrize("language", list(Language))
    def test_next_language_round_trip(self, language: Language) -> None:
        assert next_language(next_language(language)) is language

    def test_switch_button_shows_the_target_language(self) -> None:
        """ラベルは切替**先**の言語名（SPEC_01）．"""
        assert switch_button_label(Language.JAPANESE) == "English"
        assert switch_button_label(Language.ENGLISH) == "日本語"

    @pytest.mark.parametrize("language", list(Language))
    def test_switch_button_label_is_not_the_current_language(self, language: Language) -> None:
        """現在の言語名をそのまま出さない（切替先を示す）．"""
        assert switch_button_label(language) != switch_button_label(next_language(language))

    @pytest.mark.parametrize("language", list(Language))
    def test_texts_for_returns_the_registered_resource(self, language: Language) -> None:
        assert texts_for(language) is LANGUAGE_TEXTS[language]


# --- is_highlighted / format_score / row_baseline ---


class TestIsHighlighted:
    """強調対象（上位 2 色）の判定．"""

    def test_top_two_are_highlighted(self) -> None:
        top = ("pink", "purple")
        assert is_highlighted("pink", top) is True
        assert is_highlighted("purple", top) is True

    @pytest.mark.parametrize("key", ["crimson", "dark_red", "pale"])
    def test_others_are_not_highlighted(self, key: str) -> None:
        assert is_highlighted(key, ("pink", "purple")) is False

    def test_only_first_two_labels_are_used(self) -> None:
        """3 件目以降が渡されても強調は 2 色まで．"""
        assert is_highlighted("crimson", ("pink", "purple", "crimson")) is False

    def test_unknown_key_is_not_highlighted(self) -> None:
        assert is_highlighted("unknown", ("pink", "purple")) is False


class TestFormatScore:
    """スコア文字列（旧実装 `Format("%3.0lf")` と同じ 3 桁右詰め）．"""

    @pytest.mark.parametrize(
        ("percent", "expected"),
        [(0, "  0"), (3, "  3"), (42, " 42"), (100, "100")],
    )
    def test_right_aligned_to_three_columns(self, percent: int, expected: str) -> None:
        assert format_score(percent) == expected

    @pytest.mark.parametrize("percent", [0, 1, 50, 99, 100])
    def test_length_is_at_least_three(self, percent: int) -> None:
        assert len(format_score(percent)) == 3
        assert format_score(percent).strip() == str(percent)


class TestRowBaseline:
    """行のベースライン y（旧実装 `y = 250; margin = 87`）．"""

    @pytest.mark.parametrize(
        ("index", "expected"),
        [(0, 250), (1, 337), (2, 424), (3, 511), (4, 598)],
    )
    def test_matches_legacy_positions(self, index: int, expected: int) -> None:
        assert row_baseline(index) == expected

    def test_all_rows_fit_in_the_panel(self) -> None:
        assert row_baseline(len(DISPLAY_ORDER) - 1) < PANEL_SIZE


# --- build_result_rows（SPEC_01「結果パネル」） ---


class TestBuildResultRows:
    """診断結果から行データを組み立てる．"""

    @pytest.mark.parametrize("language", list(Language))
    def test_returns_five_rows_in_display_order(
        self, sample_result: DiagnosisResult, language: Language
    ) -> None:
        rows = build_result_rows(sample_result, language)
        assert tuple(row.key for row in rows) == DISPLAY_ORDER

    @pytest.mark.parametrize("language", list(Language))
    def test_only_top_two_are_highlighted(
        self, sample_result: DiagnosisResult, language: Language
    ) -> None:
        rows = build_result_rows(sample_result, language)
        highlighted = {row.key for row in rows if row.highlighted}
        assert highlighted == set(sample_result.top_labels)
        assert len(highlighted) == HIGHLIGHT_COUNT

    def test_score_text_is_only_set_on_highlighted_rows(
        self, sample_result: DiagnosisResult
    ) -> None:
        for row in build_result_rows(sample_result, Language.JAPANESE):
            if row.highlighted:
                assert row.score_text == format_score(sample_result.percentages[row.key])
            else:
                assert row.score_text is None

    def test_score_text_is_right_aligned(self, sample_result: DiagnosisResult) -> None:
        """スコアは 3 桁右詰め（pink=68 → " 68"，purple=42 → " 42"）．"""
        rows = {row.key: row for row in build_result_rows(sample_result, Language.JAPANESE)}
        assert rows["pink"].score_text == " 68"
        assert rows["purple"].score_text == " 42"

    @pytest.mark.parametrize("language", list(Language))
    def test_uses_the_language_texts(
        self, sample_result: DiagnosisResult, language: Language
    ) -> None:
        expected = {row.key: (row.name, row.description) for row in texts_for(language).rows}
        for row in build_result_rows(sample_result, language):
            assert (row.name, row.description) == expected[row.key]

    def test_swatch_matches_the_color_table(self, sample_result: DiagnosisResult) -> None:
        for row in build_result_rows(sample_result, Language.ENGLISH):
            assert row.swatch == COLOR_SWATCHES[row.key]

    @pytest.mark.parametrize(
        "top_labels",
        [("pink", "purple"), ("pale", "crimson"), ("dark_red", "pink"), ("crimson", "dark_red")],
    )
    def test_highlight_follows_top_labels(self, top_labels: tuple[str, str]) -> None:
        result = make_result(SAMPLE_PERCENTAGES, top_labels)
        rows = build_result_rows(result, Language.JAPANESE)
        assert {row.key for row in rows if row.highlighted} == set(top_labels)

    def test_zero_percent_top_label_still_shows_a_score(self) -> None:
        """スコア 0 の色が上位に来ても "  0" を表示する（空文字にしない）．"""
        result = make_result(SAMPLE_PERCENTAGES, ("dark_red", "pale"))
        rows = {row.key: row for row in build_result_rows(result, Language.JAPANESE)}
        assert rows["dark_red"].score_text == "  0"

    def test_failed_result_still_builds_rows(self) -> None:
        """診断失敗でも 5 行を組み立てられる（失敗表示はスコアに重ねて描かれる）．"""
        result = make_result(
            {"purple": 42, "dark_red": 0, "crimson": 5, "pink": 11, "pale": 3},
            ("purple", "pink"),
            failed=True,
        )
        rows = build_result_rows(result, Language.ENGLISH)
        assert tuple(row.key for row in rows) == DISPLAY_ORDER
        assert {row.key for row in rows if row.highlighted} == {"purple", "pink"}

    def test_rows_are_immutable(self, sample_result: DiagnosisResult) -> None:
        """行データは凍結データクラス（描画側で書き換えられない）．"""
        row = build_result_rows(sample_result, Language.JAPANESE)[0]
        with pytest.raises(Exception):  # noqa: B017 - FrozenInstanceError（dataclasses）
            row.name = "書き換え"  # type: ignore[misc]


# --- to_display_pixmap（SPEC_01「診断画像」） ---


class TestToDisplayPixmap:
    """BGR 画像 → 1/2 縮小 QPixmap．"""

    def test_halves_the_image_size(self, gui_app: QApplication) -> None:
        pixmap = to_display_pixmap(make_image(1024, 1280))
        assert (pixmap.width(), pixmap.height()) == (512, 640)

    def test_respects_the_divisor_argument(self, gui_app: QApplication) -> None:
        pixmap = to_display_pixmap(make_image(1024, 1280), 4)
        assert (pixmap.width(), pixmap.height()) == (256, 320)

    def test_never_produces_a_zero_sized_pixmap(self, gui_app: QApplication) -> None:
        pixmap = to_display_pixmap(make_image(1, 1))
        assert (pixmap.width(), pixmap.height()) == (1, 1)

    def test_converts_bgr_to_rgb(self, gui_app: QApplication) -> None:
        """BGR(0, 0, 255) は赤として表示される．"""
        pixmap = to_display_pixmap(make_image(64, 64, (0, 0, 255)))
        color = pixmap.toImage().pixelColor(10, 10)
        assert (color.red(), color.green(), color.blue()) == (255, 0, 0)

    def test_does_not_alias_the_source_buffer(self, gui_app: QApplication) -> None:
        """元配列を書き換えても QPixmap の内容は変わらない（バッファ寿命に依存しない）．"""
        image = make_image(64, 64, (0, 0, 255))
        pixmap = to_display_pixmap(image)
        image[:, :] = (255, 255, 255)
        color = pixmap.toImage().pixelColor(10, 10)
        assert (color.red(), color.green(), color.blue()) == (255, 0, 0)


# --- render_result_panel（描画の成立確認のみ．文字の見た目は人間が確認する） ---


class TestRenderResultPanel:
    """結果パネルの描画．

    ※ フォントの有無が環境で異なるため，ピクセル比較はしない（testing.md「GUI の自動テストは
    書かない」方針に沿い，描画が成立することのみ確認する）．
    """

    @pytest.mark.parametrize("language", list(Language))
    def test_returns_a_panel_sized_pixmap(
        self, gui_app: QApplication, sample_result: DiagnosisResult, language: Language
    ) -> None:
        rows = build_result_rows(sample_result, language)
        pixmap = render_result_panel(rows, texts_for(language), sample_result.failed)
        assert not pixmap.isNull()
        assert (pixmap.width(), pixmap.height()) == (PANEL_SIZE, PANEL_SIZE)

    def test_failed_panel_is_rendered(
        self, gui_app: QApplication, sample_result: DiagnosisResult
    ) -> None:
        rows = build_result_rows(sample_result, Language.ENGLISH)
        pixmap = render_result_panel(rows, texts_for(Language.ENGLISH), failed=True)
        assert not pixmap.isNull()
        assert (pixmap.width(), pixmap.height()) == (PANEL_SIZE, PANEL_SIZE)

    def test_failure_band_is_filled_only_when_failed(
        self, gui_app: QApplication, sample_result: DiagnosisResult
    ) -> None:
        """失敗時のみ帯の領域が塗られる（文字ではなく矩形なのでフォント非依存）．

        塗り色は旧実装 `cv::Scalar(200, 0, 0)`（BGR）＝ RGB (0, 0, 200) の青．
        """
        rows = build_result_rows(sample_result, Language.ENGLISH)
        texts = texts_for(Language.ENGLISH)
        # 帯の内側かつ下線・文字が来ない位置を見る
        sample_point = (FAILURE_RECT[0] + 5, FAILURE_RECT[1] + 5)
        band_color = (0, 0, 200)

        failed_color = (
            render_result_panel(rows, texts, failed=True).toImage().pixelColor(*sample_point)
        )
        assert (failed_color.red(), failed_color.green(), failed_color.blue()) == band_color

        normal_color = (
            render_result_panel(rows, texts, failed=False).toImage().pixelColor(*sample_point)
        )
        assert (normal_color.red(), normal_color.green(), normal_color.blue()) != band_color

    def test_returns_a_new_pixmap_each_time(
        self, gui_app: QApplication, sample_result: DiagnosisResult
    ) -> None:
        """言語切替で描き直せるよう毎回生成する（同一オブジェクトを使い回さない）．"""
        rows = build_result_rows(sample_result, Language.JAPANESE)
        texts = texts_for(Language.JAPANESE)
        first = render_result_panel(rows, texts, failed=False)
        second = render_result_panel(rows, texts, failed=False)
        assert first is not second

    def test_renders_without_highlighted_rows(
        self, gui_app: QApplication, sample_result: DiagnosisResult
    ) -> None:
        """強調行が無い（スコア・下線を描かない）場合も描画できる．"""
        rows = tuple(
            type(row)(
                key=row.key,
                name=row.name,
                description=row.description,
                swatch=row.swatch,
                highlighted=False,
                score_text=None,
            )
            for row in build_result_rows(sample_result, Language.ENGLISH)
        )
        pixmap = render_result_panel(rows, texts_for(Language.ENGLISH), failed=False)
        assert not pixmap.isNull()

    @pytest.mark.parametrize("highlighted", [True, False])
    def test_renders_overlong_texts(self, gui_app: QApplication, highlighted: bool) -> None:
        """幅に収まらない色名・説明でも描画が成立する（縮小・省略の経路を通す）．

        ※ 色名は縁取り付き（`QPainterPath`）で描かれるため，省略の経路も含めて
        例外なく描けることを確認する．見た目は人間が確認する．
        """
        rows = tuple(
            ResultRow(
                key=key,
                name="非常に長い色名" * 5,
                description="非常に長い説明文" * 10,
                swatch=COLOR_SWATCHES[key],
                highlighted=highlighted,
                score_text=format_score(50) if highlighted else None,
            )
            for key in DISPLAY_ORDER
        )
        pixmap = render_result_panel(rows, texts_for(Language.JAPANESE), failed=False)
        assert not pixmap.isNull()
        assert (pixmap.width(), pixmap.height()) == (PANEL_SIZE, PANEL_SIZE)

    def test_renders_with_empty_texts(self, gui_app: QApplication) -> None:
        """空文字でも描画が成立する（縁取りのパス生成が空になるケース）．"""
        rows = tuple(
            ResultRow(
                key=key,
                name="",
                description="",
                swatch=COLOR_SWATCHES[key],
                highlighted=False,
                score_text=None,
            )
            for key in DISPLAY_ORDER
        )
        pixmap = render_result_panel(rows, texts_for(Language.JAPANESE), failed=False)
        assert not pixmap.isNull()


# --- 結合（core.diagnose → 表示行） ---


class TestIntegrationWithDiagnose:
    """実データ（`data/data.csv`）で診断した結果を表示行に変換する．"""

    def test_rows_are_consistent_with_the_diagnosis(
        self, gui_app: QApplication, training_data: TrainingData
    ) -> None:
        result = diagnose(rgb_to_lab((180.0, 110.0, 110.0), _linear_coefficients()), training_data)
        rows = build_result_rows(result, Language.JAPANESE)

        assert tuple(row.key for row in rows) == DISPLAY_ORDER
        assert {row.key for row in rows if row.highlighted} == set(result.top_labels)
        for row in rows:
            if row.highlighted:
                assert row.score_text is not None
                assert int(row.score_text) == result.percentages[row.key]

    def test_panel_renders_for_a_real_diagnosis(
        self, gui_app: QApplication, training_data: TrainingData
    ) -> None:
        result = diagnose(rgb_to_lab((180.0, 110.0, 110.0), _linear_coefficients()), training_data)
        rows = build_result_rows(result, Language.ENGLISH)
        pixmap = render_result_panel(rows, texts_for(Language.ENGLISH), result.failed)
        assert (pixmap.width(), pixmap.height()) == (PANEL_SIZE, PANEL_SIZE)

    def test_failed_diagnosis_rows_and_panel(
        self, gui_app: QApplication, training_data: TrainingData
    ) -> None:
        """診断失敗（L* <= 0 の域外入力）でも行の組み立てと描画ができる．"""
        result = diagnose((-16.0, 0.0, 0.0), training_data)
        assert result.failed is True

        rows = build_result_rows(result, Language.ENGLISH)
        assert tuple(row.key for row in rows) == DISPLAY_ORDER
        assert len({row.key for row in rows if row.highlighted}) == HIGHLIGHT_COUNT

        pixmap = render_result_panel(rows, texts_for(Language.ENGLISH), result.failed)
        assert not pixmap.isNull()
