"""旧 exe のメイン画面から診断値を Win32 API で直接読み取る（回帰テスト採取の補助）．

回帰テスト（TEST_01）では旧 exe が表示した L\\*a\\*b\\* と 5 色スコアを転記するが，
旧実装のバグにより **「紫」のスコアが画面上で読めない**．
`tonguediagnosisMFC2Dlg.cpp:683` の `font2.CreateFontA(150, ...)` は
`CreatePointFont(150, ...)`（= 15pt）と違って **高さ 150 ピクセル**のフォントを作るため，
`IDC_PURPLE` のラベルだけが巨大化して枠からはみ出し，値が判読できない．

ただし静的コントロールの**テキスト自体は正しく設定されている**ので，
`WM_GETTEXT` で文字列を直接吸い出せば正しい値が得られる．本スクリプトはそれを行う．

依存は Python 標準の `ctypes` のみ（pywin32 等は使わない）．**Windows 専用**．

使い方（詳細は docs/06_TEST/TEST_01_回帰テスト採取手順.md）:

1. 旧 exe を起動し，変換行列と単色 PNG を読み込んで «舌色診断» まで実行しておく
2. 旧 exe を**閉じずに**，リポジトリ直下で次を実行する

```bash
uv run python scripts/read_legacy_values.py
```

採取表（`tests/fixtures/regression/*.csv`）に貼れる形式で 8 値を表示する．

うまく読めないときのオプション:

```bash
# 起動中のトップレベルウィンドウのタイトル一覧を出す（ウィンドウ特定に失敗する場合）
uv run python scripts/read_legacy_values.py --list-windows

# 見つかったウィンドウの全子コントロールの生テキストを出す（パース失敗時のデバッグ用）
uv run python scripts/read_legacy_values.py --raw

# ウィンドウタイトルの一部を明示指定する（既定は "TIDA"）
uv run python scripts/read_legacy_values.py --title "TIDA<診断>"
```

注意: 本スクリプトは**読み取り専用**で，旧 exe に対して値の書き換えや操作は行わない．
"""

from __future__ import annotations

import argparse
import ctypes
import re
import sys
from ctypes import wintypes
from dataclasses import dataclass, field

#: 旧 exe のウィンドウタイトルに含まれる文字列（部分一致で探す）
DEFAULT_TITLE_KEYWORD = "TIDA"

#: 採取表（`rgb_to_lab.csv` / `rgb_to_scores.csv`）に合わせた出力順
OUTPUT_FIELDS: tuple[str, ...] = (
    "l_star",
    "a_star",
    "b_star",
    "purple",
    "dark_red",
    "crimson",
    "pink",
    "pale",
)

#: 5 色スコアのラベル（旧実装 `m_xPurple = "紫   " + tempPu + "%"` 等）．
#: 「紅」は「淡紅」「淡白紅」の一部でもあるため，**長いラベルから先に**照合する
SCORE_LABELS: tuple[tuple[str, str], ...] = (
    ("pale", "淡白紅"),
    ("pink", "淡紅"),
    ("dark_red", "暗赤"),
    ("purple", "紫"),
    ("crimson", "紅"),
)

#: L*a*b* のラベル（旧実装 `m_xOketsu = "L*  " + tempO` 等）
LAB_LABELS: tuple[tuple[str, str], ...] = (
    ("l_star", "L*"),
    ("a_star", "a*"),
    ("b_star", "b*"),
)

#: 値部分（`%3.0lf` は右詰めなので前後に空白が入りうる．負値もありうる）
NUMBER_PATTERN = r"[+-]?\d+"

# --- Win32 API（ctypes．必要な分だけ宣言する）---

WM_GETTEXT = 0x000D
WM_GETTEXTLENGTH = 0x000E
SMTO_ABORTIFHUNG = 0x0002
#: 応答の無いウィンドウで固まらないための待ち時間（ミリ秒）
SEND_MESSAGE_TIMEOUT_MS = 2000
#: 1 コントロールから読み取る最大文字数（静的テキストなので十分に大きい）
MAX_TEXT_LENGTH = 1024

if sys.platform == "win32":
    _user32 = ctypes.WinDLL("user32", use_last_error=True)
else:  # pragma: no cover - Windows 以外では起動時に弾く
    _user32 = None

WNDENUMPROC = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)


def configure_api() -> None:
    """使用する API の引数・戻り値の型を設定する（64bit で値が壊れないようにする）．"""
    _user32.EnumWindows.argtypes = [WNDENUMPROC, wintypes.LPARAM]
    _user32.EnumWindows.restype = wintypes.BOOL
    _user32.EnumChildWindows.argtypes = [wintypes.HWND, WNDENUMPROC, wintypes.LPARAM]
    _user32.EnumChildWindows.restype = wintypes.BOOL
    _user32.GetWindowTextLengthW.argtypes = [wintypes.HWND]
    _user32.GetWindowTextLengthW.restype = ctypes.c_int
    _user32.GetWindowTextW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int]
    _user32.GetWindowTextW.restype = ctypes.c_int
    _user32.GetClassNameW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int]
    _user32.GetClassNameW.restype = ctypes.c_int
    _user32.IsWindowVisible.argtypes = [wintypes.HWND]
    _user32.IsWindowVisible.restype = wintypes.BOOL
    _user32.SendMessageTimeoutW.argtypes = [
        wintypes.HWND,
        wintypes.UINT,
        wintypes.WPARAM,
        wintypes.LPARAM,
        wintypes.UINT,
        wintypes.UINT,
        ctypes.POINTER(ctypes.c_size_t),
    ]
    _user32.SendMessageTimeoutW.restype = wintypes.LPARAM


@dataclass
class Control:
    """読み取った子コントロール 1 個分の情報．

    Attributes:
        handle: ウィンドウハンドル（整数）．
        class_name: ウィンドウクラス名（Static / Edit 等）．
        text: 取得したテキスト．
    """

    handle: int
    class_name: str
    text: str


@dataclass
class ParseResult:
    """パース結果．

    Attributes:
        values: 読み取れた項目（`OUTPUT_FIELDS` のキー → 整数値）．
        unmatched_numbers: どのラベルにも紐づかなかった数値だけのテキスト．
    """

    values: dict[str, int] = field(default_factory=dict)
    unmatched_numbers: list[str] = field(default_factory=list)

    @property
    def missing(self) -> list[str]:
        """読み取れなかった項目の一覧．"""
        return [name for name in OUTPUT_FIELDS if name not in self.values]


# --- ウィンドウ列挙・テキスト取得 ---


def window_text(handle: int) -> str:
    """ウィンドウのタイトル（キャプション）を返す．"""
    length = _user32.GetWindowTextLengthW(handle)
    if length <= 0:
        return ""
    buffer = ctypes.create_unicode_buffer(length + 1)
    _user32.GetWindowTextW(handle, buffer, length + 1)
    return buffer.value


def _class_name(handle: int) -> str:
    """ウィンドウクラス名を返す．"""
    buffer = ctypes.create_unicode_buffer(256)
    _user32.GetClassNameW(handle, buffer, 256)
    return buffer.value


def control_text(handle: int) -> str:
    """`WM_GETTEXT` で子コントロールのテキストを読む．

    旧 exe は ANSI（CP932）ビルドだが，`SendMessageTimeoutW`（W 版）を使えば
    Windows 側が UTF-16 へ変換して返すため文字化けしない．
    応答が無いウィンドウで固まらないよう，タイムアウト付きで送る．
    """
    result = ctypes.c_size_t(0)
    length_ok = _user32.SendMessageTimeoutW(
        handle,
        WM_GETTEXTLENGTH,
        0,
        0,
        SMTO_ABORTIFHUNG,
        SEND_MESSAGE_TIMEOUT_MS,
        ctypes.byref(result),
    )
    if not length_ok:
        return ""
    length = min(int(result.value), MAX_TEXT_LENGTH)
    if length <= 0:
        return ""

    buffer = ctypes.create_unicode_buffer(length + 1)
    text_ok = _user32.SendMessageTimeoutW(
        handle,
        WM_GETTEXT,
        length + 1,
        ctypes.cast(buffer, ctypes.c_void_p).value or 0,
        SMTO_ABORTIFHUNG,
        SEND_MESSAGE_TIMEOUT_MS,
        ctypes.byref(result),
    )
    if not text_ok:
        return ""
    return buffer.value


def list_top_level_windows(*, visible_only: bool = True) -> list[tuple[int, str]]:
    """トップレベルウィンドウの (ハンドル, タイトル) 一覧を返す．"""
    windows: list[tuple[int, str]] = []

    def callback(handle: int, _param: int) -> bool:
        if visible_only and not _user32.IsWindowVisible(handle):
            return True
        title = window_text(handle)
        if title:
            windows.append((handle, title))
        return True

    _user32.EnumWindows(WNDENUMPROC(callback), 0)
    return windows


def find_window(keyword: str) -> tuple[int, str] | None:
    """タイトルに `keyword` を含む最初のウィンドウを返す（部分一致・大小文字無視）．"""
    needle = keyword.casefold()
    for handle, title in list_top_level_windows():
        if needle in title.casefold():
            return handle, title
    return None


def read_controls(handle: int) -> list[Control]:
    """指定ウィンドウの全子コントロール（孫以下も含む）のテキストを読む．"""
    controls: list[Control] = []

    def callback(child: int, _param: int) -> bool:
        controls.append(Control(child, _class_name(child), control_text(child)))
        return True

    _user32.EnumChildWindows(handle, WNDENUMPROC(callback), 0)
    return controls


# --- パース ---


def parse_values(texts: list[str]) -> ParseResult:
    """コントロールのテキスト一覧から 8 値を取り出す．

    読めた項目だけを `values` に入れ，読めなかった項目は `missing` で分かるようにする
    （取りこぼしがあっても分かる範囲は出す方針）．

    Args:
        texts: 子コントロールから読んだテキストの一覧．

    Returns:
        パース結果．
    """
    result = ParseResult()
    for raw in texts:
        text = raw.replace("\u3000", " ").strip()
        if not text:
            continue
        if _match_labeled(text, SCORE_LABELS, result) or _match_labeled(text, LAB_LABELS, result):
            continue
        # ラベル無しで数値だけのコントロール（旧実装は `m_purpleStr` 等も保持している）
        if re.fullmatch(rf"\s*{NUMBER_PATTERN}\s*%?\s*", text):
            result.unmatched_numbers.append(text.strip())

    _apply_purple_fallback(result)
    return result


def _match_labeled(text: str, labels: tuple[tuple[str, str], ...], result: ParseResult) -> bool:
    """`"紫   42%"` や `"L*  -16"` の形からラベルと値を取り出して `result` に入れる．"""
    for name, label in labels:
        # ラベルは文字列の先頭に来る（「紅」が「淡紅」の一部に一致しないよう前方一致で見る）
        pattern = rf"^{re.escape(label)}\s*({NUMBER_PATTERN})\s*%?$"
        matched = re.match(pattern, text)
        if matched is None:
            continue
        # 同じラベルが複数あった場合は最初に読めた値を優先する
        result.values.setdefault(name, int(matched.group(1)))
        return True
    return False


def _apply_purple_fallback(result: ParseResult) -> None:
    """紫がラベル付きで読めなかった場合に，数値だけのコントロールで補う．

    巨大フォントのバグで «見た目» は壊れているがテキストは正しいはずなので，
    通常はラベル付きで読める．ここは «ラベルごと壊れていた» 場合の保険であり，
    候補が 1 つに絞れるときだけ採用する（誤った値を黙って使わないため）．
    """
    if "purple" in result.values:
        return
    candidates = [text for text in result.unmatched_numbers if text.strip("% ").lstrip("+-")]
    if len(candidates) != 1:
        return
    number = re.search(NUMBER_PATTERN, candidates[0])
    if number is None:
        return
    result.values["purple"] = int(number.group(0))
    result.unmatched_numbers.remove(candidates[0])
    print(
        "警告: 「紫」をラベル無しの数値コントロールから推定しました"
        f"（値: {result.values['purple']}）．--raw で妥当性を確認してください．",
        file=sys.stderr,
    )


# --- 出力 ---


def format_csv_row(result: ParseResult) -> str:
    """採取表に貼れる 1 行を作る（読めなかった項目は空欄にする）．"""
    return ",".join(str(result.values.get(name, "")) for name in OUTPUT_FIELDS)


def print_result(result: ParseResult) -> None:
    """パース結果を人間が転記しやすい形で表示する．"""
    print("# 読み取り結果")
    for name in OUTPUT_FIELDS:
        value = result.values.get(name)
        print(f"  {name:8s} = {'(読めませんでした)' if value is None else value}")

    print()
    print(",".join(OUTPUT_FIELDS))
    print(format_csv_row(result))

    if result.missing:
        print()
        print(
            f"警告: 次の項目を読み取れませんでした: {', '.join(result.missing)}",
            file=sys.stderr,
        )
        print(
            "  → 旧 exe で «舌色診断» を実行済みか確認し，--raw で生テキストを確認してください．",
            file=sys.stderr,
        )
    if result.unmatched_numbers:
        print(
            f"参考: ラベル無しの数値コントロール: {result.unmatched_numbers}",
            file=sys.stderr,
        )


def print_raw(controls: list[Control]) -> None:
    """全子コントロールの生テキストを表示する（パース失敗時のデバッグ用）．"""
    print(f"# 子コントロール {len(controls)} 個")
    for control in controls:
        text = control.text.replace("\r", "\\r").replace("\n", "\\n")
        print(f"  hwnd=0x{control.handle:08X} class={control.class_name:<16} text={text!r}")


def _build_parser() -> argparse.ArgumentParser:
    """コマンドライン引数のパーサを作る．"""
    parser = argparse.ArgumentParser(
        description="旧 exe のメイン画面から診断値（L*a*b* と 5 色スコア）を読み取る．",
    )
    parser.add_argument(
        "--title",
        default=DEFAULT_TITLE_KEYWORD,
        help=f"旧 exe のウィンドウタイトルに含まれる文字列（既定: {DEFAULT_TITLE_KEYWORD}）",
    )
    parser.add_argument(
        "--raw",
        action="store_true",
        help="全子コントロールの生テキストも表示する（パース失敗時のデバッグ用）",
    )
    parser.add_argument(
        "--list-windows",
        action="store_true",
        help="トップレベルウィンドウのタイトル一覧を表示して終了する",
    )
    return parser


def main(argv: list[str] | None = None) -> int:
    """エントリポイント．戻り値はプロセスの終了コード．"""
    arguments = _build_parser().parse_args(argv)

    if sys.platform != "win32":
        print(
            "エラー: このスクリプトは Windows 専用です（Win32 API を使用します）．",
            file=sys.stderr,
        )
        return 1
    configure_api()

    if arguments.list_windows:
        print("# トップレベルウィンドウ一覧")
        for handle, title in list_top_level_windows():
            print(f"  hwnd=0x{handle:08X} title={title!r}")
        return 0

    found = find_window(arguments.title)
    if found is None:
        print(
            f"エラー: タイトルに {arguments.title!r} を含むウィンドウが見つかりません．",
            file=sys.stderr,
        )
        print(
            "  → 旧 exe を起動したまま実行してください．"
            "ウィンドウ名が違う場合は --title で指定するか，"
            "--list-windows で一覧を確認してください．",
            file=sys.stderr,
        )
        return 1

    handle, title = found
    print(f"# 対象ウィンドウ: {title!r} (hwnd=0x{handle:08X})")
    controls = read_controls(handle)
    if not controls:
        print(
            "エラー: 子コントロールを 1 つも取得できませんでした．"
            "別のウィンドウを掴んでいる可能性があります（--list-windows で確認）．",
            file=sys.stderr,
        )
        return 1

    if arguments.raw:
        print_raw(controls)
        print()

    result = parse_values([control.text for control in controls])
    print_result(result)
    # 8 値すべて読めたときだけ 0 を返す（採取の取りこぼしに気付けるようにする）
    return 0 if not result.missing else 2


if __name__ == "__main__":
    raise SystemExit(main())
