"""メイン画面（SPEC_01「メイン画面」）．

旧実装（`org/src` の `CtonguediagnosisMFC2Dlg`）の操作フローを踏襲する．

1. 変換行列 CSV（`.csv`）を選択する
2. 舌画像（`.png`）を選択する（読込時に中央基準で 1024x1280 にクロップ）
3. 画像上を 1〜4 点左クリックして ROI を指定する（右クリックで取り消し）
4. [舌色診断] で `core` のパイプライン（ROI 平均色 → Lab → SVM）を実行する
5. [診断続行] で入力をリセットし，次の診断へ進む

依存方向は `gui` → `core` の一方向とする（project-structure.md）．診断計算は本モジュールに
実装せず，必ず `core` を呼ぶ．画面に依存しないロジック（クリック点の管理・入力ガード・
クロップ・表示座標の変換・パイプライン実行）は QWidget 非依存の関数・クラスに分離してある．

診断結果は結果画面（`result_window.ResultWindow`）にモードレスで表示する．結果の
受け渡し口として `MainWindow.diagnosis_finished` シグナルと `result_presenter`
（差し替え用のコールバック）を用意してある．結果画面は表示専用（患者側モニタに操作 UI を
出さない）のため，言語切替と「閉じる」は本画面の「結果画面の操作」から行う．表示言語は
本画面が保持し，開いている結果画面と次に開く結果画面の両方に適用する．
"""

from __future__ import annotations

import logging
import os
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from pathlib import Path

import cv2
import numpy as np
from numpy.typing import NDArray
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor, QFont, QMouseEvent, QPainter, QPixmap
from PySide6.QtWidgets import (
    QApplication,
    QFileDialog,
    QFrame,
    QGridLayout,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QLineEdit,
    QMainWindow,
    QMessageBox,
    QPushButton,
    QVBoxLayout,
    QWidget,
)

from tiasjudge.core import color, roi
from tiasjudge.core.csv_io import (
    CsvIoError,
    TrainingData,
    load_training_data,
    load_transform_matrix,
)
from tiasjudge.core.svm import COLOR_KEYS, DiagnosisResult, diagnose
from tiasjudge.gui.result_window import (
    IMAGE_DISPLAY_DIVISOR,
    Language,
    ResultWindow,
    next_language,
    show_on_secondary_screen,
    switch_button_label,
    to_display_pixmap,
)

logger = logging.getLogger(__name__)

# --- 定数（SPEC_01「メイン画面」）---

#: クロップ後の画像サイズ（旧実装 `target_width` / `target_height`）
CROP_WIDTH = 1024
CROP_HEIGHT = 1280
#: 画像表示時の縮小率（1/2 表示．旧実装 `resize(..., cols/2, rows/2)`）．
#: 結果画面と同じ縮小率を使うため `result_window` の定数を参照する
DISPLAY_DIVISOR = IMAGE_DISPLAY_DIVISOR

#: 受け付けるファイル拡張子（SPEC_01「変換行列選択」「画像選択」）
MATRIX_EXTENSION = ".csv"
IMAGE_EXTENSION = ".png"

#: ROI 枠の色（BGR）と太さ（旧実装 `cv::Scalar(0, 0, 200)` / 太さ 3）
ROI_RECT_COLOR = (0, 0, 200)
ROI_RECT_THICKNESS = 3

#: L*a*b* の表示順と行見出し（メイン画面の結果表示に使う）
LAB_LABELS: tuple[str, str, str] = ("L*", "a*", "b*")

#: 5 色の表示名（日本語．キーは `core.svm.COLOR_KEYS`）
COLOR_NAMES_JA: dict[str, str] = {
    "purple": "紫",
    "dark_red": "暗赤",
    "crimson": "紅",
    "pink": "淡紅",
    "pale": "淡白",
}

#: 学習データのパスを上書きする環境変数（配布形態が変わっても絶対パスを埋め込まないため）
TRAINING_DATA_ENV_VAR = "TIASJUDGE_DATA_CSV"

# --- 入力ガードのメッセージ（SPEC_01「入力ガード（エラー表示）」）---

MESSAGE_TRANSFORM_REQUIRED = "変換行列を読み込んでください"
MESSAGE_IMAGE_REQUIRED = "画像を読み込んでください"
MESSAGE_CLICK_REQUIRED = "画像上の舌の領域を\n1 点以上 4 点以下クリックしてください"
MESSAGE_TOO_MANY_CLICKS = (
    f"{roi.MAX_ROI_POINTS} 点を超えてクリックできません．\n舌色診断ボタンを押してください"
)
MESSAGE_ROI_OUT_OF_IMAGE = (
    f"画像の端から {roi.ROI_OFFSET} px 以上内側をクリックしてください\n"
    f"（ROI は {roi.ROI_SIZE}x{roi.ROI_SIZE} px です）"
)
MESSAGE_MATRIX_EXTENSION = "csv ファイルを選択しなおしてください"
MESSAGE_IMAGE_EXTENSION = "png ファイルを選択しなおしてください"

#: 画像未読込時のプレースホルダ文言（旧実装の `nofile.bmp` に相当．画像アセットに依存しない）
PLACEHOLDER_TEXT = "舌画像（.png）を読み込んでください"


class InputGuardError(Exception):
    """利用者の操作ミスを知らせるエラー（GUI がメッセージボックスで案内する）．

    error-handling.md の「ユーザー入力エラー」に対応する．
    """


class ImageLoadError(Exception):
    """画像ファイルを読み込めない・形式が不正な場合のエラー．"""


# --- 画面に依存しないロジック（QWidget 非依存．単体で検証できる）---


def check_extension(path: Path | str, expected: str, message: str) -> None:
    """ファイル拡張子を検証する（大文字小文字は区別しない）．

    Args:
        path: 検証するファイルパス．
        expected: 期待する拡張子（`.csv` のように先頭のドットを含む）．
        message: 不一致時に利用者へ表示するメッセージ．

    Raises:
        InputGuardError: 拡張子が `expected` と異なる場合．
    """
    if Path(path).suffix.lower() != expected:
        raise InputGuardError(message)


def view_to_image_point(view_x: int, view_y: int) -> tuple[int, int]:
    """表示座標（1/2 縮小）を元画像の座標へ変換する（SPEC_01「画像表示」）．

    旧実装（`OnLButtonDown` の `m_xView * 2`）と同じく縮小率の逆数を掛ける．

    Args:
        view_x: 表示上の x 座標．
        view_y: 表示上の y 座標．

    Returns:
        元画像上のクリック点 (x, y)．
    """
    return (view_x * DISPLAY_DIVISOR, view_y * DISPLAY_DIVISOR)


def display_size(width: int, height: int) -> tuple[int, int]:
    """画像を 1/2 表示するときの表示サイズ (幅, 高さ) を返す．

    小さな画像でも 0 px にならないよう各辺を最小 1 px に丸める．
    """
    return (max(width // DISPLAY_DIVISOR, 1), max(height // DISPLAY_DIVISOR, 1))


def lab_components(lab: color.Lab) -> tuple[float, float, float]:
    """L*a*b* を表示順（`LAB_LABELS` と同じ L*, a*, b* の順）のタプルで返す．"""
    return (lab.l_star, lab.a_star, lab.b_star)


def crop_center(image: NDArray[np.uint8]) -> NDArray[np.uint8]:
    """画像を中央基準で 1024x1280 にクロップする（SPEC_01「画像選択」）．

    旧実装（`OnEnChangeMfceditbrowseFilename2`）と同じく，元画像が目標サイズより
    小さい場合はオフセットを 0 に，切り出しサイズを画像内に収まる範囲に丸める．

    Args:
        image: 読み込んだ BGR 画像（形状 (高さ, 幅, 3)）．

    Returns:
        クロップ後の BGR 画像（元画像のコピー）．
    """
    height, width = image.shape[:2]
    x_offset = max((width - CROP_WIDTH) // 2, 0)
    y_offset = max((height - CROP_HEIGHT) // 2, 0)
    crop_width = min(CROP_WIDTH, width - x_offset)
    crop_height = min(CROP_HEIGHT, height - y_offset)
    cropped = image[y_offset : y_offset + crop_height, x_offset : x_offset + crop_width]
    return np.ascontiguousarray(cropped)


def load_image_file(path: Path | str) -> NDArray[np.uint8]:
    """PNG 画像を BGR で読み込み，中央基準でクロップして返す．

    `cv2.imread` は非 ASCII パスを扱えないため，バイト列を読んで `cv2.imdecode` する
    （旧実装では日本語ファイル名の画像を開けなかった箇所の改善）．

    Args:
        path: 画像ファイルのパス．

    Returns:
        クロップ後の BGR 画像．

    Raises:
        ImageLoadError: ファイルを読めない，または画像として解釈できない場合．
    """
    image_path = Path(path)
    try:
        buffer = np.frombuffer(image_path.read_bytes(), dtype=np.uint8)
    except OSError as error:
        raise ImageLoadError(f"画像ファイルを読み込めません: {image_path}") from error

    if buffer.size == 0:
        # 空ファイルは `cv2.imdecode` が None ではなく cv2.error を送出するため先に弾く
        raise ImageLoadError(f"画像として読み込めません（ファイルが空です）: {image_path}")

    try:
        image = cv2.imdecode(buffer, cv2.IMREAD_COLOR)
    except cv2.error as error:
        # OpenCV の内部アサーション（壊れたデータ等）も利用者向けのエラーに変換する
        raise ImageLoadError(f"画像として読み込めません: {image_path}") from error
    if image is None:
        raise ImageLoadError(f"画像として読み込めません: {image_path}")
    return crop_center(image)


def draw_roi_markers(
    image: NDArray[np.uint8], points: Sequence[tuple[int, int]]
) -> NDArray[np.uint8]:
    """クリック点に ROI の赤枠を描いた画像（コピー）を返す（SPEC_01「ROI 指定」）．

    旧実装の `cv::rectangle(m_Img, Point(x - 25, y - 25), Point(x + 25, y + 25), ...)` に対応する．
    """
    annotated = image.copy()
    for x, y in points:
        cv2.rectangle(
            annotated,
            (x - roi.ROI_OFFSET, y - roi.ROI_OFFSET),
            (x + roi.ROI_OFFSET, y + roi.ROI_OFFSET),
            ROI_RECT_COLOR,
            ROI_RECT_THICKNESS,
        )
    return annotated


def is_roi_inside_image(image: NDArray[np.uint8], point: tuple[int, int]) -> bool:
    """ROI（51x51 px）が画像内に収まるクリック点かどうかを返す．

    旧実装は範囲チェックが無く，端をクリックすると OpenCV の例外で落ちていた．
    """
    height, width = image.shape[:2]
    x, y = point
    return (
        x - roi.ROI_OFFSET >= 0
        and y - roi.ROI_OFFSET >= 0
        and x + roi.ROI_OFFSET < width
        and y + roi.ROI_OFFSET < height
    )


def default_training_data_path() -> Path:
    """同梱学習データ `data/data.csv` のパスを返す（絶対パスは埋め込まない）．

    環境変数 `TIASJUDGE_DATA_CSV` があればそれを優先する（配布形態が変わった場合の逃げ道）．
    """
    override = os.environ.get(TRAINING_DATA_ENV_VAR)
    if override:
        return Path(override)

    package_root = Path(__file__).resolve().parents[1]
    candidates = (
        package_root / "data" / "data.csv",  # 将来パッケージに同梱した場合
        package_root.parents[1] / "data" / "data.csv",  # リポジトリ直下の data/
    )
    for candidate in candidates:
        if candidate.is_file():
            return candidate
    return candidates[-1]


def run_diagnosis(
    image: NDArray[np.uint8],
    points: Sequence[tuple[int, int]],
    coefficients: NDArray[np.float64],
    training: TrainingData,
) -> DiagnosisResult:
    """診断パイプラインを実行する（SPEC_02「処理パイプライン」）．

    ROI 平均 RGB → Lab → SVM 分類の順に `core` を呼ぶだけで，計算は持たない．

    Args:
        image: クロップ後の BGR 画像．
        points: クリック点 (x, y) の並び（1〜4 点）．
        coefficients: 変換行列（形状 (17, 3)）．
        training: 学習データ．

    Returns:
        診断結果（`DiagnosisResult`．`lab` に算出した L*a*b* を含む）．
    """
    rgb = roi.mean_rgb(image, points)
    lab = color.rgb_to_lab(rgb, coefficients)
    return diagnose(lab, training)


@dataclass
class MainWindowState:
    """メイン画面の入力状態（画面部品に依存しない）．

    入力ガード（SPEC_01）を本クラスに集約し，`MainWindow` は例外を捕まえて
    メッセージボックスを出すだけにする．
    """

    coefficients: NDArray[np.float64] | None = None
    transform_path: Path | None = None
    image: NDArray[np.uint8] | None = None
    image_path: Path | None = None
    points: list[tuple[int, int]] = field(default_factory=list)
    diagnosed: bool = False

    @property
    def has_transform(self) -> bool:
        """変換行列を読み込み済みか．"""
        return self.coefficients is not None

    @property
    def has_image(self) -> bool:
        """画像を読み込み済みか．"""
        return self.image is not None

    @property
    def point_count(self) -> int:
        """クリック済みの点数．"""
        return len(self.points)

    @property
    def can_diagnose(self) -> bool:
        """[舌色診断] を有効化できるか（1 点以上クリック済み）．"""
        return self.has_transform and self.has_image and self.point_count > 0

    def set_transform(self, path: Path, coefficients: NDArray[np.float64]) -> None:
        """変換行列を設定する．"""
        self.transform_path = path
        self.coefficients = coefficients

    def clear_transform(self) -> None:
        """変換行列の選択を解除する（[診断続行] で「使用しない」を選んだ場合）．"""
        self.transform_path = None
        self.coefficients = None

    def set_image(self, path: Path, image: NDArray[np.uint8]) -> None:
        """画像を設定し，クリック点を初期化する．"""
        self.image_path = path
        self.image = image
        self.clear_points()

    def clear_image(self) -> None:
        """画像の選択を解除する（[診断続行]）．"""
        self.image_path = None
        self.image = None
        self.clear_points()

    def clear_points(self) -> None:
        """クリック点をすべて取り消す（右クリック・診断後のリセット）．"""
        self.points.clear()

    def ensure_clickable(self) -> None:
        """クリック可能な状態かを検証する（SPEC_01「入力ガード」）．

        Raises:
            InputGuardError: 変換行列または画像が未読込の場合．
        """
        if not self.has_transform:
            raise InputGuardError(MESSAGE_TRANSFORM_REQUIRED)
        if not self.has_image:
            raise InputGuardError(MESSAGE_IMAGE_REQUIRED)

    def add_point(self, point: tuple[int, int]) -> None:
        """クリック点を追加する（最大 4 点）．

        Raises:
            InputGuardError: 未読込・5 点目・ROI が画像外の場合．
        """
        self.ensure_clickable()
        if self.point_count >= roi.MAX_ROI_POINTS:
            # 旧実装は 5 点目もメッセージ表示前に加算していた（バグ）．新実装は加算しない
            raise InputGuardError(MESSAGE_TOO_MANY_CLICKS)
        image = self.image
        if image is None or not is_roi_inside_image(image, point):
            raise InputGuardError(MESSAGE_ROI_OUT_OF_IMAGE)
        self.points.append(point)

    def ensure_diagnosable(self) -> None:
        """診断を実行できる状態かを検証する（SPEC_01「入力ガード」）．

        Raises:
            InputGuardError: 未読込，またはクリック 0 点の場合．
        """
        self.ensure_clickable()
        if self.point_count == 0:
            raise InputGuardError(MESSAGE_CLICK_REQUIRED)


# --- 画面部品 ---


class ImageView(QLabel):
    """画像を表示し，左クリック（ROI 指定）と右クリック（リセット）を通知するラベル．"""

    #: 左クリック位置（表示座標．1/2 縮小後）
    clicked = Signal(int, int)
    #: 右クリック（クリック取り消し要求）
    reset_requested = Signal()

    def __init__(self, parent: QWidget | None = None) -> None:
        super().__init__(parent)
        self.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
        self.setFrameShape(QFrame.Shape.Box)
        self.setCursor(Qt.CursorShape.CrossCursor)
        self.setMouseTracking(False)

    def mousePressEvent(self, event: QMouseEvent) -> None:  # noqa: N802 - Qt のオーバーライド
        """マウス押下を左右で振り分ける．"""
        position = event.position().toPoint()
        if event.button() == Qt.MouseButton.LeftButton:
            self.clicked.emit(position.x(), position.y())
        elif event.button() == Qt.MouseButton.RightButton:
            self.reset_requested.emit()
        super().mousePressEvent(event)


#: 結果画面へ結果を渡すための差し込み口（未設定なら `ResultWindow` を開く）
ResultPresenter = Callable[[DiagnosisResult, NDArray[np.uint8]], None]


class MainWindow(QMainWindow):
    """メイン画面（SPEC_01）．"""

    #: 診断完了（結果，診断に用いたクロップ後画像）．結果画面はこのシグナルで開く
    diagnosis_finished = Signal(object, object)

    def __init__(
        self,
        training_data_path: Path | str | None = None,
        parent: QWidget | None = None,
    ) -> None:
        super().__init__(parent)
        self._training_data_path = (
            Path(training_data_path)
            if training_data_path is not None
            else default_training_data_path()
        )
        self._training: TrainingData | None = None
        self._state = MainWindowState()
        self._last_directory = Path.home()
        #: 開いている結果画面（閉じるまで保持する）
        self._result_windows: list[ResultWindow] = []
        #: 結果画面の表示言語（メイン画面が保持し，次に開く結果画面にも引き継ぐ）
        self._result_language = Language.JAPANESE
        #: 結果画面の表示関数（未設定なら `ResultWindow` を開く）
        self.result_presenter: ResultPresenter | None = None

        self.setWindowTitle("TIASjudge - 舌色診断")
        self._build_ui()
        self._refresh_image()
        self._update_controls()

    # --- 画面構築 ---

    def _build_ui(self) -> None:
        """ウィジェットを組み立てる（左: 画像／右: 操作パネル）．"""
        central = QWidget(self)
        layout = QHBoxLayout(central)

        self._image_view = ImageView(central)
        self._image_view.clicked.connect(self._on_image_clicked)
        self._image_view.reset_requested.connect(self._on_reset_requested)
        layout.addWidget(self._image_view, 0, Qt.AlignmentFlag.AlignTop)
        layout.addWidget(self._build_side_panel(central), 1)

        self.setCentralWidget(central)

    def _build_side_panel(self, parent: QWidget) -> QWidget:
        """右側の操作パネル（ファイル選択・クリック状況・診断・結果表示）を作る．"""
        panel = QWidget(parent)
        layout = QVBoxLayout(panel)

        matrix_row, self._matrix_edit, self._matrix_button = self._build_file_row(
            panel, "① 変換行列 (.csv) 読み込み", self._on_select_matrix
        )
        layout.addWidget(matrix_row)

        image_row, self._image_edit, self._image_button = self._build_file_row(
            panel, "② 舌画像 (.png) 読み込み", self._on_select_image
        )
        layout.addWidget(image_row)

        layout.addWidget(self._build_click_group(panel))

        self._diagnose_button = QPushButton("④ 舌色診断", panel)
        self._diagnose_button.clicked.connect(self._on_diagnose)
        layout.addWidget(self._diagnose_button)

        note = QLabel("結果表示ごとに選択領域はリセットされます．", panel)
        note.setWordWrap(True)
        layout.addWidget(note)

        self._continue_button = QPushButton("診断続行", panel)
        self._continue_button.clicked.connect(self._on_continue)
        self._quit_button = QPushButton("終了", panel)
        self._quit_button.clicked.connect(self.close)
        buttons = QHBoxLayout()
        buttons.addWidget(self._continue_button)
        buttons.addWidget(self._quit_button)
        layout.addLayout(buttons)

        layout.addWidget(self._build_result_control_group(panel))
        layout.addWidget(self._build_result_group(panel))
        layout.addStretch(1)
        return panel

    def _build_result_control_group(self, parent: QWidget) -> QGroupBox:
        """結果画面（患者側モニタ）を操作するボタン群を作る．

        結果画面は表示専用なので，言語切替と「閉じる」はここ（施術者側）で操作する．
        """
        group = QGroupBox("結果画面の操作", parent)
        self._result_language_button = QPushButton(group)
        self._result_language_button.clicked.connect(self.toggle_result_language)
        self._result_close_button = QPushButton("結果画面を閉じる", group)
        self._result_close_button.clicked.connect(self.close_result_windows)
        row = QHBoxLayout(group)
        row.addWidget(self._result_language_button)
        row.addWidget(self._result_close_button)
        return group

    def _build_file_row(
        self, parent: QWidget, title: str, on_browse: Callable[[], None]
    ) -> tuple[QGroupBox, QLineEdit, QPushButton]:
        """ラベル + パス表示 + 参照ボタンの 1 行を作る（変換行列・画像で共通）．

        Args:
            parent: 親ウィジェット．
            title: グループボックスの見出し．
            on_browse: [参照...] 押下時に呼ぶハンドラ．

        Returns:
            (グループボックス, パス表示欄, 参照ボタン)．
        """
        group = QGroupBox(title, parent)
        edit = QLineEdit(group)
        edit.setReadOnly(True)
        edit.setPlaceholderText("未選択")
        button = QPushButton("参照...", group)
        button.clicked.connect(on_browse)
        row = QHBoxLayout(group)
        row.addWidget(edit, 1)
        row.addWidget(button, 0)
        return group, edit, button

    def _build_click_group(self, parent: QWidget) -> QGroupBox:
        """クリック状況（●1〜●4）の表示部を作る（SPEC_01「ROI 指定」）．"""
        group = QGroupBox(f"③ 画像中の領域を {roi.MAX_ROI_POINTS} 点までクリック", parent)
        row = QHBoxLayout(group)
        row.addWidget(QLabel("現在のクリック", group))
        self._click_marks: list[QLabel] = []
        for index in range(roi.MAX_ROI_POINTS):
            mark = QLabel(f"○{index + 1}", group)
            self._click_marks.append(mark)
            row.addWidget(mark)
        row.addStretch(1)
        return group

    def _build_result_group(self, parent: QWidget) -> QGroupBox:
        """L*a*b* と 5 色スコアの表示部を作る（SPEC_01「L*a*b* 表示」「5 色スコア表示」）．"""
        group = QGroupBox("診断結果", parent)
        grid = QGridLayout(group)

        self._lab_labels: dict[str, QLabel] = {}
        for row_index, key in enumerate(LAB_LABELS):
            grid.addWidget(QLabel(key, group), row_index, 0)
            value = QLabel("-", group)
            self._lab_labels[key] = value
            grid.addWidget(value, row_index, 1)

        self._score_labels: dict[str, QLabel] = {}
        for offset, key in enumerate(COLOR_KEYS):
            row_index = offset + len(self._lab_labels)
            grid.addWidget(QLabel(COLOR_NAMES_JA[key], group), row_index, 0)
            value = QLabel("-", group)
            self._score_labels[key] = value
            grid.addWidget(value, row_index, 1)
        return group

    # --- イベントハンドラ ---

    def _on_select_matrix(self) -> None:
        """変換行列 CSV を選択して読み込む．"""
        path = self._choose_file("変換行列 CSV を選択", "変換行列 (*.csv)")
        if path is None:
            return
        try:
            check_extension(path, MATRIX_EXTENSION, MESSAGE_MATRIX_EXTENSION)
            coefficients = load_transform_matrix(path)
        except InputGuardError as error:
            self._show_error(str(error))
            return
        except CsvIoError as error:
            # core の専用例外（データエラー）を捕まえて案内する（error-handling.md）
            self._show_error(f"変換行列を読み込めません．\n{error}")
            return

        self._state.set_transform(path, coefficients)
        self._matrix_edit.setText(str(path))
        logger.info("変換行列を選択しました: %s", path)
        self._update_controls()

    def _on_select_image(self) -> None:
        """舌画像 PNG を選択し，クロップして表示する．"""
        path = self._choose_file("舌画像 PNG を選択", "舌画像 (*.png)")
        if path is None:
            return
        try:
            check_extension(path, IMAGE_EXTENSION, MESSAGE_IMAGE_EXTENSION)
            image = load_image_file(path)
        except (InputGuardError, ImageLoadError) as error:
            self._show_error(str(error))
            return

        self._state.set_image(path, image)
        self._image_edit.setText(str(path))
        logger.info("画像を選択しました: %s（%dx%d）", path, image.shape[1], image.shape[0])
        self._refresh_image()
        self._update_controls()

    def _on_image_clicked(self, view_x: int, view_y: int) -> None:
        """画像上の左クリックで ROI を追加する（表示は 1/2 縮小）．"""
        point = view_to_image_point(view_x, view_y)
        try:
            self._state.add_point(point)
        except InputGuardError as error:
            self._show_error(str(error))
            return
        logger.info("ROI を追加しました: %s（%d 点目）", point, self._state.point_count)
        self._refresh_image()
        self._update_controls()

    def _on_reset_requested(self) -> None:
        """右クリックでクリックをすべて取り消す（SPEC_01「リセット」）．"""
        if self._state.point_count == 0:
            return
        # 旧実装は画像だけ戻してクリック数を戻さないバグがあった．新実装は状態も戻す
        self._state.clear_points()
        logger.info("クリックをリセットしました")
        self._refresh_image()
        self._update_controls()

    def _on_diagnose(self) -> None:
        """[舌色診断]．診断を実行し，結果表示と入力リセットを行う．"""
        try:
            self._state.ensure_diagnosable()
        except InputGuardError as error:
            self._show_error(str(error))
            return

        training = self._load_training_data()
        if training is None:
            return

        # ensure_diagnosable が通っているので画像・変換行列は読込済み
        image = self._state.image
        coefficients = self._state.coefficients
        if image is None or coefficients is None:
            return
        points = list(self._state.points)

        QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
        try:
            result = run_diagnosis(image, points, coefficients, training)
        except ValueError as error:
            # ROI 範囲外等（GUI 側でガード済みだが，念のため案内する）
            self._show_error(f"診断を実行できません．\n{error}")
            return
        finally:
            QApplication.restoreOverrideCursor()

        self._show_scores(result)
        self._state.diagnosed = True
        # 旧実装と同じく，診断後は選択領域を初期化する（画面の注記どおり）
        self._state.clear_points()
        self._refresh_image()
        self._update_controls()
        self._present_result(result, image)

    def _on_continue(self) -> None:
        """[診断続行]．同じ変換行列を使うか確認し，画像とクリックをリセットする．"""
        answer = QMessageBox.question(
            self,
            "診断続行",
            "同じ変換行列を使用して\n診断を続行しますか?",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.Yes,
        )
        if answer == QMessageBox.StandardButton.No:
            self._state.clear_transform()
            self._matrix_edit.clear()

        self._state.clear_image()
        self._image_edit.clear()
        self._state.diagnosed = False
        self._clear_scores()
        self._refresh_image()
        self._update_controls()

    # --- 表示の更新 ---

    def _refresh_image(self) -> None:
        """画像表示を最新の状態（ROI 枠込み）に更新する．"""
        image = self._state.image
        if image is None:
            pixmap = self._placeholder_pixmap()
        else:
            annotated = draw_roi_markers(image, self._state.points)
            # 結果画面と同じ変換を使う（SPEC_01「画像表示」）
            pixmap = to_display_pixmap(annotated, DISPLAY_DIVISOR)
        self._image_view.setPixmap(pixmap)
        self._image_view.setFixedSize(pixmap.size())

    def _update_controls(self) -> None:
        """ボタンの活性・クリックマークを状態に合わせて更新する．"""
        for index, mark in enumerate(self._click_marks):
            clicked = index < self._state.point_count
            mark.setText(f"{'●' if clicked else '○'}{index + 1}")

        self._diagnose_button.setEnabled(self._state.can_diagnose)
        self._continue_button.setEnabled(self._state.diagnosed)
        # 旧実装と同じく，診断後は再読込を禁止する（[診断続行] で解除）
        self._matrix_button.setEnabled(not self._state.diagnosed)
        self._image_button.setEnabled(not self._state.diagnosed)

        # 言語切替は「次に開く画面の言語」を先に決められるよう常時有効とし，
        # 「閉じる」は対象がある時だけ有効にする
        self._result_language_button.setText(switch_button_label(self._result_language))
        self._result_close_button.setEnabled(bool(self._result_windows))

    def _show_scores(self, result: DiagnosisResult) -> None:
        """L*a*b* と 5 色スコアをパネルに表示する．"""
        for key, value in zip(LAB_LABELS, lab_components(result.lab), strict=True):
            self._lab_labels[key].setText(f"{value:.0f}")
        for key, label in self._score_labels.items():
            label.setText(f"{result.percentages[key]:3d}%")

    def _clear_scores(self) -> None:
        """L*a*b* と 5 色スコアの表示を消す．"""
        for label in (*self._lab_labels.values(), *self._score_labels.values()):
            label.setText("-")

    def _present_result(self, result: DiagnosisResult, image: NDArray[np.uint8]) -> None:
        """結果画面へ結果を渡す（SPEC_01「結果画面（モードレス表示）」）．"""
        self.diagnosis_finished.emit(result, image)
        if self.result_presenter is not None:
            # 差し替え口（別の表示方法を使いたい場合はここに関数を挿す）
            self.result_presenter(result, image)
            return
        self.open_result_window(result, image)

    def open_result_window(self, result: DiagnosisResult, image: NDArray[np.uint8]) -> ResultWindow:
        """結果画面をモードレスで開く．

        旧実装（`CResultDlg` の `Create` + `ShowWindow`）と同じく，メイン画面を
        操作可能なまま結果を表示する．複数回の診断で複数枚開けるよう，開いた画面は
        閉じるまで保持する．

        Args:
            result: 表示する診断結果．
            image: 診断に用いたクロップ後の BGR 画像．

        Returns:
            開いた結果画面．
        """
        # 現在の言語を引き継ぐ（診断続行後も維持される）
        window = ResultWindow(result, image, self._result_language, parent=self)
        window.closed.connect(self._on_result_window_closed)
        self._result_windows.append(window)
        # 2 画面運用（施術者側にメイン画面・患者側に結果画面）を想定し，
        # メイン画面と別のモニタがあればそこへ最大化する（1 台なら従来どおり通常表示）
        show_on_secondary_screen(window, QApplication.screens(), self.screen())
        self._update_controls()
        return window

    def _on_result_window_closed(self) -> None:
        """閉じられた結果画面を保持リストから外す（Qt 側の破棄に任せる）．"""
        window = self.sender()
        if isinstance(window, ResultWindow) and window in self._result_windows:
            self._result_windows.remove(window)
            window.deleteLater()
        self._update_controls()

    # --- 結果画面の操作（結果画面は表示専用なのでメイン画面から操作する）---

    @property
    def result_language(self) -> Language:
        """次に開く結果画面に適用される表示言語．"""
        return self._result_language

    def toggle_result_language(self) -> None:
        """結果画面の表示言語を切り替える（開いている全画面に反映する）．

        言語はメイン画面が保持するため，結果画面が 1 枚も開いていなくても切り替えられる
        （次に開く画面の言語を先に決めておける）．
        """
        self._result_language = next_language(self._result_language)
        # 結果画面側も切替時にログを出すため，こちらは対象枚数を添えて区別する
        logger.info(
            "結果画面の表示言語を切り替えました: %s（対象 %d 枚）",
            self._result_language.value,
            len(self._result_windows),
        )
        for window in self._result_windows:
            window.set_language(self._result_language)
        self._update_controls()

    def close_result_windows(self) -> None:
        """開いている結果画面をすべて閉じる．"""
        # `closed` シグナル経由で `_result_windows` が変化するため複製を回す
        for window in list(self._result_windows):
            window.close()

    # --- 補助 ---

    def _load_training_data(self) -> TrainingData | None:
        """学習データを読み込む（1 度読んだらキャッシュする）．失敗時は None．"""
        if self._training is not None:
            return self._training
        try:
            self._training = load_training_data(self._training_data_path)
        except CsvIoError as error:
            self._show_error(f"学習データを読み込めません．\n{error}")
            return None
        return self._training

    def _choose_file(self, title: str, name_filter: str) -> Path | None:
        """ファイル選択ダイアログを開き，選ばれたパスを返す（キャンセル時は None）．"""
        selected, _ = QFileDialog.getOpenFileName(
            self, title, str(self._last_directory), name_filter
        )
        if not selected:
            return None
        path = Path(selected)
        self._last_directory = path.parent
        return path

    def _show_error(self, message: str) -> None:
        """入力ガード・データエラーの案内を表示する．"""
        logger.warning("エラー表示: %s", message.replace("\n", " "))
        QMessageBox.critical(self, "エラー", message)

    def _placeholder_pixmap(self) -> QPixmap:
        """画像未読込時のプレースホルダを生成する（画像アセットに依存しない）．"""
        pixmap = QPixmap(*display_size(CROP_WIDTH, CROP_HEIGHT))
        pixmap.fill(QColor(240, 240, 240))
        painter = QPainter(pixmap)
        try:
            painter.setPen(QColor(120, 120, 120))
            font = QFont(painter.font())
            font.setPointSize(12)
            painter.setFont(font)
            painter.drawRect(0, 0, pixmap.width() - 1, pixmap.height() - 1)
            painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, PLACEHOLDER_TEXT)
        finally:
            painter.end()
        return pixmap
