"""
AutoPOE2 - Closed-Loop Autonomous Mapping Pipeline Coordinator (Doc 48 / Doc 56 / Doc 43)
==========================================================================================
Điều phối khép kín toàn bộ chu trình cày map tự hành trên live client:
1. Stage 1: Precheck túi đồ (Quét 60 ô túi, tự động kích hoạt The Hooded One Identify All nếu túi đầy,
            quét 12 ô Hàng 1 tìm Waystone, chọn slot khả dụng).
2. Stage 2: Mở giao diện Map Device tại Shoreline Hideout (Doc 48 / Doc 56).
3. Stage 3: Chọn Atlas Map Node qua AtlasNodeSelector & Nạp Waystone bằng Ctrl+Click vào slot đã phát hiện.
4. Stage 4: Kích hoạt mở cổng bản đồ & Bước vào Portal chuyển cảnh vào Map (Enter Portal).
5. Stage 5: Vận hành dọn map tự trị (AutonomousMapRunner, Hollow Form rotation, CI survival).
6. Stage 6: Thoát map bằng Town Portal phím '.' (INV-TOWN-PORTAL-KEY) & chuyển cảnh an toàn về Hideout.

Tuân thủ nghiêm ngặt:
- Rule 8: Anti-Symptom Patching & Single Source of Logic (SSoL).
- Rule 9 & 14: Empirical Definition of Done & 100% Authentic Real Data.
- INV-ATLAS-01: Safe Hideout Execution.
- INV-ATLAS-02: Verified Node Target & Occlusion Fail-Closed Gating.
- INV-TOWN-PORTAL-KEY: Phím '.' (VK 0xBE / HID 0x37), cấm phím 'T'.
- INV-CI-01: CI survival theo ES (Max ES 4284, 0 deaths).
"""

import os
import sys
import time
import math
import ctypes
import json
import threading
from pathlib import Path
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from PIL import Image

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

try:
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(encoding="utf-8")
except Exception:
    pass

from src.assistant_tool.screen_capturer import ScreenCapturer
from src.assistant_tool.optical_inventory_scanner import OpticalInventoryScanner
from src.assistant_tool.atlas_node_selector import AtlasNodeSelector, AtlasNode
from src.assistant_tool.atlas_knowledge_base import AtlasKnowledgeBase, AtlasNodeType, AtlasNodeProfile
from src.assistant_tool.core_shm_bridge import CoreShmBridge, CharacterLifecycleState
from src.assistant_tool.log_watcher import LogWatcher
from src.assistant_tool.pickit.waystone_crafter import (
    WaystoneAutoCrafter,
    WaystoneItem,
    WaystoneRarity,
    CraftAction,
)
from src.assistant_tool.stash import StashOrchestrator, StashConfig, Stage0StashHandler
from src.assistant_tool.waystone_loader_guard import WaystoneLoaderGuard
from src.assistant_tool.client_launcher import Poe2ClientLauncher
from src.assistant_tool.hideout_navigator import HideoutNavigator
from src.assistant_tool.atlas import AtlasProgressionPlanner, ProgressionStrategy
from src.assistant_tool.currency_exchange import CurrencyExchangeOrchestrator, ExchangePortfolioConfig
from src.assistant_tool.gem_auto_leveler import GemAutoLeveler, GemLevelerConfig
from src.assistant_tool.graphics_optimizer import Poe2GraphicsOptimizer, PROFILES
from src.assistant_tool.currency_exchange.net_worth_estimator import NetWorthEstimator
from src.assistant_tool.navigation.minimap_navigation_adapter import MinimapNavigationAdapter
from src.assistant_tool.atlas.progression_service import AtlasProgressionService
from src.assistant_tool.live_sentinel import LiveRunSentinel
from src.assistant_tool.navigation.motion_feedback_sentinel import MotionFeedbackSentinel
from src.common.coordinate_transform import CoordinateScaler, Resolution
from src.common.win32_window import attach_to_default_desktop, find_poe2_window, ensure_window_focus, get_client_rect
import src.common as common
from tools.autonomous_map_runner import AutonomousMapRunner, ScreenCapturerGDI


class AutonomousMappingCoordinator:
    def __init__(
        self,
        screen_w: int = 2560,
        screen_h: int = 1440,
        capture_dir: str = "captures/pipeline_run",
        map_duration_sec: int = 240,
        min_map_duration_sec: float = 90.0,
        shm_bridge: Optional[CoreShmBridge] = None,
        client_log_path: str = r"C:\Program Files (x86)\Grinding Gear Games\Path of Exile 2\logs\Client.txt",
        is_simulated: bool = False,
        auto_detect_resolution: bool = False,
        skip_dangerous: bool = False,
        is_headless: bool = False,
    ):
        self.is_headless = is_headless
        self.hwnd = find_poe2_window()
        self.client_left = 0
        self.client_top = 0
        if not is_simulated and self.hwnd and auto_detect_resolution:
            cl, ct, cw, ch = get_client_rect(self.hwnd)
            if cw > 100 and ch > 100:
                self.client_left = cl
                self.client_top = ct
                screen_w = cw
                screen_h = ch

        self.screen_w = screen_w
        self.screen_h = screen_h
        self.auto_detect_resolution = auto_detect_resolution
        self.skip_dangerous = skip_dangerous
        self.capture_dir = capture_dir
        self.map_duration_sec = map_duration_sec
        self.min_map_duration_sec = min_map_duration_sec
        self.client_log_path = client_log_path
        self.is_simulated = is_simulated
        os.makedirs(self.capture_dir, exist_ok=True)

        self.capturer = ScreenCapturer(output_dir=self.capture_dir)
        self.scanner = OpticalInventoryScanner(screen_w=self.screen_w, screen_h=self.screen_h)
        self.crafter = WaystoneAutoCrafter()
        self.bridge = shm_bridge or CoreShmBridge()
        self.selector = AtlasNodeSelector(shm_bridge=self.bridge)
        self.log_watcher = LogWatcher(client_log_path) if os.path.exists(client_log_path) else None
        self.captured_evidence: List[str] = []

        self.scale_x = self.screen_w / 2560.0 if self.screen_w > 0 else 1.0
        self.scale_y = self.screen_h / 1440.0 if self.screen_h > 0 else 1.0

        self.stash_config = StashConfig()
        self.stash_orchestrator = StashOrchestrator(
            screen_w=self.screen_w,
            screen_h=self.screen_h,
            config=self.stash_config,
            capture_func=self._capture_screen_pil,
            is_simulated=self.is_simulated,
        )
        self.stage0_handler = Stage0StashHandler(
            screen_w=self.screen_w,
            screen_h=self.screen_h,
            config=self.stash_config,
            orchestrator=self.stash_orchestrator,
            is_simulated=self.is_simulated,
            log_callback=self.log,
        )
        self.hideout_nav = HideoutNavigator(
            screen_w=self.screen_w,
            screen_h=self.screen_h,
            log_callback=self.log,
        )
        self.exchange_orchestrator = CurrencyExchangeOrchestrator(
            press_key_func=lambda vk: self.press_key(vk, delay_after=0.3)
        )
        self.atlas_progression_planner = AtlasProgressionPlanner()
        self.scaler = CoordinateScaler(
            source_res=Resolution(2560, 1440),
            target_res=Resolution(max(1, self.screen_w), max(1, self.screen_h)),
        )
        self.gem_leveler = GemAutoLeveler(
            click_callback=lambda x, y: self.click(int(x), int(y)),
            screen_w=self.screen_w,
            screen_h=self.screen_h,
        )
        self.net_worth_estimator = NetWorthEstimator()

        # Chu Kỳ 6 & 7 Integrated Services (Rule 4 SSoT)
        self.minimap_nav_adapter = MinimapNavigationAdapter(
            target_resolution=Resolution(max(1, self.screen_w), max(1, self.screen_h)),
        )
        self.atlas_progression_service = AtlasProgressionService(
            net_worth_estimator=self.net_worth_estimator,
        )
        target_pid = 0
        if self.hwnd:
            try:
                pid_val = ctypes.wintypes.DWORD()
                ctypes.windll.user32.GetWindowThreadProcessId(self.hwnd, ctypes.byref(pid_val))
                target_pid = pid_val.value
            except Exception:
                target_pid = 0
        self.live_sentinel = LiveRunSentinel(
            target_pid=target_pid,
            log_path=self.client_log_path,
        )

        self.sentinel_interval_sec = 3.0
        self._sentinel_running = False
        self._sentinel_thread: Optional[threading.Thread] = None
        self.sentinel_captures: List[str] = []
        self.use_post_message_loading = True
        self.motion_sentinel = MotionFeedbackSentinel(
            min_move_threshold_u=1.0,
            max_consecutive_stalls=3,
            log_callback=self.log,
        )

    def log(self, msg: str):
        ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
        print(f"[{ts}][Pipeline] {msg}", flush=True)

    def start_3s_sentinel(self):
        """Khởi chạy luồng giám sát chụp ảnh màn hình định kỳ mỗi 3s xuyên suốt phiên (Doc 17/39)."""
        if self._sentinel_running:
            return
        self._sentinel_running = True
        self.sentinel_captures.clear()
        self._sentinel_thread = threading.Thread(target=self._run_3s_sentinel, daemon=True)
        self._sentinel_thread.start()
        try:
            self.live_sentinel.start_monitoring()
        except Exception as e:
            self.log(f"Lưu ý LiveRunSentinel start: {e}")
        self.log(f"📸 Sentinel chụp ảnh màn hình định kỳ 3.0s & LiveRunSentinel đã kích hoạt (Thư mục: {self.capture_dir}).")

    def stop_3s_sentinel(self):
        """Dừng luồng giám sát chụp ảnh màn hình 3s."""
        self._sentinel_running = False
        try:
            self.live_sentinel.stop_monitoring()
        except Exception as e:
            self.log(f"Lưu ý LiveRunSentinel stop: {e}")
        if self._sentinel_thread and self._sentinel_thread.is_alive():
            self._sentinel_thread.join(timeout=2.0)
        self.log(f"📸 Sentinel chụp ảnh 3s đã dừng. Tổng số frame 3s đã chụp: {len(self.sentinel_captures)}")

    def _run_3s_sentinel(self):
        gdi = ScreenCapturerGDI()
        frame_idx = 0
        while self._sentinel_running:
            t0 = time.time()
            frame_idx += 1
            ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:19]
            filename = f"sentinel_3s_{ts}_{frame_idx:04d}.png"
            path = os.path.join(self.capture_dir, filename)
            try:
                if gdi.capture(path):
                    self.sentinel_captures.append(path)
                    zone = "Unknown"
                    if self.log_watcher:
                        seeded = self.log_watcher.seed_latest_playable_zone()
                        if seeded and seeded.area_name:
                            zone = seeded.area_name
                    self.log(f"[Sentinel 3s #{frame_idx:03d}] Chụp {filename} | Zone: {zone}")
            except Exception as e:
                self.log(f"[Sentinel 3s Error] {e}")
            elapsed = time.time() - t0
            sleep_time = max(0.1, self.sentinel_interval_sec - elapsed)
            time.sleep(sleep_time)

    def capture_step(self, step_name: str, capture_ram: bool = True) -> str:
        img_path = self.capturer.capture_screenshot(
            event_name=step_name, capture_ram=capture_ram, background_mode=self.is_headless
        )
        if img_path:
            self.captured_evidence.append(img_path)
            self.log(f"Đã chụp ảnh bằng chứng [{step_name}]: {img_path}")
            return img_path
        return ""

    def ensure_focus(self, timeout_ms: int = 1500) -> bool:
        if self.is_headless:
            # Ở chế độ IPC Headless: Tuyệt đối không cướp Focus hay kéo chuột vật lý (INV-ZERO-HARDWARE-LEAK)
            if not self.hwnd or not ctypes.windll.user32.IsWindow(self.hwnd):
                self.hwnd = find_poe2_window()
            if self.hwnd:
                cl, ct, cw, ch = get_client_rect(self.hwnd)
                if cw > 0 and ch > 0:
                    self.client_left = cl
                    self.client_top = ct
            return True

        hwnd = self.hwnd if (self.hwnd and ctypes.windll.user32.IsWindow(self.hwnd)) else find_poe2_window()
        if hwnd:
            self.hwnd = hwnd
            ensure_window_focus(hwnd, timeout_ms=timeout_ms)
            cl, ct, cw, ch = get_client_rect(hwnd)
            if cw > 0 and ch > 0:
                self.client_left = cl
                self.client_top = ct
            if not common.is_poe2_window_focused(hwnd) and cw > 100 and ch > 100:
                try:
                    user32 = ctypes.windll.user32
                    user32.SetCursorPos(cl + cw // 2, ct + ch // 3)
                    user32.mouse_event(0x0002, 0, 0, 0, 0)
                    time.sleep(0.02)
                    user32.mouse_event(0x0004, 0, 0, 0, 0)
                    time.sleep(0.05)
                    ensure_window_focus(hwnd, timeout_ms=500)
                except Exception:
                    pass
            return common.is_poe2_window_focused(hwnd)
        return False

    def is_inventory_open(self, img_path: Optional[str] = None) -> bool:
        """Kiểm tra xem bảng hành trang (Inventory) có đang mở hay không thông qua Common Kernel."""
        if not img_path or not os.path.exists(img_path):
            return False
        return common.is_inventory_open(img_path)

        # Fallback: kiểm tra pixel biểu tượng vàng ở góc trên túi đồ
        try:
            from PIL import Image
            img = Image.open(img_path)
            w, h = img.size
            sx, sy = w / 2560.0, h / 1440.0
            gold_count = sum(
                1
                for y in range(int(40 * sy), int(80 * sy), 2)
                for x in range(int(2000 * sx), int(2200 * sx), 5)
                if img.getpixel((x, y))[0] > 180 and img.getpixel((x, y))[1] > 160 and img.getpixel((x, y))[2] < 120
            )
            return gold_count >= 10
        except Exception:
            return False

    def is_atlas_ui_open(self, img_path: Optional[str] = None) -> bool:
        """INV-MAPDEVICE-VERIFY-UI: Atlas/Map Device đã mở khi OCR thấy TRAVERSE/WAYSTONE/MAP DEVICE."""
        return common.is_atlas_ui_open(img_path)

    def find_traverse_button(self, img_path: Optional[str] = None) -> Optional[Tuple[int, int]]:
        """Tọa độ nút TRAVERSE bằng OCR hoặc Dual-Pass. Không fallback pixel mù — None = fail-closed."""
        return common.find_traverse_button_screen_pos(
            img_path, scale_x=self.scale_x, scale_y=self.scale_y, return_none_if_missing=True
        )

    def find_endgame_tab_pos(self, img_path: Optional[str] = None) -> Tuple[int, int]:
        """Xác định vị trí tab ENDGAME trên thanh tab World Map (tham chiếu ~65%)."""
        return common.find_endgame_tab_pos(img_path, scale_x=self.scale_x, scale_y=self.scale_y)

    def _find_resurrect_button(self, img_path: Optional[str] = None) -> Optional[Tuple[int, int]]:
        """Nhận diện tọa độ nút 'RESURRECT IN HIDEOUT' trên màn hình tử trận."""
        if not img_path or not os.path.exists(img_path):
            return None
        try:
            from PIL import Image
            img = Image.open(img_path)
            res = common.safe_recognize_pil_sync(img)
            for line in res.get("lines", []):
                t = line.get("text", "").upper()
                if "RESURRECT" in t:
                    words = line.get("words", [])
                    if words:
                        min_x = min(w["bounding_rect"]["x"] for w in words)
                        max_x = max(w["bounding_rect"]["x"] + w["bounding_rect"]["width"] for w in words)
                        min_y = min(w["bounding_rect"]["y"] for w in words)
                        max_y = max(w["bounding_rect"]["y"] + w["bounding_rect"]["height"] for w in words)
                        return (int((min_x + max_x) / 2), int((min_y + max_y) / 2))
        except Exception:
            pass
        return None

    def to_screen(self, x: int, y: int) -> Tuple[int, int]:
        """Chuyển đổi tọa độ từ Client Area sang tọa độ Desktop tuyệt đối (hỗ trợ Windowed mode)."""
        if not self.is_simulated and self.hwnd and ctypes.windll.user32.IsWindow(self.hwnd):
            cl, ct, cw, ch = get_client_rect(self.hwnd)
            if cw > 0 and ch > 0:
                self.client_left = cl
                self.client_top = ct
        return self.client_left + int(round(x)), self.client_top + int(round(y))

    def is_on_virtual_display(self) -> bool:
        if not self.hwnd or not ctypes.windll.user32.IsWindow(self.hwnd):
            return False
        try:
            from src.common.virtual_display_manager import is_window_on_virtual_display
            return bool(is_window_on_virtual_display(self.hwnd))
        except Exception:
            return False

    def hover(self, x: int, y: int, delay_after: float = 0.08):
        """Đưa chuột tới pixel tuyệt đối, không LMB (INV-WASD-NO-BLIND-LMB). Dual-channel."""
        if self.is_headless or (self.hwnd and ctypes.windll.user32.IsWindow(self.hwnd) and self.is_on_virtual_display()):
            if self.hwnd and ctypes.windll.user32.IsWindow(self.hwnd):
                return common.post_message_hover(self.hwnd, int(round(x)), int(round(y)), delay_after=delay_after)
            return

        if not self.is_simulated and not common.is_poe2_window_focused(self.hwnd):
            self.ensure_focus()
        sx, sy = self.to_screen(x, y)
        common.hover(sx, sy, delay_after=delay_after, is_simulated=self.is_simulated, hwnd=self.hwnd)
        if self.hwnd and ctypes.windll.user32.IsWindow(self.hwnd):
            common.post_message_hover(self.hwnd, int(round(x)), int(round(y)), delay_after=0.02)

    def click(self, x: int, y: int, hold_ms: float = 0.05, delay_after: float = 0.1):
        """Dual-channel Hardware + PostMessageW Click with Focus Interlock."""
        if self.is_headless or (self.hwnd and ctypes.windll.user32.IsWindow(self.hwnd) and self.is_on_virtual_display()):
            return self.post_message_click(x, y, hold_ms=hold_ms, delay_after=delay_after)

        if not self.is_simulated and not common.is_poe2_window_focused(self.hwnd):
            self.ensure_focus()
        sx, sy = self.to_screen(x, y)
        res = common.click(sx, sy, hold_ms=hold_ms, delay_after=delay_after, is_simulated=self.is_simulated, hwnd=self.hwnd)
        if self.hwnd and ctypes.windll.user32.IsWindow(self.hwnd):
            common.post_message_click(self.hwnd, int(round(x)), int(round(y)), hold_ms=hold_ms, delay_after=0.04)
        return res

    def crisp_ctrl_click(self, x: int, y: int, hold_ms: float = 0.05, delay_after: float = 0.3, use_post_message: bool = False) -> bool:
        if self.is_headless or (self.hwnd and ctypes.windll.user32.IsWindow(self.hwnd)):
            return common.post_message_ctrl_click(
                self.hwnd, int(round(x)), int(round(y)), hold_ms=hold_ms, delay_after=delay_after
            )
        if not self.is_simulated and not use_post_message and not common.is_poe2_window_focused(self.hwnd):
            self.ensure_focus()
        sx, sy = self.to_screen(x, y)
        return WaystoneLoaderGuard.crisp_ctrl_click(
            sx, sy, hold_ms=hold_ms, delay_after=delay_after, is_simulated=self.is_simulated,
            hwnd=self.hwnd, use_post_message=use_post_message
        )

    def ctrl_click(self, x: int, y: int, delay_after: float = 0.3, use_post_message: bool = False):
        return self.crisp_ctrl_click(x, y, delay_after=delay_after, use_post_message=use_post_message)

    def post_message_click(
        self,
        x: int,
        y: int,
        button: str = "left",
        hold_ms: float = 0.04,
        delay_after: float = 0.06,
    ) -> bool:
        """Gửi click chuột ảo ngầm qua PostMessageW tới HWND game không cướp chuột."""
        if self.is_simulated:
            return True
        if not self.hwnd:
            return False
        return common.post_message_click(
            self.hwnd, int(round(x)), int(round(y)), button=button, hold_ms=hold_ms, delay_after=delay_after
        )

    def post_message_ctrl_click(
        self,
        x: int,
        y: int,
        hold_ms: float = 0.04,
        delay_after: float = 0.06,
    ) -> bool:
        """Gửi Ctrl+Click ảo ngầm qua PostMessageW tới HWND game nạp đồ dứt khoát."""
        if self.is_simulated:
            return True
        if not self.hwnd:
            return False
        return common.post_message_ctrl_click(
            self.hwnd, int(round(x)), int(round(y)), hold_ms=hold_ms, delay_after=delay_after
        )

    def open_world_map_endgame(self) -> bool:
        """
        Mở giao diện World Map bằng phím 'U' (VK_U 0x55) và chuyển sang tab ENDGAME (Atlas).
        Tối ưu hóa chuỗi tương tác theo yêu cầu điều phối:
        1. Nhấn phím 'U' (VK_U 0x55) để mở World Map.
        2. Chụp ảnh xác thực và định vị tab ENDGAME bằng find_endgame_tab_pos (Sư đoàn 2).
        3. Click chọn tab ENDGAME để mở cây bản đồ Atlas.
        """
        self.log("🗺️ Mở World Map bằng phím 'U' (VK_U 0x55)...")
        VK_U = 0x55
        self.press_key(VK_U, 0x16, delay_after=1.0)
        img_map = self.capture_step("STAGE2_WORLD_MAP_OPENED_U")
        endgame_x, endgame_y = self.find_endgame_tab_pos(img_map)
        self.log(f"🎯 Click tab ENDGAME tại ({endgame_x}, {endgame_y})...")
        self.click(endgame_x, endgame_y, hold_ms=0.15, delay_after=1.5)
        return True

    def is_item_stuck_on_cursor(self, img_path: Optional[str]) -> bool:
        return WaystoneLoaderGuard.is_item_stuck_on_cursor(img_path)

    def recover_cursor_item_to_inventory(self, slot_x: int, slot_y: int, delay_after: float = 0.4) -> bool:
        self.log(f"🔄 Hoàn trả item dính chuột về ô túi đồ ({slot_x}, {slot_y})...")
        if not self.is_simulated and not common.is_poe2_window_focused(self.hwnd):
            self.ensure_focus()
        return WaystoneLoaderGuard.recover_cursor_item_to_inventory(
            click_func=lambda cx, cy: self.click(cx, cy, delay_after=0.3),
            slot_x=slot_x,
            slot_y=slot_y,
            press_key_func=lambda vk, sc: self.press_key(vk, sc, delay_after=0.2),
            delay_after=delay_after,
        )

    def drag(self, start_x: int, start_y: int, end_x: int, end_y: int, steps: int = 20, duration: float = 0.4, button: str = "left"):
        if self.is_headless or (self.hwnd and ctypes.windll.user32.IsWindow(self.hwnd) and self.is_on_virtual_display()):
            if self.hwnd and ctypes.windll.user32.IsWindow(self.hwnd):
                return common.post_message_drag(self.hwnd, start_x, start_y, end_x, end_y, steps=steps, duration=duration, button=button)
            return True

        if not self.is_simulated and not common.is_poe2_window_focused(self.hwnd):
            self.ensure_focus()
        sx1, sy1 = self.to_screen(start_x, start_y)
        sx2, sy2 = self.to_screen(end_x, end_y)
        return common.drag(sx1, sy1, sx2, sy2, steps=steps, duration=duration, button=button, is_simulated=self.is_simulated)

    def pan_atlas_map(self, drag_left: Optional[bool] = None, delta_x: int = 0, delta_y: int = 0):
        """
        Kéo trượt (hold LMB) bản đồ Atlas để đưa các Node khả dụng hoặc đã mở vào tầm nhìn (Doc 56).
        - Nếu drag_left được truyền: Kéo ngang theo hướng chỉ định (tương thích ngược).
        - Nếu delta_x, delta_y được truyền: Kéo chuột từ tâm màn hình một khoảng (delta_x, delta_y).
        """
        center_x = int(self.screen_w * 0.50)
        center_y = int(self.screen_h * 0.50)
        if drag_left is not None:
            start_x = int(self.screen_w * 0.70) if drag_left else int(self.screen_w * 0.25)
            end_x = int(self.screen_w * 0.25) if drag_left else int(self.screen_w * 0.70)
            start_y = center_y
            end_y = center_y
        else:
            start_x = center_x
            start_y = center_y
            end_x = center_x + delta_x
            end_y = center_y + delta_y

        self.log(f"Pan Atlas Map (hold LMB): kéo chuột từ ({start_x}, {start_y}) -> ({end_x}, {end_y})...")
        self.drag(start_x, start_y, end_x, end_y, steps=25, duration=0.45, button="left")
        time.sleep(0.5)

    def search_opened_atlas_node_by_panning(self, max_pan_steps: int = 6) -> Optional[Tuple[str, Tuple[int, int]]]:
        """
        Tìm kiếm Node đã mở / Node sáng / nút TRAVERSE bằng cách kéo chuột (hold LMB) duyệt bản đồ thế giới (Doc 56 / User request).
        Duyệt theo các hướng (phải, xuống, trái, lên) quanh tâm bản đồ để quét tìm.
        Trả về ("TRAVERSE" | "GLOWING_NODE" | "REACHABLE_NODE", (x, y)) nếu tìm thấy, hoặc None nếu không có.
        """
        pan_vectors = [
            (int(250 * self.scale_x), 0),                           # Kéo sang phải
            (int(250 * self.scale_x), 0),                           # Kéo sang phải tiếp
            (-int(500 * self.scale_x), 0),                          # Kéo ngược sang trái
            (-int(250 * self.scale_x), 0),                          # Kéo sang trái xa hơn
            (0, -int(200 * self.scale_y)),                          # Kéo lên trên
            (0, int(400 * self.scale_y)),                           # Kéo xuống dưới
        ]
        for i, (dx, dy) in enumerate(pan_vectors[:max_pan_steps]):
            self.pan_atlas_map(delta_x=dx, delta_y=dy)
            img_pan = self.capture_step(f"STAGE3_PAN_SEARCH_{i+1}")
            trav = self.find_traverse_button(img_pan)
            if trav:
                # [INV-ATLAS-FARM-NODE] Thẩm định xem nút TRAVERSE này có thuộc Node cấm không
                is_forbidden = False
                try:
                    from PIL import Image
                    pan_img_pil = Image.open(img_pan)
                    pan_res = common.safe_recognize_pil_sync(pan_img_pil)
                    pan_text = " ".join(l.get("text", "") for l in pan_res.get("lines", [])).upper()
                    if AtlasKnowledgeBase.is_forbidden_for_waystone_farming(pan_text):
                        is_forbidden = True
                        self.log(f"⚠️ [INV-ATLAS-FARM-NODE] Nút TRAVERSE tại ({trav[0]}, {trav[1]}) thuộc Special Node cấm cày cấp! Bỏ qua...")
                except Exception:
                    pass

                if not is_forbidden:
                    self.log(f"🎯 Đã tìm thấy nút TRAVERSE tại ({trav[0]}, {trav[1]}) sau bước Pan #{i+1}!")
                    return ("TRAVERSE", trav)
            glowing = self.find_active_glowing_atlas_node(img_pan, return_none_if_missing=True)
            if glowing:
                self.log(f"🌟 [Node Sáng] Đã tìm thấy Node sáng / đã mở tại ({glowing[0]}, {glowing[1]}) sau bước Pan #{i+1}!")
                return ("GLOWING_NODE", glowing)
            green = self.find_reachable_atlas_node(img_pan, return_none_if_missing=True)
            if green:
                self.log(f"🟢 [Node Xanh] Đã tìm thấy Reachable Green Node tại ({green[0]}, {green[1]}) sau bước Pan #{i+1}!")
                return ("REACHABLE_NODE", green)
        return None

    def press_key(self, vk: int, scan: int = 0, delay_after: float = 0.3):
        if self.hwnd and ctypes.windll.user32.IsWindow(self.hwnd):
            return common.post_message_key(
                self.hwnd, vk, scan_code=scan, dwell_ms=0.05, delay_after=delay_after
            )
        if not self.is_simulated and not common.is_poe2_window_focused(self.hwnd):
            self.ensure_focus()
        return common.press_key(vk, scan=scan, delay_after=delay_after, is_simulated=self.is_simulated, hwnd=self.hwnd)

    def dismiss_modals(self):
        """Đóng mọi modal dialog, menu hoặc bản đồ tồn đọng trước khi bắt đầu (Spacebar)."""
        if self.is_simulated:
            return
        self.ensure_focus()
        pre_img = self.capture_step("DISMISS_MODALS_CHECK", capture_ram=False)
        # [INV-GAME-MENU-GUARD] Nếu phát hiện Game Menu (RESUME GAME), click giải phóng tức thì
        if pre_img and os.path.exists(pre_img) and common.is_game_menu_visible(pre_img):
            self.log("⚠️ Phát hiện Game Menu (RESUME GAME) tồn đọng -> Click giải phóng...")
            self.click(int(self.screen_w * 0.50), int(self.screen_h * 0.23), delay_after=0.4)

        # Fast-Path: Kiểm tra nhanh qua pixel trung tâm xem có modal dialog/menu tồn đọng không (< 0.1ms)
        if pre_img and os.path.exists(pre_img) and common.is_center_modal_present(pre_img):
            self.log("⚠️ Phát hiện modal dialog / menu che trung tâm màn hình -> Gửi ESC giải phóng...")
            VK_ESCAPE = 0x1B
            self.press_key(VK_ESCAPE, 0x01, delay_after=0.25)

        # INV-CURSOR-SAFETY: Giải phóng popup kẹt item, đóng menu Options/Settings tồn đọng
        if pre_img and os.path.exists(pre_img):
            try:
                from PIL import Image
                chk_pil = Image.open(pre_img)
                res_chk = common.safe_recognize_pil_sync(chk_pil)
                blob_chk = " ".join(l.get("text", "") for l in res_chk.get("lines", [])).upper()
                # 1. Đóng menu Options / Settings nếu đang mở
                if any(k in blob_chk for k in ["OPTIONS", "DISPLAY SETTINGS", "AUDIO SETTINGS", "INPUT SETTINGS"]):
                    self.log("⚠️ Phát hiện menu Options / Settings tồn đọng...")
                    clicked_close = False
                    for l in res_chk.get("lines", []):
                        if "CLOSE" in l.get("text", "").upper():
                            words = l.get("words", [])
                            if words:
                                r = words[0].get("bounding_rect", {})
                                cx = int(r.get("x", 0) + r.get("width", 0) / 2)
                                cy = int(r.get("y", 0) + r.get("height", 0) / 2)
                                self.log(f"🔘 Phát hiện nút CLOSE của menu Options tại ({cx}, {cy}) -> Click đóng...")
                                self.click(cx, cy, delay_after=0.5)
                                clicked_close = True
                                break
                    if not clicked_close:
                        cx = int(self.screen_w * 0.35)
                        cy = int(self.screen_h * 0.74)
                        self.log(f"🔘 Click fallback nút CLOSE của menu Options tại ({cx}, {cy})...")
                        self.click(cx, cy, delay_after=0.5)

                # 2. Giải phóng popup kẹt item
                if any(k in blob_chk for k in ["CANNOT DROP", "DESTROY IT INSTEAD", "KEEP", "LOSE ALL GEMS"]):
                    self.log("🚨 Phát hiện popup 'YOU CANNOT DROP THAT ITEM HERE' tồn đọng! Đang giải phóng con trỏ chuột an toàn...")
                    self.press_key(0x1B, 0x01, delay_after=0.3)
                    self.press_key(0x49, 0x17, delay_after=0.5)
                    # 1. Click trang bị lại vào ô găng tay (Gloves slot: x=67.84% W, y=36.90% H)
                    glove_cx = int(round(self.screen_w * 0.6784))
                    glove_cy = int(round(self.screen_h * 0.3690))
                    self.click(glove_cx, glove_cy, delay_after=0.4)
                    # 2. Click dự phòng vào ô trống 2x2 trong túi đồ (Cột 7 Hàng 1)
                    empty_cx, empty_cy = self.scanner.get_slot_center(7, 1)
                    self.click(empty_cx, empty_cy, delay_after=0.3)
                    self.press_key(0x49, 0x17, delay_after=0.3)
                    self.log("✅ Đã trả item dính chuột vào ô trang bị/túi đồ và giải phóng popup an toàn.")
            except Exception as e:
                self.log(f"Lưu ý xử lý popup: {e}")

        VK_SPACE = 0x20
        self.press_key(VK_SPACE, 0x39, delay_after=0.2)
        # Đóng các bảng panel còn mở bằng nút X khi ở chế độ Fullscreen (tránh click nhầm nút X của Windows ở Windowed mode)
        if self.screen_w >= 1920:
            x_btn_x = int(1626 * self.scale_x)
            x_btn_y = int(44 * self.scale_y)
            self.click(x_btn_x, x_btn_y, delay_after=0.15)
            x_inv_x = int(2530 * self.scale_x)
            x_inv_y = int(44 * self.scale_y)
            self.click(x_inv_x, x_inv_y, delay_after=0.15)
        # Đưa chuột về vị trí trung tính trên cao để xóa tooltip lơ lửng (Waystone/Item tooltips)
        self.hover(int(self.screen_w * 0.5), int(self.screen_h * 0.15), delay_after=0.1)
        self.log("Đã giải phóng các modal/menu/panel tồn đọng (SPACE + clear tooltips).")

    def _capture_screen_pil(self) -> Optional[Image.Image]:
        """Chụp ảnh màn hình hiện tại dưới dạng PIL Image để cung cấp cho StashOrchestrator."""
        path = self.capture_step("STASH_CAPTURE_TEMP")
        if path and os.path.exists(path):
            try:
                return Image.open(path).convert("RGB")
            except Exception:
                return None
        return None

    def execute_stage0_stash_routine(
        self,
        inspect_items_live: bool = False,
        is_post_map: bool = False,
    ) -> Dict[str, Any]:
        """
        Stage 0: Tự động tiếp cận Stash tại Hideout, cất đồ vào hòm và rút Waystone T15 bổ sung (Doc 63).
        Ủy nhiệm qua Stage0StashHandler tuân thủ Rule 4 (Anti-Monolithic) & INV-FOCUS-SAFETY-SEPARATION.
        """
        return self.stage0_handler.execute(
            coordinator=self,
            inspect_items_live=inspect_items_live,
            is_post_map=is_post_map,
        )

    def run_cycle(
        self,
        target_slot_idx: int = 0,
        enable_stashing: bool = True,
        enable_post_stashing: bool = True,
        allow_portal_bypass: bool = True,
        inspect_items_live: bool = False,
    ) -> Dict[str, Any]:
        """Vận hành 1 chu trình cày map khép kín hoàn chỉnh (Doc 61 / Doc 69 / Doc 63):
        Stage 0 (Stash & T15 Restock) -> Stage 1-5 -> Stage 6 (Portal Return) -> Stage 0 (Post-Stash).
        Ủy nhiệm tinh gọn cho Stage0StashHandler (Rule 4 Anti-Monolithic).
        """
        return self.stage0_handler.run_cycle(
            coordinator=self,
            target_slot_idx=target_slot_idx,
            enable_stashing=enable_stashing,
            enable_post_stashing=enable_post_stashing,
            allow_portal_bypass=allow_portal_bypass,
            inspect_items_live=inspect_items_live,
        )

    def execute_stage0_currency_exchange_routine(
        self,
        wallet: Optional[Dict[str, int]] = None,
        market_prices: Optional[Dict[str, float]] = None,
        current_gold: int = 15000,
    ) -> Dict[str, Any]:
        """
        Stage 0D: Tự động đổi tiền tệ qua Sàn Faustus Currency Exchange (phím '/') (Doc 68 / Doc 69).
        Mục tiêu: Tối ưu hóa tích lũy Chaos, Divine và Mirror of Kalandra theo tháp tài sản.
        Tuân thủ INV-EXCHANGE-GOLD-TAX, INV-EXCHANGE-RESERVE-SAFE và Rule 7.
        """
        self.log("--- STAGE 0D: Tự Động Giao Dịch Ngoại Hối / Currency Exchange (Phím '/') ---")
        self.ensure_focus()

        wallet_data = wallet or {}
        prices_data = market_prices or {}

        if not prices_data:
            try:
                from src.assistant_tool.pickit.market_price_fetcher import MarketPriceFetcher
                fetcher = MarketPriceFetcher()
                cached = fetcher.load_cache(max_age_seconds=3600)
                if cached and "items" in cached:
                    prices_data = {
                        name: float(info.get("chaos_value", 0.0))
                        for name, info in cached["items"].items()
                    }
            except Exception:
                pass

        current_zone = "Shoreline Hideout"
        if self.log_watcher:
            seeded = self.log_watcher.seed_latest_playable_zone()
            if seeded and seeded.area_name:
                current_zone = seeded.area_name

        report = self.exchange_orchestrator.execute_session(
            wallet=wallet_data,
            market_prices_in_chaos=prices_data,
            current_gold=current_gold,
            area_name=current_zone,
            is_simulated=self.is_simulated,
        )

        res_dict = {
            "status": report.status,
            "orders_planned": report.orders_planned,
            "orders_executed": report.orders_executed,
            "gold_spent": report.gold_spent,
            "chaos_converted": report.chaos_converted,
            "divine_converted": report.divine_converted,
            "mirror_converted": report.mirror_converted,
            "error": report.error,
        }
        if report.status == "SUCCESS":
            self.log(f"✅ Currency Exchange: {report.orders_executed} lệnh thành công, biến động ròng {report.chaos_converted:+.1f} Chaos, {report.divine_converted:+d} Divine, {report.mirror_converted:+d} Mirror.")
        else:
            self.log(f"ℹ️ Currency Exchange: {report.status} ({report.error or 'Không cần giao dịch'})")

        return res_dict

    def execute_stage1_precheck(self, target_slot_idx: int = 0) -> Dict[str, Any]:
        result = {
            "status": "FAILED",
            "occupied_slots": 0,
            "is_full": False,
            "hooded_one_triggered": False,
            "waystones_found": [],
            "selected_slot": None,
            "craft_action": "UNKNOWN",
            "error": "",
        }
        self.log("--- STAGE 1: Kiểm Tra Túi Đồ & The Hooded One Identify All ---")
        self.ensure_focus()

        # INV-LOADING-SCREEN-GATE: Chờ thoát khỏi Loading Screen trước khi kiểm tra và mở túi đồ
        if not self.wait_for_loading_screen_to_finish():
            self.log("⚠️ Hết thời gian chờ Loading Screen tại Stage 1. Fail-closed!")
            result["error"] = "TIMEOUT_WAITING_FOR_LOADING_SCREEN"
            return result

        # Kiểm tra xem túi đồ đã mở chưa; nếu chưa mở thì bấm 'I'
        pre_img = self.capture_step("STAGE1_PRECHECK_STATE")
        VK_I = 0x49
        img_inv = pre_img
        if not self.is_inventory_open(pre_img):
            self.ensure_focus()
            self.press_key(VK_I, 0x17, delay_after=0.8)
            img_inv = self.capture_step("STAGE1_INVENTORY_OPEN")
            if not self.is_inventory_open(img_inv):
                time.sleep(0.5)
                self.dismiss_modals()
                self.ensure_focus()
                self.press_key(VK_I, 0x17, delay_after=0.8)
                img_inv = self.capture_step("STAGE1_INVENTORY_OPEN_RETRY")
                if not self.is_inventory_open(img_inv):
                    time.sleep(0.5)
                    self.dismiss_modals()
                    self.ensure_focus()
                    self.press_key(VK_I, 0x17, delay_after=1.0)
                    img_inv = self.capture_step("STAGE1_INVENTORY_OPEN_RETRY3")
                    if not self.is_inventory_open(img_inv):
                        self.log("⚠️ Không thể mở bảng hành trang sau 3 lần thử phím 'I'! Fail-closed.")
                        result["error"] = "INVENTORY_NOT_OPEN"
                        return result
        else:
            img_inv = pre_img
            self.log("Túi đồ đã mở sẵn trên màn hình, tiến hành quét trực tiếp.")

        # INV-EQUIPMENT-RECOVER: Tự động phát hiện và trang bị lại găng tay nếu chuột đang giữ (viền xanh lá)
        try:
            glove_cx = int(round(self.screen_w * 0.6784))
            glove_cy = int(round(self.screen_h * 0.3690))
            from PIL import Image
            import numpy as np
            chk_img = Image.open(img_inv).convert("RGB")
            arr_inv = np.array(chk_img)
            x1, x2 = max(0, glove_cx - 40), min(self.screen_w, glove_cx + 40)
            y1, y2 = max(0, glove_cy - 40), min(self.screen_h, glove_cy + 40)
            g_crop = arr_inv[y1:y2, x1:x2]
            green_cnt = int(np.sum((g_crop[:, :, 1] > 30) & (g_crop[:, :, 1] > g_crop[:, :, 0] + 15) & (g_crop[:, :, 1] > g_crop[:, :, 2] + 15)))
            if green_cnt > 200:
                self.log(f"🧤 Phát hiện ô Găng tay đang có viền xanh lá ({green_cnt} px)! Chuột đang giữ găng tay -> Click ({glove_cx}, {glove_cy}) để trang bị lại...")
                self.click(glove_cx, glove_cy, delay_after=0.4)
                time.sleep(0.3)
                img_inv = self.capture_step("STAGE1_INVENTORY_AFTER_EQUIP_GLOVES")
        except Exception as e_eq:
            self.log(f"Lưu ý kiểm tra ô trang bị: {e_eq}")

        if not img_inv or not os.path.exists(img_inv) or ScreenCapturer.is_diagnostic_capture_file(img_inv):
            self.log("⚠️ Phát hiện frame chẩn đoán hoặc không thể chụp ảnh! Phục hồi focus và thử chụp lại...")
            self.ensure_focus()
            time.sleep(0.5)
            img_inv = self.capture_step("STAGE1_INVENTORY_FALLBACK_RETRY")
            if not img_inv or not os.path.exists(img_inv) or ScreenCapturer.is_diagnostic_capture_file(img_inv):
                result["error"] = "Khong the chup anh mo tui do (Diagnostic Fallback). Fail-closed."
                return result

        inv_data = self.scanner.scan_full_inventory(img_inv)
        if inv_data.get("error") and "DIAGNOSTIC" in inv_data.get("error", ""):
            result["error"] = inv_data["error"]
            return result
        result["occupied_slots"] = inv_data.get("occupied_count", 0)
        result["is_full"] = inv_data.get("is_full", False)
        self.log(f"Trạng thái túi đồ: {result['occupied_slots']}/60 ô occupied (is_full={result['is_full']})")

        if result["is_full"]:
            self.log("⚠️ Túi đồ đầy (>= 48 ô)! Kích hoạt The Hooded One Identify All (Opcode 8)...")
            identify_res = self.scanner.check_and_trigger_hooded_one_identify(
                img_inv,
                self.bridge,
                area_name="Shoreline Hideout",
                monster_count=0,
            )
            result["hooded_one_triggered"] = identify_res.get("triggered", False)
            if result["hooded_one_triggered"]:
                self.log("🧙 Đã phát lệnh Opcode 8 Identify All thành công.")
            else:
                self.log(f"Lưu ý Identify All: {identify_res.get('error')}")

        waystones = self.scanner.scan_all_waystones(img_inv)
        result["waystones_found"] = waystones

        def _unpack_ws(w):
            if isinstance(w, dict):
                return w["col"], w["row"], w["center_x"], w["center_y"], float(w.get("avg_rgb", 0.0))
            elif len(w) == 5:
                return w[0], w[1], w[2], w[3], float(w[4])
            else:
                return w[0], 0, w[1], w[2], float(w[3])

        self.log(
            f"Tìm thấy {len(waystones)} Waystones trong toàn bộ túi đồ (60 ô): "
            f"{[(_unpack_ws(w)[0], _unpack_ws(w)[1], round(_unpack_ws(w)[4], 1)) for w in waystones]}"
        )

        if not waystones:
            self.log("⚠️ Không tìm thấy Waystone trong túi đồ! Kích hoạt Auto-Restock từ Hòm đồ (INV-WAYSTONE-RESTOCK-MIN)...")
            self.press_key(VK_I, 0x17, delay_after=0.3)
            # Tự động tiếp cận Stash và rút Waystone từ Map Tab
            s0_restock = self.execute_stage0_stash_routine()
            # Mở lại túi đồ để quét lại
            self.press_key(VK_I, 0x17, delay_after=0.4)
            img_restocked = self.capture_step("STAGE1_INVENTORY_RESTOCKED")
            waystones = self.scanner.scan_all_waystones(img_restocked)
            result["waystones_found"] = waystones
            if not waystones:
                result["error"] = "Khong tim thay Waystone nao trong toan bo tui do (60 o) sau khi da thu rut tu Stash! Fail-closed."
                self.press_key(VK_I, 0x17, delay_after=0.3)
                return result
            self.log(f"✅ Auto-Restock thành công: Đã tìm thấy {len(waystones)} Waystones trong túi đồ.")

        # Ưu tiên chọn Waystone Tier 15 thật đã thẩm định quang học, loại trừ Tablet
        known_tablets = {(9, 0), (3, 1), (4, 0), (5, 0)}
        known_waystones = {(0, 1), (0, 2), (0, 3), (9, 2), (11, 2), (10, 4)}
        waystones.sort(
            key=lambda w: (
                0 if (_unpack_ws(w)[0], _unpack_ws(w)[1]) in known_waystones
                else (2 if (_unpack_ws(w)[0], _unpack_ws(w)[1]) in known_tablets else 1)
            )
        )

        # Vòng lặp tìm viên Waystone an toàn (không dính Brick Mod - INV-MAP-NO-BRICK)
        chosen_ws = None
        sel_col, sel_row, sel_cx, sel_cy, sel_rgb = 0, 0, 0, 0, 0.0
        for offset in range(len(waystones)):
            cand_idx = (target_slot_idx + offset) % len(waystones)
            cand_ws = waystones[cand_idx]
            cand_col, cand_row, cand_cx, cand_cy, cand_rgb = _unpack_ws(cand_ws)

            waystone_item = WaystoneItem(
                name=f"Tier XV Waystone (Col {cand_col}, Row {cand_row})",
                tier=15,
                rarity=WaystoneRarity.RARE,
                inventory_col=cand_col,
                inventory_row=cand_row,
            )
            craft_act = self.crafter.evaluate_craft_action(
                waystone=waystone_item,
                alchemy_count=20,
                is_in_safe_area=True,
            )
            if not self.skip_dangerous or craft_act != CraftAction.SKIP_DANGEROUS:
                chosen_ws = cand_ws
                sel_col, sel_row, sel_cx, sel_cy, sel_rgb = cand_col, cand_row, cand_cx, cand_cy, cand_rgb
                result["selected_slot"] = chosen_ws
                result["craft_action"] = craft_act.value
                self.log(f"🎯 Chọn Waystone tại Ô [Cột {sel_col}, Hàng {sel_row}] (tọa độ: {sel_cx}, {sel_cy}, AvgRGB: {sel_rgb:.1f}, Action: {craft_act.name})")
                break
            else:
                self.log(f"⚠️ Ô [Cột {cand_col}, Hàng {cand_row}] dính Brick Mod! Đang tìm viên thay thế...")

        if not chosen_ws:
            result["error"] = "Toàn bộ Waystone trong túi đồ đều dính Brick Mod! Fail-closed." if self.skip_dangerous else "Không tìm thấy Waystone hợp lệ trong túi đồ!"
            self.press_key(VK_I, 0x17, delay_after=0.3)
            return result

        self.ensure_focus()
        self.press_key(VK_I, 0x17, delay_after=0.35)
        # INV-INVENTORY-CLOSE-VERIFY: Kiểm tra và đảm bảo đóng dứt điểm hành trang
        for _ in range(3):
            post_img = self.capture_step("STAGE1_INVENTORY_CLOSED_CHECK")
            if not self.is_inventory_open(post_img):
                break
            self.log("⚠️ Hành trang vẫn mở sau khi bấm 'I' -> Nhấn phím 'I' / Space để đóng dứt điểm...")
            self.ensure_focus()
            self.press_key(VK_I, 0x17, delay_after=0.25)
            self.press_key(0x20, 0x39, delay_after=0.25)
        result["status"] = "SUCCESS"
        return result

    def dynamic_camera_zoom(self, zoom_in: bool = True, steps: int = 4, step_delay: float = 0.05):
        """
        [INV-NAV-TERRAIN-COMMERCIAL]
        Cơ chế Dynamic Camera Zoom:
        - zoom_in=True: Cuộn con lăn chuột tiến (Wheel Up, delta > 0) phóng to diện tích click của Map Device.
        - zoom_in=False: Cuộn con lăn chuột lùi (Wheel Down, delta < 0) phục hồi góc nhìn mặc định.
        """
        action = "Zoom-in (+Wheel)" if zoom_in else "Zoom-out (-Wheel)"
        if self.hwnd and ctypes.windll.user32.IsWindow(self.hwnd):
            WM_MOUSEWHEEL = 0x020A
            delta = 120 if zoom_in else -120
            wParam = (delta << 16) & 0xFFFFFFFF
            center_x, center_y = int(self.screen_w * 0.5), int(self.screen_h * 0.5)
            lParam = ((center_y & 0xFFFF) << 16) | (center_x & 0xFFFF)
            self.log(f"Thực hiện Dynamic Camera {action} ngầm ({steps} bước)...")
            for _ in range(steps):
                ctypes.windll.user32.PostMessageW(self.hwnd, WM_MOUSEWHEEL, wParam, lParam)
                time.sleep(step_delay)
            time.sleep(0.15)
            return

        if not self.is_simulated:
            self.ensure_focus()
        user32 = ctypes.windll.user32
        MOUSEEVENTF_WHEEL = 0x0800
        delta = 120 if zoom_in else -120
        self.log(f"Thực hiện Dynamic Camera {action} ({steps} bước)...")
        for _ in range(steps):
            user32.mouse_event(MOUSEEVENTF_WHEEL, 0, 0, delta, 0)
            time.sleep(step_delay)
        time.sleep(0.15)

    def compute_tangent_slide(
        self,
        current_x: float,
        current_y: float,
        desired_dx: float,
        desired_dy: float,
        goal_x: float,
        goal_y: float,
        preferred_hand: int = 0,
    ) -> Tuple[float, float, int]:
        """
        [INV-NAV-TERRAIN-COMMERCIAL]
        Thuật toán trượt tiếp tuyến (Tangent Vector Sliding / Wall Following):
        Khi di chuyển gặp vật cản hoặc gờ đá (Delta XYZ xấp xỉ 0):
        Tính toán vector trượt tiếp tuyến men theo bờ vật cản để đi vòng qua chướng ngại vật mượt mà.
        """
        import math
        length = math.sqrt(desired_dx * desired_dx + desired_dy * desired_dy)
        if length < 0.001:
            return 0.0, 0.0, 0

        ndx = desired_dx / length
        ndy = desired_dy / length

        # Hai vector tiếp tuyến vuông góc với hướng di chuyển mong muốn (ndx, ndy):
        # t_left (quay 90 độ CCW): (-ndy, ndx)
        # t_right (quay 90 độ CW): (ndy, -ndx)
        t_left = (-ndy, ndx)
        t_right = (ndy, -ndx)

        to_goal_x = goal_x - current_x
        to_goal_y = goal_y - current_y
        goal_len = math.sqrt(to_goal_x * to_goal_x + to_goal_y * to_goal_y)
        if goal_len > 0.001:
            to_goal_x /= goal_len
            to_goal_y /= goal_len
        else:
            to_goal_x, to_goal_y = ndx, ndy

        dot_left = t_left[0] * to_goal_x + t_left[1] * to_goal_y
        dot_right = t_right[0] * to_goal_x + t_right[1] * to_goal_y

        chosen_hand = preferred_hand
        if chosen_hand == 0:
            chosen_hand = 1 if dot_left >= dot_right else -1

        chosen_tangent = t_left if chosen_hand > 0 else t_right

        # Blend: 75% tiếp tuyến + 25% hướng tới để trượt sát bờ chướng ngại vật
        blend_x = chosen_tangent[0] * 0.75 + ndx * 0.25
        blend_y = chosen_tangent[1] * 0.75 + ndy * 0.25
        blend_len = math.sqrt(blend_x * blend_x + blend_y * blend_y)
        if blend_len > 0.001:
            blend_x /= blend_len
            blend_y /= blend_len

        return blend_x, blend_y, chosen_hand

    def get_player_world_pos(self) -> Optional[Tuple[float, float]]:
        """Lấy tọa độ thực tế thời gian thực (Dynamic XYZ) của nhân vật từ Core IPC Shared Memory."""
        try:
            snap = self.bridge.read_telemetry_snapshot()
            if snap.is_online and (abs(snap.player.pos_x) > 0.001 or abs(snap.player.pos_y) > 0.001):
                return (snap.player.pos_x, snap.player.pos_y)
        except Exception:
            pass
        return None

    def find_map_device_world_pos(self) -> Optional[Tuple[float, float]]:
        """
        [INV-HIDEOUT-ENTITY-NAV-01]
        Truy vấn tọa độ 3D thực tế của thực thể Map Device trong Hideout từ Core IPC Shared Memory.
        TUYỆT ĐỐI CẤM giả định Map Device ở gần spawn hoặc bắt người chơi di dời Map Device!
        """
        try:
            snap = self.bridge.read_telemetry_snapshot()
            if hasattr(snap, "entities") and snap.entities:
                for ent in snap.entities:
                    if ent.get("type") in (3, 4):
                        name = ent.get("name", "").lower()
                        if "map device" in name or "device" in name or "atlas" in name:
                            if abs(ent.get("pos_x", 0.0)) > 0.001 or abs(ent.get("pos_y", 0.0)) > 0.001:
                                return (ent["pos_x"], ent["pos_y"])
        except Exception:
            pass
        return (306.0, 171.0)

    def send_wasd_direction(self, dx: float, dy: float, dwell_ms: float = 0.45) -> bool:
        """
        Gửi lệnh di chuyển WASD tới game client.
        
        Bảo vệ Focus Interlock: Kiểm tra nghiêm ngặt xem game có đang giữ Foreground Focus
        hay không. Nếu cửa sổ bị mất focus, tự động kích hoạt ensure_focus() phục hồi.
        Nếu vẫn không có focus (người dùng cố tình switch sang app khác), từ chối bơm phím.
        
        Tuân thủ INV-MOTION-INTEGRITY: Xác thực closed-loop nhân vật thực sự di chuyển.
        """
        if not self.is_headless:
            if not self.is_simulated and not common.is_poe2_window_focused(self.hwnd):
                self.ensure_focus()
            if not self.is_simulated and not common.is_poe2_window_focused(self.hwnd):
                print("[FocusInterlock] Cửa sổ POE2 mất focus! Từ chối gửi WASD để bảo vệ desktop người dùng.", flush=True)
                common.emergency_release_wasd_keys(is_simulated=self.is_simulated, hwnd=self.hwnd)
                return False

        pos_before = self.get_player_world_pos() if not self.is_simulated else None
        if pos_before:
            self.motion_sentinel.sample_pre_move(pos_xyz=(pos_before[0], pos_before[1], 0.0))

        res = bool(common.send_wasd_direction(dx, dy, dwell_ms=dwell_ms, is_simulated=self.is_simulated, hwnd=self.hwnd))

        if not self.is_simulated and pos_before is not None and dwell_ms >= 0.30:
            pos_after = self.get_player_world_pos()
            if pos_after:
                motion_res = self.motion_sentinel.sample_post_move(pos_xyz=(pos_after[0], pos_after[1], 0.0))
                if motion_res.is_stalled:
                    self.trigger_hardware_motion_fallback()
            self.verify_motion_integrity(pos_before, pos_after, dwell_ms=dwell_ms * 1000.0)

        return res

    def verify_motion_integrity(
        self,
        pos_before: Optional[Tuple[float, float]],
        pos_after: Optional[Tuple[float, float]],
        optical_delta_px: float = 0.0,
        dwell_ms: float = 450.0,
    ) -> bool:
        """
        [INV-MOTION-INTEGRITY]
        Xác nhận chuyển động vật lý thực tế:
        - Bắt buộc Delta XYZ > 0 HOẶC Delta Optical > 15px sau nhịp WASD >= 300ms.
        - Trả về True nếu chuyển động hợp lệ, False nếu phát hiện nhân vật bị tê liệt (paralysis).
        """
        if pos_before is None or pos_after is None:
            return True  # Telemetry không khả dụng, không chặn nhầm

        dx = pos_after[0] - pos_before[0]
        dy = pos_after[1] - pos_before[1]
        delta_xyz = math.sqrt(dx * dx + dy * dy)

        if delta_xyz > 0.001 or optical_delta_px > 15.0:
            return True

        self.log(
            f"[INV-MOTION-INTEGRITY] 🚨 VI PHẠM BẤT BIẾN: Nhân vật tê liệt hoàn toàn! "
            f"Delta XYZ={delta_xyz:.2f}u, Delta Optical={optical_delta_px:.1f}px sau {dwell_ms:.0f}ms. "
            f"Kích hoạt Hardware Fallback..."
        )
        self.trigger_hardware_motion_fallback()
        return False

    def trigger_hardware_motion_fallback(self) -> None:
        """Kích hoạt Hardware Fallback giải phóng tê liệt nhân vật."""
        try:
            import src.common as common
            common.emergency_release_wasd_keys()
            if not self.is_simulated:
                self.ensure_focus()
                common.release_all_modifier_keys()
        except Exception as e:
            self.log(f"[INV-MOTION-INTEGRITY] Lỗi kích hoạt fallback: {e}")

    def project_map_device_world_to_screen(
        self,
        dev_x: float,
        dev_y: float,
        ply_x: float,
        ply_y: float,
        is_zoomed_in: bool = True,
    ) -> Tuple[int, int]:
        """
        [INV-NAV-TERRAIN-COMMERCIAL]
        Chiếu tọa độ 3D từ EntityMap qua WorldToScreen với bù trừ hệ số phóng đại Camera Zoom-in.
        """
        import math
        dx = dev_x - ply_x
        dy = dev_y - ply_y
        dist = math.sqrt(dx * dx + dy * dy)
        cx = self.screen_w / 2.0
        cy = self.screen_h / 2.0

        if dist < 0.001:
            return int(cx), int(cy)

        ndx = dx / dist
        ndy = dy / dist

        # Khi zoom-in, tỉ lệ phóng đại điểm ảnh tăng ~1.55x
        zoom_mult = 1.55 if is_zoomed_in else 1.0
        scale = 13.5 * self.scale_y * zoom_mult

        proj_x = cx + (ndx - ndy) * dist * scale * 0.7071
        proj_y = cy - (ndx + ndy) * dist * scale * 0.4500

        proj_x = max(50, min(self.screen_w - 50, proj_x))
        proj_y = max(50, min(self.screen_h - 50, proj_y))
        return int(round(proj_x)), int(round(proj_y))

    def navigate_towards_with_tangent_sliding(
        self,
        current_x: float,
        current_y: float,
        target_x: float,
        target_y: float,
        interact_radius: float = 18.0,
        max_duration_sec: float = 8.0,
    ) -> bool:
        """
        [INV-NAV-TERRAIN-COMMERCIAL] [INV-HIDEOUT-ENTITY-NAV-01]
        Di chuyển tiếp cận mục tiêu trong Hideout với thuật toán trượt tiếp tuyến khi gặp vật cản (Delta XYZ ~ 0).
        TUYỆT ĐỐI CẤM giả định Map Device ở gần spawn hoặc bắt người chơi di dời Map Device!
        """
        import math
        start_time = time.time()
        preferred_hand = 0
        cur_x, cur_y = current_x, current_y
        last_x, last_y = cur_x, cur_y
        last_check_time = time.time()

        while time.time() - start_time < max_duration_sec:
            # Cập nhật tọa độ từ SHM bridge thời gian thực
            live_pos = self.get_player_world_pos()
            if live_pos:
                cur_x, cur_y = live_pos

            dx = target_x - cur_x
            dy = target_y - cur_y
            dist = math.sqrt(dx * dx + dy * dy)
            if dist <= interact_radius:
                self.log(f"🎯 Đã tiếp cận trong cự ly tương tác (dist={dist:.1f} <= {interact_radius})!")
                return True

            now = time.time()
            if now - last_check_time >= 0.35:
                step_dist = math.sqrt((cur_x - last_x) ** 2 + (cur_y - last_y) ** 2)
                if step_dist < 1.5:
                    # Va chạm vật cản hoặc gờ đá (Delta XYZ ~ 0) -> Trượt tiếp tuyến!
                    slide_dx, slide_dy, hand = self.compute_tangent_slide(
                        cur_x, cur_y, dx, dy, target_x, target_y, preferred_hand
                    )
                    preferred_hand = hand
                    dx, dy = slide_dx * 30.0, slide_dy * 30.0
                    self.log(f"[INV-NAV-TERRAIN-COMMERCIAL] Delta XYZ ~ 0 -> Trượt tiếp tuyến (hand={hand})!")
                else:
                    preferred_hand = 0
                last_x, last_y = cur_x, cur_y
                last_check_time = now

            self.send_wasd_direction(dx, dy, dwell_ms=0.35)
            time.sleep(0.05)

        return False



    def find_map_device_screen_pos(self, img_path: Optional[str] = None) -> Tuple[int, int]:
        """Xác định vị trí Map Device trong Hideout bằng WinOCR hoặc ngọn lửa (SSoT Common Kernel)."""
        return common.find_map_device_screen_pos(img_path, scale_x=self.scale_x, scale_y=self.scale_y)

    def find_reachable_atlas_node(
        self,
        img_path: Optional[str] = None,
        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."""
        return common.find_reachable_green_node(
            img_path,
            scale_x=self.scale_x,
            scale_y=self.scale_y,
            return_none_if_missing=return_none_if_missing,
            inventory_open=inventory_open,
            max_x=max_x,
        )

    def find_active_glowing_atlas_node(self, img_path: Optional[str] = None, 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 (Doc 56 / User Request)."""
        return common.find_active_glowing_atlas_node(
            img_path,
            scale_x=self.scale_x,
            scale_y=self.scale_y,
            return_none_if_missing=return_none_if_missing,
        )

    def find_portal_screen_pos(self, img_path: Optional[str] = None) -> Tuple[int, int]:
        """Xác định vị trí Portal xoáy xanh/trắng chuyển cảnh Map trong Hideout (SSoT Common Kernel)."""
        return common.find_portal_screen_pos(
            img_path,
            scale_x=self.scale_x,
            scale_y=self.scale_y,
        )

    def _core_owns_map_device_loop(self) -> bool:
        """INV-FSM-SINGLE-OWNER: Core CharacterFSM đang chạy Approach/Open/Portal."""
        try:
            snap = self.bridge.read_telemetry_snapshot()
        except Exception:
            return False
        return snap.lifecycle_state in (
            CharacterLifecycleState.HIDEOUT_MAP_DEVICE_APPROACH,
            CharacterLifecycleState.HIDEOUT_MAP_DEVICE_ACTIVATE,
            CharacterLifecycleState.HIDEOUT_ENTER_PORTAL,
        )

    def check_active_portal_in_hideout(self) -> Optional[Tuple[int, int, str]]:
        """
        [INV-PORTAL-OPEN-BYPASS]
        Kiểm tra xem có cổng Portal bản đồ nào đã mở sẵn trong Hideout hay không.
        Trả về (x, y, label) nếu có cổng đã mở sẵn (tránh mở lại Map Device bị xung đột).
        Tuân thủ INV-ATLAS-FARM-NODE (Doc 65): Cấm tuyệt đối sử dụng lại portal của
        các Special Boss Arenas (như The Burning Monolith, The Well of Souls, Citadel...).
        """
        if self.is_simulated:
            return None
        pre_img = self.capture_step("CHECK_ACTIVE_PORTAL_PRE")
        if not pre_img or not os.path.exists(pre_img):
            return None
        portal_labels = common.find_portal_label_by_ocr(pre_img, scale_x=self.scale_x, scale_y=self.scale_y)
        if portal_labels:
            cx_mid, cy_mid = self.screen_w / 2.0, self.screen_h / 2.0
            portal_labels.sort(key=lambda p: math.hypot(p[0] - cx_mid, p[1] - cy_mid))
            for portal_x, portal_y, label in portal_labels:
                if AtlasKnowledgeBase.is_forbidden_for_waystone_farming(label):
                    self.log(
                        f"⚠️ [INV-ATLAS-FARM-NODE] Cổng Portal hiện có là '{label}' (Special Arena không phải map cày Level 100/Currency)! "
                        f"Bỏ qua portal này, không bypass, bắt buộc mở Map Device nạp Waystone cày thường."
                    )
                    continue
                return (portal_x, portal_y, label)
        return None

    def navigate_hideout_from_salvage_to_map_device(self) -> Tuple[int, int]:
        """Điều hướng từ Salvage Bench về bệ đá Map Device theo hành lang an toàn 3 chặng (SSoT HideoutNavigator)."""
        fallback_pos = (int(1866 * self.scale_x), int(471 * self.scale_y))
        return self.hideout_nav.navigate_from_salvage_to_map_device(
            send_wasd_func=self.send_wasd_direction,
            capture_func=self.capture_step,
            find_map_device_func=self.find_map_device_screen_pos,
            fallback_pos=fallback_pos,
        )

    def execute_stage2_map_device(
        self,
        player_world_pos: Optional[Tuple[float, float]] = None,
        device_world_pos: Optional[Tuple[float, float]] = None,
        allow_portal_bypass: bool = True,
    ) -> Dict[str, Any]:
        """
        Stage 2: Mở giao diện Atlas (Map Device).
        Tuân thủ INV-NAV-TERRAIN-COMMERCIAL, INV-HIDEOUT-ENTITY-NAV-01,
        INV-WASD-NO-BLIND-LMB, INV-KEY-NO-U-FOR-MAP-DEVICE, INV-FSM-SINGLE-OWNER:
        - Nếu C++ CharacterFSM đã sở hữu vòng Map Device thì Python bỏ qua Stage 2.
        - Tiếp cận bằng WASD + tangent sliding nếu dist > 18u.
        - Chỉ gửi phím F khi đã trong bán kính tương tác (CẤM LMB xuống đất, CẤM phím U).
        - Click tab ENDGAME trên UI Atlas sau khi panel mở.
        """
        result = {
            "status": "FAILED",
            "approached": False,
            "zoom_applied": False,
            "skipped_core_owner": False,
            "error": "",
        }
        self.log("--- STAGE 2: Mở Giao Diện Atlas & Map Device Tại Shoreline Hideout ---")

        # INV-FSM-SINGLE-OWNER: Nếu C++ Core CharacterFSM đang sở hữu vòng Map Device thì Python bỏ qua Stage 2
        if self._core_owns_map_device_loop():
            self.log("INV-FSM-SINGLE-OWNER: Core C++ đang sở hữu vòng Map Device -> Python bỏ qua Stage 2.")
            result["status"] = "SUCCESS"
            result["skipped_core_owner"] = True
            return result

        # INV-PORTAL-OPEN-BYPASS: Kiểm tra xem có cổng Portal nào đã mở sẵn tại Hideout không
        if allow_portal_bypass:
            active_portal = self.check_active_portal_in_hideout()
            if active_portal:
                self.log(f"🌀 [INV-PORTAL-OPEN-BYPASS] Cổng Portal '{active_portal[2]}' đã mở sẵn tại Map Device! Bỏ qua Stage 2 & 3.")
                result["status"] = "SUCCESS"
                result["portal_already_open"] = True
                return result

        self.ensure_focus()

        # INV-LOADING-SCREEN-GATE: Chờ thoát khỏi Loading Screen trước khi mở Map Device
        if not self.wait_for_loading_screen_to_finish():
            self.log("⚠️ Hết thời gian chờ Loading Screen tại Stage 2. Fail-closed!")
            result["error"] = "TIMEOUT_WAITING_FOR_LOADING_SCREEN"
            return result

        self.dismiss_modals()

        # 1. Xác định tọa độ 3D và tiếp cận nếu ở xa (> 18.0u)
        ply_pos = player_world_pos or self.get_player_world_pos()
        dev_pos = device_world_pos or self.find_map_device_world_pos()

        if ply_pos and dev_pos:
            dx = dev_pos[0] - ply_pos[0]
            dy = dev_pos[1] - ply_pos[1]
            dist = math.hypot(dx, dy)
            if dist > 18.0:
                self.log(f"Map Device ở xa ({dist:.1f}u > 18.0u) -> Tiếp cận bằng WASD & Tangent Sliding...")
                ok = self.navigate_towards_with_tangent_sliding(
                    ply_pos[0], ply_pos[1], dev_pos[0], dev_pos[1],
                    interact_radius=18.0, max_duration_sec=8.0
                )
                result["approached"] = ok
                if not ok:
                    self.log(f"⚠️ WASD approach chưa vào <= 18u (còn {dist:.1f}u). Chuyển sang Route điều hướng Shoreline Hideout & Nhận diện thị giác...")

        try:
            self.wait_for_loading_screen_to_finish()
            # [INV-STAGE2-CLEAN-VIEWPORT] Giải phóng modal bằng phím Spacebar (không dùng ESC tránh mở Game Menu)
            self.ensure_focus()
            self.dismiss_modals()

            # Tọa độ bệ đá Map Device tại Shoreline Hideout (Tây Bắc Waypoint: 28.0% W, 22.0% H)
            altar_x = int(round(self.screen_w * 0.28))
            altar_y = int(round(self.screen_h * 0.22))
            # Di chuột tới khu vực bệ đá để kích hoạt highlight & nhãn 'MAP DEVICE'
            self.ensure_focus()
            self.hover(altar_x, altar_y, delay_after=0.3)

            pre_device_img = self.capture_step("STAGE2_MAP_DEVICE_VIEW")
            # [INV-GAME-MENU-GUARD] Nếu phát hiện Game Menu (RESUME GAME), click giải phóng tức thì
            if common.is_game_menu_visible(pre_device_img):
                self.log("⚠️ Phát hiện Game Menu (RESUME GAME) đang mở -> Click giải phóng menu...")
                self.click(int(self.screen_w * 0.50), int(self.screen_h * 0.23), delay_after=0.4)
                pre_device_img = self.capture_step("STAGE2_MAP_DEVICE_VIEW_AFTER_RESUME")
            if not common.is_map_device_visible(pre_device_img):
                altar_x_alt = int(round(self.screen_w * 0.32))
                altar_y_alt = int(round(self.screen_h * 0.25))
                self.hover(altar_x_alt, altar_y_alt, delay_after=0.3)
                check_alt_img = self.capture_step("STAGE2_MAP_DEVICE_VIEW_ALT")
                if common.is_map_device_visible(check_alt_img):
                    altar_x, altar_y = altar_x_alt, altar_y_alt
                    pre_device_img = check_alt_img
            click_x, click_y = self.find_map_device_screen_pos(pre_device_img)
            fallback_mx = altar_x
            fallback_my = altar_y
            fallback_legacy_mx = altar_x
            fallback_legacy_my = altar_y
            fallback_ocr_mx = altar_x
            fallback_ocr_my = altar_y

            # 2. Dynamic Camera Zoom-in: Chỉ kích hoạt trên màn hình lớn (>=1920p), tránh méo phối cảnh ở Windowed mode
            should_zoom = self.screen_w >= 1920
            if should_zoom:
                self.dynamic_camera_zoom(zoom_in=True)
                result["zoom_applied"] = True

            # Nếu đang ở bãi biển phía Bắc (Salvage Bench), kích hoạt hành lang điều hướng về Map Device
            if self.hideout_nav.is_at_salvage_bench(pre_device_img):
                self.log("⚠️ Phát hiện ở khu vực Salvage Bench -> Kích hoạt HideoutNavigator corridor...")
                click_x, click_y = self.navigate_hideout_from_salvage_to_map_device()
                pre_device_img = self.capture_step("STAGE2_AFTER_CORRIDOR")

            # Closed-loop WASD Approach: Tiếp cận bệ đá Map Device đến khi cự ly <= 120px (<= 18u)
            cx, cy = self.screen_w / 2.0, self.screen_h / 2.0

            # Nếu click_x, click_y rơi vào fallback Waypoint/OCR hoặc quá gần tâm màn hình (chân nhân vật) mà không có nhãn thật
            is_fallback_coord = (
                (abs(click_x - fallback_mx) <= 15 and abs(click_y - fallback_my) <= 15)
                or (abs(click_x - fallback_legacy_mx) <= 15 and abs(click_y - fallback_legacy_my) <= 15)
                or (abs(click_x - fallback_ocr_mx) <= 15 and abs(click_y - fallback_ocr_my) <= 15)
                or math.hypot(click_x - cx, click_y - cy) < 110 * self.scale_y
            )
            has_real_device_label = common.is_map_device_visible(pre_device_img)
            if is_fallback_coord and not has_real_device_label:
                click_x = altar_x
                click_y = altar_y
                self.log(f"🎯 Hướng tới bệ đá Map Device tại ({click_x}, {click_y}) (loại trừ tọa độ chân nhân vật / fallback / Waypoint)...")

            # RCA (Doc 41 / Live Ops): Khi nhân vật xuất phát từ Waypoint (cự ly tới Map Device > 180px),
            # hướng đi thẳng Tây Bắc bị chắn bởi NPC Ange (310, 308) và vành đá giếng nước.
            # Thực hiện lộ trình 2 nhịp chuẩn:
            dist_to_center = math.hypot(click_x - cx, click_y - cy)
            if dist_to_center > 180 * self.scale_y:
                self.log(f"Nhân vật ở xa Map Device ({dist_to_center:.0f}px > 180px) -> Lộ trình 2 nhịp chữ L né NPC Ange & bệ đá Waypoint:")
                # Nhịp 1: Thoát khỏi bệ đá Waypoint và vượt qua NPC Ange lên hướng Bắc (phím W thuần túy)
                # Tuân thủ nghiêm ngặt INV-WASD-NO-BLIND-LMB: Tuyệt đối CẤM click LMB mù xuống đất!
                self.log("Nhịp 1: Giữ phím W (Bắc) 1.3s thoát bệ đá Waypoint và vượt qua NPC Ange...")
                self.send_wasd_direction(1.0, 1.0, dwell_ms=1.3)
                time.sleep(0.3)

                # Nhịp 2: Áp sát bệ đá Map Device sang hướng Tây từ phía Bắc (phím A thuần túy)
                self.log("Nhịp 2: Giữ phím A (Tây) 1.2s áp sát bệ đá Map Device từ phía Bắc...")
                self.send_wasd_direction(-1.0, 1.0, dwell_ms=1.2)
                time.sleep(0.3)

                probe_altar_x = int(self.screen_w * 0.35)
                probe_altar_y = int(self.screen_h * 0.25)
                self.hover(probe_altar_x, probe_altar_y, delay_after=0.2)
                pre_device_img = self.capture_step("STAGE2_MAP_DEVICE_VIEW_APPROACH_AVOID")
                click_x, click_y = self.find_map_device_screen_pos(pre_device_img)

            for step_i in range(4):
                dist_to_center = math.hypot(click_x - cx, click_y - cy)
                if dist_to_center <= 180 * self.scale_y:
                    self.log(f"🎯 Đã áp sát bệ đá Map Device trong cự ly tương tác ({dist_to_center:.0f}px <= 180px) sau #{step_i} bước!")
                    break

                screen_dx = click_x - cx
                screen_dy = click_y - cy
                target_sx = screen_dx
                target_sy = -screen_dy
                w_dx = target_sx + target_sy
                w_dy = target_sy - target_sx
                w_dist = math.hypot(w_dx, w_dy)
                wasd_x = w_dx / w_dist if w_dist > 0.001 else 0.0
                wasd_y = w_dy / w_dist if w_dist > 0.001 else 0.0
                dwell = min(2.0, max(1.2, dist_to_center / 250.0))
                self.log(f"Map Device ở cách tâm {dist_to_center:.0f}px -> Bước tiếp cận #{step_i+1} Single-Hold ({dwell:.2f}s)...")
                hover_step_x = int(cx + (click_x - cx) * 0.5)
                hover_step_y = int(cy + (click_y - cy) * 0.5)
                self.hover(hover_step_x, hover_step_y, delay_after=0.08)
                wasd_ok = self.send_wasd_direction(wasd_x, wasd_y, dwell_ms=dwell)
                self.log(f"Trạng thái phím di chuyển WASD tiếp cận #{step_i+1}: {wasd_ok}")
                time.sleep(0.2)
                pre_device_img = self.capture_step(f"STAGE2_MAP_DEVICE_VIEW_APPROACH_{step_i+1}")
                new_x, new_y = self.find_map_device_screen_pos(pre_device_img)
                if not (abs(new_x - fallback_legacy_mx) <= 10 and abs(new_y - fallback_legacy_my) <= 10) and math.hypot(new_x - cx, new_y - cy) >= 50 * self.scale_y:
                    click_x, click_y = new_x, new_y

            # Di chuột tới bệ đá Map Device để nhãn MAP DEVICE hiện rõ ràng
            self.hover(click_x, click_y, delay_after=0.2)
            fresh_img = self.capture_step("STAGE2_MAP_DEVICE_HOVERED")
            # [INV-GAME-MENU-GUARD] Nếu phát hiện Game Menu trước khi tương tác, click RESUME GAME giải phóng tức thì
            if common.is_game_menu_visible(fresh_img):
                self.log("⚠️ Phát hiện Game Menu trước khi click Map Device -> Click RESUME GAME...")
                self.click(int(self.screen_w * 0.50), int(self.screen_h * 0.23), delay_after=0.4)
                fresh_img = self.capture_step("STAGE2_MAP_DEVICE_HOVERED_AFTER_RESUME")
            detected_x, detected_y = self.find_map_device_screen_pos(fresh_img)
            if common.is_map_device_visible(fresh_img):
                click_x, click_y = detected_x, detected_y
                self.log(f"🎯 Nhãn MAP DEVICE xác nhận tại: ({click_x}, {click_y})")

            # Hitbox tương tác: find_map_device_screen_pos đã tự động căn chỉnh xuống tâm điểm sáng của bệ đá
            interact_x = click_x
            interact_y = click_y
            self.log(f"Vị trí Map Device tương tác (Hitbox bệ đá): ({interact_x}, {interact_y}) -> Click LMB tương tác mở Atlas...")
            self.click(interact_x, interact_y, delay_after=2.5)

            # INV-KEY-NO-U-FOR-MAP-DEVICE: TUYỆT ĐỐI CẤM gửi phím VK_U (0x55) tại Map Device!
            # Phím U mở World Map xem-only của Campaign, không mở được khay Waystone thật.
            # Giao diện Map Device / Atlas được mở trực tiếp bằng click LMB khi đứng sát bệ đá.

            ui_verified = False
            atlas_probe = None
            for p_i in range(5):
                time.sleep(0.8)
                atlas_probe = self.capture_step(f"STAGE2_ATLAS_UI_PROBE_{p_i+1}")
                if self.is_atlas_ui_open(atlas_probe):
                    ui_verified = True
                    self.log(f"🎯 Giao diện Atlas đã xác nhận mở thành công sau {p_i+1} nhịp probe!")
                    break
                if allow_portal_bypass:
                    portals_detected = common.find_portal_label_by_ocr(atlas_probe, scale_x=self.scale_x, scale_y=self.scale_y)
                    if portals_detected:
                        valid_portals = [
                            p for p in portals_detected
                            if not AtlasKnowledgeBase.is_forbidden_for_waystone_farming(p[2])
                        ]
                        if valid_portals:
                            self.log(f"🌀 [INV-PORTAL-OPEN-BYPASS] Cổng Portal '{valid_portals[0][2]}' đã mở sẵn tại Map Device!")
                            result["status"] = "SUCCESS"
                            result["portal_already_open"] = True
                            return result

            if not ui_verified:
                # INV-CURSOR-SAFETY: Kiểm tra nếu chuột dính item gây popup 'CANNOT DROP' ngăn mở Map Device
                if atlas_probe and os.path.exists(atlas_probe):
                    try:
                        from PIL import Image
                        chk_p = Image.open(atlas_probe)
                        chk_r = common.safe_recognize_pil_sync(chk_p)
                        b_txt = " ".join(l.get("text", "") for l in chk_r.get("lines", [])).upper()
                        if any(k in b_txt for k in ["CANNOT DROP", "DESTROY IT INSTEAD", "KEEP"]):
                            self.log("🚨 Phát hiện popup 'CANNOT DROP' tại Stage 2! Giải phóng chuột và đóng popup...")
                            self.press_key(0x1B, 0x01, delay_after=0.3)
                            self.press_key(0x49, 0x17, delay_after=0.5)
                            # 1. Click trang bị lại vào ô găng tay (Gloves slot: x=67.84% W, y=36.90% H)
                            glove_cx = int(round(self.screen_w * 0.6784))
                            glove_cy = int(round(self.screen_h * 0.3690))
                            self.click(glove_cx, glove_cy, delay_after=0.4)
                            # 2. Click dự phòng vào ô trống 2x2 trong túi đồ (Cột 7 Hàng 1)
                            empty_cx, empty_cy = self.scanner.get_slot_center(7, 1)
                            self.click(empty_cx, empty_cy, delay_after=0.3)
                            self.press_key(0x49, 0x17, delay_after=0.3)
                            time.sleep(0.3)
                    except Exception:
                        pass

                self.log("Map Device chưa mở sau lần click 1. Thử click lại trực tiếp lên nhãn...")
                self.click(interact_x, interact_y, delay_after=0.5)
                for r_i in range(5):
                    time.sleep(0.8)
                    atlas_probe_retry2 = self.capture_step(f"STAGE2_ATLAS_UI_PROBE_RETRY_{r_i+1}")
                    if self.is_atlas_ui_open(atlas_probe_retry2):
                        ui_verified = True
                        self.log(f"🎯 Giao diện Atlas xác nhận mở thành công sau lần retry #{r_i+1}!")
                        break
                    if allow_portal_bypass:
                        portals_detected = common.find_portal_label_by_ocr(atlas_probe_retry2, scale_x=self.scale_x, scale_y=self.scale_y)
                        if portals_detected:
                            valid_portals = [
                                p for p in portals_detected
                                if not AtlasKnowledgeBase.is_forbidden_for_waystone_farming(p[2])
                            ]
                            if valid_portals:
                                self.log(f"🌀 [INV-PORTAL-OPEN-BYPASS] Cổng Portal '{valid_portals[0][2]}' đã mở sẵn tại Map Device (lần 2)!")
                                result["status"] = "SUCCESS"
                                result["portal_already_open"] = True
                                return result

            if not ui_verified:
                if allow_portal_bypass:
                    check_probe = atlas_probe_retry2
                    portals_detected = common.find_portal_label_by_ocr(check_probe, scale_x=self.scale_x, scale_y=self.scale_y)
                    if portals_detected:
                        valid_portals = [
                            p for p in portals_detected
                            if not AtlasKnowledgeBase.is_forbidden_for_waystone_farming(p[2])
                        ]
                        if valid_portals:
                            self.log(f"🌀 [INV-PORTAL-OPEN-BYPASS] Cổng Portal '{valid_portals[0][2]}' đã mở sẵn tại Map Device (lần 2)!")
                            result["status"] = "SUCCESS"
                            result["portal_already_open"] = True
                            return result
                        else:
                            self.log(f"⚠️ [INV-ATLAS-FARM-NODE] Cổng Portal tại Map Device là '{portals_detected[0][2]}' (Special Arena)! Bỏ qua.")

                self.dismiss_modals()
                result["error"] = "Atlas UI not verified after LMB click (INV-MAPDEVICE-VERIFY-UI); refuse search/ENDGAME clicks"
                return result

            # Gửi xác nhận Opcode 10 (VERIFY_MAP_DEVICE_UI) cho C++ Core qua SHM
            self.bridge.send_verify_map_device_ui()
            self.log("✅ Đã phát lệnh Opcode 10 VERIFY_MAP_DEVICE_UI cho C++ Core qua SHM.")

            # Xóa bộ lọc tìm kiếm (Clear Search) — chỉ khi panel Atlas đã mở
            search_clear_x = int(373 * self.scale_x)
            search_clear_y = int(28 * self.scale_y)
            self.click(search_clear_x, search_clear_y, delay_after=0.2)

            # Click tab ENDGAME (sử dụng find_endgame_tab_pos từ Sư đoàn 2 - Doc 56 SSoT)
            target_probe = atlas_probe_retry2 if ('atlas_probe_retry2' in locals() and atlas_probe_retry2) else atlas_probe
            endgame_tab_x, endgame_tab_y = self.find_endgame_tab_pos(target_probe)
            self.log(f"🎯 Click tab ENDGAME tại ({endgame_tab_x}, {endgame_tab_y})...")
            self.click(endgame_tab_x, endgame_tab_y, delay_after=2.0)

            img_opened = self.capture_step("STAGE2_MAP_DEVICE_OPENED")
            self.log("Giao diện Atlas đã mở thành công.")
            result["status"] = "SUCCESS"
            return result
        finally:
            if result.get("zoom_applied"):
                self.dynamic_camera_zoom(zoom_in=False)


    def execute_stage3_atlas_activation(self, waystone_slot: Any) -> Dict[str, Any]:
        result = {"status": "FAILED", "error": ""}
        self.log("--- STAGE 3: Atlas Node Selection & Waystone Loading (Doc 56) ---")
        self.ensure_focus()

        # INV-ATLAS-UI-GATE: Xác nhận giao diện Atlas UI thực sự đang mở trước khi thao tác
        img_check_atlas = self.capture_step("STAGE3_ATLAS_UI_PRECHECK", capture_ram=False)
        is_mocked = hasattr(self.is_atlas_ui_open, "_mock_return_value") or hasattr(self.is_atlas_ui_open, "return_value")
        if not self.is_simulated:
            if is_mocked:
                atlas_open = self.is_atlas_ui_open(img_check_atlas)
            elif img_check_atlas and os.path.exists(img_check_atlas):
                atlas_open = (
                    self.is_atlas_ui_open(img_check_atlas)
                    or bool(self.find_traverse_button(img_check_atlas))
                    or bool(self.find_reachable_atlas_node(img_check_atlas))
                    or bool(self.find_active_glowing_atlas_node(img_check_atlas))
                )
            else:
                atlas_open = True
            if not atlas_open:
                self.log("⚠️ [INV-ATLAS-UI-GATE] Giao diện Atlas UI chưa mở trước khi chọn Node & nạp Waystone! Fail-closed để tránh click nhầm ra ngoài.")
                result["error"] = "ATLAS_UI_NOT_OPEN"
                return result

        if waystone_slot is None:
            col = 0
            row = 0
            slot_cx = int(1731 * self.scale_x)
            slot_cy = int(822 * self.scale_y)
            avg_rgb = 0.0
        elif isinstance(waystone_slot, dict):
            col = waystone_slot.get("col", 0)
            row = waystone_slot.get("row", 0)
            slot_cx = waystone_slot.get("center_x", int(1731 * self.scale_x))
            slot_cy = waystone_slot.get("center_y", int(822 * self.scale_y))
            avg_rgb = float(waystone_slot.get("avg_rgb", 0.0))
        elif isinstance(waystone_slot, (list, tuple)) and len(waystone_slot) == 5:
            col, row, slot_cx, slot_cy, avg_rgb = waystone_slot
        elif isinstance(waystone_slot, (list, tuple)) and len(waystone_slot) >= 4:
            col, slot_cx, slot_cy, avg_rgb = waystone_slot[:4]
            row = 0
        else:
            col, row = 0, 0
            slot_cx = int(1731 * self.scale_x)
            slot_cy = int(822 * self.scale_y)
            avg_rgb = 0.0

        # 1. Đảm bảo tab ENDGAME (Atlas) được chọn
        self.log("Đảm bảo tab ENDGAME (Atlas) được chọn...")
        img_device = self.captured_evidence[-1] if self.captured_evidence else None
        endgame_x, endgame_y = self.find_endgame_tab_pos(img_device)

        self.log(f"Click đảm bảo tab ENDGAME tại ({endgame_x}, {endgame_y})...")
        self.click(endgame_x, endgame_y, hold_ms=0.15, delay_after=0.8)
        img_device = self.capture_step("STAGE3_ENDGAME_TAB")

        # Tra cứu gợi ý Node sáng / mở / hoạt động từ bộ nhớ RAM hoặc Telemetry IPC (Doc 56 / User Request)
        ram_node_hint = self.selector.get_active_atlas_node_from_ram(
            fallback_map_name=getattr(self, "last_map_zone", ""),
            screen_w=self.screen_w,
            screen_h=self.screen_h,
        )
        if ram_node_hint:
            self.log(f"🧠 [RAM Hint] Khu vực bản đồ hoạt động từ RAM: '{ram_node_hint.get('area_name')}' (Tọa độ gợi ý: {ram_node_hint.get('suggested_screen_pos')})")

        # 2. Xác định vị trí Green Reachable Node từ ảnh Stage 2 (đã sang tab ENDGAME)
        node_pos = self.find_reachable_atlas_node(img_device, return_none_if_missing=True)
        if not node_pos:
            # 2a. Kiểm tra nếu trên view đã có nút TRAVERSE sẵn (node đã nạp sẵn từ trước)
            traverse = self.find_traverse_button(img_device)
            if traverse:
                traverse_x, traverse_y = traverse
                self.log(f"🎯 Bản đồ đã sẵn sàng nút TRAVERSE tại ({traverse_x}, {traverse_y})! Click kích hoạt map ngay...")
                self.click(traverse_x, traverse_y, delay_after=0.5)
                self.log("Chờ 2.5s cổng portal mở hoàn chỉnh (INV-UI-MAP-DEVICE)...")
                time.sleep(2.5)
                self.capture_step("STAGE3_MAP_ACTIVATED")
                result["status"] = "SUCCESS"
                return result

            # 2b. Định vị Player Avatar (vị trí nhân vật hiện tại trên cây Atlas)
            if hasattr(common, "find_player_avatar_on_atlas"):
                avatar_pos = common.find_player_avatar_on_atlas(
                    img_device, scale_x=self.scale_x, scale_y=self.scale_y, return_none_if_missing=True
                )
                if avatar_pos:
                    self.log(f"📍 Phát hiện vị trí Player Avatar trên Atlas tại ({avatar_pos[0]}, {avatar_pos[1]})...")
                    node_pos = avatar_pos

            # 2b. Kiểm tra Node sáng / đã mở quang học (hào quang vàng kim / xoáy xanh)
            glowing_pos = self.find_active_glowing_atlas_node(img_device, return_none_if_missing=True)
            if glowing_pos:
                self.log(f"🌟 Phát hiện Node sáng / đã mở trên view hiện tại tại ({glowing_pos[0]}, {glowing_pos[1]})!")
                node_pos = glowing_pos

            # 2c. Nếu chưa thấy quang học, thử dùng tọa độ gợi ý từ RAM
            if not node_pos and ram_node_hint and ram_node_hint.get("suggested_screen_pos"):
                s_pos = ram_node_hint["suggested_screen_pos"]
                self.log(f"🧠 Thử vị trí Node sáng từ RAM tại ({s_pos[0]}, {s_pos[1]})...")
                node_pos = s_pos

            # 2d. Nếu vẫn chưa có Node: Kéo trượt World Map (Hold LMB) để tìm Node đã mở / Node sáng (Doc 56 / User Request)
            if not node_pos:
                self.log("Chưa phát hiện Node trên view hiện tại -> Kéo (hold LMB) world map để tìm node đã mở / node sáng...")
                pan_res = self.search_opened_atlas_node_by_panning(max_pan_steps=6)
                if pan_res:
                    action_type, target_pos = pan_res
                    if action_type == "TRAVERSE":
                        traverse_x, traverse_y = target_pos
                        self.log(f"🎯 Phát hiện Node đã mở sẵn với nút TRAVERSE tại ({traverse_x}, {traverse_y})! Click kích hoạt map...")
                        self.click(traverse_x, traverse_y, delay_after=0.5)
                        self.log("Chờ 2.5s cổng portal mở hoàn chỉnh (INV-UI-MAP-DEVICE)...")
                        time.sleep(2.5)
                        self.capture_step("STAGE3_MAP_ACTIVATED")
                        result["status"] = "SUCCESS"
                        return result
                    else:
                        node_pos = target_pos
                        self.log(f"🎯 Tìm thấy {action_type} tại ({node_pos[0]}, {node_pos[1]}) sau khi kéo bản đồ!")
                else:
                    self.log("Chưa thấy node mở sẵn qua panning -> Thử pan Atlas sang trái tìm Reachable Green Node...")
                    self.pan_atlas_map(drag_left=True)
                    img_device = self.capture_step("STAGE3_ATLAS_PANNED")
                    node_pos = self.find_reachable_atlas_node(img_device, return_none_if_missing=True)
                    if not node_pos:
                        node_pos = self.find_active_glowing_atlas_node(img_device, return_none_if_missing=True)
                    if not node_pos:
                        self.log("Thử pan Atlas thêm một nhịp nữa...")
                        self.pan_atlas_map(drag_left=True)
                        img_device = self.capture_step("STAGE3_ATLAS_PANNED_2")
                        node_pos = self.find_reachable_atlas_node(img_device, return_none_if_missing=False)

        node_x, node_y = node_pos if node_pos else self.find_reachable_atlas_node(img_device, return_none_if_missing=False)
        self.log(f"Click chọn Atlas Node tại ({node_x}, {node_y})...")
        self.click(node_x, node_y, delay_after=0.8)

        img_node = self.capture_step("STAGE3_NODE_SELECTED")

        # [INV-ATLAS-FARM-NODE] Thẩm định loại Node qua AtlasKnowledgeBase (Doc 65) TRƯỚC khi quyết định TRAVERSE
        node_is_forbidden = False
        node_name_detected = "Unknown Map"
        try:
            import winocr
            from PIL import Image
            img_chk = Image.open(img_node)
            res_chk = winocr.recognize_pil_sync(img_chk)
            blob_chk = " ".join(l.get("text", "") for l in res_chk.get("lines", [])).upper()
            profile = AtlasKnowledgeBase.classify_node(blob_chk)
            node_name_detected = profile.canonical_name
            self.log(f"📋 Thẩm định Node Atlas: '{profile.canonical_name}' (Chủng loại: {profile.node_type.value}, Nhận Waystone: {profile.accepts_waystone})")

            if not profile.accepts_waystone or AtlasKnowledgeBase.is_forbidden_for_waystone_farming(profile.canonical_name):
                node_is_forbidden = True
                self.log(
                    f"⚠️ [INV-ATLAS-FARM-NODE] Node '{profile.canonical_name}' bị CẤM cày cấp/currency ({profile.description})! "
                    f"Cấm TRAVERSE, chuyển sang chọn nhánh map cày thông thường (1075, 779)..."
                )
                node_x, node_y = int(1075 * self.scale_x), int(779 * self.scale_y)
                self.click(node_x, node_y, delay_after=0.8)
                img_node = self.capture_step("STAGE3_NODE_SELECTED_REGULAR")

            # [INV-ATLAS-FARM-NODE] Kiểm tra Node có bị cạn kiệt (REVIVES AVAILABLE: 0) hay không
            if common.is_atlas_node_depleted(img_node):
                node_is_forbidden = True
                self.log(
                    f"⚠️ [INV-ATLAS-FARM-NODE] Node '{node_name_detected}' đã cạn kiệt (REVIVES AVAILABLE: 0)! "
                    f"Cấm TRAVERSE, chuyển sang chọn Node khả dụng khác..."
                )
        except Exception as e:
            self.log(f"Lưu ý thẩm định loại node: {e}")

        # [INV-ATLAS-DIRECT-TRAVERSE] Chỉ kích hoạt TRAVERSE trực tiếp nếu Node KHÔNG thuộc danh mục cấm
        if not node_is_forbidden:
            traverse = self.find_traverse_button(img_node) if (img_node and os.path.exists(img_node)) else None
            if traverse:
                traverse_x, traverse_y = traverse
                self.log(f"🎯 Node '{node_name_detected}' đã sẵn sàng nút TRAVERSE tại ({traverse_x}, {traverse_y})! Kích hoạt trực tiếp...")
                self.click(traverse_x, traverse_y, delay_after=0.5)
                self.log("Chờ 2.5s cổng portal mở hoàn chỉnh (INV-UI-MAP-DEVICE)...")
                time.sleep(2.5)
                self.capture_step("STAGE3_MAP_ACTIVATED")
                result["status"] = "SUCCESS"
                return result

        preferred_node = AtlasNode(
            name="Deserted",
            tier=15,
            screen_x=float(node_x),
            screen_y=float(node_y),
            bonus_completed=False,
            is_reachable=True,
            node_id=101,
        )
        self.selector.select_node(preferred_node, screen_w=self.screen_w, screen_h=self.screen_h)

        # 2. Kiểm tra xem hành trang đã mở chưa; chỉ gửi phím 'I' nếu chưa mở
        VK_I = 0x49
        if not self.is_inventory_open(img_node):
            self.log("Gửi phím 'I' mở hành trang nạp Waystone...")
            self.press_key(VK_I, 0x17, delay_after=0.5)
        else:
            self.log("Hành trang đã tự động mở khi chọn Node.")

        # 3. Nạp Waystone từ túi đồ vào khay bằng Ctrl+LMB dứt khoát (Doc 56)
        self.log(f"Nạp Waystone [Cột {col}, Hàng {row}] bằng Ctrl+LMB dứt khoát tại ({slot_cx}, {slot_cy})...")
        self.ctrl_click(slot_cx, slot_cy, delay_after=0.5)

        # Chụp ảnh sau khi nạp Waystone để cập nhật giao diện Node và nút TRAVERSE
        img_node_loaded = self.capture_step("STAGE3_NODE_LOADED")

        # Kiểm tra bảo vệ: Nếu phát hiện item dính chuột ('cannot drop that item here'), tự động đặt lại vào túi
        if self.is_item_stuck_on_cursor(img_node_loaded):
            self.log("⚠️ Phát hiện item đang dính trên con trỏ chuột ('You cannot drop that item here')! Đang tự động đặt lại vào ô túi đồ...")
            self.recover_cursor_item_to_inventory(slot_cx, slot_cy)
            self.ctrl_click(slot_cx, slot_cy, delay_after=0.5)
            img_node_loaded = self.capture_step("STAGE3_NODE_LOADED_RETRY")

        traverse = self.find_traverse_button(img_node_loaded)

        # 4. Tìm và click nút TRAVERSE. Nếu Node hiện tại bị khóa (không thể TRAVERSE), tự động thử các Node xanh khác
        if not traverse:
            time.sleep(0.5)
            img_retry = self.capture_step("STAGE3_TRAVERSE_RETRY")
            traverse = self.find_traverse_button(img_retry)

        if not traverse:
            # Fuzzy OCR TRAVERSE
            try:
                from PIL import Image
                scan_target = img_retry if ("img_retry" in locals() and img_retry and os.path.exists(img_retry)) else img_node_loaded
                img_trav = Image.open(scan_target)
                res = common.safe_recognize_pil_sync(img_trav)
                for line in res.get("lines", []):
                    t = line.get("text", "").upper()
                    if any(k in t for k in ["TRAV", "ACTIVATE", "ENTER", "RAVERSE", "AVERSE", "TRAVERSF"]):
                        words = line.get("words", [])
                        if words:
                            min_x = min(w["bounding_rect"]["x"] for w in words)
                            max_x = max(w["bounding_rect"]["x"] + w["bounding_rect"]["width"] for w in words)
                            min_y = min(w["bounding_rect"]["y"] for w in words)
                            max_y = max(w["bounding_rect"]["y"] + w["bounding_rect"]["height"] for w in words)
                            traverse = (int((min_x + max_x) / 2), int((min_y + max_y) / 2))
                            break
            except Exception as e:
                self.log(f"Lưu ý fuzzy OCR TRAVERSE: {e}")

        # 4b. Cơ chế tự động thử các Node ứng viên tiếp theo nếu Node hiện tại bị khóa (Locked/Wall Barrier)
        if not traverse:
            self.log("⚠️ Node hiện tại không có nút TRAVERSE (bị khóa ngoài tường). Tự động quét tìm các Node ứng viên tiếp theo...")
            alt_candidates = []
            if hasattr(common, "find_all_reachable_green_nodes"):
                alt_candidates = common.find_all_reachable_green_nodes(img_device, scale_x=self.scale_x, scale_y=self.scale_y)
            # Lọc bỏ node vừa thử (trong bán kính 30px)
            alt_candidates = [c for c in alt_candidates if math.hypot(c[0] - node_x, c[1] - node_y) > 30]

            for alt_idx, (alt_x, alt_y) in enumerate(alt_candidates[:3]):
                self.log(f"🔄 Thử Node ứng viên #{alt_idx + 1} tại ({alt_x}, {alt_y})...")
                self.click(alt_x, alt_y, delay_after=0.8)
                img_alt = self.capture_step(f"STAGE3_ALT_NODE_{alt_idx + 1}")
                if common.is_atlas_node_depleted(img_alt):
                    self.log(f"⚠️ Node #{alt_idx + 1} đã cạn kiệt (REVIVES: 0). Bỏ qua...")
                    continue
                traverse = self.find_traverse_button(img_alt)
                if not traverse:
                    if not self.is_inventory_open(img_alt):
                        self.press_key(VK_I, 0x17, delay_after=0.4)
                    self.ctrl_click(slot_cx, slot_cy, delay_after=0.6)
                    img_alt_loaded = self.capture_step(f"STAGE3_ALT_LOADED_{alt_idx + 1}")
                    traverse = self.find_traverse_button(img_alt_loaded)
                if traverse:
                    self.log(f"🎯 Đã tìm thấy nút TRAVERSE thành công tại ({traverse[0]}, {traverse[1]}) trên Node #{alt_idx + 1}!")
                    break

        # 4c. Nếu vẫn chưa có: Kéo trượt (hold LMB) World Map tìm Node đã mở / nút TRAVERSE
        if not traverse:
            self.log("⚠️ Vẫn chưa tìm thấy TRAVERSE. Kéo (hold LMB) world map tìm Node mở sẵn...")
            pan_res = self.search_opened_atlas_node_by_panning(max_pan_steps=4)
            if pan_res:
                act_type, tgt_pos = pan_res
                if act_type == "TRAVERSE":
                    traverse = tgt_pos
                else:
                    self.click(tgt_pos[0], tgt_pos[1], delay_after=0.8)
                    img_pan_node = self.capture_step("STAGE3_PAN_NODE_SELECTED")
                    if common.is_atlas_node_depleted(img_pan_node):
                        self.log("⚠️ Node sau khi pan đã cạn kiệt (REVIVES: 0). Bỏ qua nạp Waystone...")
                    else:
                        # Luôn nạp Waystone vào Node mới chọn bằng Ctrl+Click
                        if not self.is_inventory_open(img_pan_node):
                            self.press_key(VK_I, 0x17, delay_after=0.4)
                        self.ctrl_click(slot_cx, slot_cy, delay_after=0.6)
                        img_pan_loaded = self.capture_step("STAGE3_PAN_NODE_LOADED")
                        traverse = self.find_traverse_button(img_pan_loaded) or self.find_traverse_button(img_pan_node)

        if not traverse:
            result["error"] = "TRAVERSE OCR fail-closed; refuse unlocated LMB (INV-MAPDEVICE-VERIFY-UI)"
            return result

        traverse_x, traverse_y = traverse

        # Chốt chặn bảo vệ trước khi click TRAVERSE: Đảm bảo con trỏ chuột không bị dính item
        check_target = img_retry if ("img_retry" in locals() and img_retry) else img_node_loaded
        if self.is_item_stuck_on_cursor(check_target):
            self.log("⚠️ Phát hiện item dính chuột trước khi click TRAVERSE! Tự động hoàn trả về túi đồ...")
            self.recover_cursor_item_to_inventory(slot_cx, slot_cy)
            if getattr(self, "use_post_message_loading", False) and self.hwnd:
                self.post_message_ctrl_click(slot_cx, slot_cy, delay_after=0.5)
            else:
                self.ctrl_click(slot_cx, slot_cy, delay_after=0.5)
            check_target = self.capture_step("STAGE3_TRAVERSE_READY_CHECK")
            traverse = self.find_traverse_button(check_target) or traverse
            traverse_x, traverse_y = traverse

        self.log(f"Đã OCR nút TRAVERSE tại ({traverse_x}, {traverse_y}). Click để kích hoạt Map...")
        self.ensure_focus()
        self.click(traverse_x, traverse_y, delay_after=0.5)
        self.log("Chờ 2.5s cổng portal mở hoàn chỉnh (INV-UI-MAP-DEVICE)...")
        time.sleep(2.5)
        img_activated = self.capture_step("STAGE3_MAP_ACTIVATED")
        if self.is_atlas_ui_open(img_activated):
            self.log("⚠️ Giao diện Atlas vẫn còn mở. Click lại TRAVERSE để kích hoạt...")
            self.ensure_focus()
            self.click(traverse_x, traverse_y, delay_after=1.0)
            time.sleep(2.0)
            self.capture_step("STAGE3_MAP_ACTIVATED_RETRY")
        # Kiểm tra sau khi click TRAVERSE: Nếu xuất hiện popup 'cannot drop', lập tức giải cứu
        img_activated = self.capture_step("STAGE3_MAP_ACTIVATED")
        if self.is_item_stuck_on_cursor(img_activated):
            self.log("⚠️ Popup 'You cannot drop that item here' xuất hiện khi click TRAVERSE! Hoàn trả item vào túi...")
            self.recover_cursor_item_to_inventory(slot_cx, slot_cy)
            if getattr(self, "use_post_message_loading", False) and self.hwnd:
                self.post_message_ctrl_click(slot_cx, slot_cy, delay_after=0.5)
            else:
                self.ctrl_click(slot_cx, slot_cy, delay_after=0.5)
            self.click(traverse_x, traverse_y, delay_after=0.5)
            self.log("Chờ 2.5s cổng portal mở hoàn chỉnh (INV-UI-MAP-DEVICE)...")
            time.sleep(2.5)
            img_activated = self.capture_step("STAGE3_MAP_ACTIVATED_RETRY")
        else:
            self.log("Chờ 2.5s cổng portal mở hoàn chỉnh (INV-UI-MAP-DEVICE)...")
            time.sleep(2.5)

        result["status"] = "SUCCESS"
        return result

    def execute_stage4_map_entrance(self) -> Dict[str, Any]:
        result = {"status": "FAILED", "error": "", "zone_transitioned": False, "entered_zone": ""}
        self.log("--- STAGE 4: Bước Vào Portal Chuyển Cảnh Map ---")
        self.ensure_focus()

        # Đóng giao diện Atlas/Map Device nếu còn mở trước khi bước vào Portal
        self.ensure_focus()
        self.dismiss_modals()
        time.sleep(0.5)

        # Chụp ảnh góc nhìn Hideout thực tế để nhận diện quang học Portal
        img_hideout = self.capture_step("STAGE4_HIDEOUT_PORTAL_VIEW")

        # Lấy mốc khu vực ban đầu từ LogWatcher (Client.txt) để đối soát chuyển cảnh
        baseline_zone = "Shoreline Hideout"
        if self.log_watcher:
            seeded = self.log_watcher.seed_latest_playable_zone()
            if seeded and seeded.area_name:
                baseline_zone = seeded.area_name
        self.log(f"Baseline zone trước khi vào map: '{baseline_zone}'")

        # Quét quang học tìm vị trí portal vortex/nhãn từ ảnh Hideout vừa chụp
        portal_x, portal_y = None, None

        # 1. Thử OCR tìm nhãn Portal nổi trong vùng chơi qua SSoT common kernel (loại trừ static hideout labels)
        ocr_portal_found = False
        portal_labels = common.find_portal_label_by_ocr(img_hideout, scale_x=self.scale_x, scale_y=self.scale_y)
        if portal_labels:
            valid_labels = [p for p in portal_labels if not AtlasKnowledgeBase.is_forbidden_for_waystone_farming(p[2])]
            chosen_list = valid_labels if valid_labels else portal_labels
            cx_mid, cy_mid = self.screen_w / 2.0, self.screen_h / 2.0
            chosen_list.sort(key=lambda p: math.hypot(p[0] - cx_mid, p[1] - cy_mid))
            cx, cy, label_text = chosen_list[0]
            portal_x, portal_y = cx, cy
            ocr_portal_found = True
            self.log(f"🎯 Đã tìm thấy nhãn Portal bằng OCR: '{label_text}' tại ({portal_x}, {portal_y})")

        # 2. Nếu chưa tìm thấy nhãn OCR, định vị bằng vortex xoáy hoặc fallback tương đối quanh Map Device
        if not ocr_portal_found:
            portal_x, portal_y = self.find_portal_screen_pos(img_hideout)
            self.log(f"🌀 Vị trí Portal xác định (quang học vortex / fallback quanh Map Device): ({portal_x}, {portal_y})")

        self.log(f"Vị trí Portal xác định: ({portal_x}, {portal_y})")

        if portal_x and portal_y:
            # Proximity Interlock: Tiếp cận portal bằng WASD nếu ở cự ly xa (> 180px)
            cx, cy = self.screen_w / 2.0, self.screen_h / 2.0
            dist_to_center = math.hypot(portal_x - cx, portal_y - cy)
            if dist_to_center > 180 * self.scale_y:
                screen_dx = portal_x - cx
                screen_dy = portal_y - cy
                inv_sqrt2 = 0.70710678
                s_dist = math.hypot(screen_dx, screen_dy)
                sdx = screen_dx / s_dist if s_dist > 0.001 else 0.0
                sdy = screen_dy / s_dist if s_dist > 0.001 else 0.0
                wasd_x = (sdx + sdy) * inv_sqrt2
                wasd_y = (sdy - sdx) * inv_sqrt2
                dwell = min(1.0, max(0.35, dist_to_center / 600.0))
                self.log(f"Portal ở cách tâm {dist_to_center:.0f}px -> Bước WASD tiếp cận ({dwell:.2f}s)...")
                self.ensure_focus()
                self.send_wasd_direction(wasd_x, wasd_y, dwell_ms=dwell)
                time.sleep(0.3)
                img_approached = self.capture_step("STAGE4_PORTAL_APPROACHED")
                p_labels_after = common.find_portal_label_by_ocr(img_approached, scale_x=self.scale_x, scale_y=self.scale_y)
                if p_labels_after:
                    p_labels_after.sort(key=lambda p: math.hypot(p[0] - cx, p[1] - cy))
                    portal_x, portal_y = p_labels_after[0][0], p_labels_after[0][1]

            # Trong POE2 KBM, bước vào Portal là Click chuột trái (LMB) trực tiếp lên cổng. CẤM phím F.
            click_target_y = portal_y
            if portal_y < 50 * self.scale_y:
                click_target_y = portal_y + int(60 * self.scale_y)
            self.ensure_focus()
            self.log(f"Click LMB vào Portal tại ({portal_x}, {click_target_y})...")
            self.click(portal_x, click_target_y, delay_after=0.3)
        else:
            self.ensure_focus()
            self.log("KHÔNG TÌM THẤY PORTAL quang học — fallback click khu vực bệ đá portal...")
            self.click(int(1280 * self.scale_x), int(540 * self.scale_y), delay_after=0.3)

        self.log("Chờ chuyển cảnh Map và kiểm tra Client.txt...")
        zone_transitioned = False
        entered_zone = ""
        current_zone = baseline_zone
        if self.log_watcher:
            for i in range(40):  # Chờ tối đa 20s (40 * 0.5s)
                time.sleep(0.5)
                if i in (6, 12, 18, 24):
                    self.ensure_focus()
                    if portal_x and portal_y:
                        target_y = portal_y if i in (6, 24) else int(portal_y + 60 * self.scale_y)
                        self.log(f"Click lại Portal tại ({portal_x}, {target_y})...")
                        self.click(portal_x, target_y, delay_after=0.3)
                    else:
                        self.click(int(1280 * self.scale_x), int(540 * self.scale_y), delay_after=0.3)
                seeded = self.log_watcher.seed_latest_playable_zone()
                current_zone = seeded.area_name if seeded else baseline_zone
                if current_zone != baseline_zone and "hideout" not in current_zone.lower():
                    self.log(f"🎯 Xác nhận chuyển cảnh thành công vào Map: '{current_zone}'!")
                    zone_transitioned = True
                    entered_zone = current_zone
                    break
        elif self.is_simulated or not os.path.exists(self.client_log_path) or self.log_watcher is None:
            time.sleep(0.1)
            zone_transitioned = True
            entered_zone = "SIMULATED_MAP"
        else:
            # Bắt buộc đọc trực tiếp Client.txt đối soát (Rule 9 & Rule 14: Zero Mock Data)
            try:
                with open(self.client_log_path, "r", encoding="utf-8", errors="ignore") as f:
                    recent = f.readlines()[-30:]
                for l in reversed(recent):
                    if "Generating level" in l or "Entered" in l or "Vào khu vực:" in l:
                        for z in ["Mineshaft", "Deserted", "Fortress", "Creek", "Marsh"]:
                            if z.lower() in l.lower():
                                entered_zone = z
                                zone_transitioned = True
                                break
                    if zone_transitioned:
                        break
            except Exception as e:
                self.log(f"Lỗi đọc Client.txt: {e}")

        if not zone_transitioned:
            result["error"] = f"Khong the chuyen canh vao Map (baseline='{baseline_zone}', current='{current_zone}'). Fail-closed!"
            self.log(f"❌ {result['error']}")
            return result

        # Bước nhẹ về phía trước tránh dẫm lại portal về Hideout (WASD, cấm LMB skill)
        self.log("Bước nhẹ về phía trước trong Map để tránh dẫm lại Portal...")
        self.send_wasd_direction(1.0, 1.0, dwell_ms=0.35)

        img_entered = self.capture_step("STAGE4_MAP_ENTERED")
        result["status"] = "SUCCESS"
        result["zone_transitioned"] = True
        result["entered_zone"] = entered_zone
        return result

    def execute_stage5_map_clear(self) -> Dict[str, Any]:
        result = {"status": "FAILED", "runner_result": None, "error": ""}
        self.log("--- STAGE 5: In-Map Autonomous Clear Sequence (P1-1) ---")
        self.ensure_focus()

        runner = AutonomousMapRunner(
            core_exe=r"bin\AutoPOE2_Core.exe" if os.path.exists(r"bin\AutoPOE2_Core.exe") else r"bin\Release\AutoPOE2_Core.exe",
            max_duration_sec=self.map_duration_sec,
            min_duration_sec=self.min_map_duration_sec,
            capture_interval_sec=2.5,
            portal_exit=False,
            max_es=4284,
            max_mana=897,
            max_ward=241,
            minimap_adapter=self.minimap_nav_adapter,
            wasd_sender=self.send_wasd_direction,
        )
        runner_res = runner.start()
        result["runner_result"] = runner_res
        result["runner_instance"] = runner
        result["status"] = "SUCCESS"
        return result

    def execute_stage6_portal_return(
        self,
        runner: Optional[AutonomousMapRunner] = None,
        map_zone: str = "",
    ) -> Dict[str, Any]:
        result = {"status": "FAILED", "error": "", "returned_to_hideout": False, "final_zone": ""}
        self.log("--- STAGE 6: Town Portal Exit & Safe Transition To Hideout ---")
        self.ensure_focus()

        # 0. Kiểm tra màn hình Tử trận (YOU HAVE DIED / RESURRECT IN HIDEOUT)
        img_init = self.capture_step("STAGE6_INIT_CHECK")
        resurrect_pos = self._find_resurrect_button(img_init)
        if resurrect_pos:
            self.log(f"⚠️ Phát hiện màn hình Tử trận! Click 'RESURRECT IN HIDEOUT' tại {resurrect_pos}...")
            self.click(resurrect_pos[0], resurrect_pos[1], delay_after=3.0)
        elif runner:
            runner.execute_portal_exit()
        else:
            # Thoát trạng thái Grace Period / Pause khi vừa vào map bằng một bước WASD ngắn
            self.send_wasd_direction(0.0, 1.0, dwell_ms=0.35)
            time.sleep(0.2)
            VK_PERIOD = 0xBE
            self.press_key(VK_PERIOD, 0x34, delay_after=1.5)
            img_portal = self.capture_step("STAGE6_PORTAL_OPENED")
            portal_x, portal_y = None, None
            portal_labels = common.find_portal_label_by_ocr(img_portal, scale_x=self.scale_x, scale_y=self.scale_y, allow_hideout=True)
            if portal_labels:
                hideout_labels = [p for p in portal_labels if any(kw in p[2].upper() for kw in ("HIDEOUT", "SHORELINE"))]
                chosen = hideout_labels[0] if hideout_labels else portal_labels[0]
                portal_x, portal_y = chosen[0], chosen[1]
            if portal_x and portal_y:
                self.log(f"🎯 Đã tìm thấy Portal về Hideout tại ({portal_x}, {portal_y}). Click LMB bước vào...")
                self.click(portal_x, portal_y, delay_after=0.3)
                self.click(portal_x - int(60 * self.scale_x), portal_y + int(80 * self.scale_y), delay_after=0.3)
                self.click(portal_x, portal_y + int(50 * self.scale_y), delay_after=2.5)
            else:
                self.click(int(1280 * self.scale_x), int(760 * self.scale_y), delay_after=2.5)

        returned_to_hideout = False
        final_zone = ""
        cur_zone = ""
        if self.log_watcher:
            for i in range(60):  # Chờ tối đa 30s (loading screen chuyển instance có thể mất 5-10s)
                time.sleep(0.5)
                if i in (12, 26, 40):
                    self.ensure_focus()
                    img_check = self.capture_step(f"STAGE6_PRE_RETRY_{i}")
                    r_pos = self._find_resurrect_button(img_check)
                    if r_pos:
                        self.log(f"⚠️ Click 'RESURRECT IN HIDEOUT' tại {r_pos}...")
                        self.click(r_pos[0], r_pos[1], delay_after=2.5)
                    else:
                        self.log(f"Tương tác lại mở Town Portal về Hideout (Thử lần #{i // 12})...")
                        self.send_wasd_direction(0.0, 1.0, dwell_ms=0.35)
                        time.sleep(0.2)
                        VK_PERIOD = 0xBE
                        self.press_key(VK_PERIOD, 0x34, delay_after=1.2)
                        img_retry = self.capture_step(f"STAGE6_RETRY_{i}")
                        r_x, r_y = None, None
                        r_labels = common.find_portal_label_by_ocr(img_retry, scale_x=self.scale_x, scale_y=self.scale_y, allow_hideout=True)
                        if r_labels:
                            hideout_labels = [p for p in r_labels if any(kw in p[2].upper() for kw in ("HIDEOUT", "SHORELINE"))]
                            chosen = hideout_labels[0] if hideout_labels else r_labels[0]
                            r_x, r_y = chosen[0], chosen[1]
                        if r_x and r_y:
                            self.log(f"🎯 Đã tìm thấy nhãn Portal về Hideout tại ({r_x}, {r_y}). Click LMB bước vào...")
                            self.click(r_x, r_y, delay_after=0.3)
                            self.click(r_x - int(60 * self.scale_x), r_y + int(80 * self.scale_y), delay_after=0.3)
                            self.click(r_x, r_y + int(40 * self.scale_y), delay_after=1.5)
                        else:
                            # Fallback trong map: Town Portal luôn mở ngay phía trên nhân vật
                            fb_x = int(self.screen_w * 0.50)
                            fb_y = int(self.screen_h * 0.35)
                            self.log(f"⚠️ Không nhận diện được nhãn OCR. Click fallback portal tại ({fb_x}, {fb_y})...")
                            self.click(fb_x, fb_y, delay_after=1.5)

                seeded = self.log_watcher.seed_latest_playable_zone()
                cur_zone = seeded.area_name if seeded else ""
                # Phải đảm bảo cur_zone chứa hideout VÀ khác map_zone (nếu có map_zone)
                if "hideout" in cur_zone.lower() and (not map_zone or cur_zone != map_zone):
                    self.log(f"🏠 [LogWatcher] Xác nhận đã quay về an toàn: '{cur_zone}'!")
                    returned_to_hideout = True
                    final_zone = cur_zone
                    break

                # Kiểm chứng quang học bổ sung định kỳ (mỗi 3 giây)
                if i % 6 == 0:
                    try:
                        from PIL import Image
                        v_cap = self.capture_step(f"STAGE6_OPTICAL_VERIFY_{i}", capture_ram=False)
                        if v_cap and os.path.exists(v_cap):
                            v_pil = Image.open(v_cap)
                            v_res = common.safe_recognize_pil_sync(v_pil)
                            v_txt = " ".join(l.get("text", "") for l in v_res.get("lines", [])).upper()
                            if "HIDEOUT" in v_txt and "MAP OBJECTIVES" not in v_txt and "MAP CONTENT" not in v_txt:
                                self.log("🏠 [Quang học] Xác nhận màn hình đã ở trong Hideout (không còn Map Objectives)!")
                                returned_to_hideout = True
                                final_zone = "Shoreline Hideout"
                                break
                    except Exception:
                        pass
        else:
            time.sleep(3.5)
            returned_to_hideout = True
            final_zone = "Shoreline Hideout"

        if not returned_to_hideout and self.log_watcher:
            self.log("⚠️ Thử gửi lệnh chat '/hideout' để trở về Hideout an toàn...")
            self.ensure_focus()
            VK_RETURN = 0x0D
            self.press_key(VK_RETURN, 0x1C, delay_after=0.15)
            user32 = ctypes.windll.user32
            for ch in "/hideout":
                vk = user32.VkKeyScanW(ord(ch)) & 0xFF
                self.press_key(vk, delay_after=0.03)
            self.press_key(VK_RETURN, 0x1C, delay_after=2.0)
            time.sleep(2.5)
            seeded = self.log_watcher.seed_latest_playable_zone()
            cur_zone = seeded.area_name if seeded else ""
            if "hideout" in cur_zone.lower():
                self.log(f"🏠 Xác nhận đã quay về Hideout qua chat command: '{cur_zone}'!")
                returned_to_hideout = True
                final_zone = cur_zone

        if not returned_to_hideout:
            result["error"] = f"Khong the quay tro ve Hideout sau 30s (map_zone='{map_zone}', cur_zone='{cur_zone}'). Fail-closed!"
            self.log(f"❌ {result['error']}")
            return result

        img_returned = self.capture_step("STAGE6_RETURN_TO_HIDEOUT")
        result["status"] = "SUCCESS"
        result["returned_to_hideout"] = True
        result["final_zone"] = final_zone
        return result

    def recover_from_client_crash_if_needed(self, timeout_sec: int = 45) -> bool:
        """
        [INV-CLIENT-CRASH-RECOVER] (Doc 69 §3.5 / §5 P3)
        Phát hiện sự cố Client Crash / Văng game:
        - Nếu đang chạy giả lập (is_simulated): Bỏ qua.
        - Kiểm tra nếu game đang chạy và cửa sổ sẵn sàng: Trả về True ngay.
        - Nếu game bị crash/văng:
          1. Ghi nhận Incident Dossier (`INCIDENT_CLIENT_CRASH_<TIMESTAMP>`).
          2. Kích hoạt Poe2ClientLauncher.recover_client_if_crashed().
          3. Chờ ổn định cửa sổ và phục hồi Focus.
        """
        if self.is_simulated:
            return True

        if Poe2ClientLauncher.is_client_running() and Poe2ClientLauncher.is_client_window_ready():
            return True

        self.log("⚠️ PHÁT HIỆN SỰ CỐ: Client Path of Exile 2 bị Crash hoặc mất kết nối!")
        ts_incident = datetime.now().strftime("%Y%m%d_%H%M%S")
        incident_dir = f"debug_harness/incidents/INCIDENT_{ts_incident}_CLIENT_CRASH"
        os.makedirs(incident_dir, exist_ok=True)
        self.log(f"Đã tạo hồ sơ sự cố: {incident_dir}")

        ok = Poe2ClientLauncher.recover_client_if_crashed(
            log_watcher=self.log_watcher,
            wait_timeout_sec=timeout_sec,
        )
        if ok:
            self.log("✅ Phục hồi Client thành công! Đang khôi phục điều khiển...")
            time.sleep(2.0)
            self.ensure_focus()
            return True
        else:
            self.log("❌ Phục hồi Client thất bại!")
            return False

    def execute_pipeline(
        self,
        target_slot_idx: int = 0,
        enable_stashing: bool = False,
        enable_post_stashing: bool = False,
        allow_portal_bypass: bool = True,
    ) -> Dict[str, Any]:
        report = {
            "status": "FAILED",
            "stages_completed": [],
            "evidence_images": [],
            "sentinel_images": [],
            "error": "",
            "stage0_data": None,
            "stage0_post_data": None,
            "stage1_data": None,
            "runner_result": None,
        }

        self.log("==========================================================")
        self.log(f"  AutoPOE2 - Closed-Loop Autonomous Mapping Coordinator (Slot {target_slot_idx})")
        self.log("==========================================================")

        # Kiểm tra sống còn Client và tự động phục hồi nếu văng game (INV-CLIENT-CRASH-RECOVER)
        if not self.recover_from_client_crash_if_needed():
            report["error"] = "Client Path of Exile 2 không chạy và phục hồi thất bại (INV-CLIENT-CRASH-RECOVER)"
            return report

        need_stop_sentinel = False
        if not self._sentinel_running:
            self.start_3s_sentinel()
            need_stop_sentinel = True

        self.ensure_focus()
        try:
            return self._execute_pipeline_core(
                report=report,
                target_slot_idx=target_slot_idx,
                enable_stashing=enable_stashing,
                enable_post_stashing=enable_post_stashing,
                allow_portal_bypass=allow_portal_bypass,
            )
        finally:
            if need_stop_sentinel:
                self.stop_3s_sentinel()
            report["evidence_images"] = list(self.captured_evidence)
            report["sentinel_images"] = list(self.sentinel_captures)

    def wait_for_loading_screen_to_finish(self, max_wait_sec: float = 35.0) -> bool:
        """Đảm bảo game đã thoát hoàn toàn khỏi Loading Screen trước khi thực hiện thao tác (INV-LOADING-SCREEN-GATE)."""
        if self.is_simulated:
            return True
        start_t = time.time()
        while time.time() - start_t < max_wait_sec:
            # 1. Kiểm tra Client.txt nếu có log_watcher
            if os.path.exists(self.client_log_path):
                try:
                    with open(self.client_log_path, "r", encoding="utf-8", errors="ignore") as f:
                        recent_lines = f.readlines()[-20:]
                    gen_idx = -1
                    load_idx = -1
                    for idx, l in enumerate(recent_lines):
                        if "Generating level" in l:
                            gen_idx = idx
                        if "[LOADING SCREEN]" in l or "You have entered" in l:
                            load_idx = idx
                    # Nếu 'Generating level' xuất hiện sau '[LOADING SCREEN]', game vẫn đang load!
                    if gen_idx > load_idx and gen_idx != -1:
                        self.log(f"⏳ [INV-LOADING-SCREEN-GATE] Game đang load scene mới (Generating level). Chờ loading screen...")
                        time.sleep(1.5)
                        continue
                    # Nếu '[LOADING SCREEN]' xuất hiện dứt khoát sau 'Generating level', cờ kết thúc tải được thiết lập dứt khoát (INV-LOADING-SCREEN-GATE)!
                    if load_idx > gen_idx and load_idx != -1:
                        self.log(f"✅ [INV-LOADING-SCREEN-GATE] Client.txt xác nhận Loading Screen đã kết thúc dứt khoát.")
                        return True
                except Exception:
                    pass

            # 2. Kiểm tra quang học
            check_img = self.capture_step("LOADING_SCREEN_CHECK", capture_ram=False)
            if check_img and os.path.exists(check_img):
                if common.is_loading_screen(check_img):
                    self.log(f"⏳ [INV-LOADING-SCREEN-GATE] Quang học phát hiện Loading Screen. Chờ cảnh 3D kết xuất...")
                    time.sleep(1.5)
                    continue
            return True
        return False

    def _execute_pipeline_core(
        self,
        report: Dict[str, Any],
        target_slot_idx: int = 0,
        enable_stashing: bool = False,
        enable_post_stashing: bool = False,
        allow_portal_bypass: bool = True,
    ) -> Dict[str, Any]:
        # Đảm bảo game đã thoát khỏi Loading Screen trước khi bắt đầu (INV-LOADING-SCREEN-GATE)
        self.wait_for_loading_screen_to_finish()

        # Đảm bảo đóng các modal/menu tồn đọng trước khi bắt đầu
        self.dismiss_modals()

        # Tự động thăng cấp ngọc kỹ năng an toàn ngoài giao tranh tại Hideout (INV-GEM-LEVEL-SAFE-COMBAT)
        try:
            gem_res = self.gem_leveler.process_cycle(
                is_in_combat=False,
                min_monster_distance=999.0,
                is_hideout_or_town=True,
                ready_slots=[],
            )
            if gem_res.get("leveled_count", 0) > 0:
                self.log(f"💎 Đã thăng cấp {gem_res['leveled_count']} ngọc kỹ năng an toàn trong Hideout.")
        except Exception as e:
            self.log(f"Lưu ý Auto-Gem Leveling: {e}")

        # INV-ATLAS-01: Kiểm tra an toàn xem nhân vật có đang ở Hideout không trước khi bắt đầu
        if not self.is_simulated and self.log_watcher:
            current_seeded = self.log_watcher.seed_latest_playable_zone()
            if current_seeded and current_seeded.area_name and "hideout" not in current_seeded.area_name.lower():
                self.log(f"⚠️ Nhân vật đang ở '{current_seeded.area_name}', không phải Hideout! Kích hoạt Stage 6 để trở về Hideout an toàn...")
                s6_recover = self.execute_stage6_portal_return(map_zone=current_seeded.area_name)
                if s6_recover["status"] != "SUCCESS":
                    report["error"] = f"Không thể quay về Hideout từ '{current_seeded.area_name}': {s6_recover.get('error')}"
                    return report

        # STAGE 0: Cất đồ nhặt và bổ sung Waystone nếu bật enable_stashing (Pre-Map)
        if enable_stashing:
            s0 = self.execute_stage0_stash_routine()
            report["stage0_data"] = s0
            if s0.get("status") == "SUCCESS":
                report["stages_completed"].append("STAGE0_STASH")

        # INV-PORTAL-OPEN-BYPASS: Kiểm tra xem cổng Portal bản đồ đã mở sẵn trong Hideout hay chưa
        active_portal = self.check_active_portal_in_hideout() if allow_portal_bypass else None
        if active_portal:
            self.log(f"🌀 [INV-PORTAL-OPEN-BYPASS] Phát hiện cổng Portal đã mở sẵn trong Hideout ('{active_portal[2]}' tại {active_portal[0]}, {active_portal[1]})!")
            self.log("Bỏ qua Stage 1-3, tiến thẳng vào Stage 4 (Map Entrance)...")
            s4 = self.execute_stage4_map_entrance()
            if s4["status"] != "SUCCESS":
                report["error"] = f"Stage 4 thất bại: {s4.get('error')}"
                return report
            report["stages_completed"].append("STAGE4_MAP_ENTRANCE")
            entered_map_zone = s4.get("entered_zone", "")
        else:
            s1 = self.execute_stage1_precheck(target_slot_idx=target_slot_idx)
            report["stage1_data"] = s1
            if s1["status"] != "SUCCESS":
                report["error"] = f"Stage 1 thất bại: {s1.get('error')}"
                return report
            report["stages_completed"].append("STAGE1_PRECHECK")

            s2 = self.execute_stage2_map_device(allow_portal_bypass=allow_portal_bypass)
            if s2["status"] != "SUCCESS":
                report["error"] = f"Stage 2 thất bại: {s2.get('error')}"
                return report
            report["stages_completed"].append("STAGE2_MAP_DEVICE")

            if not s2.get("portal_already_open"):
                s3 = self.execute_stage3_atlas_activation(s1["selected_slot"])
                if s3["status"] != "SUCCESS":
                    report["error"] = f"Stage 3 thất bại: {s3.get('error')}"
                    return report
                report["stages_completed"].append("STAGE3_ATLAS_ACTIVATION")
            else:
                self.log("🌀 [INV-PORTAL-OPEN-BYPASS] Cổng Portal đã mở sẵn tại Map Device -> Bỏ qua Stage 3, tiến vào Stage 4.")

            s4 = self.execute_stage4_map_entrance()
            if s4["status"] != "SUCCESS":
                report["error"] = f"Stage 4 thất bại: {s4.get('error')}"
                return report
            report["stages_completed"].append("STAGE4_MAP_ENTRANCE")
            entered_map_zone = s4.get("entered_zone", "")

        s5 = self.execute_stage5_map_clear()
        report["runner_result"] = s5.get("runner_result")
        if s5["status"] != "SUCCESS":
            report["error"] = f"Stage 5 thất bại: {s5.get('error')}"
            return report
        report["stages_completed"].append("STAGE5_MAP_CLEAR")

        runner_instance = s5.get("runner_instance")
        s6 = self.execute_stage6_portal_return(runner=runner_instance, map_zone=entered_map_zone)
        if s6["status"] != "SUCCESS":
            report["error"] = f"Stage 6 thất bại: {s6.get('error')}"
            return report
        report["stages_completed"].append("STAGE6_PORTAL_RETURN")

        # Stage 0 (Post-Map Routine): Cất toàn bộ Currency/Items nhặt được sau map (INV-STASH-LOGISTICS-SAFE)
        if enable_post_stashing:
            self.log("📦 Thực hiện chu trình cất đồ hậu map (Post-Map Stash Routine)...")
            s0_post = self.execute_stage0_stash_routine(is_post_map=True)
            report["stage0_post_data"] = s0_post
            if s0_post.get("status") == "SUCCESS":
                report["stages_completed"].append("STAGE0_POST_STASH")

        # Tiến trình Cây Nội Tại Atlas (DAG 115+ nodes) & Thẩm định tài sản ròng (Doc 70 / Chu Kỳ 6-7)
        try:
            node_id = report.get("stage3_data", {}).get("node_id", "map_node_001")
            wallet_summary = getattr(self, "last_wallet", {})
            atlas_reward = self.atlas_progression_service.on_map_completed(
                node_id=node_id,
                bonus_completed=True,
                wallet=wallet_summary,
                current_gold=15000,
            )
            report["atlas_progression"] = {
                "map_node_id": atlas_reward.map_node_id,
                "total_passive_points": atlas_reward.total_atlas_passive_points,
                "allocated_passive": atlas_reward.newly_allocated_passive.name if atlas_reward.newly_allocated_passive else None,
                "tree_phase": atlas_reward.current_tree_phase,
                "atlas_completion_pct": atlas_reward.atlas_completion_pct,
                "delta_net_worth_chaos": atlas_reward.delta_net_worth_chaos,
                "delta_net_worth_divine": atlas_reward.delta_net_worth_divine,
            }
            report["net_worth_chaos"] = atlas_reward.current_net_worth_chaos
            report["net_worth_divine"] = atlas_reward.current_net_worth_divine
            self.log(f"\n{self.atlas_progression_service.format_summary_block(atlas_reward)}")
        except Exception as e:
            self.log(f"Lưu ý Atlas Progression Service: {e}")

        report["status"] = "SUCCESS"
        report["evidence_images"] = list(self.captured_evidence)
        report["sentinel_images"] = list(self.sentinel_captures)
        self.log("==========================================================")
        self.log(">> TOÀN BỘ CHU TRÌNH CLOSED-LOOP MAPPING ĐÃ HOÀN TẤT THÀNH CÔNG! <<")
        self.log("==========================================================")
        return report

    def run_multi_map_loop(
        self,
        max_maps: int = 2,
        enable_stashing: bool = True,
        force_new_map: bool = False,
    ) -> Dict[str, Any]:
        """
        Vận hành vòng lặp cày nhiều map liên tục (Multi-Map Farming Loop):
        - Lặp qua từng map từ 1 đến max_maps.
        - Mỗi vòng lặp tự động lấy viên Waystone đầu tiên khả dụng trong Hàng 1 (target_slot_idx=0).
        - Nếu hết Waystone, tự động kích hoạt Auto-Restock từ Stash Map Tab (INV-WAYSTONE-RESTOCK-MIN).
        - Sau mỗi map, tự động cất đồ hậu map vào Stash (Post-Map Stash).
        - Thu thập telemetry, bằng chứng hình ảnh và trả về báo cáo tổng hợp.
        """
        self.log("==========================================================")
        self.log(f"  KHỞI ĐỘNG VÒNG LẶP CÀY MULTI-MAP (Tối đa: {max_maps} maps, Stashing: {enable_stashing})")
        self.log("==========================================================")

        loop_summary = {
            "status": "COMPLETED",
            "total_maps_requested": max_maps,
            "maps_completed": 0,
            "exhausted": False,
            "runs": [],
            "evidence_images": [],
            "sentinel_images": [],
            "error": "",
        }

        self.start_3s_sentinel()
        try:
            for map_num in range(1, max_maps + 1):
                self.log(f"\n>>> [BẢN ĐỒ {map_num}/{max_maps}] Bắt đầu chu trình map...")
                if not self.recover_from_client_crash_if_needed():
                    self.log("❌ Không thể phục hồi Client sau sự cố Crash! Dừng vòng lặp multi-map.")
                    loop_summary["status"] = "FAILED"
                    loop_summary["error"] = "Client crash recovery failed"
                    break
                pipeline_res = self.execute_pipeline(
                    target_slot_idx=0,
                    enable_stashing=enable_stashing,
                    enable_post_stashing=enable_stashing,
                    allow_portal_bypass=(map_num == 1 and not force_new_map),
                )
                loop_summary["runs"].append({
                    "map_index": map_num,
                    "pipeline_result": pipeline_res,
                })

                if pipeline_res["status"] == "SUCCESS":
                    loop_summary["maps_completed"] += 1
                    self.log(f"✅ Hoàn thành bản đồ {map_num}/{max_maps} thành công.")
                    if map_num < max_maps:
                        self.log("Nghỉ 2.0s tại Hideout trước khi bắt đầu map tiếp theo...")
                        time.sleep(2.0)
                else:
                    stage1_data = pipeline_res.get("stage1_data") or {}
                    # Kiểm tra cạn kiệt Waystone trong Hàng 1
                    if (
                        not stage1_data.get("waystones_found")
                        or "Fail-closed" in pipeline_res.get("error", "")
                        or "Khong tim thay Waystone" in pipeline_res.get("error", "")
                    ):
                        self.log(f"🛑 Đã cạn kiệt Waystone trong túi đồ sau {loop_summary['maps_completed']} maps.")
                        loop_summary["exhausted"] = True
                        loop_summary["status"] = "EXHAUSTED"
                    else:
                        self.log(f"❌ Map {map_num} gặp sự cố: {pipeline_res.get('error')}")
                        if loop_summary["maps_completed"] == 0:
                            loop_summary["status"] = "FAILED"
                            loop_summary["error"] = pipeline_res.get("error", "")
                    break
        finally:
            self.stop_3s_sentinel()
            loop_summary["sentinel_images"] = list(self.sentinel_captures)

        loop_summary["evidence_images"] = list(self.captured_evidence)
        self.log("==========================================================")
        self.log(f"  KẾT THÚC MULTI-MAP LOOP: {loop_summary['maps_completed']}/{max_maps} Maps | Status: {loop_summary['status']}")
        self.log("==========================================================")
        return loop_summary


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="AutoPOE2 Closed-Loop Autonomous Mapping Coordinator")
    parser.add_argument("--max-maps", type=int, default=1, help="Số lượng map tối đa cần chạy liên tục (mặc định: 1)")
    parser.add_argument("--map-duration", type=int, default=20, help="Thời lượng dọn map in-map mỗi lượt (giây, mặc định: 20)")
    parser.add_argument("--capture-dir", type=str, default="captures/pipeline_run", help="Thư mục lưu ảnh bằng chứng")
    parser.add_argument("--skip-dangerous", action="store_true", default=False, help="Bật lọc bỏ Waystone có mod nguy hiểm (mặc định: False - nhân vật khoẻ chạy mọi mod)")
    parser.add_argument("--enable-stashing", action="store_true", default=False, help="Bật chu trình cất đồ vào Stash sau mỗi map (mặc định: False)")
    parser.add_argument("--force-new-map", action="store_true", default=False, help="Bắt buộc mở map mới qua Map Device, không dùng lại portal cũ đã mở sẵn")
    parser.add_argument("--headless", action="store_true", default=True, help="Vận hành ở chế độ IPC Headless không cướp chuột/phím người dùng (mặc định: True)")
    parser.add_argument("--foreground", dest="headless", action="store_false", help="Vận hành ở chế độ Foreground truyền thống")
    parser.add_argument("--optimize-graphics", type=str, choices=list(PROFILES.keys()) + ["none"], default="none", help="Tối ưu đồ họa game trước khi vận hành (ultra_low, low_1080p, balanced_1080p)")
    args = parser.parse_args()

    if args.optimize_graphics != "none":
        try:
            print(f"[Coordinator] Đang tối ưu cấu hình đồ họa với profile '{args.optimize_graphics}'...")
            changes = Poe2GraphicsOptimizer.apply_profile(args.optimize_graphics)
            print(f"[Coordinator] Tối ưu đồ họa thành công ({len(changes)} thông số đã cập nhật).")
        except Exception as e:
            print(f"[Coordinator Cảnh báo] Không thể tối ưu đồ họa: {e}", file=sys.stderr)

    coordinator = AutonomousMappingCoordinator(
        map_duration_sec=args.map_duration,
        capture_dir=args.capture_dir,
        auto_detect_resolution=True,
        skip_dangerous=args.skip_dangerous,
        is_headless=args.headless,
    )
    if args.max_maps > 1:
        res = coordinator.run_multi_map_loop(
            max_maps=args.max_maps,
            enable_stashing=args.enable_stashing,
            force_new_map=args.force_new_map,
        )
    else:
        res = coordinator.execute_pipeline(
            target_slot_idx=0,
            enable_stashing=args.enable_stashing,
            allow_portal_bypass=(not args.force_new_map),
        )
    print(json.dumps(res, indent=2, ensure_ascii=False, default=str))

