"""AutoPOE2 - Thư viện Thị Giác Máy Tính & WinOCR Dùng Chung (Vision OCR Foundation).

Cung cấp các tiện ích xử lý ảnh quang học và nhận diện ký tự:
- Nhận diện trạng thái mở của giao diện (Atlas UI, Inventory).
- Tìm kiếm tọa độ nút bấm bằng WinOCR (TRAVERSE, SEARCH, ENDGAME).
- Quét và phân cụm điểm ảnh theo màu (Color Clustering) cho Reachable Node, Flame.
- Xác định tọa độ màn hình của các thực thể quang học (Map Device, Portal).
"""

from __future__ import annotations

import ctypes
import math
import os
import threading
from typing import Any, Callable, List, Optional, Sequence, Tuple, Union

# Khởi tạo COM MTA ở cấp module cho thread hiện tại (chống lỗi RPC_E_CHANGED_MODE và bảo vệ WinRT OCR)
try:
    ctypes.windll.ole32.CoInitializeEx(None, 0)
except Exception:
    pass

try:
    from PIL import Image
except ImportError:
    Image = None  # type: ignore[assignment]

try:
    import winocr
except ImportError:
    winocr = None

from src.common.coordinate_transform import (
    CoordinateScaler,
    Resolution,
    ScalingMode,
    RES_1080P,
    RES_1440P,
    resolve_resolution,
)


_IMAGE_CACHE: dict[str, Tuple[float, Image.Image]] = {}


def safe_recognize_pil_sync(img_pil: Any, lang: str = "en") -> dict:
    """Nhận diện OCR bọc lớp bảo vệ Thread-Safety và kháng Exception native 100%."""
    if img_pil is None or winocr is None:
        return {"lines": [], "text": ""}
    try:
        # Kiểm tra tính hợp lệ của ảnh đầu vào trước khi chuyển giao cho WinRT native buffer
        if hasattr(img_pil, "size"):
            w, h = img_pil.size
            if w <= 0 or h <= 0:
                return {"lines": [], "text": ""}
        elif hasattr(img_pil, "width") and hasattr(img_pil, "height"):
            if img_pil.width <= 0 or img_pil.height <= 0:
                return {"lines": [], "text": ""}

        try:
            ctypes.windll.ole32.CoInitializeEx(None, 0)
        except Exception:
            pass

        res = winocr.recognize_pil_sync(img_pil, lang=lang)
        if isinstance(res, dict):
            return res
        return {"lines": [], "text": str(res) if res else ""}
    except Exception as e:
        return {"lines": [], "text": "", "error": str(e)}



def is_center_modal_present(img_input: Union[str, Image.Image]) -> bool:
    """Kiểm tra nhanh qua pixel trung tâm xem có modal dialog / menu che giữa màn hình hay không.
    
    Tránh việc crop và gọi WinRT OCR toàn màn hình vô ích khi nhân vật đang đứng trong thế giới 3D.
    - Trong POE2, các modal popup (Confirmation, Drop item, Options ESC menu) có khung panel xám/đen
      rất đồng đều và tối bao phủ vùng trung tâm (avg RGB <= 28.0, max RGB <= 50.0).
    - Ngược lại, trong thế giới game 3D (Hideout/Map/Town), khu vực trung tâm hiển thị nhân vật và sàn
      với độ tương phản cao, hoa văn chi tiết (max RGB > 50.0, độ sáng trung bình thường > 30.0).
    - Trả về False tức thời (< 0.1ms) nếu không có modal.
    """
    img = _load_image(img_input)
    if img is None:
        return False
    try:
        w, h = img.size
        cx, cy = w // 2, h // 2
        pts = [(cx + dx, cy + dy) for dx in range(-40, 41, 20) for dy in range(-40, 41, 20)]
        vals = [sum(img.getpixel(pt)[:3]) / 3.0 for pt in pts if 0 <= pt[0] < w and 0 <= pt[1] < h]
        if not vals:
            return False
        avg_v = sum(vals) / float(len(vals))
        max_v = max(vals)
        std_v = (sum((v - avg_v) ** 2 for v in vals) / float(len(vals))) ** 0.5
        # Modal dialog: panel nền tối đồng nhất hoặc độ phân tán cực thấp
        if (avg_v <= 28.0 and max_v <= 50.0) or (std_v <= 4.0 and avg_v <= 35.0):
            return True
    except Exception:
        pass
    return False


def _load_image(img_input: Union[str, Image.Image]) -> Optional[Image.Image]:
    """Tải đối tượng PIL Image từ đường dẫn tệp hoặc đối tượng có sẵn (kèm bộ đệm LRU/mtime)."""
    if img_input is None:
        return None
    if isinstance(img_input, str):
        if not os.path.exists(img_input):
            return None
        try:
            mtime = os.path.getmtime(img_input)
            cached = _IMAGE_CACHE.get(img_input)
            if cached and cached[0] == mtime:
                return cached[1]
            img = Image.open(img_input).convert("RGB")
            if len(_IMAGE_CACHE) > 16:
                _IMAGE_CACHE.pop(next(iter(_IMAGE_CACHE)))
            _IMAGE_CACHE[img_input] = (mtime, img)
            return img
        except Exception:
            return None
    try:
        if getattr(img_input, "mode", None) == "RGB":
            return img_input
        return img_input.convert("RGB")
    except Exception:
        return img_input


def is_atlas_ui_open(img_input: Union[str, Image.Image]) -> bool:
    """Kiểm tra giao diện Map Device / Atlas đã mở thực sự hay chưa (INV-MAPDEVICE-VERIFY-UI).
    
    Yêu cầu nhận diện được ít nhất một trong các tín hiệu thị giác đặc trưng:
    - Nút 'TRAVERSE' (nút kích hoạt map)
    - Khay hoặc từ khóa 'WAYSTONE'
    - Hộp tìm kiếm 'SEARCH' / 'SEARCH HERE' ở thanh trên Atlas
    - Nền bản đồ Atlas bao phủ trung tâm (tỷ lệ điểm ảnh tối trung tâm > 55%)
    TUYỆT ĐỐI CẤM dùng từ 'MAP DEVICE' đứng lẻ vì sàn Hideout luôn có nhãn 3D 'Map Device'.
    """
    img = _load_image(img_input)
    if img is None:
        return False
    # Fast check: chỉ bỏ qua nếu là Loading Screen thật sự hoặc Black Transition Frame
    # Tuyệt đối CẤM chỉ kiểm tra điểm ảnh trung tâm vì nền bản đồ vũ trụ Atlas Map trong POE2 là màu đen!
    if is_loading_screen(img):
        return False
    try:
        rgb_img = img.convert("RGB")
        w, h = rgb_img.size

        # OCR kiểm tra các từ khóa độc nhất của UI (quét vùng banner trên cùng và vùng nút TRAVERSE)
        if winocr is not None:
            # Quét vùng banner trên cùng (top 22%) chứa Search bar, tabs Act 1-4, Interlude, Endgame, Maps
            crop_banner = rgb_img.crop((0, 0, w, int(h * 0.22)))
            res_banner = safe_recognize_pil_sync(crop_banner)
            blob = " ".join(line.get("text", "") for line in res_banner.get("lines", [])).upper()

            # Kháng NPC dialog: nếu có GOODBYE hoặc BUY OR SELL thì là NPC dialog, KHÔNG PHẢI Atlas UI
            if "GOODBYE" in blob or "BUY OR SELL" in blob or "INTRODUCTION" in blob:
                return False
            if any(k in blob for k in ["SEARCH", "ENDGAME", "INTERLUDE", "ACT 1", "ACT 2", "ACT 3", "ACT 4", "ATLAS", "MAPS", "BONUS", "TIER"]):
                return True

            # Quét thêm vùng nút TRAVERSE / WAYSTONE
            crop_bottom = rgb_img.crop((int(w * 0.40), int(h * 0.40), w, h))
            res_bottom = safe_recognize_pil_sync(crop_bottom)
            blob_bottom = " ".join(line.get("text", "") for line in res_bottom.get("lines", [])).upper()
            if "TRAVERSE" in blob_bottom or "WAYSTONE" in blob_bottom:
                return True

            # Dual-Pass Fallback: Kiểm tra nút TRAVERSE bằng Edge / CLAHE nếu OCR bị chói sáng bloom
            try:
                from src.common.vision_filters import find_traverse_button_dual_pass
                if find_traverse_button_dual_pass(rgb_img) is not None:
                    return True
            except Exception:
                pass
    except Exception:
        pass
    return False


def is_loading_screen(img_input: Union[str, Image.Image]) -> bool:
    """Xác định xem màn hình hiện tại có đang ở Loading Screen hay không.
    
    Phát hiện các thông điệp tip đặc trưng trên Loading Screen của POE2
    (ví dụ: 'Remember to return to a Well to refill your flasks and replenish your Life and Mana.'),
    hoặc màn hình đen chuyển cảnh (Black Transition Frame).
    """
    img = _load_image(img_input)
    if img is None:
        return False

    # 1. Fast-Path: Kiểm tra chi tiết HUD 3D in-game (Life/ES globe góc dưới trái, Mana globe góc dưới phải)
    # Trong POE2, loading screen có viền đen/vignette ở 2 góc dưới (độ sáng < 20).
    # Trong khi đó, trong thế giới game 3D (Hideout/Town/Map/Atlas UI), 2 quả cầu HUD luôn hiển thị rõ ràng.
    try:
        w, h = img.size
        # Lấy mẫu 16 điểm góc dưới trái (Life/ES globe: x in 4%..13% w, y in 84%..96% h)
        bl_vals = []
        for sx in (0.04, 0.07, 0.10, 0.13):
            for sy in (0.84, 0.88, 0.92, 0.96):
                r, g, b = img.getpixel((int(w * sx), int(h * sy)))[:3]
                bl_vals.append((r + g + b) / 3.0)
        bl_avg = sum(bl_vals) / float(len(bl_vals))

        # Lấy mẫu 16 điểm góc dưới phải (Mana globe: x in 87%..96% w, y in 84%..96% h)
        br_vals = []
        for sx in (0.87, 0.90, 0.93, 0.96):
            for sy in (0.84, 0.88, 0.92, 0.96):
                r, g, b = img.getpixel((int(w * sx), int(h * sy)))[:3]
                br_vals.append((r + g + b) / 3.0)
        br_avg = sum(br_vals) / float(len(br_vals))

        # Nếu ít nhất 1 quả cầu HUD xuất hiện hoặc trung bình 2 quả cầu > 20.0: CHẮC CHẮN ĐANG TRONG GAME 3D
        if bl_avg > 25.0 or br_avg > 25.0 or (bl_avg + br_avg) / 2.0 > 20.0:
            return False
    except Exception:
        pass

    # 2. Phát hiện nhanh màn hình chuyển cảnh đen hoàn toàn (Black Transition Frame)
    try:
        w, h = img.size
        samples = []
        for step_y in range(15, 90, 15):
            for step_x in range(15, 90, 15):
                px = int(w * step_x / 100.0)
                py = int(h * step_y / 100.0)
                r, g, b = img.getpixel((px, py))[:3]
                samples.append((r + g + b) / 3.0)
        if samples and (sum(samples) / float(len(samples))) < 2.5:
            return True
    except Exception:
        pass

    if winocr is None:
        return False

    keywords = (
        "REMEMBER TO RETURN",
        "WELL TO REFILL",
        "REPLENISH YOUR LIFE",
        "FLASKS AND REPLENISH",
        "RETURN TO A WELL",
        "REFILL YOUR FLASKS",
        "LOADING SCREEN",
        "DODGE ROLL",
        "DODGE ROLLING",
        "WAYSTONES CAN BE",
        "INSERTED INTO THE MAP DEVICE",
        "GEMS CAN BE UNSOCKETED",
        "RESPEC PASSIVE SKILLS",
        "CURRENCY EXCHANGE",
    )

    try:
        w, h = img.size
        # 2. Fast Path: Quét nửa dưới màn hình (50% - 100% chiều cao) nơi hiển thị Loading Tips trong POE2
        crop_lower = img.crop((0, int(h * 0.50), w, h))
        res_lower = safe_recognize_pil_sync(crop_lower)
        blob_lower = " ".join(line.get("text", "") for line in res_lower.get("lines", [])).upper()
        if any(kw in blob_lower for kw in keywords):
            return True
    except Exception:
        pass
    return False


def is_inventory_open(img_input: Union[str, Image.Image]) -> bool:
    """Kiểm tra xem bảng hành trang (Inventory) có đang mở hay không.

    Fast-Path (< 0.5ms):
    1. Kiểm tra dải banner màu vàng/nâu đặc trưng ở nửa trên bên phải (x: 65%..95%, y: 4.8%..7.6%).
    2. Kiểm tra nền panel trang bị tối màu (x: 75%..92%, y: 18%..32%).
    3. Kiểm tra cụm pixel icon vàng (Gold currency icon) ở góc dưới (x: 65%..75%, y: 70%..85%).
    Nếu Fast-Path thỏa mãn, trả về True ngay lập tức (< 0.5ms) không cần gọi OCR toàn màn hình.
    Nếu Fast-Path chưa chắc chắn, tự động fallback sang WinRT OCR để chống false negative.
    """
    img = _load_image(img_input)
    if img is None:
        return False
    try:
        w_tmp, h_tmp = img.size
        if sum(img.getpixel((w_tmp // 2, h_tmp // 2))[:3]) < 5:
            return False
    except Exception:
        pass
    try:
        w, h = img.size
        pix = img.load()
        if pix is None:
            return False

        # --- FAST-PATH: Kiểm tra không cần OCR (< 0.5ms) ---
        step_y = max(1, int(h * 0.007))
        step_x = max(1, int(w * 0.015))
        gold_banner_count = 0
        total_banner = 0
        for y in range(int(h * 0.048), int(h * 0.076), step_y):
            for x in range(int(w * 0.65), int(w * 0.95), step_x):
                r, g, b = pix[x, y][:3]
                total_banner += 1
                if (r >= 135 and g >= 115 and (r > b + 25) and (g > b + 12)) or \
                   (r >= 180 and g >= 150 and b <= 180 and (r > b + 30)):
                    gold_banner_count += 1

        banner_ratio = gold_banner_count / float(total_banner) if total_banner > 0 else 0

        if banner_ratio >= 0.09:
            dark_count = 0
            total_dark = 0
            step_dark_y = max(1, int(h * 0.025))
            step_dark_x = max(1, int(w * 0.035))
            for y in range(int(h * 0.18), int(h * 0.32), step_dark_y):
                for x in range(int(w * 0.75), int(w * 0.92), step_dark_x):
                    r, g, b = pix[x, y][:3]
                    total_dark += 1
                    if r <= 42 and g <= 42 and b <= 42:
                        dark_count += 1
            dark_ratio = dark_count / float(total_dark) if total_dark > 0 else 0
            if dark_ratio >= 0.28:
                return True

            # Kiểm tra pixel icon vàng (Gold currency icon) ở góc dưới (x: 65%..75%, y: 70%..85%)
            gold_icon_found = 0
            for y in range(int(h * 0.70), int(h * 0.85), max(1, int(h * 0.015))):
                for x in range(int(w * 0.65), int(w * 0.75), max(1, int(w * 0.015))):
                    r, g, b = pix[x, y][:3]
                    if r >= 150 and g >= 125 and b <= 95 and (r > b + 50) and (g > b + 30):
                        gold_icon_found += 1
                        if gold_icon_found >= 2:
                            return True

        # Vùng tiêu đề hành trang nằm ở nửa phải màn hình (bắt đầu từ h*0.01 để không bị chém mất lề trên của banner)
        inv_crop = img.crop((int(w * 0.55), int(h * 0.01), int(w * 0.98), int(h * 0.35)))
        res = safe_recognize_pil_sync(inv_crop)
        blob = " ".join(line.get("text", "") for line in res.get("lines", [])).upper()
        return "INVENTORY" in blob or "WEAPON" in blob or "ARMOUR" in blob or "BAG" in blob or "COSMETICS" in blob
    except Exception:
        return False


def find_button_by_text(
    img_input: Union[str, Image.Image],
    token: str,
    crop_box: Optional[Tuple[int, int, int, int]] = None,
) -> Optional[Tuple[int, int]]:
    """Tìm tâm điểm pixel (X, Y) của một từ khóa văn bản trên màn hình.
    
    @param img_input: Đường dẫn ảnh hoặc đối tượng PIL Image
    @param token: Từ khóa cần tìm (không phân biệt hoa thường, ví dụ: 'TRAVERSE')
    @param crop_box: Vùng cắt (left, top, right, bottom) để thu hẹp phạm vi quét
    @return: (cx, cy) hoặc None nếu không tìm thấy
    """
    img = _load_image(img_input)
    if img is None:
        return None
    try:
        orig_w, orig_h = img.size
        token_upper = token.upper()

        # Bắt buộc: Loại trừ vùng Quest Tracker bên phải (x > 0.70 * w) khi tìm nút TRAVERSE / ENTER
        is_traverse_or_enter = token_upper in ("TRAVERSE", "TRAVERS", "ACTIVATE", "ENTER")
        if is_traverse_or_enter and crop_box is None:
            crop_box = (0, 0, int(orig_w * 0.70), orig_h)

        x_off, y_off = 0, 0
        if crop_box:
            img = img.crop(crop_box)
            x_off, y_off = crop_box[0], crop_box[1]

        res = safe_recognize_pil_sync(img)
        for line in res.get("lines", []):
            line_text = line.get("text", "").upper()
            # Kháng bug chữ 'ENTER': Nếu dòng chứa câu tooltip Waystone ("allowing you to enter a map") -> Bỏ qua
            if is_traverse_or_enter and "ENTER" in line_text and "MAP" in line_text:
                continue
            if token_upper not in line_text:
                continue
            words = line.get("words", [])
            matched_words = [w for w in words if token_upper in w.get("text", "").upper()]
            target_words = matched_words if matched_words else words
            if not target_words:
                continue
            min_x = min(w["bounding_rect"]["x"] for w in target_words)
            max_x = max(w["bounding_rect"]["x"] + w["bounding_rect"]["width"] for w in target_words)
            min_y = min(w["bounding_rect"]["y"] for w in target_words)
            max_y = max(w["bounding_rect"]["y"] + w["bounding_rect"]["height"] for w in target_words)
            cx = int(x_off + (min_x + max_x) / 2)
            cy = int(y_off + (min_y + max_y) / 2)

            # Bắt buộc: Loại trừ nếu tọa độ tâm rơi vào vùng Quest Tracker bên phải (x > 0.70 * orig_w)
            if is_traverse_or_enter and cx > int(orig_w * 0.70):
                continue
            return cx, cy
    except Exception:
        pass

    # Dual-Pass Fallback cho nút TRAVERSE / ACTIVATE khi WinOCR trực tiếp bị chói sáng bloom
    if token_upper in ("TRAVERSE", "TRAVERS", "ACTIVATE"):
        try:
            from src.common.vision_filters import find_traverse_button_dual_pass
            btn_pos = find_traverse_button_dual_pass(img_input)
            if btn_pos and btn_pos[0] <= int(orig_w * 0.55):
                return btn_pos
        except Exception:
            pass

    return None


def find_traverse_button_screen_pos(
    img_input: Optional[Union[str, Image.Image]] = None,
    scale_x: Optional[float] = None,
    scale_y: Optional[float] = None,
    return_none_if_missing: bool = True,
) -> Optional[Tuple[int, int]]:
    """Xác định vị trí nút TRAVERSE trên giao diện Map Device / Atlas (SSoT Common Kernel).

    Quy chuẩn nhận diện (Doc 56 / Doc 48 / SSoT 2026):
    - Kích thước nút: ~150x40 ở 1440p (co giãn theo tỷ lệ màn hình).
    - Vùng panel phụ bản ở giữa / dưới màn hình:
      x in [0.30 * w .. 0.60 * w], y in [0.35 * h .. 0.88 * h].
    - Sử dụng WinOCR tìm các token: TRAVERSE, TRAVERS, TRAVER, ACTIVATE, ENTER (loại trừ tooltip).
    - Dual-Pass Fallback qua CLAHE + Template Matching khi chữ bị bloom/chói.
    - Không click mù ra ngoài panel hoặc vùng Quest Tracker.
    """
    if img_input is None:
        if return_none_if_missing:
            return None
        sx = scale_x if scale_x is not None else 1.0
        sy = scale_y if scale_y is not None else 1.0
        return int(1092 * sx), int(748 * sy)

    img = _load_image(img_input)
    if img is None:
        if return_none_if_missing:
            return None
        sx = scale_x if scale_x is not None else 1.0
        sy = scale_y if scale_y is not None else 1.0
        return int(1092 * sx), int(748 * sy)

    w, h = img.size
    sx = scale_x if scale_x is not None else (w / 2560.0)
    sy = scale_y if scale_y is not None else (h / 1440.0)

    # 1. Quét bằng find_button_by_text với các token phổ biến
    for token in ["TRAVERSE", "TRAVERS", "TRAVER", "ACTIVATE", "TRAVERSF", "RAVERSE"]:
        pos = find_button_by_text(img, token)
        if pos:
            # Xác thực vị trí nút TRAVERSE nằm trong panel phụ bản giữa/dưới màn hình
            # x <= 0.60 * w để không bấm vào Quest Tracker / Inventory
            if pos[0] <= int(w * 0.60) and pos[1] >= int(h * 0.35):
                return pos

    # 2. Dual-pass fallback
    try:
        from src.common.vision_filters import find_traverse_button_dual_pass
        btn_pos = find_traverse_button_dual_pass(img)
        if btn_pos and btn_pos[0] <= int(w * 0.60):
            return btn_pos
    except Exception:
        pass

    if not return_none_if_missing:
        return int(1092 * sx), int(748 * sy)
    return None


def find_endgame_tab_pos(
    img_input: Optional[Union[str, Image.Image]] = None,
    scale_x: Optional[float] = None,
    scale_y: Optional[float] = None,
) -> Tuple[int, int]:
    """Xác định vị trí click tab ENDGAME trên thanh tab World Map (SSoT Common Kernel).

    Quy chuẩn nhận diện:
    - Vùng tab bar ở đỉnh màn hình: y in [0.02 * h .. 0.12 * h], x in [0.15 * w .. 0.85 * w].
    - Nhận diện văn bản WinOCR với các từ khóa: ENDGAME, NDGAME, END, GAME hoặc căn theo INTERLUDE.
    - Điểm pixel tham chiếu chuẩn (~65% thanh tab World Map):
      (1647 * sx, 94 * sy). Tại 2560x1440: (1647, 94); tại 1197x897: (770, 59); tại 1920x1080: (1235, 71).
    """
    if img_input is None:
        sx = scale_x if scale_x is not None else 1.0
        sy = scale_y if scale_y is not None else 1.0
        return int(1647 * sx), int(94 * sy)

    img = _load_image(img_input)
    if img is None:
        sx = scale_x if scale_x is not None else 1.0
        sy = scale_y if scale_y is not None else 1.0
        return int(1647 * sx), int(94 * sy)

    w, h = img.size
    sx = scale_x if scale_x is not None else (w / 2560.0)
    sy = scale_y if scale_y is not None else (h / 1440.0)
    default_pos = (int(1647 * sx), int(94 * sy))

    try:
        crop_x_off = int(w * 0.15)
        crop_y_off = int(25 * sy)
        tab_crop = img.crop((crop_x_off, crop_y_off, int(w * 0.85), int(130 * sy)))
        res = safe_recognize_pil_sync(tab_crop)
        for line in res.get("lines", []):
            for word in line.get("words", []):
                t = word.get("text", "").upper()
                if any(k in t for k in ("ENDGAME", "NDGAME", "END", "GAME", "ENIY", "ENIX", ";AME", "AME", "ENRX")):
                    rect = word["bounding_rect"]
                    cx = int(crop_x_off + rect["x"] + rect["width"] / 2.0)
                    cy = int(crop_y_off + rect["y"] + rect["height"] / 2.0)
                    return cx, cy
                elif any(k in t for k in ("INTERLUDE", "INTERLUD", "INTER")):
                    rect = word["bounding_rect"]
                    # ENDGAME nằm ngay bên phải INTERLUDE (~115px ở 2560, ~53px ở 1189)
                    cx = int(crop_x_off + rect["x"] + rect["width"] + 115 * sx)
                    cy = int(crop_y_off + rect["y"] + rect["height"] / 2.0)
                    return cx, cy
    except Exception:
        pass

    return default_pos


def find_color_clusters(
    img_input: Union[str, Image.Image],
    color_predicate: Callable[[int, int, int], bool],
    roi: Optional[Tuple[int, int, int, int]] = None,
    step: int = 2,
    cluster_radius_x: float = 30.0,
    cluster_radius_y: float = 30.0,
    min_cluster_size: int = 15,
) -> List[Tuple[int, int, int]]:
    """Thuật toán phân cụm điểm ảnh theo màu (Color Clustering).
    
    @param color_predicate: Hàm nhận (r, g, b) và trả về True nếu thỏa mãn
    @param roi: Vùng quan tâm (x1, y1, x2, y2)
    @return: Danh sách các cụm dạng [(cx, cy, số_điểm), ...]
    """
    img = _load_image(img_input)
    if img is None:
        return []

    w, h = img.size
    x1 = max(0, roi[0]) if roi else 0
    y1 = max(0, roi[1]) if roi else 0
    x2 = min(w, roi[2]) if roi else w
    y2 = min(h, roi[3]) if roi else h

    matched_pts = []
    for y in range(y1, y2, step):
        for x in range(x1, x2, step):
            r, g, b = img.getpixel((x, y))[:3]
            if color_predicate(r, g, b):
                matched_pts.append((x, y))

    if not matched_pts:
        return []

    clusters: List[List[Tuple[int, int]]] = []
    for pt in matched_pts:
        found = False
        for c in clusters:
            if abs(pt[0] - c[0][0]) < cluster_radius_x and abs(pt[1] - c[0][1]) < cluster_radius_y:
                c.append(pt)
                found = True
                break
        if not found:
            clusters.append([pt])

    valid = [c for c in clusters if len(c) >= min_cluster_size]
    results = []
    for c in valid:
        cx = sum(p[0] for p in c) // len(c)
        cy = sum(p[1] for p in c) // len(c)
        results.append((cx, cy, len(c)))

    results.sort(key=lambda item: item[2], reverse=True)
    return results


def find_portal_label_by_ocr(
    img_input: Union[str, Image.Image],
    scale_x: float = 1.0,
    scale_y: float = 1.0,
    allow_hideout: bool = False,
) -> List[Tuple[int, int, str]]:
    """Tìm tọa độ nhãn Portal trên màn hình Hideout bằng WinOCR thông minh.
    
    Tự động lọc bỏ các nhãn tĩnh của Hideout (NPC, Stash, Waypoint, Map Device, HUD).
    Bất kỳ nhãn nào còn lại quanh khu vực chơi trung tâm Hideout (y in 250..950, x in 500..1800)
    chính là nhãn cổng Portal map (như 'PLANTATION', 'DESERTED', 'THE WELL OF SOULS', 'PORTAL').
    """
    img = _load_image(img_input)
    if img is None or winocr is None:
        return []

    w, h = img.size
    sx, sy = w / 2560.0, h / 1440.0

    blacklist = {
        "STASH", "WAYPOINT", "MAP DEVICE", "DEVICE", "RELIC LOCKER", "REFORGING", "DISENCHANTING",
        "BENCH", "ANVIL", "DORYANI", "ANGE", "FARROW", "ZELINA", "ALVA", "HILDA", "ZOLIN",
        "COSMETICS", "WELL", "EXPEDITION", "SCIENCE", "CHAMBER", "BEAST", "HUNTING", "REFLECTION",
        "LIFE", "SHIELD", "WARD", "MANA", "SPIRIT", "QUESTS", "POE2VISUALTOOL", "AUT0-MINIMAP",
        "ZOOM", "POE2", "FOG", "REVEAL", "NATIVE", "FPS", "PLAYER", "PRECURSORS",
        "WAYSTONE", "TIER", "ORB", "SCROLL", "RING", "AMULET", "BELT", "BOOTS", "GLOVES",
        "ARMOUR", "HELMET", "BOW", "STAFF", "WAND", "SWORD", "AXE", "MACE", "SHIELD",
        "FLASK", "GEM", "CHARM", "CROSSBOW", "FOCUS", "SCEPTRE", "QUIVER", "DAGGER", "FLAIL",
        "GOLD", "CORRUPTED", "MIRRORED", "UNIDENTIFIED"
    }

    try:
        res = safe_recognize_pil_sync(img)
        candidates = []
        for line in res.get("lines", []):
            t = line.get("text", "").strip()
            t_u = t.upper()
            if len(t) < 3:
                continue
            words = line.get("words", [])
            if not words:
                continue
            min_x = min(w_info["bounding_rect"]["x"] for w_info in words)
            max_x = max(w_info["bounding_rect"]["x"] + w_info["bounding_rect"]["width"] for w_info in words)
            min_y = min(w_info["bounding_rect"]["y"] for w_info in words)
            max_y = max(w_info["bounding_rect"]["y"] + w_info["bounding_rect"]["height"] for w_info in words)

            # Portal trong Hideout chỉ nằm ở khu vực gameplay trung tâm:
            # Loại trừ thanh HUD đáy (y > 70%), viền đỉnh (y < 4%) và lề phải sát góc (min_x > 94%)
            if max_y > h * 0.70 or min_y < h * 0.04 or min_x > w * 0.94:
                continue

            # Loại trừ HIDEOUT nếu không ở chế độ tìm Town Portal về Hideout
            if not allow_hideout and "HIDEOUT" in t_u:
                continue

            # Lọc bỏ tin nhắn chat (Chat box leakage):
            # Nhãn portal là tên địa danh ngắn (<= 4 từ, <= 30 ký tự), không chứa đại từ nhân xưng hoặc ký tự chat
            if len(words) > 4 or len(t) > 30:
                continue
            chat_indicators = {":", '"', "$", "#", "@", "%", "&", "!", "?", "'", "”", "“"}
            if any(ci in t for ci in chat_indicators):
                continue
            chat_words = {"I", "HAD", "YOU", "MY", "ME", "WTS", "WTB", "COULDN'T", "CAN", "PLS", "HAVE", "SELLING", "BUYING", "NEED", "OUTSIDE", "MOVE", "NOT", "WAS", "FOR"}
            if set(t_u.split()).intersection(chat_words):
                continue

            is_map_keyword = any(kw in t_u for kw in [
                "BURNING", "MONOLITH", "MINESHAFT", "DESERTED", "MARSH", "CREEK", "FORTRESS",
                "SOULS", "PORTAL", "SANDSPIT", "CRATER", "VAULT", "ISLE", "GROVE", "HIDEOUT",
                "CROSSCUT", "RUINS", "BASTION", "CITADEL", "TEMPLE", "SANCTUM",
                "PLATEAU", "MEADOWS", "CANAL", "SEWER", "DEPTHS", "CRYPT", "TOMB", "BARROWS",
                "GRAVEYARD", "RIDGE", "SPIRE", "PEAK", "VALLEY", "GORGE", "CANYON", "OASIS",
                "DUNE", "STRAND", "ATOLL", "REEF", "SHORE", "BEACH", "BAY", "COVE", "ISLAND",
                "VOLCANO", "CALDERA", "PIT", "CAVERN", "CELLAR", "DUNGEON", "PRISON", "FOUNDRY",
                "GARDEN", "ORCHARD", "PARK", "COURTYARD", "PLAZA", "TERRACE", "PROMENADE",
                "ARENA", "COLOSSEUM", "BLIGHTED"
            ])
            if is_map_keyword:
                if any(q in t_u for q in [
                    "REACH", "TRAVEL", "DEFEAT", "RAVEL", "KINGSMARCH", "PRECURSORS",
                    "REFLECTION", "EXPEDITION", "SCIENCE", "HUNTING", "BEAST", "CHAMBER",
                    "ENIGMA", "PRECURSOR"
                ]):
                    continue
                cx = int((min_x + max_x) / 2)
                cy = int((min_y + max_y) / 2)
                # Loại trừ vùng Minimap góc trên phải (x > 0.85 * w và y < 0.35 * h)
                if cx > w * 0.85 and cy < h * 0.35:
                    continue
                # Loại trừ vùng Quest Tracker lề phải (cx > 0.72 * w) và lề trái (cx < 0.08 * w)
                if cx > w * 0.72 or cx < w * 0.08:
                    continue
                candidates.append((cx, cy, t))

        return candidates
    except Exception:
        return []


def is_atlas_node_depleted(img_input: Union[str, Image.Image]) -> bool:
    """Kiểm tra xem Atlas Node đang chọn có bị cạn kiệt (REVIVES AVAILABLE: 0) hay không."""
    img = _load_image(img_input)
    if img is None:
        return False
    try:
        res = safe_recognize_pil_sync(img)
        for line in res.get("lines", []):
            t = line.get("text", "").upper().replace(" ", "")
            # Nhận diện REVIVES AVAILABLE: 0 (hoặc OCR nhầm số 0 thành chữ O)
            if "REVIVE" in t and any(z in t for z in [":0", ":O", "AVAILABLE:0", "AVAILABLE:O", "=0", "=O"]):
                return True
    except Exception:
        pass
    return False


def is_map_device_visible(img_input: Union[str, Image.Image]) -> bool:
    """Kiểm tra quang học xem Map Device hoặc cổng portal có thực sự xuất hiện trong khung hình hay không."""
    img = _load_image(img_input)
    if img is None or is_loading_screen(img):
        return False
    if winocr is not None:
        try:
            w, h = img.size
            regions = [
                (int(w * 0.10), int(h * 0.05), int(w * 0.48), int(h * 0.50)),  # Ưu tiên số 1: Vùng Tây Bắc Shoreline Hideout
                (0, 0, int(w * 0.60), int(h * 0.70)),
                (int(w * 0.35), 0, int(w * 0.90), int(h * 0.70)),
                (0, 0, w, int(h * 0.85)),
            ]
            for r in regions:
                crop_img = img.crop(r)
                res = safe_recognize_pil_sync(crop_img)
                for line in res.get("lines", []):
                    text = line.get("text", "").upper()
                    clean = text.replace(" ", "").replace("'", "")
                    if (
                        "DEVICE" in clean
                        or "MAPDEV" in clean
                        or ("MAPD" in clean and any(k in clean for k in ["EVI", "FC", "TNC", "RR"]))
                        or any(d in clean for d in ["UEVICE", "WEVICE", "EVICE"])
                        or ("MAP" in text and any(d in clean for d in ["DEV", "DEVI", "EVI"]))
                    ):
                        return True
        except Exception:
            pass
    try:
        p_labels = find_portal_label_by_ocr(img)
        if p_labels:
            return True
    except Exception:
        pass
    return False


def is_game_menu_visible(img_input: Union[str, Image.Image]) -> bool:
    """Kiểm tra quang học xem Game Menu chính của POE2 (RESUME GAME, OPTIONS, EXIT) có đang mở hay không."""
    img = _load_image(img_input)
    if img is None or is_loading_screen(img):
        return False
    if winocr is not None:
        try:
            w, h = img.size
            crop_img = img.crop((int(w * 0.35), int(h * 0.15), int(w * 0.65), int(h * 0.55)))
            res = safe_recognize_pil_sync(crop_img)
            texts = [l.get("text", "").upper() for l in res.get("lines", [])]
            return any(("RESUME" in t or "EXIT TO" in t or "MICROTRANSACTION" in t) for t in texts)
        except Exception:
            pass
    return False


def find_map_device_screen_pos(
    img_input: Union[str, Image.Image],
    scale_x: Optional[float] = None,
    scale_y: Optional[float] = None,
    fallback_pos: Tuple[int, int] = (536, 280),
    fast_path: bool = False,
) -> Tuple[int, int]:
    """Xác định vị trí tương tác Map Device trong Hideout.

    Thứ tự ưu tiên:
    0. Fast-Path (< 0.5ms nếu fast_path=True): Phân cụm màu lửa & màu ngọc xanh tại bệ đá Shoreline Hideout.
    1. Nhận diện nhãn 'MAP DEVICE' / 'DEVICE' / 'MAP DEV' qua OCR (loại trừ HUD và vùng minimap).
    2. Quét cụm màu ngọc xanh (Cyan gem / runes) trên bệ đá Map Device tại Shoreline Hideout.
    3. Quét cụm màu ngọn lửa (Flame color clustering) trên các ngọn đuốc bệ đá.
    4. Tọa độ fallback đã calibrate tại Shoreline Hideout: (1866, 471) @1440p hoặc (872, 293) @windowed (tự động co giãn theo Resolution).
    """
    img = _load_image(img_input)
    if img is None:
        sx = scale_x if scale_x is not None else 1.0
        sy = scale_y if scale_y is not None else 1.0
        return int(round(fallback_pos[0] * sx)), int(round(fallback_pos[1] * sy))

    w, h = img.size
    res = Resolution(w, h)
    scaler = CoordinateScaler(source_res=RES_1440P, target_res=res)

    if scale_x is not None and scale_y is not None:
        sx = scale_x
        sy = scale_y
        default_x = int(round(fallback_pos[0] * scale_x))
        default_y = int(round(fallback_pos[1] * scale_y))
    else:
        sx = scaler.scale_x
        sy = scaler.scale_y
        default_x, default_y = scaler.transform_point(fallback_pos[0], fallback_pos[1])

    # 0. Fast-Path (< 0.5ms): Bỏ qua OCR, định vị tức thời bằng phân cụm màu lửa & ngọc xanh bệ đá
    if fast_path:
        pix = img.load()
        if pix is not None:
            step = max(6, int(8 * sx))
            flames = 0
            cyans = 0
            stone = 0
            roi_x1 = max(0, int(default_x - 100 * sx))
            roi_x2 = min(w, int(default_x + 100 * sx))
            roi_y1 = max(0, int(default_y - 80 * sy))
            roi_y2 = min(h, int(default_y + 80 * sy))
            for y in range(roi_y1, roi_y2, step):
                for x in range(roi_x1, roi_x2, step):
                    r, g, b = pix[x, y][:3]
                    if r >= 180 and 80 <= g <= 220 and b <= 140 and (r > b + 40):
                        flames += 1
                    elif (g >= 100 and b >= 100 and g > r + 15 and b > r + 5) or \
                         (g >= 125 and g > r * 1.25 and g > b * 1.05):
                        cyans += 1
                    elif 30 <= r <= 110 and 30 <= g <= 100 and 25 <= b <= 80:
                        stone += 1
            if (flames >= 3) or (cyans >= 3) or (stone >= 10):
                return default_x, default_y
        return default_x, default_y

    # INV-LOADING-SCREEN-GATE: Tuyệt đối chống đọc nhầm hình nền artwork Loading Screen thành bệ đá Map Device
    if is_loading_screen(img):
        return default_x, default_y

    # 1. OCR Tìm nhãn 'MAP DEVICE' / 'DEVICE' qua Multi-Region / Quadrants
    # (Tránh lỗi WinOCR bị tràn bộ đệm/bỏ sót dòng khi quét toàn bộ ảnh phức tạp cùng lúc)
    bench_pts: List[Tuple[float, float]] = []
    if winocr is not None:
        try:
            regions = [
                (int(w * 0.10), int(h * 0.05), int(w * 0.48), int(h * 0.50)),  # Ưu tiên số 1: Vùng Tây Bắc Shoreline Hideout
                (0, 0, int(w * 0.60), int(h * 0.70)),
                (int(w * 0.35), 0, int(w * 0.90), int(h * 0.70)),
                (0, 0, w, int(h * 0.85)),
            ]
            for rx1, ry1, rx2, ry2 in regions:
                crop_img = img.crop((rx1, ry1, rx2, ry2))
                res = safe_recognize_pil_sync(crop_img)
                for line in res.get("lines", []):
                    text = line.get("text", "").upper()
                    clean = text.replace(" ", "").replace("'", "")
                    if (
                        "DEVICE" in clean
                        or "MAPDEV" in clean
                        or ("MAPD" in clean and any(k in clean for k in ["EVI", "FC", "TNC", "RR"]))
                        or any(d in clean for d in ["UEVICE", "WEVICE", "EVICE"])
                        or ("MAP" in text and any(d in clean for d in ["DEV", "DEVI", "EVI"]))
                    ):
                        words = line.get("words", [])
                        valid_words = [
                            w_info for w_info in words
                            if not ((w_info["bounding_rect"]["x"] + rx1) > 0.90 * w and (w_info["bounding_rect"]["y"] + ry1) < 0.18 * h)
                            and 0.02 * w <= (w_info["bounding_rect"]["x"] + rx1) <= 0.98 * w
                        ]
                        matched_words = [
                            w_info for w_info in valid_words
                            if any(k in w_info.get("text", "").upper() for k in ["MAP", "DEV", "DEVICE", "UEVICE", "WEVICE", "EVICE"])
                            or w_info.get("text", "").upper().replace(" ", "").replace("'", "") in ["D", "EVI", "FC", "TNC"]
                        ]
                        target_words = matched_words if matched_words else valid_words
                        if target_words:
                            min_x = min(w_info["bounding_rect"]["x"] for w_info in target_words) + rx1
                            max_x = max(w_info["bounding_rect"]["x"] + w_info["bounding_rect"]["width"] for w_info in target_words) + rx1
                            min_y = min(w_info["bounding_rect"]["y"] for w_info in target_words) + ry1
                            max_y = max(w_info["bounding_rect"]["y"] + w_info["bounding_rect"]["height"] for w_info in target_words) + ry1
                            # Nhấp chuột trực tiếp vào tâm bệ đá 3D Map Device (+42*sy bên dưới nhãn chữ nổi)
                            cx = int((min_x + max_x) / 2)
                            cy = int((min_y + max_y) / 2) + int(42 * sy)
                            return cx, cy

            # 1b. Fallback theo vị trí các cổng Portal đã mở sẵn (Tâm bệ đá Map Device nằm tại trung tâm các cổng)
            try:
                p_labels = find_portal_label_by_ocr(img, scale_x=sx, scale_y=sy)
                if p_labels:
                    px = sum(p[0] for p in p_labels) // len(p_labels)
                    py = sum(p[1] for p in p_labels) // len(p_labels)
                    return px, py
            except Exception:
                pass

            # 1c. Fallback theo vị trí WAYPOINT trong Shoreline Hideout (Map Device nằm cách Waypoint ~644px trái, ~367px trên @1440p)
            for line in res.get("lines", []):
                text = line.get("text", "").upper()
                if any(k in text for k in ["WAYPOINT", "WAYPOIN", "WAYPOI"]):
                    for w_info in line.get("words", []):
                        if any(k in w_info.get("text", "").upper() for k in ["WAYPOINT", "WAYPOIN", "WAYPOI"]):
                            wx = int(w_info["bounding_rect"]["x"] + w_info["bounding_rect"]["width"] / 2)
                            wy = int(w_info["bounding_rect"]["y"] + w_info["bounding_rect"]["height"] / 2)
                            calc_x = int(round(wx - 644 * sx))
                            calc_y = max(int(15 * sy), int(round(wy - 367 * sy)))
                            if calc_x >= 0 and calc_y >= 0:
                                if abs(calc_x - default_x) <= 30 and abs(calc_y - default_y) <= 30:
                                    return default_x, default_y
                                return calc_x, calc_y

            # Lưu vị trí các Bench chế tạo để loại trừ ngọn lửa rèn không phải Map Device
            bench_pts = []
            for line in res.get("lines", []):
                text = line.get("text", "").upper()
                if any(b in text for b in ["REFORGING", "SALVAGE", "BENCH", "ANVIL"]):
                    for w_info in line.get("words", []):
                        bench_pts.append((
                            w_info["bounding_rect"]["x"] + w_info["bounding_rect"]["width"] / 2,
                            w_info["bounding_rect"]["y"] + w_info["bounding_rect"]["height"] / 2,
                        ))
        except Exception:
            bench_pts = []

    # 2. Tìm theo cụm bệ đá ngọc xanh của Map Device tại Shoreline Hideout
    try:
        def is_cyan_gem(r: int, g: int, b: int) -> bool:
            return (g >= 110 and b >= 110 and g > r + 20 and b > r + 10) or \
                   (g >= 135 and g > r * 1.35 and g > b * 1.1)

        pedestal_roi = (
            max(0, int(default_x - 180 * sx)),
            max(0, int(default_y - 160 * sy)),
            min(w, int(default_x + 180 * sx)),
            min(h, int(default_y + 160 * sy)),
        )
        cyan_clusters = find_color_clusters(
            img,
            color_predicate=is_cyan_gem,
            roi=pedestal_roi,
            cluster_radius_x=30 * sx,
            cluster_radius_y=30 * sy,
            min_cluster_size=20,
        )
        if cyan_clusters:
            best_cyan = min(
                cyan_clusters,
                key=lambda c: (c[0] - default_x) ** 2 + (c[1] - default_y) ** 2
            )
            if (best_cyan[0] - default_x) ** 2 + (best_cyan[1] - default_y) ** 2 < (140 * sx) ** 2:
                return default_x, default_y
            return best_cyan[0], best_cyan[1]
    except Exception:
        pass

    # 3. Phân cụm màu lửa ngọn đuốc trên bệ đá Map Device (R > 190, 90 <= G <= 220, B <= 130, R > B + 50)
    try:
        def is_flame(r: int, g: int, b: int) -> bool:
            return r >= 190 and 90 <= g <= 220 and b <= 130 and (r > b + 50)

        flame_roi = (
            max(0, int(default_x - 140 * sx)),
            max(0, int(default_y - 120 * sy)),
            min(w, int(default_x + 140 * sx)),
            min(h, int(default_y + 120 * sy)),
        )
        clusters = find_color_clusters(
            img,
            color_predicate=is_flame,
            roi=flame_roi,
            cluster_radius_x=25 * sx,
            cluster_radius_y=25 * sy,
            min_cluster_size=20,
        )
        cx_screen, cy_screen = w // 2, int(h * 0.54)
        player_radius_sq = (120 * sy) ** 2
        waypoint_x, waypoint_y = int(548 * (w / 1189.0)), int(400 * (h / 891.0))
        waypoint_radius_sq = (80 * sy) ** 2

        if clusters:
            valid_flames = [
                c for c in clusters
                if not any((c[0] - bx) ** 2 + (c[1] - by) ** 2 < (180 * sy) ** 2 for bx, by in bench_pts)
                and (c[0] - cx_screen) ** 2 + (c[1] - cy_screen) ** 2 >= player_radius_sq
                and (c[0] - waypoint_x) ** 2 + (c[1] - waypoint_y) ** 2 >= waypoint_radius_sq
            ]
            if valid_flames:
                near_default = [
                    c for c in valid_flames
                    if (c[0] - default_x) ** 2 + (c[1] - default_y) ** 2 < (120 * sx) ** 2
                ]
                if near_default:
                    return default_x, default_y
                best_flame = valid_flames[0]
                return int(best_flame[0]), int(best_flame[1] + 10 * sy)
    except Exception:
        pass

    return default_x, default_y


def find_portal_screen_pos(
    img_input: Optional[Union[str, Image.Image]] = None,
    scale_x: Optional[float] = None,
    scale_y: Optional[float] = None,
    fallback_pos: Optional[Tuple[int, int]] = None,
    map_device_pos: Optional[Tuple[int, int]] = None,
) -> Tuple[int, int]:
    """Xác định vị trí Portal map (vortex xoáy hoặc nhãn map portal) trong Hideout.

    Thứ tự ưu tiên:
    1. Nhận diện nhãn Portal nổi bằng OCR (`find_portal_label_by_ocr`), chọn portal hợp lệ gần bệ đá Map Device nhất.
    2. Quét cụm màu vortex xoáy Portal (xanh lam, cyan, ngọc bích hoặc ngọn lửa) quanh bệ đá Map Device.
    3. Tọa độ fallback tương đối quanh bệ đá Map Device thật: (altar_x - 30*sx, altar_y - 40*sy)
       thay vì tọa độ ma thuật (220, 187).
    """
    img = _load_image(img_input) if img_input is not None else None
    if img is None:
        sx = scale_x if scale_x is not None else 1.0
        sy = scale_y if scale_y is not None else 1.0
        if fallback_pos is not None:
            return int(round(fallback_pos[0] * sx)), int(round(fallback_pos[1] * sy))
        if map_device_pos is not None:
            md_x, md_y = map_device_pos
        else:
            md_x, md_y = find_map_device_screen_pos(None, scale_x=sx, scale_y=sy)
        return int(round(md_x - 30 * sx)), int(round(md_y - 40 * sy))

    w, h = img.size
    res = Resolution(w, h)
    scaler = CoordinateScaler(source_res=RES_1440P, target_res=res)

    if scale_x is not None and scale_y is not None:
        sx = scale_x
        sy = scale_y
    else:
        sx = scaler.scale_x
        sy = scaler.scale_y

    # Xác định vị trí bệ đá Map Device làm tâm tham chiếu
    if map_device_pos is not None:
        altar_x, altar_y = map_device_pos
    else:
        altar_x, altar_y = find_map_device_screen_pos(img, scale_x=sx, scale_y=sy)

    # Fallback tương đối quanh bệ đá Map Device thật (altar_x - 30*sx, altar_y - 40*sy)
    if fallback_pos is not None:
        default_x = int(round(fallback_pos[0] * sx))
        default_y = int(round(fallback_pos[1] * sy))
    else:
        default_x = int(round(altar_x - 30 * sx))
        default_y = int(round(altar_y - 40 * sy))

    # 1. Nhận diện nhãn Portal bằng OCR (chọn nhãn gần bệ đá Map Device nhất)
    try:
        p_labels = find_portal_label_by_ocr(img, scale_x=sx, scale_y=sy)
        if p_labels:
            chosen = min(p_labels, key=lambda p: (p[0] - altar_x) ** 2 + (p[1] - altar_y) ** 2)
            return chosen[0], chosen[1]
    except Exception:
        pass

    # 2. Quét cụm màu vortex xoáy Portal (Blue / Cyan / White core / Fiery)
    try:
        def is_vortex_pixel(r: int, g: int, b: int) -> bool:
            # Blue / Cyan swirl
            if (b > 160 and b > r + 25 and (b > g or g >= 140)):
                return True
            # White-cyan intense vortex core
            if (r >= 210 and g >= 225 and b >= 240):
                return True
            # Fiery / Burning portal (orange/red/gold flame)
            if (r >= 190 and g >= 90 and r > b + 40 and r > g + 15):
                return True
            return False

        # Quét vùng gameplay trung tâm quanh bệ đá Map Device (radius ~360*sx)
        roi_altar = (
            max(0, int(altar_x - 360 * sx)),
            max(int(h * 0.05), int(altar_y - 280 * sy)),
            min(int(w * 0.92), int(altar_x + 360 * sx)),
            min(int(h * 0.70), int(altar_y + 280 * sy)),
        )

        clusters = find_color_clusters(
            img,
            color_predicate=is_vortex_pixel,
            roi=roi_altar,
            step=max(2, int(3 * sx)),
            cluster_radius_x=40 * sx,
            cluster_radius_y=40 * sy,
            min_cluster_size=max(15, int(35 * sx * sy)),
        )

        # Nếu quanh altar chưa thấy, mở rộng quét toàn bộ vùng gameplay
        if not clusters:
            roi_general = (
                int(w * 0.08),
                int(h * 0.05),
                int(w * 0.90),
                int(h * 0.70),
            )
            clusters = find_color_clusters(
                img,
                color_predicate=is_vortex_pixel,
                roi=roi_general,
                step=max(2, int(3 * sx)),
                cluster_radius_x=40 * sx,
                cluster_radius_y=40 * sy,
                min_cluster_size=max(15, int(35 * sx * sy)),
            )

        if clusters:
            # Loại trừ HUD góc dưới trái (Life/ES) và góc dưới phải (Mana) và Minimap
            valid_clusters = [
                c for c in clusters
                if not (c[0] < 0.16 * w and c[1] > 0.45 * h)
                and not (c[0] > 0.84 * w and c[1] > 0.45 * h)
                and not (c[0] > 0.82 * w and c[1] < 0.35 * h)
                and c[1] >= 0.08 * h
            ]
            if valid_clusters:
                if altar_x > 0 and altar_y > 0:
                    import math
                    near_altar = [
                        c for c in valid_clusters
                        if math.hypot(c[0] - altar_x, c[1] - altar_y) <= 260 * max(sx, sy)
                    ]
                    if near_altar:
                        best = max(near_altar, key=lambda c: c[2])
                        return best[0], best[1]
                    # Nếu không có cụm vortex nào quanh altar, dùng vị trí cổng trực tiếp trên bệ đá Map Device
                    return int(round(altar_x)), int(round(altar_y + 40 * sy))
                best = max(valid_clusters, key=lambda c: c[2])
                return best[0], best[1]
    except Exception:
        pass

    return default_x, default_y


def find_reachable_green_node(
    img_input: Union[str, Image.Image],
    scale_x: float = 1.0,
    scale_y: float = 1.0,
    fallback_pos: Tuple[int, int] = (1043, 810),
    return_none_if_missing: bool = False,
    inventory_open: bool = False,
    max_x: Optional[int] = None,
) -> Optional[Tuple[int, int]]:
    """Tìm kiếm tọa độ của Node khả dụng (màu xanh lá cây / reachable green node) trên Atlas.
    
    Quy tắc an toàn (INV-ATLAS-FARM-NODE):
    - Giới hạn ROI chuẩn: y in [200..1280], x in [250..2200]
    - Khi Inventory mở: Giới hạn nửa bên trái màn hình (x < 650 trên 1197x897 hoặc x < w * 0.55).
    - Loại bỏ khu vực đền thờ trung tâm (The Well of Souls).
    - Lọc bỏ các cụm điểm ảnh quá to (size > 140 là các cấu trúc Gateway/Pinnacle arena).
    - Ưu tiên các cụm Regular Map có kích thước chuẩn trong khu vực bản đồ cày chính (y lớn hơn).
    """
    default_x = int(fallback_pos[0] * scale_x)
    default_y = int(fallback_pos[1] * scale_y)

    img = _load_image(img_input)
    if img is None:
        return None if return_none_if_missing else (default_x, default_y)

    w, h = img.size
    sx, sy = w / 2560.0, h / 1440.0

    # Giới hạn nửa trái màn hình khi mở túi đồ để không click vào inventory
    limit_x = None
    if max_x is not None:
        limit_x = max_x
    elif inventory_open:
        limit_x = 650 if w <= 1280 else int(w * 0.55)

    def is_green_node(r: int, g: int, b: int) -> bool:
        return (g > 150 and g > r + 30 and g > b + 30) or (g > 140 and g > r * 1.30 and g > b * 1.30)

    # Bao quát ROI chuẩn bản đồ cày Atlas (loại trừ đỉnh và HUD/toolbar đáy y > 1160)
    atlas_roi = (int(250 * sx), int(200 * sy), int(2200 * sx), int(1160 * sy))
    clusters = find_color_clusters(
        img,
        color_predicate=is_green_node,
        roi=atlas_roi,
        cluster_radius_x=max(18.0, 35 * sx),
        cluster_radius_y=max(18.0, 35 * sy),
        min_cluster_size=15,
    )

    if clusters:
        center_x, center_y = 1250.0 * sx, 480.0 * sy
        min_distance_from_center = 120.0 * sy  # Loại trừ The Well of Souls ở trung tâm

        # Lọc bỏ đền thờ trung tâm, cấu trúc quá to (>140px), vùng rìa ngoài tường (x < 380*sx) và HUD đỉnh/đáy (300*sy..1200*sy)
        valid_nodes = [
            c for c in clusters
            if math.hypot(c[0] - center_x, c[1] - center_y) >= min_distance_from_center
            and c[0] >= 380.0 * sx
            and 300.0 * sy <= c[1] <= 1200.0 * sy
            and c[2] <= 140
            and (limit_x is None or c[0] < limit_x)
        ]

        if valid_nodes:
            # Sắp xếp ưu tiên cụm xanh có kích thước lớn và rõ nét nhất
            valid_nodes.sort(key=lambda c: -c[2])
            best = valid_nodes[0]
            return int(best[0]), int(best[1])

        # Fallback nếu không có node ở x >= 380*sx
        fallback_candidates = [
            c for c in clusters
            if math.hypot(c[0] - center_x, c[1] - center_y) >= min_distance_from_center
            and 300.0 * sy <= c[1] <= 1200.0 * sy
            and c[2] <= 140
            and (limit_x is None or c[0] < limit_x)
        ]
        if fallback_candidates:
            fallback_candidates.sort(key=lambda c: -c[2])
            best = fallback_candidates[0]
            return int(best[0]), int(best[1])

    return None if return_none_if_missing else (default_x, default_y)


def find_all_reachable_green_nodes(
    img_input: Union[str, Image.Image],
    scale_x: float = 1.0,
    scale_y: float = 1.0,
    inventory_open: bool = False,
    max_x: Optional[int] = None,
) -> List[Tuple[int, int]]:
    """Tìm danh sách tất cả các Node khả dụng (màu xanh lá cây) hợp lệ trên Atlas xếp theo độ ưu tiên."""
    img = _load_image(img_input)
    if img is None:
        return []

    w, h = img.size
    sx, sy = w / 2560.0, h / 1440.0

    limit_x = None
    if max_x is not None:
        limit_x = max_x
    elif inventory_open:
        limit_x = 650 if w <= 1280 else int(w * 0.55)

    def is_green_node(r: int, g: int, b: int) -> bool:
        return (g > 150 and g > r + 30 and g > b + 30) or (g > 140 and g > r * 1.30 and g > b * 1.30)

    atlas_roi = (int(250 * sx), int(200 * sy), int(2200 * sx), int(1160 * sy))
    clusters = find_color_clusters(
        img,
        color_predicate=is_green_node,
        roi=atlas_roi,
        cluster_radius_x=max(18.0, 35 * sx),
        cluster_radius_y=max(18.0, 35 * sy),
        min_cluster_size=15,
    )

    if not clusters:
        return []

    center_x, center_y = 1250.0 * sx, 480.0 * sy
    min_distance_from_center = 120.0 * sy

    valid_nodes = [
        c for c in clusters
        if math.hypot(c[0] - center_x, c[1] - center_y) >= min_distance_from_center
        and c[0] >= 380.0 * sx
        and 300.0 * sy <= c[1] <= 1200.0 * sy
        and c[2] <= 140
        and (limit_x is None or c[0] < limit_x)
    ]
    valid_nodes.sort(key=lambda c: -c[2])
    return [(int(c[0]), int(c[1])) for c in valid_nodes]


def find_active_glowing_atlas_node(
    img_input: Union[str, Image.Image],
    scale_x: float = 1.0,
    scale_y: float = 1.0,
    return_none_if_missing: bool = True,
) -> Optional[Tuple[int, int]]:
    """Tìm kiếm tọa độ của Node sáng / Node đã mở (Active / Glowing Node) trên Atlas.
    
    Quy chuẩn nhận diện (Doc 56 / User Request):
    - Node đã mở hoặc đang sáng có hào quang màu vàng kim / hổ phách (amber/gold) đặc trưng,
      hoặc xoáy xanh vortex của Portal instance đã mở sẵn.
    - Lọc bỏ vùng đền thờ trung tâm (The Well of Souls).
    - Trả về tọa độ (x, y) của cụm sáng rõ nét nhất, hoặc None nếu không có.
    """
    img = _load_image(img_input)
    if img is None:
        return None

    w, h = img.size
    sx, sy = w / 2560.0, h / 1440.0

    def is_glowing_node(r: int, g: int, b: int) -> bool:
        # Hào quang vàng kim / hổ phách của node sáng
        is_gold = (r > 200 and g > 165 and b < 140) or (r > 210 and g > 190 and b > 140 and r > b + 50)
        # Xoáy xanh lam / portal vortex của map đã kích hoạt sẵn
        is_blue_vortex = (b > 185 and b > r + 35 and b > g + 15)
        return is_gold or is_blue_vortex

    atlas_roi = (int(250 * sx), int(180 * sy), int(2250 * sx), int(1160 * sy))
    clusters = find_color_clusters(
        img,
        color_predicate=is_glowing_node,
        roi=atlas_roi,
        cluster_radius_x=max(20.0, 40 * sx),
        cluster_radius_y=max(20.0, 40 * sy),
        min_cluster_size=20,
    )

    if clusters:
        center_x, center_y = 1250.0 * sx, 480.0 * sy
        min_distance_from_center = 120.0 * sy

        candidates = [
            c for c in clusters
            if math.hypot(c[0] - center_x, c[1] - center_y) >= min_distance_from_center
            and c[2] <= 160
        ]
        if candidates:
            candidates.sort(key=lambda c: (-c[2], -c[1]))
            best = candidates[0]
            return int(best[0]), int(best[1])

    return None


def find_player_avatar_on_atlas(
    img_input: Union[str, Image.Image],
    scale_x: float = 1.0,
    scale_y: float = 1.0,
    return_none_if_missing: bool = True,
) -> Optional[Tuple[int, int]]:
    """Nhận diện biểu tượng Player Avatar hình tròn vàng trên cây Atlas (Doc 48 / Live Test Fix).

    Cơ chế Endgame Atlas POE2:
    Khi click vào chính Node mà nhân vật đang đứng (Player Avatar tại đỉnh Great Wall / Current Node),
    nút TRAVERSE xuất hiện ngay lập tức ('The Burning Monolith').
    Hàm này ưu tiên định vị node hiện tại trước khi quét các green node ở xa.

    Thuật toán Dual-Pass:
    Pass 1: So khớp mẫu NCC Template Matching với các template pin/avatar thực tế từ captures/.
    Pass 2: Quét phân cụm màu vàng kim / hổ phách (Golden Pin Ring) trong vùng Atlas trung tâm.
    Pass 3: Fallback tọa độ đỉnh Great Wall đã hiệu chuẩn (1280 * sx, 560 * sy) nếu return_none_if_missing=False.

    @param img_input: Đường dẫn tệp ảnh hoặc đối tượng PIL Image.
    @param scale_x: Tỷ lệ co dãn trục X (tùy chọn).
    @param scale_y: Tỷ lệ co dãn trục Y (tùy chọn).
    @param return_none_if_missing: True nếu trả về None khi không phát hiện, False nếu fallback tọa độ chuẩn.
    @return: (cx, cy) pixel trên màn hình hoặc None.
    """
    img = _load_image(img_input)
    if img is None:
        if not return_none_if_missing:
            return int(round(1280.0 * scale_x)), int(round(560.0 * scale_y))
        return None

    w, h = img.size
    sx, sy = w / 2560.0, h / 1440.0

    # --- PASS 1: So khớp mẫu NCC với template pin / avatar thật ---
    captures_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "captures"))
    template_candidates = ["pin_1280_560.png", "player_icon_crop.png", "test_player_icon_crop.png"]

    try:
        import numpy as np
        f_gray = np.array(img.convert("L"), dtype=np.float32)
        rx1, ry1 = int(w * 0.30), int(h * 0.20)
        rx2, ry2 = int(w * 0.70), int(h * 0.80)

        for tpl_name in template_candidates:
            tpl_path = os.path.join(captures_dir, tpl_name)
            if not os.path.exists(tpl_path):
                continue
            tpl_img = Image.open(tpl_path).convert("L")
            tw = max(16, int(round(tpl_img.width * sx)))
            th = max(16, int(round(tpl_img.height * sy)))
            tpl_scaled = np.array(tpl_img.resize((tw, th), Image.Resampling.BICUBIC), dtype=np.float32)
            t_mean = tpl_scaled - tpl_scaled.mean()
            t_norm = np.linalg.norm(t_mean)
            if t_norm < 1e-5:
                continue

            limit_x = min(rx2, f_gray.shape[1] - tw)
            limit_y = min(ry2, f_gray.shape[0] - th)
            best_score = -1.0
            best_loc = None

            for y in range(ry1, limit_y, 4):
                for x in range(rx1, limit_x, 4):
                    patch = f_gray[y:y + th, x:x + tw]
                    p_mean = patch - patch.mean()
                    p_norm = np.linalg.norm(p_mean)
                    if p_norm > 1e-5:
                        c = float(np.sum(p_mean * t_mean) / (p_norm * t_norm))
                        if c > best_score:
                            best_score = c
                            best_loc = (int(x + tw // 2), int(y + th // 2))

            if best_score >= 0.70 and best_loc:
                return best_loc
    except Exception:
        pass

    # --- PASS 2: Phân cụm màu viền tròn vàng kim / hổ phách (Golden Ring Clustering) ---
    def is_avatar_gold(r: int, g: int, b: int) -> bool:
        return (r > 165 and g > 130 and b < 135 and r > b + 35 and g > b + 15)

    atlas_roi = (int(w * 0.30), int(h * 0.20), int(w * 0.70), int(h * 0.80))
    clusters = find_color_clusters(
        img,
        color_predicate=is_avatar_gold,
        roi=atlas_roi,
        cluster_radius_x=max(18.0, 30.0 * sx),
        cluster_radius_y=max(18.0, 30.0 * sy),
        min_cluster_size=15,
    )

    if clusters:
        valid = [c for c in clusters if 15 <= c[2] <= 500]
        if valid:
            valid.sort(key=lambda c: -c[2])
            return int(valid[0][0]), int(valid[0][1])

    # --- PASS 3: Fallback tọa độ đã hiệu chuẩn (1280 * sx, 560 * sy) ---
    if not return_none_if_missing:
        return int(round(1280.0 * sx)), int(round(560.0 * sy))

    return None
