# heart_chrome_overlay.py
# pip install pywin32 pillow
#
# VERSION 규칙:
# - 수정 시 VERSION 숫자만 +1 한다.
#
# v10 최종 로직:
# 1. 하트3개 Chrome 창을 찾는다.
# 2. 그 창의 DWM 0,0 위치를 구한다.
# 3. 예상 DOM 시작점은 창 0,0 기준 대략 x=16px, y=120px 근처로 본다.
# 4. 그 근처에서 노란색 기준점 덩어리를 찾는다.
# 5. 노란색 덩어리가 있으면:
#    - 노란 덩어리 bounding box 좌상단을 DOM 0,0으로 본다.
#    - dx = yellow_x - window_left, dy = yellow_y - window_top 차이값을 저장한다.
#    - overlay = window_left + dx, window_top + dy 로 이동한다.
# 6. 노란색이 없어도 저장된 dx, dy가 있으면:
#    - overlay = 현재 window_left + 저장된 dx, 현재 window_top + 저장된 dy 로 이동한다.
#    - 즉, 창 이동을 따라간다.
# 7. 노란색도 없고 저장된 dx, dy도 없으면 → 하트3개 Chrome 창 0,0으로 이동한다.
# 8. 하트3개 Chrome 자체를 못 찾으면 → 기존 전역 위치 유지.
# 9. 최초부터 못 찾으면 → 이동 안 함.
# 10. 오버레이 안 textbox에 현재 안착 screen 좌표를 x:202 y:156 형식으로 표시한다.
#     - textbox는 선택/복사 가능하다.
# 11. 오버레이 가로폭을 좌표 표시가 가능한 최소 수준으로 줄였다.
# 12. 배경색을 회색으로 변경.
#     노란색 탐지 순간에는 배경만 투명색으로 바꿔서 아래 노란 기준점이 보이게 한다.
#     캡처 후 다시 회색 배경으로 복원한다.
# 13. 처음 노란 기준점을 잡은 뒤에는 매 tick마다 전체 노란색 탐지를 하지 않는다.
#     검정 ㄴ자 꺾임 픽셀 1개와 노란 바탕 픽셀 1개만 짧게 확인한다.
#     두 포인트 색이 그대로면 저장된 dom_delta로만 따라가서 깜빡임을 줄인다.
#     두 포인트 색이 바뀔 때만 전체 노란색 탐지를 다시 수행한다.
#     평상시 alpha를 조금 더 낮춰 눈에 덜 거슬리게 했다.
# 14. 오버레이 표시 위치와 실제 DOM 좌표를 분리했다.
#     실제 좌표 true_x,true_y는 노란 DOM 0,0 좌표 그대로 유지한다.
#     오버레이는 true_x + visual_offset_x 위치에 표시해서 노란 기준점을 가리지 않는다.
#     textbox에는 오버레이 위치가 아니라 실제 DOM 좌표 true_x,true_y를 표시한다.
# 15. v144: Heart 화면 오버레이는 기본 click-through 처리한다.
#     A/B/C/D 원점보정 마커 위에 겹쳐 보여도 elementFromPoint/hover/click을 먹지 않는다.
# 16. v145: Heart 10px 노란 기준점 의존을 IG_Ext A/C 보정 marker 인지로 대체한다.
#     A/TL visual marker를 찾으면 그 중심에서 DOM origin을 역산한다.
#     C/BL visual marker는 같은 왼쪽 열 보정 marker 검증/진단 정보로 함께 기록한다.
# 17. v146: visual_offset_x=44 기준을 실제 legacy Heart marker origin 역산에도 반영한다.
#     legacy Heart 10px marker가 DOM origin이 아니라 origin+visual_offset 위치에 있으면
#     DOM origin = detected_legacy_marker - visual_offset 으로 계산한다.
#     좌표 표시 박스도 DOM origin +44px 기준으로 맞춘다.
# 18. v147: 좌표 표시 박스 더블클릭 초기화를 추가한다.
#     저장 DOM delta/watch point/_FOUND 위치를 함께 무효화한다.
#     감시 포인트를 못 읽거나 재탐지 실패한 저장 delta는 click-safe로 내보내지 않는다.
#     마지막으로 확인한 Heart Chrome hwnd는 계속 추적해 화면 밖 이동/복원 후 재탐지를 돕는다.
# 19. v149: Chrome 0.67/0.8/1.0에서 A marker yellow fill 크기로 scale을 실측한다.
#     A center client (22,18)에 동일 scale을 적용해 DOM origin을 역산한다.
#     Heart 좌표창은 실제 A component 오른쪽 8px 밖으로 이동해 A를 시각적으로 가리지 않는다.
#
#
# 추가 기능 없음.
# 로그 파일 저장 없음. print() 화면 출력만 한다.
# geometry() 사용 안 함.
# SetWindowPos()만 사용.

from __future__ import annotations

import ctypes
import datetime
import time
import traceback
import threading
import copy
import socket
import json
import unicodedata
import tkinter as tk
import argparse

import win32con
import win32gui
import win32process

try:
    from PIL import ImageGrab
except ImportError:
    ImageGrab = None


VERSION = 97
HEART8772_BUILD_VERSION = "2.3.8.149"
HEART8772_BUILD_TAG = "v149"
HEART8772_LABEL = "Heart8772 v149"

_FOUND_X: int | None = None
_FOUND_Y: int | None = None
_LAST_TARGET_HWND: int | None = None

# DOM 0,0 기준점과 Chrome 창 0,0 사이의 차이값
# 예: window=(192,34), yellow=(202,156) 이면 dx=10, dy=122
_DOM_DX: int | None = None
_DOM_DY: int | None = None

# 기준점 감시용 2포인트.
# 모두 Chrome window 0,0 기준 상대좌표다.
_WATCH_BLACK_DX: int | None = None
_WATCH_BLACK_DY: int | None = None
_WATCH_YELLOW_DX: int | None = None
_WATCH_YELLOW_DY: int | None = None
_LAST_MARKER_POINT_CHECK_TS: float = 0.0
_SAFE_OVERLAY_DX: int | None = None
_SAFE_OVERLAY_DY: int | None = None


# v89 연동 어댑터 상태.
# 원본 overlay 기능은 그대로 유지하고, ws8771/click 쪽이 읽을 snapshot만 추가한다.
_SNAPSHOT_LOCK = threading.Lock()
_SNAPSHOT: dict = {
    "ok": False,
    "state": "not_started",
    "version": VERSION,
    "updated_ms": 0,
    "stale": True,
}
_GLOBAL_THREAD: threading.Thread | None = None
_GLOBAL_STOP_REQUESTED = False
_MAX_AGE_SEC: float = 3.0


def estimate_vcal_scale_from_yellow_component(width: int | float, height: int | float) -> tuple[float, float] | None:
    """Estimate browser CSS-to-screen scale from the yellow inner fill of the 36px A marker.

    The visible yellow fill is 30 CSS px because the marker has a 3px border on both sides.
    At Chrome zoom 0.67/0.8/1.0 the captured fill is therefore about 20/24/30 screen px.
    """
    try:
        w = float(width)
        h = float(height)
    except Exception:
        return None
    if w <= 0 or h <= 0:
        return None
    sx = w / 30.0
    sy = h / 30.0
    if not (0.50 <= sx <= 2.00 and 0.50 <= sy <= 2.00):
        return None
    if abs(sx - sy) > 0.20:
        return None
    return sx, sy


def origin_from_vcal_a_component(center_x: int | float, center_y: int | float, width: int | float, height: int | float) -> tuple[int, int, float, float] | None:
    """Recover DOM screen origin from the detected A-marker yellow component with scale."""
    scale = estimate_vcal_scale_from_yellow_component(width, height)
    if scale is None:
        return None
    sx, sy = scale
    return (
        int(round(float(center_x) - 22.0 * sx)),
        int(round(float(center_y) - 18.0 * sy)),
        float(sx),
        float(sy),
    )


def safe_overlay_position_for_a_marker(
    origin_x: int,
    origin_y: int,
    marker_info: dict | None,
    overlay_w: int,
    overlay_h: int,
    visual_offset_x: int,
    visual_offset_y: int,
    window_rect: tuple[int, int, int, int] | None = None,
    gap: int = 8,
) -> tuple[int, int, str]:
    """Place the Heart coordinate window outside the physical A marker rectangle."""
    base_x = int(origin_x + visual_offset_x)
    base_y = int(origin_y + visual_offset_y)
    info = marker_info if isinstance(marker_info, dict) else {}
    if str(info.get("role") or "") != "tl_a":
        return base_x, base_y, "visual_offset_default"
    try:
        left = int(info["abs_x"])
        top = int(info["abs_y"])
        right = left + max(1, int(info["w"]))
        bottom = top + max(1, int(info["h"]))
    except Exception:
        return base_x, base_y, "visual_offset_missing_marker_rect"
    if window_rect and len(window_rect) == 4:
        win_left, win_top, win_right, win_bottom = [int(v) for v in window_rect]
    else:
        win_left, win_top, win_right, win_bottom = -10**9, -10**9, 10**9, 10**9
    right_x = right + int(gap)
    if right_x + int(overlay_w) <= win_right:
        return right_x, base_y, "a_marker_right_gap"
    below_y = bottom + int(gap)
    below_x = min(max(base_x, win_left), max(win_left, win_right - int(overlay_w)))
    if below_y + int(overlay_h) <= win_bottom:
        return below_x, below_y, "a_marker_below_gap"
    left_x = left - int(overlay_w) - int(gap)
    if left_x >= win_left:
        return left_x, base_y, "a_marker_left_gap"
    fallback_x = max(win_left, min(base_x, win_right - int(overlay_w)))
    fallback_y = max(win_top, min(base_y, win_bottom - int(overlay_h)))
    return fallback_x, fallback_y, "a_marker_window_clamped"


def reset_calibration_state(reason: str = "startup") -> None:
    """Reset in-memory DOM calibration so old 4/4 values are never trusted on start."""
    global _FOUND_X, _FOUND_Y, _DOM_DX, _DOM_DY
    global _WATCH_BLACK_DX, _WATCH_BLACK_DY, _WATCH_YELLOW_DX, _WATCH_YELLOW_DY, _LAST_MARKER_POINT_CHECK_TS
    global _SAFE_OVERLAY_DX, _SAFE_OVERLAY_DY
    _FOUND_X = None
    _FOUND_Y = None
    _DOM_DX = None
    _DOM_DY = None
    _WATCH_BLACK_DX = None
    _WATCH_BLACK_DY = None
    _WATCH_YELLOW_DX = None
    _WATCH_YELLOW_DY = None
    _LAST_MARKER_POINT_CHECK_TS = 0.0
    _SAFE_OVERLAY_DX = None
    _SAFE_OVERLAY_DY = None
    _publish_snapshot({
        "ok": False,
        "state": "calibration_reset",
        "reason": reason,
        "service": "heart8772",
        "dom_origin": None,
        "dom_delta": {"dx": None, "dy": None},
    })


def _now_ms() -> int:
    return int(time.time() * 1000)


def _publish_snapshot(doc: dict) -> None:
    """Publish read-only tracker state for host/click modules.

    Safety:
    - Does not click or move the mouse.
    - ok=True is used only when a DOM-origin-like marker/delta is available.
    - Original overlay fallback behavior is not removed; fallback states are merely not
      exported as click-safe ok=True.
    """
    try:
        d = dict(doc or {})
        d.setdefault("version", VERSION)
        d.setdefault("heart_build_version", HEART8772_BUILD_VERSION)
        d.setdefault("heart_build_tag", HEART8772_BUILD_TAG)
        d.setdefault("heart_label", HEART8772_LABEL)
        d["updated_ms"] = _now_ms()
        with _SNAPSHOT_LOCK:
            _SNAPSHOT.clear()
            _SNAPSHOT.update(d)
    except Exception:
        pass


def get_global_heart_snapshot() -> dict:
    with _SNAPSHOT_LOCK:
        snap = copy.deepcopy(_SNAPSHOT)
    updated = float(snap.get("updated_ms") or 0) / 1000.0
    age = 999999.0 if updated <= 0 else max(0.0, time.time() - updated)
    stale = bool(age > _MAX_AGE_SEC)
    snap["age_sec"] = round(age, 3)
    snap["stale"] = stale
    snap["ok"] = bool(snap.get("ok")) and not stale
    return snap


def _call_optional_logger(logger, message: str) -> None:
    if logger is None:
        return
    try:
        logger(str(message))
    except Exception:
        pass


def _call_optional_tts(tts_func, message: str, level: int = 2) -> None:
    if tts_func is None:
        return
    try:
        tts_func(str(message), int(level))
    except Exception:
        pass


def run_heart_chrome_overlay(
    target_hwnd: int | None = None,
    *,
    poll_ms: int = 100,
    overlay_w: int = 110,
    overlay_h: int = 24,
    offset_x: int = 0,
    offset_y: int = 2,
    visual_offset_x: int = 44,
    visual_offset_y: int = 0,
    alpha: float = 0.48,
    overlay_click_through: bool = True,
    require_chrome_class: bool = True,
    dom_guess_x: int = 16,
    dom_guess_y: int = 120,
    marker_check_ms: int = 500,
    marker_micro_sleep: float = 0.0,
    verbose: bool = True,
    logger=None,
    tts_func=None,
    tts_level: int = 2,
) -> None:
    """
    하트3개 Chrome 창 기준으로 오버레이 위치를 잡는다.

    위치 결정:
    - 하트3개 Chrome 창을 찾음
      - 창 안의 예상 DOM 시작점 근처에서 노란 기준점 덩어리를 찾음
      - 노란 기준점 덩어리가 있으면 그 bbox 좌상단으로 이동하고 dx, dy 차이값 저장
      - 노란 기준점이 있으면 dx, dy와 감시용 2포인트를 저장
      - 이후 감시용 2포인트 색이 그대로면 전체 탐지 없이 저장된 dx, dy로 이동
      - 감시용 2포인트 색이 바뀌면 전체 노란 기준점 탐지를 다시 수행
      - 노란 기준점도 없고 저장된 dx, dy도 없으면 창 0,0으로 이동
      - 오버레이는 실제 좌표에서 visual_offset_x만큼 비켜서 표시
      - textbox에는 실제 DOM screen 좌표를 표시
    - 하트3개 Chrome 창을 못 찾음
      - 전역 위치가 있으면 그 위치 유지
      - 전역 위치가 없으면 이동하지 않음

    실패 처리:
    - 임의 기본 좌표를 넣지 않는다.
    - geometry()를 쓰지 않는다.
    """

    global _FOUND_X, _FOUND_Y

    prefix = "❤️❤️❤️"
    chrome_class_prefix = "Chrome_WidgetWin"

    normal_bg = "#555555"
    transparent_bg = "#00ff01"
    text_fg = "white"

    # v146: IG_Ext visual calibration marker geometry and legacy visual offset.
    # TL/A marker CSS: left:4px; top:0px; width/height:36px.
    # Its center is therefore DOM-origin + (22,18).
    VCAL_A_CENTER_CLIENT_X = 22
    VCAL_A_CENTER_CLIENT_Y = 18
    VCAL_MARKER_YELLOW_FILL_CSS_SIZE = 30
    VCAL_MARKER_MIN_SIZE = 15
    VCAL_MARKER_MAX_SIZE = 60

    def now() -> str:
        return datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]

    def log(msg: str) -> None:
        text = f"[{now()}] {msg}"
        if verbose:
            print(text, flush=True)
        _call_optional_logger(logger, msg)

    def tts_once(key: str, msg: str, level: int | None = None, min_interval: float = 3.0) -> None:
        try:
            bucket = getattr(tts_once, "_last", {})
            t = time.time()
            last = float(bucket.get(key, 0) or 0)
            if t - last < min_interval:
                return
            bucket[key] = t
            setattr(tts_once, "_last", bucket)
            _call_optional_tts(tts_func, msg, int(tts_level if level is None else level))
        except Exception:
            pass

    def enable_dpi_awareness() -> None:
        try:
            ctypes.windll.shcore.SetProcessDpiAwareness(2)
            log("SetProcessDpiAwareness(2) OK")
        except Exception as e1:
            try:
                ctypes.windll.user32.SetProcessDPIAware()
                log("SetProcessDPIAware() OK")
            except Exception as e2:
                log(f"DPI awareness failed: {e1} / {e2}")

    DWMWA_EXTENDED_FRAME_BOUNDS = 9
    dwmapi = ctypes.WinDLL("dwmapi")

    class RECT(ctypes.Structure):
        _fields_ = [
            ("left", ctypes.c_long),
            ("top", ctypes.c_long),
            ("right", ctypes.c_long),
            ("bottom", ctypes.c_long),
        ]

    def get_dwm_rect(hwnd: int) -> tuple[int, int, int, int, str]:
        rect = RECT()
        try:
            result = dwmapi.DwmGetWindowAttribute(
                hwnd,
                DWMWA_EXTENDED_FRAME_BOUNDS,
                ctypes.byref(rect),
                ctypes.sizeof(rect),
            )
            if result == 0:
                return rect.left, rect.top, rect.right, rect.bottom, "DWM"
        except Exception:
            pass

        left, top, right, bottom = win32gui.GetWindowRect(hwnd)
        return left, top, right, bottom, "GetWindowRect"

    def norm_title(s: str | None) -> str:
        if not s:
            return ""

        s = unicodedata.normalize("NFKC", s)

        for ch in ["\ufe0f", "\u200d", "\u200c", "\u200b", "\ufeff"]:
            s = s.replace(ch, "")

        s = s.strip()
        s = s.replace("❤", "♥")
        s = s.replace("♡", "♥")
        return s

    def is_target_title(title: str) -> bool:
        return norm_title(title).startswith(norm_title(prefix))

    def is_chrome_class(cls: str) -> bool:
        if not require_chrome_class:
            return True
        return cls.startswith(chrome_class_prefix)

    def root_hwnd(hwnd: int) -> int:
        try:
            root = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
            return int(root) if root else int(hwnd)
        except Exception:
            return int(hwnd)

    def safe_title(hwnd: int) -> str:
        try:
            return win32gui.GetWindowText(hwnd)
        except Exception as e:
            return f"<GetWindowText error: {e}>"

    def safe_class(hwnd: int) -> str:
        try:
            return win32gui.GetClassName(hwnd)
        except Exception as e:
            return f"<GetClassName error: {e}>"

    def safe_pid(hwnd: int) -> tuple[int | None, int | None]:
        try:
            tid, pid = win32process.GetWindowThreadProcessId(hwnd)
            return tid, pid
        except Exception:
            return None, None

    def build_window_info(hwnd: int) -> dict | None:
        try:
            hwnd = root_hwnd(hwnd)

            if not win32gui.IsWindow(hwnd):
                return None

            if not win32gui.IsWindowVisible(hwnd):
                return None

            if win32gui.IsIconic(hwnd):
                return None

            cls = safe_class(hwnd)
            if not is_chrome_class(cls):
                return None

            title = safe_title(hwnd)
            if not is_target_title(title):
                return None

            tid, pid = safe_pid(hwnd)

            return {
                "hwnd": hwnd,
                "title": title,
                "pid": pid,
                "tid": tid,
                "class": cls,
            }
        except Exception:
            return None

    def get_foreground_target_info() -> dict | None:
        try:
            hwnd = win32gui.GetForegroundWindow()
            if not hwnd:
                return None
            return build_window_info(int(hwnd))
        except Exception:
            return None

    def find_target_chrome_full_search() -> dict | None:
        found: list[dict] = []

        def callback(hwnd: int, _) -> bool:
            info = build_window_info(hwnd)
            if info:
                found.append(info)
                return False
            return True

        win32gui.EnumWindows(callback, None)
        return found[0] if found else None

    def get_target_auto() -> tuple[dict | None, str]:
        """
        자동 모드:
        1. foreground 우선
        2. 마지막으로 확인한 Heart Chrome hwnd가 아직 살아 있으면 계속 추적
        3. 전역 위치가 아직 없으면 전체 검색 계속

        v147:
        화면 밖/다른 모니터 이동 뒤 복원되는 동안 foreground가 아니어도
        직전에 확인한 Heart Chrome hwnd를 다시 검사해 저장 delta 고착을 줄인다.
        """
        target = get_foreground_target_info()
        if target:
            return target, "FOREGROUND"

        if _LAST_TARGET_HWND is not None:
            target = build_window_info(int(_LAST_TARGET_HWND))
            if target:
                return target, "LAST_KNOWN_HWND"

        if _FOUND_X is None or _FOUND_Y is None:
            target = find_target_chrome_full_search()
            if target:
                return target, "INITIAL_FULL_SEARCH"

        return None, "NOT_FOUND"

    def is_yellow_pixel(r: int, g: int, b: int) -> bool:
        """
        노란 기준점 판정.
        순수 #ffff00뿐 아니라 모서리 안티앨리어싱까지 어느 정도 허용한다.
        """
        return (
            r >= 230
            and g >= 200
            and b <= 110
            and r > b + 100
            and g > b + 90
        )

    def is_black_pixel(r: int, g: int, b: int) -> bool:
        """
        검정 ㄴ자 테두리 판정.
        완전 검정뿐 아니라 안티앨리어싱된 진한 회색까지 허용한다.
        """
        return r <= 80 and g <= 80 and b <= 80


    def prepare_overlay_for_capture(sleep_sec: float = 0.0) -> None:
        """
        v14:
        오버레이가 실제 노란 DOM 0,0 좌표에서 오른쪽으로 비켜서 표시된다.
        그래서 노란 기준점을 가리지 않는다.
        캡처 전에 배경 투명/숨김 처리를 하지 않는다.
        """
        try:
            if sleep_sec > 0:
                time.sleep(max(0.0, float(sleep_sec)))
        except Exception:
            pass

    def restore_overlay_after_capture() -> None:
        """
        v14:
        캡처 전에 UI를 바꾸지 않으므로 복원할 것도 없다.
        """
        return

    def capture_region(left: int, top: int, right: int, bottom: int):
        if ImageGrab is None:
            return None

        if right <= left or bottom <= top:
            return None

        try:
            return ImageGrab.grab(bbox=(left, top, right, bottom), all_screens=True)
        except TypeError:
            try:
                return ImageGrab.grab(bbox=(left, top, right, bottom))
            except Exception:
                return None
        except Exception:
            return None

    def read_screen_points(points: list[tuple[int, int]], overlay_hwnd: int):
        """
        화면상의 몇 개 포인트만 읽는다.
        오버레이가 기준점을 덮고 있으므로 아주 짧게 배경만 투명 처리한다.
        전체 노란색 탐지가 아니라 2포인트 확인용이다.
        """
        if ImageGrab is None or not points:
            return None

        xs = [p[0] for p in points]
        ys = [p[1] for p in points]

        left = min(xs) - 1
        top = min(ys) - 1
        right = max(xs) + 2
        bottom = max(ys) + 2

        prepare_overlay_for_capture(marker_micro_sleep)
        img = capture_region(left, top, right, bottom)
        restore_overlay_after_capture()

        if img is None:
            return None

        rgb = img.convert("RGB")
        result = []

        for px_x, px_y in points:
            try:
                result.append(rgb.getpixel((px_x - left, px_y - top)))
            except Exception:
                return None

        return result

    def marker_watch_points_available() -> bool:
        return (
            _WATCH_BLACK_DX is not None
            and _WATCH_BLACK_DY is not None
            and _WATCH_YELLOW_DX is not None
            and _WATCH_YELLOW_DY is not None
        )

    def check_marker_watch_points(
        win_left: int,
        win_top: int,
        overlay_hwnd: int,
    ) -> bool | None:
        """
        저장된 2포인트를 확인한다.

        return:
        - True  : 검정/노란 2포인트가 그대로다. 전체 재탐지 불필요.
        - False : 두 포인트 중 하나가 바뀌었다. 전체 재탐지 필요.
        - None  : 포인트 정보가 없거나 읽기 실패. 깜빡임 방지를 위해 재탐지하지 않는다.
        """
        global _LAST_MARKER_POINT_CHECK_TS

        if not marker_watch_points_available():
            return None

        now_t = time.time()
        interval = max(0.05, marker_check_ms / 1000.0)

        if now_t - _LAST_MARKER_POINT_CHECK_TS < interval:
            return True

        _LAST_MARKER_POINT_CHECK_TS = now_t

        black_x = int(win_left + _WATCH_BLACK_DX)
        black_y = int(win_top + _WATCH_BLACK_DY)
        yellow_x = int(win_left + _WATCH_YELLOW_DX)
        yellow_y = int(win_top + _WATCH_YELLOW_DY)

        colors = read_screen_points(
            [(black_x, black_y), (yellow_x, yellow_y)],
            overlay_hwnd,
        )

        if colors is None:
            return None

        br, bg, bb = colors[0]
        yr, yg, yb = colors[1]

        black_ok = is_black_pixel(br, bg, bb)
        yellow_ok = is_yellow_pixel(yr, yg, yb)

        return bool(black_ok and yellow_ok)

    def find_black_point_near_yellow_component(img, comp: dict) -> tuple[int, int] | None:
        """
        노란 기준점 주변에서 검정 ㄴ자 꺾임에 해당할 만한 검정 픽셀을 찾는다.
        반환 좌표는 이미지 내부 좌표다.
        """
        rgb = img.convert("RGB")
        w, h = rgb.size
        px = rgb.load()

        target_x = int(comp["x"]) - 1
        target_y = int(comp["y"]) - 1

        sx0 = max(0, int(comp["x"]) - 4)
        sy0 = max(0, int(comp["y"]) - 4)
        sx1 = min(w - 1, int(comp["x"]) + int(comp["w"]) + 4)
        sy1 = min(h - 1, int(comp["y"]) + int(comp["h"]) + 4)

        best = None
        best_score = None

        for yy in range(sy0, sy1 + 1):
            for xx in range(sx0, sx1 + 1):
                r, g, b = px[xx, yy]
                if not is_black_pixel(r, g, b):
                    continue

                # 노란 bbox 좌상단 근처의 검정 픽셀을 우선한다.
                score = abs(xx - target_x) + abs(yy - target_y)

                if best_score is None or score < best_score:
                    best_score = score
                    best = (xx, yy)

        return best


    def find_yellow_components_in_image(img) -> list[dict]:
        """
        이미지 안의 노란색 connected component를 bbox 단위로 반환한다.
        """
        rgb = img.convert("RGB")
        w, h = rgb.size
        px = rgb.load()

        visited = bytearray(w * h)
        components: list[dict] = []

        def idx(x: int, y: int) -> int:
            return y * w + x

        for y0 in range(h):
            for x0 in range(w):
                i0 = idx(x0, y0)
                if visited[i0]:
                    continue

                r, g, b = px[x0, y0]
                if not is_yellow_pixel(r, g, b):
                    visited[i0] = 1
                    continue

                stack = [(x0, y0)]
                visited[i0] = 1

                min_x = max_x = x0
                min_y = max_y = y0
                count = 0

                while stack:
                    x, y = stack.pop()
                    count += 1

                    if x < min_x:
                        min_x = x
                    if x > max_x:
                        max_x = x
                    if y < min_y:
                        min_y = y
                    if y > max_y:
                        max_y = y

                    # 4-neighbor
                    if x > 0:
                        nx, ny = x - 1, y
                        ni = idx(nx, ny)
                        if not visited[ni]:
                            rr, gg, bb = px[nx, ny]
                            if is_yellow_pixel(rr, gg, bb):
                                visited[ni] = 1
                                stack.append((nx, ny))
                            else:
                                visited[ni] = 1

                    if x + 1 < w:
                        nx, ny = x + 1, y
                        ni = idx(nx, ny)
                        if not visited[ni]:
                            rr, gg, bb = px[nx, ny]
                            if is_yellow_pixel(rr, gg, bb):
                                visited[ni] = 1
                                stack.append((nx, ny))
                            else:
                                visited[ni] = 1

                    if y > 0:
                        nx, ny = x, y - 1
                        ni = idx(nx, ny)
                        if not visited[ni]:
                            rr, gg, bb = px[nx, ny]
                            if is_yellow_pixel(rr, gg, bb):
                                visited[ni] = 1
                                stack.append((nx, ny))
                            else:
                                visited[ni] = 1

                    if y + 1 < h:
                        nx, ny = x, y + 1
                        ni = idx(nx, ny)
                        if not visited[ni]:
                            rr, gg, bb = px[nx, ny]
                            if is_yellow_pixel(rr, gg, bb):
                                visited[ni] = 1
                                stack.append((nx, ny))
                            else:
                                visited[ni] = 1

                bw = max_x - min_x + 1
                bh = max_y - min_y + 1
                area = bw * bh
                density = count / area if area else 0.0

                # 10px 기준점 중심.
                # 둥근 모서리, 안티앨리어싱을 고려해서 범위는 여유 있게 둔다.
                if (
                    4 <= bw <= 40
                    and 4 <= bh <= 40
                    and 8 <= count <= 1000
                    and density >= 0.20
                ):
                    components.append(
                        {
                            "x": min_x,
                            "y": min_y,
                            "w": bw,
                            "h": bh,
                            "count": count,
                            "density": density,
                        }
                    )

        return components

    def find_dom_origin_yellow_marker(
        win_left: int,
        win_top: int,
        win_right: int,
        win_bottom: int,
        overlay_hwnd: int,
    ) -> tuple[int, int, dict] | None:
        """
        하트 Chrome 창 내부에서 DOM origin 기준점을 찾는다.

        v146 핵심:
        - 예전 Heart 10px 노란 기준점만 믿지 않는다.
        - IG_Ext의 A/TL visual calibration marker가 보이면, 그 marker 중심에서
          DOM origin을 역산한다.
            A center screen = DOM origin + (22,18)
            DOM origin = A center screen - (22,18)
        - C/BL marker는 같은 왼쪽 열의 보조 marker로 함께 수집해 진단/검증에 쓴다.
        - 기존 10px marker는 fallback으로만 남기되, marker가 visual_offset 위치에
          표시된 기준점이면 DOM origin = detected_marker - visual_offset 으로 역산한다.
        """
        if ImageGrab is None:
            return None

        expected_x = int(win_left + dom_guess_x)
        expected_y = int(win_top + dom_guess_y)

        # v145: A/TL뿐 아니라 C/BL도 같은 왼쪽 열에서 같이 볼 수 있게
        # 왼쪽 narrow strip을 스캔한다. 전체 화면 스캔이 아니라 Chrome 내부 왼쪽 열만 본다.
        scan_left = max(int(win_left), int(expected_x - 55))
        scan_top = max(int(win_top), int(expected_y - 95))
        scan_right = min(int(win_right), int(expected_x + 210))
        scan_bottom = min(int(win_bottom), int(win_bottom - 1))

        prepare_overlay_for_capture()
        img = capture_region(scan_left, scan_top, scan_right, scan_bottom)
        restore_overlay_after_capture()

        if img is None:
            return None

        components = find_yellow_components_in_image(img)
        if not components:
            return None

        visual_candidates: list[dict] = []
        legacy_candidates: list[dict] = []

        for c in components:
            abs_x = int(scan_left + c["x"])
            abs_y = int(scan_top + c["y"])
            cx = int(round(abs_x + float(c["w"]) / 2.0))
            cy = int(round(abs_y + float(c["h"]) / 2.0))
            base = {
                **c,
                "abs_x": abs_x,
                "abs_y": abs_y,
                "center_x": cx,
                "center_y": cy,
                "expected_x": expected_x,
                "expected_y": expected_y,
                "scan_rect": (scan_left, scan_top, scan_right, scan_bottom),
            }
            w = int(c.get("w") or 0)
            h = int(c.get("h") or 0)

            scale_origin = origin_from_vcal_a_component(cx, cy, w, h)
            if (
                VCAL_MARKER_MIN_SIZE <= w <= VCAL_MARKER_MAX_SIZE
                and VCAL_MARKER_MIN_SIZE <= h <= VCAL_MARKER_MAX_SIZE
                and scale_origin is not None
            ):
                # A/TL은 예상 DOM origin 근처의 위쪽 marker다.
                # C/BL은 같은 왼쪽 열의 아래쪽 marker라서 origin 산출에는 단독 사용하지 않고 진단용으로 둔다.
                role = "tl_a" if abs(cy - expected_y) <= 120 else "bl_c_candidate"
                # v149: yellow fill 실측 크기(30 CSS px 기준)에서 scale을 구한 뒤
                # A center client (22,18)에 같은 scale을 적용해 DOM origin을 역산한다.
                origin_x, origin_y, marker_scale_x, marker_scale_y = scale_origin
                position_score = abs(origin_x - expected_x) + abs(origin_y - expected_y)
                expected_fill_w = VCAL_MARKER_YELLOW_FILL_CSS_SIZE * marker_scale_x
                expected_fill_h = VCAL_MARKER_YELLOW_FILL_CSS_SIZE * marker_scale_y
                size_score = abs(w - expected_fill_w) + abs(h - expected_fill_h)
                role_penalty = 0 if role == "tl_a" else 800
                base.update({
                    "role": role,
                    "origin_abs_x": origin_x,
                    "origin_abs_y": origin_y,
                    "marker_scale_x": float(marker_scale_x),
                    "marker_scale_y": float(marker_scale_y),
                    "origin_method": "ig_ext_visual_calibration_marker_A_center_minus_scaled_client_offset_v149" if role == "tl_a" else "ig_ext_visual_calibration_marker_C_observed_only",
                    "score": int(position_score + size_score + role_penalty),
                })
                visual_candidates.append(base)
            else:
                # 기존 10px Heart marker fallback.
                # v146: 이 marker가 실제 DOM origin에 직접 찍힌 것이 아니라
                # overlay/visual 기준점 위치(origin + visual_offset)에 찍힌 경우가 있으므로
                # DOM origin은 detected marker 좌표에서 visual_offset을 빼서 역산한다.
                legacy_origin_x = int(round(abs_x - visual_offset_x))
                legacy_origin_y = int(round(abs_y - visual_offset_y))
                position_score = abs(legacy_origin_x - expected_x) + abs(legacy_origin_y - expected_y)
                size_score = abs(w - 10) * 2 + abs(h - 10) * 2
                base.update({
                    "role": "legacy_heart_10px_marker",
                    "origin_abs_x": legacy_origin_x,
                    "origin_abs_y": legacy_origin_y,
                    "legacy_marker_abs_x": abs_x,
                    "legacy_marker_abs_y": abs_y,
                    "legacy_visual_offset_x": int(visual_offset_x),
                    "legacy_visual_offset_y": int(visual_offset_y),
                    "origin_method": "legacy_heart_10px_marker_minus_visual_offset_v146",
                    "score": int(position_score + size_score + 300),
                })
                legacy_candidates.append(base)

        # A/TL visual marker가 있으면 그것을 최우선 기준점으로 쓴다.
        a_candidates = [c for c in visual_candidates if c.get("role") == "tl_a"]
        all_candidates = a_candidates or legacy_candidates
        if not all_candidates:
            return None

        best = min(all_candidates, key=lambda d: int(d.get("score") or 999999))

        # 같은 스캔에서 보인 C/BL 후보를 진단용으로 함께 남긴다.
        ac_seen = []
        for c in sorted(visual_candidates, key=lambda d: int(d.get("score") or 999999))[:8]:
            ac_seen.append({
                "role": c.get("role"),
                "abs_x": c.get("abs_x"),
                "abs_y": c.get("abs_y"),
                "center_x": c.get("center_x"),
                "center_y": c.get("center_y"),
                "w": c.get("w"),
                "h": c.get("h"),
                "origin_abs_x": c.get("origin_abs_x"),
                "origin_abs_y": c.get("origin_abs_y"),
                "marker_scale_x": c.get("marker_scale_x"),
                "marker_scale_y": c.get("marker_scale_y"),
                "score": c.get("score"),
            })
        best["ig_ext_ac_markers_seen"] = ac_seen
        best["preferred_marker_replacement"] = "IG_Ext A/C visual calibration marker replaces Heart 10px marker dependency"

        # 노란 바탕 포인트: bbox 안쪽 1px 지점. watch point 호환용.
        yellow_img_x = int(best["x"]) + min(1, max(0, int(best["w"]) - 1))
        yellow_img_y = int(best["y"]) + min(1, max(0, int(best["h"]) - 1))

        best["yellow_abs_x"] = int(scan_left + yellow_img_x)
        best["yellow_abs_y"] = int(scan_top + yellow_img_y)

        black_point = find_black_point_near_yellow_component(img, best)
        if black_point is not None:
            bx, by = black_point
            best["black_abs_x"] = int(scan_left + bx)
            best["black_abs_y"] = int(scan_top + by)

        return int(best["origin_abs_x"]), int(best["origin_abs_y"]), best

    def collect_child_hwnds(hwnd: int) -> list[int]:
        result: list[int] = []

        def enum_child(child_hwnd: int, _) -> bool:
            result.append(child_hwnd)
            return True

        try:
            win32gui.EnumChildWindows(hwnd, enum_child, None)
        except Exception:
            pass

        return result

    def get_overlay_hwnds(tk_win: tk.Toplevel) -> list[int]:
        tk_win.update_idletasks()
        hwnds: list[int] = []

        try:
            frame_text = tk_win.tk.call("wm", "frame", tk_win._w)
            hwnds.append(int(frame_text, 0))
        except Exception:
            pass

        try:
            hwnds.append(int(tk_win.winfo_id()))
        except Exception:
            pass

        expanded: list[int] = []
        for hwnd in hwnds:
            expanded.append(hwnd)
            expanded.extend(collect_child_hwnds(hwnd))

        uniq: list[int] = []
        for hwnd in expanded:
            if hwnd and hwnd not in uniq:
                uniq.append(hwnd)

        return uniq

    def get_primary_overlay_hwnd(tk_win: tk.Toplevel) -> int:
        hwnds = get_overlay_hwnds(tk_win)
        if hwnds:
            return hwnds[0]
        return int(tk_win.winfo_id())

    def apply_overlay_style(hwnd: int) -> None:
        """Apply Heart visual overlay style.

        v144 safety rule:
        - Heart8772 remains origin provider only.
        - The visual coordinate box must not intercept mouse/hover/click over
          IG_Ext A/B/C/D calibration markers.
        - Default is Windows click-through. Use --interactive-overlay only when
          the user explicitly needs to select/copy the text box by mouse.
        """
        try:
            ex_style = win32gui.GetWindowLong(hwnd, win32con.GWL_EXSTYLE)
            ex_style |= win32con.WS_EX_TOOLWINDOW
            try:
                ex_style |= win32con.WS_EX_LAYERED
            except Exception:
                pass

            if overlay_click_through:
                ex_style |= win32con.WS_EX_TRANSPARENT
                ex_style |= win32con.WS_EX_NOACTIVATE
            else:
                ex_style &= ~win32con.WS_EX_TRANSPARENT

            win32gui.SetWindowLong(hwnd, win32con.GWL_EXSTYLE, ex_style)

            win32gui.SetWindowPos(
                hwnd,
                win32con.HWND_TOPMOST,
                0,
                0,
                0,
                0,
                win32con.SWP_NOMOVE
                | win32con.SWP_NOSIZE
                | win32con.SWP_FRAMECHANGED,
            )
        except Exception as e:
            log(f"OVERLAY_STYLE_APPLY_FAIL hwnd={hwnd} err={e}")

    def move_overlay(overlay_hwnd: int, x: int, y: int) -> None:
        win32gui.SetWindowPos(
            overlay_hwnd,
            win32con.HWND_TOPMOST,
            int(x),
            int(y),
            int(overlay_w),
            int(overlay_h),
            win32con.SWP_NOACTIVATE | win32con.SWP_SHOWWINDOW,
        )

    def compute_target_pos_for_window(
        hwnd: int,
        overlay_hwnd: int,
    ) -> tuple[int, int, str, tuple[int, int, int, int], str, dict | None]:
        """
        하트 Chrome 창에 대해 최종 위치를 계산한다.

        v13:
        1. dom_delta가 있고 감시 2포인트가 정상이라면 전체 노란색 탐지를 하지 않는다.
        2. 감시 2포인트가 바뀐 경우에만 전체 노란색 탐지를 다시 한다.
        3. dom_delta가 없으면 최초 탐지를 한다.

        v147:
        - 감시 포인트를 못 읽은 상태(None)를 더 이상 정상 delta로 즉시 확정하지 않는다.
        - None/False 모두 전체 재탐지를 시도한다.
        - 재탐지 실패 시 기존 위치 표시는 유지하되 click-safe snapshot에서는 제외한다.
        """
        global _DOM_DX, _DOM_DY
        global _WATCH_BLACK_DX, _WATCH_BLACK_DY, _WATCH_YELLOW_DX, _WATCH_YELLOW_DY

        left, top, right, bottom, rect_src = get_dwm_rect(hwnd)
        saved_point_check: str | None = None

        if _DOM_DX is not None and _DOM_DY is not None:
            point_state = check_marker_watch_points(left, top, overlay_hwnd)

            if point_state is True:
                return (
                    int(left + _DOM_DX + offset_x),
                    int(top + _DOM_DY + offset_y),
                    "SAVED_DOM_DELTA_POINT_OK",
                    (left, top, right, bottom),
                    rect_src,
                    {
                        "dom_dx": _DOM_DX,
                        "dom_dy": _DOM_DY,
                        "point_check": "OK",
                        "click_safe": True,
                    },
                )

            def log_rescan_event(key: str, message: str) -> None:
                try:
                    bucket = getattr(compute_target_pos_for_window, "_last_rescan_log", {})
                    now_t = time.time()
                    last = float(bucket.get(key, 0) or 0)
                    if now_t - last >= 1.5:
                        bucket[key] = now_t
                        setattr(compute_target_pos_for_window, "_last_rescan_log", bucket)
                        log(message)
                except Exception:
                    log(message)

            if point_state is None:
                saved_point_check = "NO_POINT_OR_CAPTURE_FAIL_RESCAN_REQUIRED"
                log_rescan_event("point_unverified", "MARKER_POINT_UNVERIFIED: 감시 포인트 없음/캡처 실패, 저장 delta 확정 금지 후 전체 재탐지")
            else:
                saved_point_check = "POINT_CHANGED_RESCAN_REQUIRED"
                log_rescan_event("point_changed", "MARKER_POINT_CHANGED: 검정/노란 감시 포인트 변화, 전체 노란 기준점 재탐지")

        yellow = find_dom_origin_yellow_marker(
            left,
            top,
            right,
            bottom,
            overlay_hwnd,
        )

        if yellow is not None:
            x, y, yellow_info = yellow

            _DOM_DX = int(x - left)
            _DOM_DY = int(y - top)

            if "black_abs_x" in yellow_info and "black_abs_y" in yellow_info:
                _WATCH_BLACK_DX = int(yellow_info["black_abs_x"] - left)
                _WATCH_BLACK_DY = int(yellow_info["black_abs_y"] - top)
            else:
                _WATCH_BLACK_DX = None
                _WATCH_BLACK_DY = None

            if "yellow_abs_x" in yellow_info and "yellow_abs_y" in yellow_info:
                _WATCH_YELLOW_DX = int(yellow_info["yellow_abs_x"] - left)
                _WATCH_YELLOW_DY = int(yellow_info["yellow_abs_y"] - top)
            else:
                _WATCH_YELLOW_DX = None
                _WATCH_YELLOW_DY = None

            yellow_info = {
                **yellow_info,
                "dom_dx": _DOM_DX,
                "dom_dy": _DOM_DY,
                "watch_black_dx": _WATCH_BLACK_DX,
                "watch_black_dy": _WATCH_BLACK_DY,
                "watch_yellow_dx": _WATCH_YELLOW_DX,
                "watch_yellow_dy": _WATCH_YELLOW_DY,
            }

            return (
                int(left + _DOM_DX + offset_x),
                int(top + _DOM_DY + offset_y),
                "YELLOW_DOM_ORIGIN",
                (left, top, right, bottom),
                rect_src,
                yellow_info,
            )

        if _DOM_DX is not None and _DOM_DY is not None:
            return (
                int(left + _DOM_DX + offset_x),
                int(top + _DOM_DY + offset_y),
                "SAVED_DOM_DELTA_UNVERIFIED_SCAN_FAIL",
                (left, top, right, bottom),
                rect_src,
                {
                    "dom_dx": _DOM_DX,
                    "dom_dy": _DOM_DY,
                    "point_check": saved_point_check or "SCAN_FAIL_WITH_SAVED_DELTA",
                    "click_safe": False,
                    "warning": "saved_dom_delta_kept_for_overlay_only_not_click_safe",
                },
            )

        return (
            int(left + offset_x),
            int(top + offset_y),
            "WINDOW_ZERO",
            (left, top, right, bottom),
            rect_src,
            None,
        )

    def move_overlay_to_found_window(overlay_hwnd: int, hwnd: int):
        global _FOUND_X, _FOUND_Y, _SAFE_OVERLAY_DX, _SAFE_OVERLAY_DY

        true_x, true_y, pos_source, rect, rect_src, yellow_info = compute_target_pos_for_window(
            hwnd,
            overlay_hwnd,
        )

        # 찾았을 때만 전역 위치 갱신.
        # _FOUND_X/Y는 오버레이 표시 좌표가 아니라 실제 DOM screen 좌표다.
        _FOUND_X = true_x
        _FOUND_Y = true_y

        has_a_marker = isinstance(yellow_info, dict) and str(yellow_info.get("role") or "") == "tl_a"
        if has_a_marker:
            overlay_x, overlay_y, overlay_policy = safe_overlay_position_for_a_marker(
                int(true_x),
                int(true_y),
                yellow_info,
                int(overlay_w),
                int(overlay_h),
                int(visual_offset_x),
                int(visual_offset_y),
                rect,
            )
            _SAFE_OVERLAY_DX = int(overlay_x - true_x)
            _SAFE_OVERLAY_DY = int(overlay_y - true_y)
        elif _SAFE_OVERLAY_DX is not None and _SAFE_OVERLAY_DY is not None:
            overlay_x = int(true_x + _SAFE_OVERLAY_DX)
            overlay_y = int(true_y + _SAFE_OVERLAY_DY)
            overlay_policy = "saved_a_marker_safe_offset"
        else:
            overlay_x, overlay_y, overlay_policy = safe_overlay_position_for_a_marker(
                int(true_x), int(true_y), yellow_info, int(overlay_w), int(overlay_h),
                int(visual_offset_x), int(visual_offset_y), rect,
            )
        if isinstance(yellow_info, dict):
            yellow_info = dict(yellow_info)
            yellow_info["overlay_policy"] = overlay_policy
            yellow_info["overlay_x"] = int(overlay_x)
            yellow_info["overlay_y"] = int(overlay_y)
            yellow_info["safe_overlay_dx"] = _SAFE_OVERLAY_DX
            yellow_info["safe_overlay_dy"] = _SAFE_OVERLAY_DY

        move_overlay(overlay_hwnd, overlay_x, overlay_y)
        set_coord_text(true_x, true_y)

        return true_x, true_y, overlay_x, overlay_y, pos_source, rect, rect_src, yellow_info

    enable_dpi_awareness()

    mode = "HWND_FIXED" if target_hwnd is not None else "AUTO_FOREGROUND_PRIORITY"
    _publish_snapshot({"ok": False, "state": "overlay_starting", "reason": "heart_overlay_thread_started"})

    log("=" * 80)
    log(f"heart_chrome_overlay VERSION={VERSION} {HEART8772_LABEL} / {HEART8772_BUILD_VERSION}")
    log("run_heart_chrome_overlay 시작")
    log(f"mode={mode}")
    log(f"target_hwnd={target_hwnd}")
    log(f"prefix={prefix!r}")
    log(f"overlay_size=({overlay_w}x{overlay_h})")
    log(f"offset=({offset_x},{offset_y})")
    log(f"visual_offset=({visual_offset_x},{visual_offset_y})")
    log(f"alpha={alpha}")
    log(f"dom_guess=({dom_guess_x},{dom_guess_y})")
    log("위치 로직:")
    log("1. 하트 Chrome 찾음")
    log("2. 창 0,0 기준 대략 (16,120) 근처 왼쪽 열에서 IG_Ext A/C marker 또는 legacy 10px marker 검색")
    log("3. A/TL visual marker를 찾으면 yellow fill 크기로 scale을 구하고 center-(22×scale,18×scale)로 DOM origin 역산")
    log("4. 노란색 찾으면 window 0,0 과의 dx, dy 차이값 저장")
    log("5. 노란색 없어도 저장된 dx, dy가 있으면 현재 window 위치 + dx, dy 로 따라감")
    log("6. 노란색도 dx, dy도 없으면 하트 Chrome 창 0,0")
    log("7. 하트 Chrome 못 찾으면 전역 위치 유지")
    log("로그 파일 저장 없음")
    log("geometry() 사용 안 함")
    log("SetWindowPos()만 사용")
    log("textbox에 현재 screen 좌표 표시: x:202 y:156")
    log("overlay compact size 기본값: 112x24")
    log("overlay color: gray background + white text")
    log("capture 순간: 투명/숨김 처리 안 함")
    log("v13: 최초 탐지 후에는 2포인트만 확인, 색 변화 시에만 전체 재탐지")
    log("v147: IG_Ext A/C visual marker를 origin 기준으로 우선 사용")
    log("v147: legacy Heart 10px marker fallback은 detected_marker - visual_offset 으로 DOM origin 역산")
    log("v149: 좌표 표시 박스는 A marker 실제 사각형 오른쪽 8px 밖을 우선하고, 평상시는 DOM origin +44px 기준 표시")
    log("v147: 감시 포인트 미확인/재탐지 실패 저장 delta는 click-safe 제외")
    log("v147: 좌표 표시 박스 더블클릭 초기화는 --interactive-overlay 모드에서 동작")
    log(f"v147: overlay_click_through={bool(overlay_click_through)}")
    log(f"marker_check_ms={marker_check_ms}, marker_micro_sleep={marker_micro_sleep}")
    log("=" * 80)

    if ImageGrab is None:
        log("WARNING: pillow가 없어 노란색 탐지를 못 합니다. pip install pillow 필요.")
        log("노란색 탐지 실패 시 창 0,0으로 이동합니다.")
        tts_once("pillow_missing", "Pillow가 없어 노란 기준점 탐지를 못 합니다", 1, min_interval=30.0)

    root = tk.Tk()
    root.withdraw()

    overlay = tk.Toplevel(root)
    overlay.withdraw()
    overlay.overrideredirect(True)
    overlay.attributes("-topmost", True)
    overlay.attributes("-alpha", alpha)
    overlay.configure(bg=normal_bg)

    coord_text = tk.StringVar(value="x:- y:-")

    coord_box = tk.Entry(
        overlay,
        textvariable=coord_text,
        bg=normal_bg,
        fg=text_fg,
        readonlybackground=normal_bg,
        selectbackground="#ffffff",
        selectforeground="#000000",
        insertbackground=text_fg,
        font=("Consolas", 9, "bold"),
        borderwidth=0,
        relief="flat",
        justify="center",
        width=13,
    )
    coord_box.pack(fill="both", expand=True)
    coord_box.configure(state="readonly")

    def set_coord_text(x: int, y: int) -> None:
        value = f"x:{int(x)} y:{int(y)}"
        try:
            coord_box.configure(state="normal")
            coord_text.set(value)
            coord_box.configure(state="readonly")
        except Exception:
            pass

    def copy_coord_text(event=None):
        try:
            overlay.clipboard_clear()
            overlay.clipboard_append(coord_text.get())
            overlay.update()
        except Exception:
            pass
        return "break"

    def reset_calibration_from_coord_box(event=None):
        # v147: 좌표 표시 박스 더블클릭 수동 초기화.
        # 표시값만 바꾸는 것이 아니라 메모리 DOM delta/watch point/last found 위치를 함께 무효화한다.
        # 기본 click-through 모드에서는 Windows가 마우스 이벤트를 Chrome으로 넘기므로
        # 이 기능은 --interactive-overlay 실행 시 동작한다.
        try:
            reset_calibration_state("coord_box_double_click_reset")
            coord_box.configure(state="normal")
            coord_text.set("x:- y:- reset")
            coord_box.configure(state="readonly")
            try:
                state["last_log_key"] = None
                state["found_state"] = False
            except Exception:
                pass
            log("MANUAL_CALIBRATION_RESET: 좌표 표시 박스 더블클릭으로 DOM delta/watch point/_FOUND 초기화")
            tts_once("manual_calibration_reset", "하트 좌표 보정을 초기화했습니다", 2, min_interval=2.0)
        except Exception as e:
            log(f"MANUAL_CALIBRATION_RESET_FAIL: {e}")
        return "break"

    coord_box.bind("<Control-a>", lambda e: (coord_box.select_range(0, "end"), "break")[1])
    coord_box.bind("<Control-A>", lambda e: (coord_box.select_range(0, "end"), "break")[1])
    coord_box.bind("<Control-c>", copy_coord_text)
    coord_box.bind("<Control-C>", copy_coord_text)
    coord_box.bind("<Double-Button-1>", reset_calibration_from_coord_box)
    overlay.bind("<Double-Button-1>", reset_calibration_from_coord_box)

    overlay.update_idletasks()

    overlay_hwnd = get_primary_overlay_hwnd(overlay)
    log(f"overlay_hwnd={overlay_hwnd}")

    apply_overlay_style(overlay_hwnd)
    _publish_snapshot({"ok": False, "state": "overlay_visible", "reason": "heart_overlay_window_created", "overlay_hwnd": int(overlay_hwnd)})

    state = {
        "last_log_key": None,
        "last_not_found_log": 0.0,
        "found_state": False,
    }

    def tick() -> None:
        global _FOUND_X, _FOUND_Y, _LAST_TARGET_HWND

        try:
            t = time.time()

            if target_hwnd is not None:
                target = build_window_info(int(target_hwnd))
                source = "TARGET_HWND"
            else:
                target, source = get_target_auto()

            if target:
                hwnd = int(target["hwnd"])
                _LAST_TARGET_HWND = hwnd
                title = target["title"]

                if not state["found_state"]:
                    log(
                        "TARGET_FOUND_EVENT: 하트 Chrome 찾음 "
                        f"source={source} "
                        f"hwnd={hwnd} "
                        f"title={title!r}"
                    )
                    state["found_state"] = True
                    tts_once("target_found", "하트 크롬 창을 찾았습니다", 2, min_interval=10.0)

                x, y, overlay_x, overlay_y, pos_source, rect, rect_src, yellow_info = move_overlay_to_found_window(
                    overlay_hwnd,
                    hwnd,
                )

                left, top, right, bottom = rect

                click_safe = pos_source in ("YELLOW_DOM_ORIGIN", "SAVED_DOM_DELTA_POINT_OK")
                snap_reason = "dom_origin_ready" if click_safe else "overlay_fallback_not_click_safe"
                _publish_snapshot({
                    "ok": bool(click_safe),
                    "state": "dom_origin_ok" if click_safe else "dom_origin_not_confirmed",
                    "reason": snap_reason,
                    "pos_source": pos_source,
                    "dom_origin": {"x": int(x), "y": int(y), "source": pos_source},
                    "dom_delta": {"dx": _DOM_DX, "dy": _DOM_DY},
                    "window": {"hwnd": hwnd, "title": title, "rect": {"left": int(left), "top": int(top), "right": int(right), "bottom": int(bottom), "source": rect_src}},
                    "overlay": {"x": int(overlay_x), "y": int(overlay_y), "hwnd": int(overlay_hwnd), "click_through": bool(overlay_click_through), "visual_offset_x": int(visual_offset_x), "visual_offset_y": int(visual_offset_y)},
                    "yellow_info": yellow_info,
                })

                if click_safe:
                    tts_once("dom_origin_ok", f"하트 기준점 확인 x {int(x)} y {int(y)}", 2, min_interval=20.0)

                if yellow_info is not None and "abs_x" in yellow_info:
                    yellow_part = (
                        f" yellow_bbox=({yellow_info['abs_x']},{yellow_info['abs_y']},"
                        f"{yellow_info['w']}x{yellow_info['h']})"
                        f" expected=({yellow_info['expected_x']},{yellow_info['expected_y']})"
                        f" dom_delta=({yellow_info.get('dom_dx')},{yellow_info.get('dom_dy')})"
                        f" watch_black=({yellow_info.get('watch_black_dx')},{yellow_info.get('watch_black_dy')})"
                        f" watch_yellow=({yellow_info.get('watch_yellow_dx')},{yellow_info.get('watch_yellow_dy')})"
                        f" score={yellow_info['score']}"
                    )
                elif yellow_info is not None:
                    yellow_part = (
                        f" dom_delta=({yellow_info.get('dom_dx')},{yellow_info.get('dom_dy')})"
                        f" point_check={yellow_info.get('point_check')}"
                    )
                else:
                    yellow_part = ""

                log_key = ("FOUND_MOVE", source, hwnd, x, y, overlay_x, overlay_y, pos_source)
                if state["last_log_key"] != log_key:
                    log(
                        "FOLLOW_MOVE "
                        f"source={source} "
                        f"hwnd={hwnd} "
                        f"title={title!r} "
                        f"pos_source={pos_source} "
                        f"true_pos=({x},{y}) "
                        f"overlay_zero=({overlay_x},{overlay_y}) "
                        f"visual_offset=({visual_offset_x},{visual_offset_y}) "
                        f"global_pos=({_FOUND_X},{_FOUND_Y}) "
                        f"window_rect=({left},{top},{right},{bottom}) "
                        f"rect_src={rect_src}"
                        f"{yellow_part}"
                    )
                    state["last_log_key"] = log_key

            else:
                if state["found_state"]:
                    log(
                        "TARGET_LOST_EVENT: 하트 Chrome 못찾음 "
                        f"global_pos=({_FOUND_X},{_FOUND_Y})"
                    )
                    state["found_state"] = False
                    tts_once("target_lost", "하트 크롬 창을 놓쳤습니다", 1, min_interval=10.0)

                if _FOUND_X is not None and _FOUND_Y is not None:
                    move_overlay(
                        overlay_hwnd,
                        int(_FOUND_X + visual_offset_x),
                        int(_FOUND_Y + visual_offset_y),
                    )
                    set_coord_text(_FOUND_X, _FOUND_Y)
                    _publish_snapshot({
                        "ok": False,
                        "state": "chrome_window_not_found",
                        "reason": "target_lost_keep_overlay_position_not_click_safe",
                        "dom_origin": {"x": int(_FOUND_X), "y": int(_FOUND_Y), "source": "last_known_overlay_only"},
                        "overlay": {"x": int(_FOUND_X + visual_offset_x), "y": int(_FOUND_Y + visual_offset_y), "hwnd": int(overlay_hwnd), "click_through": bool(overlay_click_through), "visual_offset_x": int(visual_offset_x), "visual_offset_y": int(visual_offset_y)},
                    })

                    log_key = (
                        "LOST_KEEP_GLOBAL",
                        _FOUND_X,
                        _FOUND_Y,
                        visual_offset_x,
                        visual_offset_y,
                    )
                    if state["last_log_key"] != log_key:
                        log(
                            "TARGET_NOT_FOUND_KEEP: 전역 위치 유지 "
                            f"global_pos=({_FOUND_X},{_FOUND_Y})"
                        )
                        state["last_log_key"] = log_key
                        state["last_not_found_log"] = t
                else:
                    log_key = ("LOST_WAIT_NO_POSITION",)
                    if state["last_log_key"] != log_key:
                        log("TARGET_NOT_FOUND_WAIT: 전역 위치 없음, 이동 없음")
                        _publish_snapshot({"ok": False, "state": "chrome_window_not_found", "reason": "target_not_found_no_position"})
                        state["last_log_key"] = log_key
                        state["last_not_found_log"] = t

        except Exception:
            log("ERROR in tick(): 기본값 없음, 상태 강제 변경 없음")
            tb = traceback.format_exc()
            log(tb)
            _publish_snapshot({"ok": False, "state": "tracker_exception", "reason": "tick_exception", "traceback": tb})
            tts_once("tick_error", "하트 추적 중 오류가 발생했습니다", 1, min_interval=15.0)

        root.after(poll_ms, tick)

    tick()

    try:
        root.mainloop()
    except KeyboardInterrupt:
        log("STOP: Ctrl+C")
    finally:
        try:
            overlay.destroy()
        except Exception:
            pass

        try:
            root.destroy()
        except Exception:
            pass
        _publish_snapshot({"ok": False, "state": "stopped", "reason": "heart_overlay_mainloop_finished"})


def start_global_heart_tracker(
    *,
    poll_sec: float = 0.5,
    max_age_sec: float = 3.0,
    verbose: bool = True,
    show_overlay: bool = True,
    overlay_click_through: bool = True,
    logger=None,
    tts_func=None,
    tts_level: int = 2,
):
    """Start original heart overlay in a daemon thread and expose snapshots.

    Original v14 heart overlay behavior is kept. This function only adds host
    integration: thread start, snapshot readout, optional logger, optional TTS.
    """
    global _GLOBAL_THREAD, _GLOBAL_STOP_REQUESTED, _MAX_AGE_SEC
    _MAX_AGE_SEC = max(1.0, float(max_age_sec or 3.0))
    if not show_overlay:
        # 사용자가 요청한 본질은 화면 표시이므로 False가 와도 화면을 줄이지 않는다.
        show_overlay = True
    if _GLOBAL_THREAD is not None and _GLOBAL_THREAD.is_alive():
        return _GLOBAL_THREAD
    _GLOBAL_STOP_REQUESTED = False

    poll_ms = int(max(50, min(5000, round(float(poll_sec or 0.5) * 1000))))

    def runner() -> None:
        try:
            run_heart_chrome_overlay(
                poll_ms=poll_ms,
                verbose=verbose,
                logger=logger,
                tts_func=tts_func,
                tts_level=tts_level,
                overlay_click_through=bool(overlay_click_through),
            )
        except Exception:
            tb = traceback.format_exc()
            _publish_snapshot({"ok": False, "state": "thread_exception", "reason": "heart_overlay_thread_exception", "traceback": tb})
            _call_optional_logger(logger, "hert8772position thread exception " + tb)
            _call_optional_tts(tts_func, "하트 화면 실행 중 오류가 발생했습니다", 1)

    _GLOBAL_THREAD = threading.Thread(target=runner, name="HeartChromeOverlayV14Adapter", daemon=True)
    _GLOBAL_THREAD.start()
    return _GLOBAL_THREAD


def stop_global_heart_tracker() -> None:
    # Tk mainloop 종료는 원본 구조상 외부 강제 종료를 하지 않는다.
    # 프로세스 종료 시 daemon thread가 함께 종료된다.
    global _GLOBAL_STOP_REQUESTED
    _GLOBAL_STOP_REQUESTED = True


# -----------------------------
# hert8772position independent service
# -----------------------------
_HEART_SERVICE_THREAD: threading.Thread | None = None
_HEART_SERVICE_STOP = threading.Event()


def _snapshot_for_ipc() -> dict:
    snap = get_global_heart_snapshot()
    if not isinstance(snap, dict):
        snap = {"ok": False, "state": "bad_tracker_snapshot"}
    snap.setdefault("service", "heart8772")
    snap.setdefault("port", 8772)
    return snap


def start_heart_snapshot_server(host: str = "127.0.0.1", port: int = 8772, *, logger=None) -> threading.Thread | None:
    """Start a tiny localhost TCP JSON service for WS.

    This is intentionally separate from ws8771. It only returns the current
    heart snapshot. It never moves/clicks the mouse and never changes Tk widgets.
    """
    global _HEART_SERVICE_THREAD
    if _HEART_SERVICE_THREAD is not None and _HEART_SERVICE_THREAD.is_alive():
        return _HEART_SERVICE_THREAD
    _HEART_SERVICE_STOP.clear()

    def log(msg: str) -> None:
        try:
            if logger:
                logger(str(msg))
            else:
                print(str(msg), flush=True)
        except Exception:
            pass

    def serve() -> None:
        sock = None
        try:
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            sock.bind((str(host), int(port)))
            sock.listen(8)
            sock.settimeout(0.5)
            log(f"[heart8772] listen OK tcp://{host}:{port}")
            while not _HEART_SERVICE_STOP.is_set():
                try:
                    conn, addr = sock.accept()
                except socket.timeout:
                    continue
                except OSError:
                    break
                with conn:
                    try:
                        conn.settimeout(0.5)
                        data = conn.recv(2048)
                        req = {}
                        try:
                            line = data.split(b"\n", 1)[0].decode("utf-8", errors="replace").strip()
                            if line:
                                obj = json.loads(line)
                                if isinstance(obj, dict):
                                    req = obj
                        except Exception:
                            req = {}

                        act = str(req.get("act") or "get_snapshot")
                        req_reason = str(req.get("reason") or "snapshot")
                        log_exchange = bool(req.get("log_exchange")) or act in ("reset_calibration", "reset_dom_calibration")
                        if log_exchange:
                            log(f"[heart8772] ORIGIN_REQ act={act} from=WS reason={req_reason}")
                        if act in ("reset_calibration", "reset_dom_calibration"):
                            reset_calibration_state(str(req.get("reason") or "ipc_reset_calibration"))
                            payload = _snapshot_for_ipc()
                            payload["reset_ok"] = True
                            payload["reset_act"] = act
                        else:
                            payload = _snapshot_for_ipc()
                        _origin = payload.get("dom_origin") if isinstance(payload, dict) and isinstance(payload.get("dom_origin"), dict) else {}
                        if log_exchange:
                            log(f"[heart8772] ORIGIN_RES ok={bool(payload.get('ok')) if isinstance(payload,dict) else False} origin={_origin.get('x')},{_origin.get('y')} state={payload.get('state') if isinstance(payload,dict) else 'bad'}")
                        conn.sendall((json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n").encode("utf-8"))
                    except Exception as e:
                        try:
                            err = {"ok": False, "state": "heart_service_request_error", "error": str(e), "service": "heart8772"}
                            conn.sendall((json.dumps(err, ensure_ascii=False) + "\n").encode("utf-8"))
                        except Exception:
                            pass
        except Exception as e:
            # Port already in use means another heart service may already be alive.
            log(f"[heart8772] service not started: {type(e).__name__}: {e}")
        finally:
            try:
                if sock:
                    sock.close()
            except Exception:
                pass

    _HEART_SERVICE_THREAD = threading.Thread(target=serve, name="heart8772-snapshot-server", daemon=True)
    _HEART_SERVICE_THREAD.start()
    return _HEART_SERVICE_THREAD


__all__ = [
    "VERSION",
    "HEART8772_BUILD_VERSION",
    "HEART8772_BUILD_TAG",
    "HEART8772_LABEL",
    "run_heart_chrome_overlay",
    "start_global_heart_tracker",
    "stop_global_heart_tracker",
    "get_global_heart_snapshot",
    "start_heart_snapshot_server",
    "reset_calibration_state",
]


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Hert 8772 position service")
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", type=int, default=8772)
    parser.add_argument("--reset-calibration", action="store_true", help="Reset old DOM calibration/delta before tracking starts.")
    parser.add_argument("--verbose", action="store_true", help="Print detailed Hert tracking logs.")
    parser.add_argument("--interactive-overlay", action="store_true", help="Disable click-through so the Heart coordinate overlay can be selected/copied by mouse.")
    args = parser.parse_args(argv)
    # v94 policy: startup must not trust old 4/4 calibration. Reset by default; the flag is accepted for explicitness.
    # v147 policy: manual/scan-fail reset clears saved DOM delta and watch points together.
    reset_calibration_state("startup_reset_calibration")
    print(f"[heart8772] calibration reset; old DOM correction is not trusted ({HEART8772_BUILD_TAG})", flush=True)
    start_heart_snapshot_server(str(args.host), int(args.port))
    run_heart_chrome_overlay(verbose=bool(args.verbose), overlay_click_through=not bool(args.interactive_overlay))
    return 0


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