from __future__ import annotations

import ctypes as c
from ctypes import wintypes as w
import math
import time

from PIL import ImageGrab

from .errors import Cancelled, GameClientClosed, MiniError, InputInterrupted, ObservationUnavailable, require
from .coordinates import client_point, is_fixed_profile_size, is_half_window, profile_name, top_left_region
from .coordinates import AUTOMATION_LAYOUT_MODE, region_center
from .interaction_regions import safe_click_point, click_is_forbidden
from .timing import input_delay
from . import execution_rules
from .route_geometry import screen_point
from .game_ui_settings import GameUiSettingsReader
from .coordinate_store import CoordinateRef
from .ui_route_runtime import UiRouteRuntime


user32 = c.WinDLL("user32", use_last_error=True)
kernel32 = c.WinDLL("kernel32", use_last_error=True)


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


class POINT(c.Structure):
    _fields_ = [("x", c.c_long), ("y", c.c_long)]


class LASTINPUTINFO(c.Structure):
    _fields_ = [("cbSize", w.UINT), ("dwTime", w.DWORD)]


class UnsentInputInterrupted(InputInterrupted):
    """Fresh user activity/focus change before an input's press boundary."""


def _grab_game_region(bounds):
    try:
        from .screen_capture import capture_region
        return capture_region(bounds)
    except OSError as exc:
        raise ObservationUnavailable("게임 화면 캡처를 읽지 못했습니다. 같은 관측을 다시 확인합니다.") from exc


PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
WM_KEYDOWN, WM_KEYUP = 0x0100, 0x0101
VK = {"SPACE": 0x20, "ESC": 0x1B, "ENTER": 0x0D, "B": 0x42, "C": 0x43, "T": 0x54, "K": 0x4B}
_FISHING_KEYS = {"S": (0x53, 0.15), "W": (0x57, 0.15), "SPACE": (0x20, 0.06)}


class KEYBDINPUT(c.Structure):
    _fields_ = [("wVk", w.WORD), ("wScan", w.WORD), ("dwFlags", w.DWORD),
                ("time", w.DWORD), ("dwExtraInfo", c.c_size_t)]


class MOUSEINPUT(c.Structure):
    _fields_ = [("dx", w.LONG), ("dy", w.LONG), ("mouseData", w.DWORD),
                ("dwFlags", w.DWORD), ("time", w.DWORD), ("dwExtraInfo", c.c_size_t)]


class INPUTUNION(c.Union):
    _fields_ = [("ki", KEYBDINPUT), ("mi", MOUSEINPUT)]


class INPUT(c.Structure):
    _anonymous_ = ("value",)
    _fields_ = [("type", w.DWORD), ("value", INPUTUNION)]


user32.SendInput.argtypes = [w.UINT, c.POINTER(INPUT), c.c_int]
user32.SendInput.restype = w.UINT

user32.IsWindow.argtypes = [w.HWND]
user32.IsWindow.restype = w.BOOL
user32.IsWindowVisible.argtypes = [w.HWND]
user32.IsWindowVisible.restype = w.BOOL
user32.GetWindowThreadProcessId.argtypes = [w.HWND, c.POINTER(w.DWORD)]
user32.GetWindowThreadProcessId.restype = w.DWORD
user32.GetClientRect.argtypes = [w.HWND, c.POINTER(RECT)]
user32.GetClientRect.restype = w.BOOL
user32.ClientToScreen.argtypes = [w.HWND, c.POINTER(POINT)]
user32.ClientToScreen.restype = w.BOOL
user32.SetForegroundWindow.argtypes = [w.HWND]
user32.SetForegroundWindow.restype = w.BOOL
user32.AllowSetForegroundWindow.argtypes = [w.DWORD]
user32.AllowSetForegroundWindow.restype = w.BOOL
user32.GetForegroundWindow.argtypes = []
user32.GetForegroundWindow.restype = w.HWND
user32.ShowWindow.argtypes = [w.HWND, c.c_int]
user32.ShowWindow.restype = w.BOOL
user32.GetAsyncKeyState.argtypes = [c.c_int]
user32.GetAsyncKeyState.restype = w.SHORT
user32.WindowFromPoint.argtypes = [POINT]
user32.WindowFromPoint.restype = w.HWND
user32.GetAncestor.argtypes = [w.HWND, w.UINT]
user32.GetAncestor.restype = w.HWND
user32.IsIconic.argtypes = [w.HWND]
user32.IsIconic.restype = w.BOOL
user32.SetCursorPos.argtypes = [c.c_int, c.c_int]
user32.SetCursorPos.restype = w.BOOL
user32.GetCursorPos.argtypes = [c.POINTER(POINT)]
user32.GetCursorPos.restype = w.BOOL
user32.mouse_event.argtypes = [w.DWORD, w.DWORD, w.DWORD, w.DWORD, c.c_size_t]
user32.mouse_event.restype = None
user32.keybd_event.argtypes = [w.BYTE, w.BYTE, w.DWORD, c.c_size_t]
user32.keybd_event.restype = None
user32.GetLastInputInfo.argtypes = [c.POINTER(LASTINPUTINFO)]
user32.GetLastInputInfo.restype = w.BOOL
try:
    user32.SetProcessDpiAwarenessContext.argtypes = [c.c_void_p]
    user32.SetProcessDpiAwarenessContext.restype = w.BOOL
    user32.GetDpiForWindow.argtypes = [w.HWND]
    user32.GetDpiForWindow.restype = w.UINT
except AttributeError:
    pass
kernel32.OpenProcess.argtypes = [w.DWORD, w.BOOL, w.DWORD]
kernel32.OpenProcess.restype = w.HANDLE
kernel32.QueryFullProcessImageNameW.argtypes = [w.HANDLE, w.DWORD, w.LPWSTR, c.POINTER(w.DWORD)]
kernel32.QueryFullProcessImageNameW.restype = w.BOOL
kernel32.CloseHandle.argtypes = [w.HANDLE]
kernel32.CloseHandle.restype = w.BOOL
kernel32.GetExitCodeProcess.argtypes = [w.HANDLE, c.POINTER(w.DWORD)]
kernel32.GetExitCodeProcess.restype = w.BOOL
kernel32.GetTickCount.argtypes = []
kernel32.GetTickCount.restype = w.DWORD


def _process_name(pid):
    handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
    if not handle:
        return ""
    try:
        size = w.DWORD(32768)
        buffer = c.create_unicode_buffer(size.value)
        if not kernel32.QueryFullProcessImageNameW(handle, 0, buffer, c.byref(size)):
            return ""
        return buffer.value.rsplit("\\", 1)[-1]
    finally:
        kernel32.CloseHandle(handle)


def process_image_name(pid):
    """Public identity boundary used by the passive Npcap reader."""
    return _process_name(pid)


def process_alive(pid):
    """Read-only tri-state: access failure is unknown, never assumed exited."""
    handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION,False,pid)
    if not handle:
        return False if c.get_last_error()==87 else None  # ERROR_INVALID_PARAMETER: PID gone
    try:
        code = w.DWORD()
        if not kernel32.GetExitCodeProcess(handle,c.byref(code)):
            return None
        return code.value==259  # STILL_ACTIVE
    finally:
        kernel32.CloseHandle(handle)


def allow_foreground_process(pid):
    """Pass foreground permission to one authenticated engine, without focusing.

    Called by the UI owner for its explicit Start request. The engine still
    waits for user input to stop and checks cancellation before activation.
    Never use ASFW_ANY: unrelated processes must not receive this permission.
    """
    if type(pid) is not int or not 0 < pid < 0xFFFFFFFF:
        return False
    try:
        return bool(user32.AllowSetForegroundWindow(pid))
    except (AttributeError, OSError):
        return False


def dpi_aware():
    """Keep client rectangles, captures and cursor positions in physical pixels."""
    try:
        user32.SetProcessDpiAwarenessContext(c.c_void_p(-4))
    except (AttributeError, OSError):
        pass


def find_game():
    matches = []
    callback_type = c.WINFUNCTYPE(w.BOOL, w.HWND, w.LPARAM)

    @callback_type
    def callback(hwnd, _):
        if not user32.IsWindowVisible(hwnd):
            return True
        pid = w.DWORD()
        user32.GetWindowThreadProcessId(hwnd, c.byref(pid))
        if _process_name(pid.value).casefold() == "mabinogimobile.exe":
            matches.append((int(hwnd), int(pid.value)))
        return True

    user32.EnumWindows(callback, 0)
    require(len(matches) == 1, "마비노기 모바일 클라이언트를 정확히 하나만 실행해 주세요.")
    return matches[0]


class ReadOnlyGameWindow:
    """Geometry/capture only. No click, key, focus or automation aspect gate."""
    def __init__(self):
        self.hwnd, self.pid = find_game()
        self.refresh()

    def refresh(self):
        if not user32.IsWindow(self.hwnd):
            raise GameClientClosed("게임 창이 종료되었습니다.")
        client = RECT()
        if not user32.GetClientRect(self.hwnd, c.byref(client)):
            raise ObservationUnavailable("게임 화면 크기를 읽지 못했습니다.")
        point = POINT(0, 0)
        if not user32.ClientToScreen(self.hwnd, c.byref(point)):
            raise ObservationUnavailable("게임 화면 좌표를 읽지 못했습니다.")
        if client.right <= 0 or client.bottom <= 0:
            raise ObservationUnavailable("게임 콘텐츠 영역을 아직 읽을 수 없습니다.")
        self.left, self.top = point.x, point.y
        self.width, self.height = client.right, client.bottom
        return self

    def capture(self):
        self.refresh()
        return _grab_game_region((self.left, self.top, self.left + self.width, self.top + self.height))

    def capture_visible_region(self, region, *, anchor_top_left=False):
        """Passive tiny-ROI capture; never focus/move the user's windows/mouse."""
        self.refresh()
        if user32.IsIconic(self.hwnd):
            return None
        if anchor_top_left:
            local = top_left_region(region,self.width,self.height)
        else:
            local = tuple(round(v*(self.width if i%2==0 else self.height)) for i,v in enumerate(region))
        require(0 <= local[0] < local[2] <= self.width and 0 <= local[1] < local[3] <= self.height,
                "판독 영역이 게임 콘텐츠 밖입니다.")
        bounds = tuple(v+(self.left if i%2==0 else self.top) for i,v in enumerate(local))
        def visible():
            # Reject occluding app windows across the caption, not just its center.
            return all(user32.GetAncestor(user32.WindowFromPoint(POINT(x,y)),2)==self.hwnd
                       for x in range(bounds[0]+1,bounds[2],max(1,(bounds[2]-bounds[0])//12))
                       for y in range(bounds[1]+1,bounds[3],max(1,(bounds[3]-bounds[1])//4)))
        if not visible():
            return None
        image = _grab_game_region(bounds)
        return image if visible() else None


class GameWindow(ReadOnlyGameWindow):
    """One prepared-container input path for fixed and responsive profiles."""

    def __init__(self, *, coordinate_runtime=None):
        if coordinate_runtime is not None and not isinstance(coordinate_runtime, UiRouteRuntime):
            raise TypeError("coordinate_runtime must be the central UiRouteRuntime")
        self.coordinate_runtime = coordinate_runtime or UiRouteRuntime(require_prepared=True)
        super().__init__()

    def refresh(self):
        return self._retry_observation(self._refresh_calculated_session)

    def _retry_observation(self, operation):
        """Only a bound automation waits; diagnostics keep a bounded read."""
        announced = False
        while True:
            cancel = getattr(self, "_automation_cancel", None)
            if cancel is not None and cancel.is_set():
                raise Cancelled("사용자가 작업을 중단했습니다.")
            try:
                return operation()
            except ObservationUnavailable as exc:
                if cancel is None:
                    raise
                if not user32.IsWindow(self.hwnd):
                    raise GameClientClosed("게임 창이 종료되었습니다.") from exc
                if not announced:
                    self._automation_status(str(exc) + " · 입력 없이 재확인")
                    announced = True
                # No synthetic input, focus change, receipt change or route restart.
                time.sleep(.1)

    def _interrupted_input(self, message):
        # A scroll/text prefix must not be resent by the unsent-input loop.
        return (InputInterrupted if getattr(self, "_dispatch_started", False)
                else UnsentInputInterrupted)(message)

    def _refresh_calculated_session(self):
        # Observation may follow a resize. Input still needs an ID from the
        # current store, profile/scene checks, and the normal OS/receipt gates.
        ReadOnlyGameWindow.refresh(self)
        from .coordinates import can_inspect_client, ASPECT_TOLERANCE
        require(can_inspect_client(self.width, self.height), "게임 콘텐츠 영역은 최소 720×480이어야 합니다.")
        if not hasattr(self, "ui_settings_reader"):
            self.ui_settings_reader = GameUiSettingsReader()
        self.ui_settings = self.ui_settings_reader.read()
        if not self.ui_settings.available:
            if self.coordinate_runtime.require_prepared:
                self.coordinate_runtime.invalidate_preparation("게임 UI 설정을 확인할 수 없습니다. 화면비 확인을 다시 눌러 주세요.")
                self.coordinate_runtime.ensure_prepared()
            self.coordinate_runtime.coordinates.invalidate()
            raise InputInterrupted("게임 UI 스타일·배율을 읽지 못해 좌표 갱신을 기다립니다.")
        require(self.ui_settings.style in ("pc", "mobile", "auto") and
                self.ui_settings.percent in (70, 80, 90, 100), "게임 UI 설정값을 확인해 주세요.")
        signature = ((self.width, self.height), self.ui_settings.signature)
        if self.coordinate_runtime.require_prepared:
            # The whole coordinate catalog belongs to the explicit screen
            # confirmation. A new consumer must not discard it at construction.
            self.coordinate_runtime.validate_prepared_window(self)
        if signature != getattr(self, "_layout_signature", None):
            self.layout_generation = getattr(self, "layout_generation", 0) + 1
            self.layout_changed_at = time.monotonic()
            self._layout_signature = signature
            if not self.coordinate_runtime.require_prepared:
                self.coordinate_runtime.coordinates.invalidate()
        legacy = (self.ui_settings.style == "pc" and self.ui_settings.percent == 70 and
                  abs(self.width / self.height - 16 / 9) < ASPECT_TOLERANCE and
                  is_fixed_profile_size((self.width, self.height)))
        self.dynamic_layout = not legacy
        self.layout_mode = "source-coordinate-refs"
        self.profile = "16:9-pc70" if legacy else "source-calculated"
        return self

    def _adaptive(self):
        return bool(getattr(self, "dynamic_layout", False))

    def _observed_target(self, target):
        """All profiles consume the same prepared-container reference ABI."""
        if getattr(self, "restart_required", False):
            raise MiniError(self.restart_reason)
        from .calculated_target import CalculatedTarget
        if isinstance(target, CoordinateRef):
            runtime = getattr(self, "coordinate_runtime", None)
            require(isinstance(runtime, UiRouteRuntime), "좌표 저장소가 연결되지 않았습니다.")
            resolved = runtime.resolve_input(target, self)
            require(isinstance(resolved, CalculatedTarget), "준비된 계산 좌표만 입력 대상으로 사용할 수 있습니다.")
            # Pool generation/context only; input does not run target OCR.
            return resolved
        raise TypeError("GameWindow 공간 입력은 CoordinateRef만 받습니다. 좌표 생산/준비 연결을 확인하세요.")

    def _quit_regions(self, target):
        # Prepared once with the viewport; never recapture before a click.
        return self.coordinate_runtime.prepared_bundle("input.constraints")["quit"]["metadata"]["regions"]

    def bind_automation(self, cancel, status, *, idle_seconds=None):
        """Enable recovery only for an explicitly running automation session."""
        self._automation_cancel = cancel
        self._automation_status = status
        self._automation_idle_seconds = idle_seconds
        self._focus_retry_at = 0.0

    def _automation_check(self):
        cancel = getattr(self, "_automation_cancel", None)
        if cancel is not None and cancel.is_set():
            raise Cancelled("사용자가 작업을 중단했습니다.")
        self.refresh()  # Client closure and prepared viewport/settings remain fatal.

    def _automation_wait(self, seconds):
        end = time.monotonic() + seconds
        while True:
            self._automation_check()
            remaining = end - time.monotonic()
            if remaining <= 0:
                return
            time.sleep(min(.1, remaining))

    def _input_idle(self):
        stamp = last_input_stamp()
        own_stamp = getattr(self, "last_sent_input_stamp", None)
        if own_stamp is None:
            own_stamp = getattr(self, "_automation_own_stamp", None)
        owned = (self.is_foreground() and stamp is not None
                 and stamp == own_stamp)
        if not owned and seconds_since_input() < _resolve_idle_seconds(
                getattr(self, "_automation_idle_seconds", None)):
            return False
        return stamp is not None and not input_is_held() and last_input_stamp() == stamp

    def _check_input_activity(self):
        if not hasattr(self, "_automation_cancel") or getattr(self, "_input_pressed", False):
            return
        guard = getattr(self, "_automation_input_guard", None)
        if callable(guard):
            guard()
        self._automation_check()
        stamp = last_input_stamp()
        if (stamp is None or stamp != getattr(self, "_input_ready_stamp", None)
                or input_is_held() or last_input_stamp() != stamp):
            raise self._interrupted_input("사용자 입력이 새로 감지되어 전송 전 같은 단계에서 대기합니다.")

    def _press_interrupted(self, dispatched_tick):
        # GetLastInputInfo can lag mouse_event/keybd_event. A late observation
        # of our press is not human activity; only a newer event, unknown input,
        # or focus loss invalidates ownership before the mandatory release.
        stamp = last_input_stamp()
        return (stamp is None or not self.is_foreground()
                or 0 < ((stamp - dispatched_tick) & 0xFFFFFFFF) < 0x80000000)

    def _remember_release(self, interrupted):
        self._input_pressed = False
        # A release can overwrite the OS timestamp of a user's activity during
        # the hold. Never adopt that release as an uninterrupted owned sequence.
        self.last_sent_input_stamp = None if interrupted else last_input_stamp()
        self._input_ready_stamp = self.last_sent_input_stamp
        if interrupted:
            self._automation_own_stamp = None

    def ensure_foreground(self, *, block=True, for_input=False):
        """Resume the same call after idle/focus recovery; never replay a route.

        Passive readers never bind this context. Nonblocking checks keep
        CLI/packet observations running during user activity. Explicit screen
        observation retries once when idle; input preparation may wait here.
        """
        if not hasattr(self, "_automation_cancel"):
            return self.is_foreground()
        announced = None
        while True:
            self._automation_check()
            foreground = self.is_foreground() and not user32.IsIconic(self.hwnd)
            if foreground and (not for_input or self._input_idle()):
                return True
            # Never refocus while a synthetic press still needs its key-up.
            pressed = getattr(self, "_input_pressed", False)
            retry = execution_rules.number("focus.retry_seconds", default=.25)
            now = time.monotonic()
            idle = self._input_idle()
            if (not pressed and now >= getattr(self, "_focus_retry_at", 0.0)
                    and idle):
                self._automation_check()
                # Observe again immediately before the OS foreground mutation.
                idle = self._input_idle()
                if idle:
                    self._focus_retry_at = now + retry
                    guard = getattr(self, "_automation_input_guard", None)
                    if callable(guard):
                        ownership_stamp = last_input_stamp()
                        guard()
                        if for_input and (ownership_stamp is None or last_input_stamp() != ownership_stamp):
                            # A delayed permission read must not bless a new
                            # user-edited menu merely because idle time passed.
                            raise self._interrupted_input("전경 권한 확인 중 사용자 입력 변경 · 현재 입력 문맥 재확인")
                    if not self._input_idle():
                        continue  # User activity during the ownership query wins.
                    if user32.IsIconic(self.hwnd):
                        user32.ShowWindow(self.hwnd, 9)  # SW_RESTORE, no input.
                    user32.SetForegroundWindow(self.hwnd)
                    self._automation_check()
                    if self.is_foreground() and not user32.IsIconic(self.hwnd):
                        if block:
                            self._automation_wait(.08)
                            continue  # Revalidate after the compositor settles.
                        return True
            if not block:
                return False
            message = ("사용자 입력 안정 대기 · 입력이 멈추면 같은 단계에서 자동 복귀"
                       if not idle or pressed else
                       "게임 전경 전환 대기 · Windows 전경 전환 응답을 기다리며 같은 단계 유지")
            if announced != message:
                self._automation_status(message)
                announced = message
            self._automation_wait(retry)

    def capture(self):
        return self._retry_observation(self._capture_once)

    def _capture_once(self):
        managed = hasattr(self, "_automation_cancel")
        if managed:
            self._automation_check()
        else:
            self.refresh()
        captured_at = time.monotonic()
        generation = getattr(self, "layout_generation", None)
        visible = self.is_foreground() and not user32.IsIconic(self.hwnd)
        if managed and not visible and self._input_idle():
            # A screen-dependent route still resumes by itself once idle. While
            # the user works, return immediately so passive observations run.
            visible = self.ensure_foreground(block=False)
        if visible or (not managed and not self._adaptive()):
            image = super().capture()
            if managed:
                self._automation_check()
                visible = self.is_foreground() and not user32.IsIconic(self.hwnd)
        if not visible and (managed or self._adaptive()):
            # Do not expose another app's pixels as game UI while input/focus
            # recovery is pending; keep CLI/packet loops progressing with an
            # explicitly unavailable frame until the game is visible.
            from PIL import Image
            image = Image.new("RGB", (self.width, self.height))
        image.info["game_frame_visible"] = visible
        image.info["game_ui_nonlegacy"] = bool(self._adaptive())
        image.info["game_layout_generation"] = generation
        image.info["game_frame_captured_at"] = captured_at
        runtime = getattr(self, "coordinate_runtime", None)
        if runtime is not None and getattr(runtime, "_prepared_ready", False):
            image.info["prepared_geometry"] = runtime.prepared_geometry_values()
            if self._adaptive():
                observations = runtime.prepared_bundle("common.observe")
                image.info["prepared_hud_regions"] = {
                    name: observations["ocr.common.hud_"+name]["metadata"]["normalized_roi"]
                    for name in ("menu", "map", "shortcuts")}
        return image

    def _check_half_target(self, x, y, *, force=False):
        if hasattr(self, "_automation_cancel") and not self.is_foreground():
            raise self._interrupted_input("입력 위치 확인 중 게임 포커스가 변경되었습니다.")
        if force or is_half_window(self.width, self.height) or self._adaptive():
            if user32.GetAncestor(user32.WindowFromPoint(POINT(x, y)), 2) != self.hwnd:
                raise self._interrupted_input("입력 위치를 다른 창이 가리고 있습니다. 가림 해제 후 같은 입력을 이어갑니다.")

    def _place_pointer(self, x, y, *, force=False):
        origin = (self.left, self.top)
        self.refresh()
        if (self.left, self.top) != origin:
            raise self._interrupted_input("포인터 이동 전 게임 창이 이동했습니다. 새 원점에서 다시 투영합니다.")
        self._check_input_activity()
        moved = user32.SetCursorPos(x, y)
        if moved and hasattr(self, "_automation_cancel"):
            self._input_ready_stamp = last_input_stamp()
        if force or is_half_window(self.width, self.height) or self._adaptive():
            actual = POINT()
            if not moved or not user32.GetCursorPos(c.byref(actual)) or (actual.x, actual.y) != (x, y):
                raise self._interrupted_input("게임 포인터 이동을 확인하지 못했습니다. 클릭·스크롤을 보내지 않았습니다.")
            if not self.is_foreground():
                raise self._interrupted_input("포인터 이동 중 게임 포커스가 변경되었습니다.")
            self._check_half_target(actual.x, actual.y, force=force)
        return moved

    def _rule_before_event(self, target, origin, x, y, check):
        """Last-mile rule guard; coordinates are not an authorization receipt."""
        check()
        self.refresh()
        self._observed_target(target)
        if (self.left, self.top) != origin:
            raise self._interrupted_input("좌표 계산 후 게임 창이 이동했습니다. 새 원점에서 다시 투영합니다.")
        if user32.IsIconic(self.hwnd) or not self.is_foreground():
            raise self._interrupted_input("규칙 좌표 입력 직전 게임 창 상태가 변경되었습니다.")
        self._check_half_target(x, y, force=True)

    def point(self, normalized):
        self.refresh()
        resolved = self._observed_target(normalized)
        from .coordinates import physical_client_point
        x, y = physical_client_point(region_center(resolved), self.width, self.height)
        return screen_point((x, y), (self.width, self.height), (self.left, self.top))

    def focus(self):
        if hasattr(self, "_automation_cancel"):
            self.ensure_foreground(for_input=True)
            self._input_ready_stamp = last_input_stamp()
            return
        self.refresh()
        if user32.GetForegroundWindow() != self.hwnd:
            user32.SetForegroundWindow(self.hwnd)
        time.sleep(0.08)
        self.refresh()
        if user32.GetForegroundWindow() != self.hwnd:
            raise self._interrupted_input("게임 창을 앞으로 가져오지 못했습니다. 같은 입력에서 전경을 다시 확인합니다.")

    def _interaction_delay(self, phase):
        if getattr(self, "_coordinate_sequence_depth", 0) and phase in ("before", "after"):
            return 0.0
        return input_delay(phase)

    def click(self, normalized, *, wait=time.sleep, check=lambda: None, before_press=None):
        self.refresh()
        layout_generation = getattr(self, "layout_generation", None)
        observed = normalized
        initial = self._observed_target(observed)
        require(not getattr(initial, "metadata", {}).get("pointer_only", False),
                "포인터 이동 전용 좌표에는 클릭을 보낼 수 없습니다.")
        self.focus()
        wait(self._interaction_delay("before"))
        check()
        if user32.GetForegroundWindow() != self.hwnd:
            raise self._interrupted_input("클릭 대기 중 게임 포커스가 변경되었습니다.")
        self.refresh()
        if getattr(self, "layout_generation", None) != layout_generation:
            raise InputInterrupted("클릭 대기 중 화면 크기/UI 설정이 변경되었습니다. 현재 단계에서 새 좌표를 계산합니다.")
        resolved = self._observed_target(observed)
        blockers = self._quit_regions(resolved)
        normalized = safe_click_point(resolved, image_size=(self.width, self.height),
                                      blocked_regions=blockers)
        from .coordinates import physical_client_point
        local = physical_client_point(normalized, self.width, self.height)
        x, y = screen_point(local, (self.width, self.height), (self.left, self.top))
        projected_origin = (self.left, self.top)
        # Final physical-pixel check after client origin/DPI/size projection.
        require(not click_is_forbidden(((x - self.left) / self.width, (y - self.top) / self.height),
                                       image_size=(self.width, self.height), blocked_regions=blockers),
                "화면 좌표 변환 후 종료 금지 영역의 클릭을 차단했습니다.")
        self._check_half_target(x, y, force=True)
        self._place_pointer(x, y, force=True)
        self._rule_before_event(observed, projected_origin, x, y, check)
        self._check_input_activity()
        if before_press is not None:
            before_press()  # Durable receipt only after pre-input validation.
        self._input_pressed = True
        self._dispatch_started = True
        user32.mouse_event(0x0002, 0, 0, 0, 0)
        dispatched_tick = int(kernel32.GetTickCount())
        try:
            wait(self._interaction_delay("hold"))
        finally:
            # Cancellation during the press must still release the button.
            interrupted = self._press_interrupted(dispatched_tick)
            user32.mouse_event(0x0004, 0, 0, 0, 0)
            self._remember_release(interrupted)
        wait(self._interaction_delay("after"))

    def move(self, normalized):
        self.refresh()
        self._observed_target(normalized)
        self.focus()
        x, y = self.point(normalized)
        self._check_half_target(x, y)
        self._place_pointer(x, y)

    def is_foreground(self):
        return user32.GetForegroundWindow() == self.hwnd

    def pointer_position(self):
        self.refresh()
        point = POINT()
        if not user32.GetCursorPos(c.byref(point)):
            return None
        return ((point.x - self.left) / self.width, (point.y - self.top) / self.height)

    def park_pointer(self, normalized):
        if hasattr(self, "_automation_cancel"):
            self.ensure_foreground(for_input=True)
            self._input_ready_stamp = last_input_stamp()
        x, y = self.point(normalized)
        if not self.is_foreground():
            raise self._interrupted_input("포인터 이동 전 게임 포커스가 변경되었습니다.")
        self._check_half_target(x, y)
        if not self._place_pointer(x, y):
            raise self._interrupted_input("포인터를 안전 위치로 이동하지 못했습니다.")

    def key(self, name, *, wait=time.sleep, check=lambda: None, before_press=None):
        self.refresh()
        code = VK[name]
        return self._key_with_timing(code, wait=wait, check=check, before_press=before_press)

    def fishing_key(self, name, *, wait=time.sleep, check=lambda: None, before_press=None, hold_seconds=None):
        """Authored fishing taps; the caller supplies only a bounded hold wait.

        The fishing route decides when S/W movement or another Space is needed;
        it also owns cast spacing and the current pull opportunity. Neither
        randomized delays nor the general coordinate interval belong here.
        ``wait`` must not run observations/guards while the key is held; use
        the route's cancellation-event wait, not the general service sleep.
        The route samples one shared S/W hold; this method validates and uses
        it unchanged. Omission retains 0.15 seconds. Space stays at 0.06.
        """
        require(isinstance(name, str) and name in _FISHING_KEYS,
                "낚시 입력은 S/W/SPACE만 사용할 수 있습니다.")
        code, default_hold = _FISHING_KEYS[name]
        if name == "SPACE":
            require(hold_seconds is None, "SPACE의 낚시 입력 시간은 0.06초 고정입니다.")
        elif hold_seconds is not None:
            require(isinstance(hold_seconds, (int, float)) and not isinstance(hold_seconds, bool)
                    and .131 <= hold_seconds <= .159 and math.isfinite(hold_seconds),
                    "낚시 S/W 입력 시간은 0.131~0.159초의 유한한 숫자여야 합니다.")
        if hold_seconds is None:
            hold_seconds = default_hold
        self.refresh()
        return self._key_with_timing(code, wait=wait, check=check,
                                     before_press=before_press, hold_seconds=hold_seconds)

    def _key_with_timing(self, code, *, wait, check, before_press, hold_seconds=None):
        self.focus()
        if hold_seconds is None:
            wait(self._interaction_delay("before"))
        check()
        self.refresh()
        if user32.GetForegroundWindow() != self.hwnd:
            raise self._interrupted_input("키 입력 대기 중 게임 포커스가 변경되었습니다.")
        self._check_input_activity()
        if before_press is not None:
            before_press()  # Mark ownership only after all pre-input guards.
        self._input_pressed = True
        self._dispatch_started = True
        dispatched_tick = None
        interrupted = True
        try:
            user32.keybd_event(code, 0, 0, 0)
            dispatched_tick = int(kernel32.GetTickCount())
            wait(self._interaction_delay("hold") if hold_seconds is None else hold_seconds)
        finally:
            try:
                if dispatched_tick is not None:
                    interrupted = self._press_interrupted(dispatched_tick)
            finally:
                # Even a failed OS timestamp/ownership read cannot suppress
                # key-up. No foreground recovery or observation runs here.
                try:
                    user32.keybd_event(code, 0, 0x0002, 0)
                finally:
                    self._remember_release(interrupted)
        if hold_seconds is None:
            wait(self._interaction_delay("after"))

    def scroll(self, normalized, delta, *, wait=time.sleep, check=lambda: None, before_press=None):
        self.refresh()
        resolved = self._observed_target(normalized)
        observed = normalized
        self.focus()
        x, y = self.point(normalized)
        self._check_half_target(x, y, force=True)
        self._place_pointer(x, y, force=True)
        # UnityPlayer8534a2 clamps EACH raw mouse wheel event to[-1,1]. Thus
        # one -600 event is not five -120 events. Source-calculated offsets
        # count distinct events (ScrollSpeed30 local UI units each).
        if (is_half_window(self.width, self.height) and
                getattr(resolved, "metadata", {}).get("wheel_input_mode") == "recorded-event"):
            require(int(delta) == delta and abs(delta) <= 600 and delta % 120 == 0,
                    "실측 창모드의 스크롤 입력 범위가 아닙니다.")
            # The measured list pages used one +/-600 event per step. Splitting
            # it into five events does not reproduce the same game scroll.
            chunks = (int(delta),) if delta else ()
        else:
            # Source plans count120-unit wheel events, including at1916x2041.
            # Client size alone must not replace five planned notches with the
            # unrelated single-event contract of the old measured half tape.
            chunks = wheel_chunks(delta)
        first_wheel = True
        for chunk in chunks:
            check()
            self._observed_target(observed)
            x, y = self.point(normalized)
            projected_origin = (self.left, self.top)
            self._check_half_target(x, y, force=True)
            if user32.GetForegroundWindow() != self.hwnd:
                raise self._interrupted_input("스크롤 중 게임 포커스가 변경되었습니다.")
            self._place_pointer(x, y, force=True)
            self._rule_before_event(observed, projected_origin, x, y, check)
            self._check_input_activity()
            if first_wheel and before_press is not None:
                before_press()
            first_wheel = False
            self._dispatch_started = True
            user32.mouse_event(0x0800, 0, 0, c.c_ulong(chunk).value, 0)
            self.last_sent_input_stamp = last_input_stamp()
            self._input_ready_stamp = self.last_sent_input_stamp
            wait(self._interaction_delay("wheel"))

    def snack_text(self, value, *, wait=time.sleep, check=lambda: None, before_press=None):
        """Only the authored snack crafting count. Never use the clipboard."""
        require(type(value) is str and value.isascii() and value.isdecimal() and bool(value.strip("0")),
                "간식 제작 횟수는 양수 정수 숫자만 입력할 수 있습니다.")
        return self._replace_owned_text(value, wait=wait, check=check, before_press=before_press)

    def bag_text(self, value, *, wait=time.sleep, check=lambda: None, before_press=None):
        """Replace only the bag route's already selected item-name/count field."""
        require(type(value) is str and 1 <= len(value) <= 512 and bool(value.strip())
                and all(ord(char) >= 32 and ord(char) != 127 and not 0xD800 <= ord(char) <= 0xDFFF for char in value),
                "가방 검색어/수량 입력 형식이 올바르지 않습니다.")
        return self._replace_owned_text(value, wait=wait, check=check, before_press=before_press, resume_prefix=False)

    def _replace_owned_text(self, value, *, wait, check, before_press, resume_prefix=True):
        # Same scoped Unicode input/idle/cancel boundary as snack quantities.
        # Do not touch the user's clipboard or turn this into a chat sender.
        self.refresh()
        self.focus()
        wait(self._interaction_delay("before")); check()
        self.refresh()
        if user32.GetForegroundWindow() != self.hwnd:
            raise self._interrupted_input("텍스트 입력 전 게임 포커스가 변경되었습니다.")
        self._check_input_activity()
        if before_press is not None:
            before_press()
        self._input_pressed = True
        self._dispatch_started = True
        user32.keybd_event(0x11, 0, 0, 0)  # Ctrl+A replaces the owned field.
        interrupted = False
        try:
            self.refresh()
            user32.keybd_event(0x41, 0, 0, 0)
            dispatched_tick = int(kernel32.GetTickCount())
            try:
                wait(self._interaction_delay("hold"))
            finally:
                interrupted = self._press_interrupted(dispatched_tick)
                user32.keybd_event(0x41, 0, 2, 0)
        finally:
            user32.keybd_event(0x11, 0, 2, 0)
            self._remember_release(interrupted)
        encoded = value.encode("utf-16-le")
        for offset in range(0, len(encoded), 2):
            check()
            if interrupted and not resume_prefix:
                raise InputInterrupted("가방 텍스트 입력 중 사용자 조작 · 나머지 문자를 보내지 않습니다.")
            self.refresh()
            if interrupted and hasattr(self, "_automation_cancel"):
                # Keep the already-sent prefix; wait at the next unsent char.
                self.ensure_foreground(for_input=True)
                self._input_ready_stamp = last_input_stamp()
            if user32.GetForegroundWindow() != self.hwnd:
                raise self._interrupted_input("텍스트 입력 중 게임 포커스가 변경되었습니다.")
            self._check_input_activity()
            unit = int.from_bytes(encoded[offset:offset+2], "little")
            press = INPUT(type=1, ki=KEYBDINPUT(wScan=unit, dwFlags=4))
            release = INPUT(type=1, ki=KEYBDINPUT(wScan=unit, dwFlags=6))
            self._input_pressed = True
            if user32.SendInput(1, c.byref(press), c.sizeof(INPUT)) != 1:
                self._input_pressed = False
                raise InputInterrupted("게임 텍스트 입력 미확인 · 이미 보낸 입력을 반복하지 않고 현재 필드를 다시 확인합니다.")
            dispatched_tick = int(kernel32.GetTickCount())
            try:
                wait(self._interaction_delay("hold"))
            finally:
                interrupted = self._press_interrupted(dispatched_tick)
                user32.SendInput(1, c.byref(release), c.sizeof(INPUT))
                self._remember_release(interrupted)
        if interrupted and not resume_prefix:
            raise InputInterrupted("가방 텍스트 입력 직후 사용자 조작 · 현재 검색 필드를 다시 확인합니다.")
        wait(self._interaction_delay("after"))


def wheel_chunks(delta):
    delta = int(delta)
    if delta == 0:
        return ()
    sign = 1 if delta > 0 else -1
    whole, remainder = divmod(abs(delta), 120)
    result = [sign * 120] * whole
    if remainder:
        result.append(sign * remainder)
    return tuple(result)


def seconds_since_input():
    info = LASTINPUTINFO(c.sizeof(LASTINPUTINFO), 0)
    if not user32.GetLastInputInfo(c.byref(info)):
        # An unavailable observation is not proof that the user is idle.
        # Keep the cancellable input wait active until Windows answers again.
        return 0.0
    now = kernel32.GetTickCount()
    return ((now - info.dwTime) & 0xFFFFFFFF) / 1000.0


def last_input_stamp():
    """Input timing only; no key values, text, or pointer coordinates."""
    info = LASTINPUTINFO(c.sizeof(LASTINPUTINFO), 0)
    getter = getattr(user32, "GetLastInputInfo", None)
    return int(info.dwTime) if getter and getter(c.byref(info)) else None


def input_is_held():
    """Read only a held/not-held bit; no hook, key identity, or text is retained."""
    getter = getattr(user32, "GetAsyncKeyState", None)
    return bool(getter and any(getter(key) & 0x8000 for key in range(1, 255)))


def input_resume_delay_seconds(store=None):
    """Read the saved preference on demand, including while a run is waiting."""
    preference = getattr(store, "preference", None)
    value = (preference(("input", "resume_delay_seconds"), 5.0)
             if callable(preference) else None)
    return _resolve_idle_seconds(value if value is not None else (5.0 if callable(preference) else None))


def _resolve_idle_seconds(value=None):
    # A callable keeps settings changes live even inside a blocking idle wait.
    # Standalone/unbound consumers retain the existing external-rule fallback.
    if callable(value):
        value = value()
    if value is None:
        value = execution_rules.number("focus.idle_seconds", default=5.0)
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        return 5.0
    if not 0 <= value <= 3600 or not math.isfinite(value):
        return 5.0
    return float(value)


def wait_input_idle(cancel, status=None, idle_seconds=None, check=None):
    announced = False
    while True:
        if cancel.is_set():
            raise Cancelled("사용자가 작업을 중단했습니다.")
        if check is not None:
            check()  # Observe game closure even while human input keeps us idle.
        stamp = last_input_stamp()
        if (stamp is not None and seconds_since_input() >= _resolve_idle_seconds(idle_seconds)
                and not input_is_held() and last_input_stamp() == stamp):
            break
        if status and not announced:
            # GetLastInputInfo includes our own earlier click as well. Do not
            # label all recent OS input as current human activity.
            status("최근 입력 안정 대기 · 마지막 입력 후 잠시 기다린 뒤 이어서 진행")
            announced = True
        time.sleep(0.1)
    if status and announced:
        status("입력 안정 확인 · 기존 단계에서 계속 진행")
