"""Human-like behavioral layer for cloakbrowser.

Activated via humanize=True in launch() / launch_async().
Patches page methods to use Bezier mouse curves, realistic typing, and smooth scrolling.

Stealth-aware (fixes #110):
  - isInputElement / isSelectorFocused use CDP Isolated Worlds instead of page.evaluate
  - Shift symbol typing uses CDP Input.dispatchKeyEvent for isTrusted=true events
  - Falls back to page.evaluate only when CDP session is unavailable

Supports both sync and async Playwright APIs.
"""

from __future__ import annotations

import json
import logging
import sys
import time
from typing import Any, Optional

from .config import HumanConfig, HumanPreset, resolve_config, merge_config
from .config import rand, rand_range, sleep_ms, async_sleep_ms
from .mouse import RawMouse, human_move, human_click, click_target, human_idle
from .keyboard import RawKeyboard, human_type
from .scroll import scroll_to_element, human_scroll_into_view
from .mouse_async import AsyncRawMouse, async_human_move, async_human_click, async_human_idle
from .keyboard_async import AsyncRawKeyboard, async_human_type
from .scroll_async import async_scroll_to_element, async_human_scroll_into_view
from .actionability import (
    ensure_actionable, ensure_stable, check_pointer_events,
    ensure_actionable_handle, check_pointer_events_handle,
    ActionabilityError, ElementNotAttachedError, ElementNotVisibleError,
    ElementNotStableError, ElementNotEnabledError, ElementNotEditableError,
    ElementNotReceivingEventsError,
    CHECKS_CLICK, CHECKS_HOVER, CHECKS_INPUT, CHECKS_FOCUS, CHECKS_CHECK,
)
from .actionability_async import (
    async_ensure_actionable, async_ensure_stable, async_check_pointer_events,
    async_ensure_actionable_handle, async_check_pointer_events_handle,
)

_SELECT_ALL = "Meta+a" if sys.platform == "darwin" else "Control+a"

__all__ = [
    "patch_browser", "patch_context", "patch_page",
    "patch_browser_async", "patch_context_async", "patch_page_async",
    "HumanConfig", "resolve_config", "merge_config",
    "human_move", "human_click", "click_target", "human_idle",
    "human_type", "scroll_to_element", "human_scroll_into_view",
]

logger = logging.getLogger("cloakbrowser.human")


# ============================================================================
# CDP Isolated World — stealth DOM evaluation
# ============================================================================

class _SyncIsolatedWorld:
    """Manages a CDP isolated execution context for DOM reads (sync).

    Produces clean Error.stack traces (no 'eval at evaluate :302:')
    and is invisible to querySelector monkey-patches in the main world.
    Context ID is invalidated on navigation and auto-recreated on next call.
    """

    __slots__ = ("_page", "_cdp", "_context_id")

    def __init__(self, page: Any):
        self._page = page
        self._cdp: Any = None
        self._context_id: Optional[int] = None

    def _ensure_cdp(self) -> Any:
        if self._cdp is None:
            self._cdp = self._page.context.new_cdp_session(self._page)
        return self._cdp

    def _create_world(self) -> int:
        cdp = self._ensure_cdp()
        tree = cdp.send("Page.getFrameTree")
        frame_id = tree["frameTree"]["frame"]["id"]
        result = cdp.send("Page.createIsolatedWorld", {
            "frameId": frame_id,
            "worldName": "",
            "grantUniveralAccess": True,
        })
        self._context_id = result["executionContextId"]
        return self._context_id

    def evaluate(self, expression: str) -> Any:
        """Evaluate JS in isolated world. Auto-recreates on stale context."""
        if self._context_id is None:
            self._create_world()

        for attempt in range(2):
            try:
                result = self._cdp.send("Runtime.evaluate", {
                    "expression": expression,
                    "contextId": self._context_id,
                    "returnByValue": True,
                })
                if "exceptionDetails" in result:
                    if attempt == 0:
                        self._create_world()
                        continue
                    return None
                return result.get("result", {}).get("value")
            except Exception:
                if attempt == 0:
                    self._context_id = None
                    try:
                        self._create_world()
                    except Exception:
                        return None
                    continue
                return None
        return None

    def invalidate(self) -> None:
        """Mark context as stale — call after navigation."""
        self._context_id = None

    def get_cdp_session(self) -> Any:
        """Get the underlying CDP session (reused for Input.dispatchKeyEvent)."""
        return self._ensure_cdp()


class _AsyncIsolatedWorld:
    """Manages a CDP isolated execution context for DOM reads (async).

    Same as _SyncIsolatedWorld but uses await for all CDP calls.
    """

    __slots__ = ("_page", "_cdp", "_context_id")

    def __init__(self, page: Any):
        self._page = page
        self._cdp: Any = None
        self._context_id: Optional[int] = None

    async def _ensure_cdp(self) -> Any:
        if self._cdp is None:
            self._cdp = await self._page.context.new_cdp_session(self._page)
        return self._cdp

    async def _create_world(self) -> int:
        cdp = await self._ensure_cdp()
        tree = await cdp.send("Page.getFrameTree")
        frame_id = tree["frameTree"]["frame"]["id"]
        result = await cdp.send("Page.createIsolatedWorld", {
            "frameId": frame_id,
            "worldName": "",
            "grantUniveralAccess": True,
        })
        self._context_id = result["executionContextId"]
        return self._context_id

    async def evaluate(self, expression: str) -> Any:
        """Evaluate JS in isolated world. Auto-recreates on stale context."""
        if self._context_id is None:
            await self._create_world()

        for attempt in range(2):
            try:
                result = await self._cdp.send("Runtime.evaluate", {
                    "expression": expression,
                    "contextId": self._context_id,
                    "returnByValue": True,
                })
                if "exceptionDetails" in result:
                    if attempt == 0:
                        await self._create_world()
                        continue
                    return None
                return result.get("result", {}).get("value")
            except Exception:
                if attempt == 0:
                    self._context_id = None
                    try:
                        await self._create_world()
                    except Exception:
                        return None
                    continue
                return None
        return None

    def invalidate(self) -> None:
        """Mark context as stale — call after navigation."""
        self._context_id = None

    async def get_cdp_session(self) -> Any:
        """Get the underlying CDP session (reused for Input.dispatchKeyEvent)."""
        return await self._ensure_cdp()


# ============================================================================
# Cursor state
# ============================================================================

class _CursorState:
    __slots__ = ("x", "y", "initialized")

    def __init__(self) -> None:
        self.x: float = 0
        self.y: float = 0
        self.initialized: bool = False


# ============================================================================
# Stealth DOM queries — isolated world with evaluate fallback
# ============================================================================

def _is_input_element(page: Any, selector: str) -> bool:
    """Check if selector is an input element. Uses CDP isolated world when available."""
    world: Optional[_SyncIsolatedWorld] = getattr(page, '_stealth_world', None)
    if world is not None:
        try:
            escaped = json.dumps(selector)
            result = world.evaluate(
                f"(() => {{"
                f"  const el = document.querySelector({escaped});"
                f"  if (!el) return false;"
                f"  const tag = el.tagName.toLowerCase();"
                f"  return tag === 'input' || tag === 'textarea'"
                f"    || el.getAttribute('contenteditable') === 'true';"
                f"}})()"
            )
            return bool(result)
        except Exception:
            pass

    # Fallback: page.evaluate (detectable — should only happen if CDP fails)
    try:
        return page.evaluate(
            """(sel) => {
                const el = document.querySelector(sel);
                if (!el) return false;
                const tag = el.tagName.toLowerCase();
                return tag === 'input' || tag === 'textarea'
                    || el.getAttribute('contenteditable') === 'true';
            }""",
            selector,
        )
    except Exception:
        return False


async def _async_is_input_element(page: Any, selector: str) -> bool:
    """Check if selector is an input element (async). Uses CDP isolated world when available."""
    world: Optional[_AsyncIsolatedWorld] = getattr(page, '_stealth_world', None)
    if world is not None:
        try:
            escaped = json.dumps(selector)
            result = await world.evaluate(
                f"(() => {{"
                f"  const el = document.querySelector({escaped});"
                f"  if (!el) return false;"
                f"  const tag = el.tagName.toLowerCase();"
                f"  return tag === 'input' || tag === 'textarea'"
                f"    || el.getAttribute('contenteditable') === 'true';"
                f"}})()"
            )
            return bool(result)
        except Exception:
            pass

    try:
        return await page.evaluate(
            """(sel) => {
                const el = document.querySelector(sel);
                if (!el) return false;
                const tag = el.tagName.toLowerCase();
                return tag === 'input' || tag === 'textarea'
                    || el.getAttribute('contenteditable') === 'true';
            }""",
            selector,
        )
    except Exception:
        return False


def _is_selector_focused(page: Any, selector: str) -> bool:
    """Check if the element matching selector is currently focused.
    Uses CDP isolated world when available."""
    world: Optional[_SyncIsolatedWorld] = getattr(page, '_stealth_world', None)
    if world is not None:
        try:
            escaped = json.dumps(selector)
            result = world.evaluate(
                f"(() => {{"
                f"  const el = document.querySelector({escaped});"
                f"  return el === document.activeElement;"
                f"}})()"
            )
            return bool(result)
        except Exception:
            pass

    try:
        return page.evaluate(
            """(sel) => {
                const el = document.querySelector(sel);
                return el === document.activeElement;
            }""",
            selector,
        )
    except Exception:
        return False


async def _async_is_selector_focused(page: Any, selector: str) -> bool:
    """Check if the element matching selector is currently focused (async).
    Uses CDP isolated world when available."""
    world: Optional[_AsyncIsolatedWorld] = getattr(page, '_stealth_world', None)
    if world is not None:
        try:
            escaped = json.dumps(selector)
            result = await world.evaluate(
                f"(() => {{"
                f"  const el = document.querySelector({escaped});"
                f"  return el === document.activeElement;"
                f"}})()"
            )
            return bool(result)
        except Exception:
            pass

    try:
        return await page.evaluate(
            """(sel) => {
                const el = document.querySelector(sel);
                return el === document.activeElement;
            }""",
            selector,
        )
    except Exception:
        return False


# ============================================================================
# Locator class-level patching (sync)
# ============================================================================

_locator_sync_patched = False


def _patch_locator_class_sync():
    """Patch all Locator interaction methods to go through humanized page methods."""
    global _locator_sync_patched
    if _locator_sync_patched:
        return
    _locator_sync_patched = True

    from playwright.sync_api._generated import Locator

    _orig_fill = Locator.fill
    _orig_click = Locator.click
    _orig_type = Locator.type
    _orig_dblclick = Locator.dblclick
    _orig_hover = Locator.hover
    _orig_check = Locator.check
    _orig_uncheck = Locator.uncheck
    _orig_set_checked = Locator.set_checked
    _orig_select_option = Locator.select_option
    _orig_press = Locator.press
    _orig_press_sequentially = Locator.press_sequentially
    _orig_tap = Locator.tap
    _orig_drag_to = Locator.drag_to
    _orig_clear = Locator.clear
    _orig_scroll_into_view = getattr(Locator, 'scroll_into_view_if_needed', None)

    def _get_selector(self):
        return self._impl_obj._selector

    def _is_humanized(self):
        return hasattr(self.page, '_original')

    def _get_cfg(self):
        return getattr(self.page, '_human_cfg', None)

    def _route_target(self):
        """Resolution target for a humanized locator action (#428).

        Main-frame locators return the Page (unchanged behavior). A locator that
        belongs to a *sub-frame* returns that frame's humanized wrapper so the
        selector resolves in the frame's own document. Returns ``None`` when the
        owning sub-frame is known but not yet humanized, so the caller falls back
        to the native Playwright locator method (frame-correct, un-humanized).
        """
        page = self.page
        impl_frame = getattr(getattr(self, "_impl_obj", None), "_frame", None)
        if impl_frame is None:
            return page
        try:
            if impl_frame is page.main_frame._impl_obj:
                return page
            for f in page.frames:
                if getattr(f, "_impl_obj", None) is impl_frame:
                    return f if getattr(f, "_human_patched", False) else None
            # Frame not among page.frames (unknown/detached) -> legacy Page path.
            return page
        except Exception:
            return page

    def _forward_kwargs(kwargs):
        out = {}
        if "timeout" in kwargs:
            out["timeout"] = kwargs["timeout"]
        if "human_config" in kwargs:
            out["human_config"] = kwargs["human_config"]
        if "force" in kwargs:
            out["force"] = kwargs["force"]
        return out

    def _humanized_fill(self, value, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                _orig_fill(self, value, **kwargs)
            else:
                tgt.fill(_get_selector(self), value, **_forward_kwargs(kwargs))
        else:
            _orig_fill(self, value, **kwargs)

    def _humanized_click(self, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                _orig_click(self, **kwargs)
            else:
                tgt.click(_get_selector(self), **_forward_kwargs(kwargs))
        else:
            _orig_click(self, **kwargs)

    def _humanized_type(self, text, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                _orig_type(self, text, **kwargs)
            else:
                tgt.type(_get_selector(self), text, **_forward_kwargs(kwargs))
        else:
            _orig_type(self, text, **kwargs)

    def _humanized_dblclick(self, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                _orig_dblclick(self, **kwargs)
            else:
                tgt.dblclick(_get_selector(self), **_forward_kwargs(kwargs))
        else:
            _orig_dblclick(self, **kwargs)

    def _humanized_hover(self, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                _orig_hover(self, **kwargs)
            else:
                tgt.hover(_get_selector(self), **_forward_kwargs(kwargs))
        else:
            _orig_hover(self, **kwargs)

    def _humanized_scroll_into_view_if_needed(self, **kwargs):
        if _is_humanized(self):
            page = self.page
            cfg = _get_cfg(self)
            cursor = getattr(page, '_human_cursor', None)
            raw = getattr(page, '_human_raw_mouse', None)
            call_cfg = merge_config(cfg, kwargs.get("human_config")) if cfg else None
            if call_cfg is None or cursor is None or raw is None:
                if _orig_scroll_into_view is not None:
                    native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
                    return _orig_scroll_into_view(self, **native_kwargs)
                return
            timeout = kwargs.get("timeout", 30000)
            try:
                _, nx, ny, _ = human_scroll_into_view(
                    page, raw,
                    lambda: self.bounding_box(timeout=timeout),
                    cursor.x, cursor.y, call_cfg,
                )
                cursor.x = nx
                cursor.y = ny
            except Exception:
                if _orig_scroll_into_view is not None:
                    native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
                    _orig_scroll_into_view(self, **native_kwargs)
        elif _orig_scroll_into_view is not None:
            _orig_scroll_into_view(self, **kwargs)

    def _humanized_check(self, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                _orig_check(self, **kwargs)
                return
            fwd = _forward_kwargs(kwargs)
            cfg = _get_cfg(self)
            if cfg and cfg.idle_between_actions:
                raw = type("_R", (), {"move": self.page._original.mouse_move})()
                human_idle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), 0, 0, cfg)
            checked = self.is_checked()
            if not checked:
                tgt.click(_get_selector(self), **fwd)
        else:
            _orig_check(self, **kwargs)

    def _humanized_uncheck(self, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                _orig_uncheck(self, **kwargs)
                return
            fwd = _forward_kwargs(kwargs)
            cfg = _get_cfg(self)
            if cfg and cfg.idle_between_actions:
                raw = type("_R", (), {"move": self.page._original.mouse_move})()
                human_idle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), 0, 0, cfg)
            checked = self.is_checked()
            if checked:
                tgt.click(_get_selector(self), **fwd)
        else:
            _orig_uncheck(self, **kwargs)

    def _humanized_set_checked(self, checked, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                _orig_set_checked(self, checked, **kwargs)
                return
            fwd = _forward_kwargs(kwargs)
            current = self.is_checked()
            if current != checked:
                tgt.click(_get_selector(self), **fwd)
        else:
            _orig_set_checked(self, checked, **kwargs)

    def _humanized_select_option(self, value=None, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                _orig_select_option(self, value, **kwargs)
                return
            fwd = _forward_kwargs(kwargs)
            selector = _get_selector(self)
            tgt.hover(selector, **fwd)
            sleep_ms(rand(100, 300))
            _orig_select_option(self, value, **kwargs)
        else:
            _orig_select_option(self, value, **kwargs)

    def _humanized_press(self, key, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                _orig_press(self, key, **kwargs)
                return
            fwd = _forward_kwargs(kwargs)
            selector = _get_selector(self)
            if not _is_selector_focused(tgt, selector):
                tgt.click(selector, **fwd)
            sleep_ms(rand(50, 150))
            self.page.keyboard.press(key)
        else:
            _orig_press(self, key, **kwargs)

    def _humanized_press_sequentially(self, text, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                _orig_press_sequentially(self, text, **kwargs)
                return
            fwd = _forward_kwargs(kwargs)
            selector = _get_selector(self)
            if not _is_selector_focused(tgt, selector):
                tgt.click(selector, **fwd)
            sleep_ms(rand(50, 150))
            self.page.keyboard.type(text)
        else:
            _orig_press_sequentially(self, text, **kwargs)

    def _humanized_tap(self, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                _orig_tap(self, **kwargs)
            else:
                tgt.click(_get_selector(self), **_forward_kwargs(kwargs))
        else:
            _orig_tap(self, **kwargs)

    def _humanized_drag_to(self, target, **kwargs):
        if _is_humanized(self):
            page = self.page
            originals = getattr(page, '_original', None)
            src_box = self.bounding_box()
            tgt_box = target.bounding_box()
            if src_box and tgt_box and originals:
                sx = src_box['x'] + src_box['width'] / 2
                sy = src_box['y'] + src_box['height'] / 2
                tx = tgt_box['x'] + tgt_box['width'] / 2
                ty = tgt_box['y'] + tgt_box['height'] / 2
                page.mouse.move(sx, sy)
                sleep_ms(rand(100, 200))
                originals.mouse_down()
                sleep_ms(rand(80, 150))
                page.mouse.move(tx, ty)
                sleep_ms(rand(80, 150))
                originals.mouse_up()
            else:
                _orig_drag_to(self, target, **kwargs)
        else:
            _orig_drag_to(self, target, **kwargs)

    def _humanized_clear(self, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                _orig_clear(self, **kwargs)
                return
            fwd = _forward_kwargs(kwargs)
            selector = _get_selector(self)
            if not _is_selector_focused(tgt, selector):
                tgt.click(selector, **fwd)
            sleep_ms(rand(50, 100))
            self.page.keyboard.press(_SELECT_ALL)
            sleep_ms(rand(30, 80))
            self.page.keyboard.press("Backspace")
        else:
            _orig_clear(self, **kwargs)

    Locator.fill = _humanized_fill
    Locator.click = _humanized_click
    Locator.type = _humanized_type
    Locator.dblclick = _humanized_dblclick
    Locator.hover = _humanized_hover
    Locator.check = _humanized_check
    Locator.uncheck = _humanized_uncheck
    Locator.set_checked = _humanized_set_checked
    Locator.select_option = _humanized_select_option
    Locator.press = _humanized_press
    Locator.press_sequentially = _humanized_press_sequentially
    Locator.tap = _humanized_tap
    Locator.drag_to = _humanized_drag_to
    Locator.clear = _humanized_clear
    if _orig_scroll_into_view is not None:
        Locator.scroll_into_view_if_needed = _humanized_scroll_into_view_if_needed


# ============================================================================
# Locator class-level patching (async)
# ============================================================================

_locator_async_patched = False


def _patch_locator_class_async():
    """Patch all async Locator interaction methods to go through humanized page methods."""
    global _locator_async_patched
    if _locator_async_patched:
        return
    _locator_async_patched = True

    from playwright.async_api._generated import Locator as AsyncLocator

    _orig_fill = AsyncLocator.fill
    _orig_click = AsyncLocator.click
    _orig_type = AsyncLocator.type
    _orig_dblclick = AsyncLocator.dblclick
    _orig_hover = AsyncLocator.hover
    _orig_check = AsyncLocator.check
    _orig_uncheck = AsyncLocator.uncheck
    _orig_set_checked = AsyncLocator.set_checked
    _orig_select_option = AsyncLocator.select_option
    _orig_press = AsyncLocator.press
    _orig_press_sequentially = AsyncLocator.press_sequentially
    _orig_tap = AsyncLocator.tap
    _orig_drag_to = AsyncLocator.drag_to
    _orig_clear = AsyncLocator.clear
    _orig_scroll_into_view = getattr(AsyncLocator, 'scroll_into_view_if_needed', None)

    def _get_selector(self):
        return self._impl_obj._selector

    def _is_humanized(self):
        return hasattr(self.page, '_original')

    def _get_cfg(self):
        return getattr(self.page, '_human_cfg', None)

    def _route_target(self):
        """Resolution target for a humanized locator action (#428).

        Main-frame locators return the Page (unchanged behavior). A locator that
        belongs to a *sub-frame* returns that frame's humanized wrapper so the
        selector resolves in the frame's own document. Returns ``None`` when the
        owning sub-frame is known but not yet humanized, so the caller falls back
        to the native Playwright locator method (frame-correct, un-humanized).
        """
        page = self.page
        impl_frame = getattr(getattr(self, "_impl_obj", None), "_frame", None)
        if impl_frame is None:
            return page
        try:
            if impl_frame is page.main_frame._impl_obj:
                return page
            for f in page.frames:
                if getattr(f, "_impl_obj", None) is impl_frame:
                    return f if getattr(f, "_human_patched", False) else None
            # Frame not among page.frames (unknown/detached) -> legacy Page path.
            return page
        except Exception:
            return page

    def _forward_kwargs(kwargs):
        out = {}
        if "timeout" in kwargs:
            out["timeout"] = kwargs["timeout"]
        if "human_config" in kwargs:
            out["human_config"] = kwargs["human_config"]
        if "force" in kwargs:
            out["force"] = kwargs["force"]
        return out

    async def _humanized_fill(self, value, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                await _orig_fill(self, value, **kwargs)
            else:
                await tgt.fill(_get_selector(self), value, **_forward_kwargs(kwargs))
        else:
            await _orig_fill(self, value, **kwargs)

    async def _humanized_click(self, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                await _orig_click(self, **kwargs)
            else:
                await tgt.click(_get_selector(self), **_forward_kwargs(kwargs))
        else:
            await _orig_click(self, **kwargs)

    async def _humanized_type(self, text, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                await _orig_type(self, text, **kwargs)
            else:
                await tgt.type(_get_selector(self), text, **_forward_kwargs(kwargs))
        else:
            await _orig_type(self, text, **kwargs)

    async def _humanized_dblclick(self, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                await _orig_dblclick(self, **kwargs)
            else:
                await tgt.dblclick(_get_selector(self), **_forward_kwargs(kwargs))
        else:
            await _orig_dblclick(self, **kwargs)

    async def _humanized_hover(self, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                await _orig_hover(self, **kwargs)
            else:
                await tgt.hover(_get_selector(self), **_forward_kwargs(kwargs))
        else:
            await _orig_hover(self, **kwargs)

    async def _humanized_scroll_into_view_if_needed(self, **kwargs):
        if _is_humanized(self):
            page = self.page
            cfg = _get_cfg(self)
            cursor = getattr(page, '_human_cursor', None)
            raw = getattr(page, '_human_raw_mouse', None)
            call_cfg = merge_config(cfg, kwargs.get("human_config")) if cfg else None
            if call_cfg is None or cursor is None or raw is None:
                if _orig_scroll_into_view is not None:
                    native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
                    await _orig_scroll_into_view(self, **native_kwargs)
                return
            timeout = kwargs.get("timeout", 30000)

            async def _get_box():
                return await self.bounding_box(timeout=timeout)
            try:
                _, nx, ny, _ = await async_human_scroll_into_view(
                    page, raw, _get_box,
                    cursor.x, cursor.y, call_cfg,
                )
                cursor.x = nx
                cursor.y = ny
            except Exception:
                if _orig_scroll_into_view is not None:
                    native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
                    await _orig_scroll_into_view(self, **native_kwargs)
        elif _orig_scroll_into_view is not None:
            await _orig_scroll_into_view(self, **kwargs)

    async def _humanized_check(self, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                await _orig_check(self, **kwargs)
                return
            fwd = _forward_kwargs(kwargs)
            cfg = _get_cfg(self)
            if cfg and cfg.idle_between_actions:
                raw = type("_R", (), {"move": self.page._original.mouse_move})()
                await async_human_idle(
                    raw,
                    rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]),
                    0, 0, cfg,
                )
            checked = await self.is_checked()
            if not checked:
                await tgt.click(_get_selector(self), **fwd)
        else:
            await _orig_check(self, **kwargs)

    async def _humanized_uncheck(self, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                await _orig_uncheck(self, **kwargs)
                return
            fwd = _forward_kwargs(kwargs)
            cfg = _get_cfg(self)
            if cfg and cfg.idle_between_actions:
                raw = type("_R", (), {"move": self.page._original.mouse_move})()
                await async_human_idle(
                    raw,
                    rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]),
                    0, 0, cfg,
                )
            checked = await self.is_checked()
            if checked:
                await tgt.click(_get_selector(self), **fwd)
        else:
            await _orig_uncheck(self, **kwargs)

    async def _humanized_set_checked(self, checked, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                await _orig_set_checked(self, checked, **kwargs)
                return
            fwd = _forward_kwargs(kwargs)
            current = await self.is_checked()
            if current != checked:
                await tgt.click(_get_selector(self), **fwd)
        else:
            await _orig_set_checked(self, checked, **kwargs)

    async def _humanized_select_option(self, value=None, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                await _orig_select_option(self, value, **kwargs)
                return
            fwd = _forward_kwargs(kwargs)
            selector = _get_selector(self)
            await tgt.hover(selector, **fwd)
            await async_sleep_ms(rand(100, 300))
            await _orig_select_option(self, value, **kwargs)
        else:
            await _orig_select_option(self, value, **kwargs)

    async def _humanized_press(self, key, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                await _orig_press(self, key, **kwargs)
                return
            fwd = _forward_kwargs(kwargs)
            selector = _get_selector(self)
            if not await _async_is_selector_focused(tgt, selector):
                await tgt.click(selector, **fwd)
            await async_sleep_ms(rand(50, 150))
            await self.page.keyboard.press(key)
        else:
            await _orig_press(self, key, **kwargs)

    async def _humanized_press_sequentially(self, text, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                await _orig_press_sequentially(self, text, **kwargs)
                return
            fwd = _forward_kwargs(kwargs)
            selector = _get_selector(self)
            if not await _async_is_selector_focused(tgt, selector):
                await tgt.click(selector, **fwd)
            await async_sleep_ms(rand(50, 150))
            await self.page.keyboard.type(text)
        else:
            await _orig_press_sequentially(self, text, **kwargs)

    async def _humanized_tap(self, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                await _orig_tap(self, **kwargs)
            else:
                await tgt.click(_get_selector(self), **_forward_kwargs(kwargs))
        else:
            await _orig_tap(self, **kwargs)

    async def _humanized_drag_to(self, target, **kwargs):
        if _is_humanized(self):
            page = self.page
            originals = getattr(page, '_original', None)
            src_box = await self.bounding_box()
            tgt_box = await target.bounding_box()
            if src_box and tgt_box and originals:
                sx = src_box['x'] + src_box['width'] / 2
                sy = src_box['y'] + src_box['height'] / 2
                tx = tgt_box['x'] + tgt_box['width'] / 2
                ty = tgt_box['y'] + tgt_box['height'] / 2
                await page.mouse.move(sx, sy)
                await async_sleep_ms(rand(100, 200))
                await originals.mouse_down()
                await async_sleep_ms(rand(80, 150))
                await page.mouse.move(tx, ty)
                await async_sleep_ms(rand(80, 150))
                await originals.mouse_up()
            else:
                await _orig_drag_to(self, target, **kwargs)
        else:
            await _orig_drag_to(self, target, **kwargs)

    async def _humanized_clear(self, **kwargs):
        if _is_humanized(self):
            tgt = _route_target(self)
            if tgt is None:
                await _orig_clear(self, **kwargs)
                return
            fwd = _forward_kwargs(kwargs)
            selector = _get_selector(self)
            if not await _async_is_selector_focused(tgt, selector):
                await tgt.click(selector, **fwd)
            await async_sleep_ms(rand(50, 100))
            await self.page.keyboard.press(_SELECT_ALL)
            await async_sleep_ms(rand(30, 80))
            await self.page.keyboard.press("Backspace")
        else:
            await _orig_clear(self, **kwargs)

    AsyncLocator.fill = _humanized_fill
    AsyncLocator.click = _humanized_click
    AsyncLocator.type = _humanized_type
    AsyncLocator.dblclick = _humanized_dblclick
    AsyncLocator.hover = _humanized_hover
    AsyncLocator.check = _humanized_check
    AsyncLocator.uncheck = _humanized_uncheck
    AsyncLocator.set_checked = _humanized_set_checked
    AsyncLocator.select_option = _humanized_select_option
    AsyncLocator.press = _humanized_press
    AsyncLocator.press_sequentially = _humanized_press_sequentially
    AsyncLocator.tap = _humanized_tap
    AsyncLocator.drag_to = _humanized_drag_to
    AsyncLocator.clear = _humanized_clear
    if _orig_scroll_into_view is not None:
        AsyncLocator.scroll_into_view_if_needed = _humanized_scroll_into_view_if_needed


# ============================================================================
# SYNC patching
# ============================================================================


def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
    """Replace page methods with human-like implementations (sync)."""
    originals = type("Originals", (), {
        "click": page.click,
        "type": page.type,
        "fill": page.fill,
        "goto": page.goto,
        "hover": page.hover,
        "dblclick": page.dblclick,
        "select_option": page.select_option,
        "mouse_move": page.mouse.move,
        "mouse_click": page.mouse.click,
        "mouse_wheel": page.mouse.wheel,
        "mouse_down": page.mouse.down,
        "mouse_up": page.mouse.up,
        "keyboard_type": page.keyboard.type,
        "keyboard_down": page.keyboard.down,
        "keyboard_up": page.keyboard.up,
        "keyboard_press": page.keyboard.press,
        "keyboard_insert_text": page.keyboard.insert_text,
    })()

    page._original = originals
    page._human_cfg = cfg
    page._human_cursor = cursor

    # --- Stealth infrastructure ---
    try:
        stealth = _SyncIsolatedWorld(page)
        page._stealth_world = stealth
        cdp_session = stealth.get_cdp_session()
    except Exception:
        stealth = None
        page._stealth_world = None
        cdp_session = None
        logger.debug("Could not create CDP session — stealth features disabled")

    raw_mouse: RawMouse = type("_RawMouse", (), {
        "move": originals.mouse_move,
        "down": originals.mouse_down,
        "up": originals.mouse_up,
        "wheel": originals.mouse_wheel,
    })()

    raw_keyboard: RawKeyboard = type("_RawKeyboard", (), {
        "down": originals.keyboard_down,
        "up": originals.keyboard_up,
        "type": originals.keyboard_type,
        "insert_text": originals.keyboard_insert_text,
    })()

    page._human_raw_mouse = raw_mouse

    def _ensure_cursor_init() -> None:
        if not cursor.initialized:
            cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1])
            cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1])
            originals.mouse_move(cursor.x, cursor.y)
            cursor.initialized = True

    def _human_goto(url: str, **kwargs: Any) -> Any:
        response = originals.goto(url, **kwargs)
        # Invalidate isolated world after navigation (context ID becomes stale)
        if stealth is not None:
            stealth.invalidate()
        return response

    def _human_click(selector: str, **kwargs: Any) -> None:
        _ensure_cursor_init()
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        timeout = kwargs.get("timeout", 30000)
        force = kwargs.get("force", False)
        skip_checks = kwargs.pop("_skip_checks", False)
        deadline = time.monotonic() + timeout / 1000.0

        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)

        if not force and not skip_checks:
            ensure_actionable(page, selector, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
        if call_cfg.idle_between_actions:
            human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
        box, cx, cy, did_scroll = scroll_to_element(
            page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(),
        )
        cursor.x = cx
        cursor.y = cy
        is_input = _is_input_element(page, selector)
        if not force and did_scroll:
            ensure_stable(page, selector, timeout=_remaining_ms())
            box = page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box
        target = click_target(box, is_input, call_cfg)
        if not force:
            check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms())
        human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
        cursor.x = target.x
        cursor.y = target.y
        human_click(raw_mouse, is_input, call_cfg)

    def _human_dblclick(selector: str, **kwargs: Any) -> None:
        _ensure_cursor_init()
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        timeout = kwargs.get("timeout", 30000)
        force = kwargs.get("force", False)
        deadline = time.monotonic() + timeout / 1000.0

        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)

        if not force:
            ensure_actionable(page, selector, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
        if call_cfg.idle_between_actions:
            human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
        box, cx, cy, did_scroll = scroll_to_element(
            page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(),
        )
        cursor.x = cx
        cursor.y = cy
        is_input = _is_input_element(page, selector)
        if not force and did_scroll:
            ensure_stable(page, selector, timeout=_remaining_ms())
            box = page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box
        target = click_target(box, is_input, call_cfg)
        if not force:
            check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms())
        human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
        cursor.x = target.x
        cursor.y = target.y
        raw_mouse.down(click_count=2)
        sleep_ms(rand(30, 60))
        raw_mouse.up(click_count=2)

    def _human_hover(selector: str, **kwargs: Any) -> None:
        _ensure_cursor_init()
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        timeout = kwargs.get("timeout", 30000)
        force = kwargs.get("force", False)
        skip_checks = kwargs.pop("_skip_checks", False)
        deadline = time.monotonic() + timeout / 1000.0

        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)

        if not force and not skip_checks:
            ensure_actionable(page, selector, CHECKS_HOVER, timeout=_remaining_ms(), force=force)
        if call_cfg.idle_between_actions:
            human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
        box, cx, cy, did_scroll = scroll_to_element(
            page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(),
        )
        cursor.x = cx
        cursor.y = cy
        if not force and did_scroll:
            ensure_stable(page, selector, timeout=_remaining_ms())
            box = page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box
        target = click_target(box, False, call_cfg)
        if not force:
            check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms())
        human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
        cursor.x = target.x
        cursor.y = target.y

    def _human_type(selector: str, text: str, **kwargs: Any) -> None:
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        timeout = kwargs.get("timeout", 30000)
        force = kwargs.get("force", False)
        deadline = time.monotonic() + timeout / 1000.0

        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)

        if not force:
            ensure_actionable(page, selector, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
        sleep_ms(rand_range(call_cfg.field_switch_delay))
        _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))
        sleep_ms(rand(100, 250))
        human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp_session)

    def _human_fill(selector: str, value: str, **kwargs: Any) -> None:
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        timeout = kwargs.get("timeout", 30000)
        force = kwargs.get("force", False)
        deadline = time.monotonic() + timeout / 1000.0

        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)

        if not force:
            ensure_actionable(page, selector, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
        sleep_ms(rand_range(call_cfg.field_switch_delay))
        _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))
        sleep_ms(rand(100, 250))
        originals.keyboard_press(_SELECT_ALL)
        sleep_ms(rand(30, 80))
        originals.keyboard_press("Backspace")
        sleep_ms(rand(50, 150))
        human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp_session)

    def _human_check(selector: str, **kwargs: Any) -> None:
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0

        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)

        if not force:
            ensure_actionable(page, selector, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
        try:
            checked = page.is_checked(selector)
        except Exception:
            checked = False
        if not checked:
            _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))

    def _human_uncheck(selector: str, **kwargs: Any) -> None:
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0

        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)

        if not force:
            ensure_actionable(page, selector, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
        try:
            checked = page.is_checked(selector)
        except Exception:
            checked = True
        if checked:
            _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))

    def _human_select_option(selector: str, value: Any = None, **kwargs: Any) -> Any:
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0

        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)

        if not force:
            ensure_actionable(page, selector, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
        _human_hover(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))
        sleep_ms(rand(100, 300))
        pw_kwargs = {k: v for k, v in kwargs.items() if k not in ("human_config", "force")}
        return originals.select_option(selector, value, **pw_kwargs)

    def _human_press(selector: str, key: str, **kwargs: Any) -> None:
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0

        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)

        if not force:
            ensure_actionable(page, selector, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
        if not _is_selector_focused(page, selector):
            _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))
        sleep_ms(rand(50, 150))
        originals.keyboard_press(key)

    def _human_mouse_move(x: float, y: float, **kwargs: Any) -> None:
        _ensure_cursor_init()
        human_move(raw_mouse, cursor.x, cursor.y, x, y, cfg)
        cursor.x = x
        cursor.y = y

    def _human_mouse_click(x: float, y: float, **kwargs: Any) -> None:
        _ensure_cursor_init()
        human_move(raw_mouse, cursor.x, cursor.y, x, y, cfg)
        cursor.x = x
        cursor.y = y
        human_click(raw_mouse, False, cfg)

    def _human_keyboard_type(text: str, **kwargs: Any) -> None:
        human_type(page, raw_keyboard, text, cfg, cdp_session=cdp_session)

    page.goto = _human_goto
    page.click = _human_click
    page.dblclick = _human_dblclick
    page.hover = _human_hover
    page.type = _human_type
    page.fill = _human_fill
    page.check = _human_check
    page.uncheck = _human_uncheck
    page.select_option = _human_select_option
    page.press = _human_press
    page.mouse.move = _human_mouse_move
    page.mouse.click = _human_mouse_click
    page.keyboard.type = _human_keyboard_type
    # --- Patch Frame-level methods (for sub-frames) ---
    _patch_frames_sync(page, cfg, cursor, raw_mouse, raw_keyboard, originals)

    # --- Patch ElementHandle selectors (query_selector, query_selector_all, wait_for_selector) ---
    _patch_page_element_handles_sync(page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session)

    # Initialize cursor immediately so it doesn't visibly jump from (0,0)
    cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1])
    cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1])
    try:
        originals.mouse_move(cursor.x, cursor.y)
        cursor.initialized = True
    except Exception:
        pass

    # --- Patch Locator class (class-level, runs once) ---
    _patch_locator_class_sync()


# ============================================================================
# SYNC ElementHandle patching
# ============================================================================


def _is_input_element_handle_sync(el: Any) -> bool:
    """Check if an ElementHandle is an input/textarea/contenteditable (sync)."""
    try:
        return el.evaluate(
            """(node) => {
                const tag = node.tagName ? node.tagName.toLowerCase() : '';
                return tag === 'input' || tag === 'textarea'
                    || node.getAttribute && node.getAttribute('contenteditable') === 'true';
            }"""
        )
    except Exception:
        return False


def _patch_single_element_handle_sync(
    el: Any, page: Any, cfg: HumanConfig, cursor: _CursorState,
    raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any,
    stealth: Any, cdp_session: Any,
) -> None:
    """Patch all interaction methods on a sync Playwright ElementHandle."""
    if getattr(el, '_human_patched', False):
        return
    el._human_patched = True

    # Save originals
    _orig_click = el.click
    _orig_dblclick = el.dblclick
    _orig_hover = el.hover
    _orig_type = el.type
    _orig_fill = el.fill
    _orig_press = el.press
    _orig_select_option = el.select_option
    _orig_check = el.check
    _orig_uncheck = el.uncheck
    _orig_set_checked = getattr(el, 'set_checked', None)
    _orig_tap = el.tap
    _orig_focus = el.focus
    _orig_scroll_into_view = getattr(el, 'scroll_into_view_if_needed', None)

    # Nested selectors
    _orig_qs = el.query_selector
    _orig_qsa = el.query_selector_all
    _orig_wfs = el.wait_for_selector

    def _patched_qs(selector: str, **kwargs: Any) -> Any:
        child = _orig_qs(selector, **kwargs)
        if child is not None:
            _patch_single_element_handle_sync(
                child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
            )
        return child

    def _patched_qsa(selector: str, **kwargs: Any) -> Any:
        children = _orig_qsa(selector, **kwargs)
        for child in children:
            _patch_single_element_handle_sync(
                child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
            )
        return children

    def _patched_wfs(selector: str, **kwargs: Any) -> Any:
        child = _orig_wfs(selector, **kwargs)
        if child is not None:
            _patch_single_element_handle_sync(
                child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
            )
        return child

    el.query_selector = _patched_qs
    el.query_selector_all = _patched_qsa
    el.wait_for_selector = _patched_wfs

    # Helper: move cursor to element. Accepts optional ``call_cfg`` so per-call
    # ``human_config`` overrides on type/fill carry through to mouse timing.
    # Also scrolls into view first so off-screen elements don't silently fall
    # back to the unpatched native method (#129, #172 follow-up).
    def _move_to_element(call_cfg: HumanConfig = cfg):
        if not cursor.initialized:
            cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1])
            cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1])
            originals.mouse_move(cursor.x, cursor.y)
            cursor.initialized = True

        # Scroll into view first — best-effort. If the element can't be located
        # we fall through to bounding_box() below which returns None and lets
        # the caller fall back to the original Playwright method.
        try:
            _, nx, ny, _ = human_scroll_into_view(
                page, raw_mouse, lambda: el.bounding_box(),
                cursor.x, cursor.y, call_cfg,
            )
            cursor.x = nx
            cursor.y = ny
        except Exception:
            pass

        box = el.bounding_box()
        if not box:
            return None

        is_inp = _is_input_element_handle_sync(el)
        target = click_target(box, is_inp, call_cfg)

        if call_cfg.idle_between_actions:
            human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)

        human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
        cursor.x = target.x
        cursor.y = target.y
        return {'box': box, 'is_inp': is_inp}

    # --- el.click() ---
    def _human_el_click(**kwargs: Any) -> None:
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0
        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)
        if not force:
            ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
        info = _move_to_element(call_cfg)
        if info is None:
            return _orig_click(**kwargs)
        if not force:
            check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
        human_click(raw_mouse, info['is_inp'], call_cfg)

    # --- el.dblclick() ---
    def _human_el_dblclick(**kwargs: Any) -> None:
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0
        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)
        if not force:
            ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
        info = _move_to_element(call_cfg)
        if info is None:
            return _orig_dblclick(**kwargs)
        if not force:
            check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
        raw_mouse.down(click_count=2)
        sleep_ms(rand(30, 60))
        raw_mouse.up(click_count=2)

    # --- el.hover() ---
    def _human_el_hover(**kwargs: Any) -> None:
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0
        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)
        if not force:
            ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=_remaining_ms(), force=force)
        info = _move_to_element(call_cfg)
        if info is None:
            return _orig_hover(**kwargs)

    # --- el.type() ---
    def _human_el_type(text: str, **kwargs: Any) -> None:
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0
        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)
        if not force:
            ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
        info = _move_to_element(call_cfg)
        if info is None:
            return _orig_type(text, **kwargs)
        if not force:
            check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
        human_click(raw_mouse, info['is_inp'], call_cfg)
        sleep_ms(rand(100, 250))
        human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp_session)

    # --- el.fill() ---
    def _human_el_fill(value: str, **kwargs: Any) -> None:
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0
        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)
        if not force:
            ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
        info = _move_to_element(call_cfg)
        if info is None:
            return _orig_fill(value, **kwargs)
        if not force:
            check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
        human_click(raw_mouse, info['is_inp'], call_cfg)
        sleep_ms(rand(100, 250))
        originals.keyboard_press(_SELECT_ALL)
        sleep_ms(rand(30, 80))
        originals.keyboard_press("Backspace")
        sleep_ms(rand(50, 150))
        human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp_session)

    # --- el.scroll_into_view_if_needed() ---
    # Playwright's native version snaps the page — a strong bot signal.
    # Replace with the same accelerate → cruise → decelerate → overshoot wheel
    # sequence used by page.click(). Falls back to the native method if the
    # element is detached or scrolling fails.
    def _human_el_scroll_into_view_if_needed(**kwargs: Any) -> None:
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        if not cursor.initialized:
            cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1])
            cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1])
            try:
                originals.mouse_move(cursor.x, cursor.y)
                cursor.initialized = True
            except Exception:
                pass
        try:
            _, nx, ny, _ = human_scroll_into_view(
                page, raw_mouse, lambda: el.bounding_box(),
                cursor.x, cursor.y, call_cfg,
            )
            cursor.x = nx
            cursor.y = ny
        except Exception:
            if _orig_scroll_into_view is not None:
                native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
                _orig_scroll_into_view(**native_kwargs)

    # --- el.press() ---
    def _human_el_press(key: str, **kwargs: Any) -> None:
        sleep_ms(rand(20, 60))
        originals.keyboard_down(key)
        sleep_ms(rand_range(cfg.key_hold))
        originals.keyboard_up(key)

    # --- el.select_option() ---
    def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any:
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0
        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)
        if not force:
            ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
        info = _move_to_element()
        if info is None:
            return _orig_select_option(value, **kwargs)
        human_click(raw_mouse, False, cfg)
        sleep_ms(rand(100, 300))
        return _orig_select_option(value, **kwargs)

    # --- el.check() ---
    def _human_el_check(**kwargs: Any) -> None:
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0
        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)
        if not force:
            ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
        try:
            if el.is_checked():
                return
        except Exception:
            pass
        info = _move_to_element()
        if info is None:
            return _orig_check(**kwargs)
        if not force:
            check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
        human_click(raw_mouse, info['is_inp'], cfg)

    # --- el.uncheck() ---
    def _human_el_uncheck(**kwargs: Any) -> None:
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0
        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)
        if not force:
            ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
        try:
            if not el.is_checked():
                return
        except Exception:
            pass
        info = _move_to_element()
        if info is None:
            return _orig_uncheck(**kwargs)
        if not force:
            check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
        human_click(raw_mouse, info['is_inp'], cfg)

    # --- el.set_checked() ---
    def _human_el_set_checked(checked: bool, **kwargs: Any) -> None:
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0
        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)
        if not force:
            ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
        try:
            current = el.is_checked()
            if current == checked:
                return
        except Exception:
            pass
        info = _move_to_element()
        if info is None and _orig_set_checked:
            return _orig_set_checked(checked, **kwargs)
        if info:
            if not force:
                check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
            human_click(raw_mouse, info['is_inp'], cfg)

    # --- el.tap() ---
    def _human_el_tap(**kwargs: Any) -> None:
        info = _move_to_element()
        if info is None:
            return _orig_tap(**kwargs)
        human_click(raw_mouse, info['is_inp'], cfg)

    # --- el.focus() ---
    # FIX: move cursor humanly but use programmatic focus (no click side-effects).
    # Stock Playwright el.focus() never clicks — it just calls element.focus() in JS.
    # Clicking would trigger onclick, submit forms, navigate links, etc.
    def _human_el_focus() -> None:
        _move_to_element()  # human-like cursor movement (Bézier)
        _orig_focus()       # programmatic focus, no click side-effects

    el.click = _human_el_click
    el.dblclick = _human_el_dblclick
    el.hover = _human_el_hover
    el.type = _human_el_type
    el.fill = _human_el_fill
    el.press = _human_el_press
    el.select_option = _human_el_select_option
    el.check = _human_el_check
    el.uncheck = _human_el_uncheck
    if _orig_set_checked is not None:
        el.set_checked = _human_el_set_checked
    el.tap = _human_el_tap
    el.focus = _human_el_focus
    if _orig_scroll_into_view is not None:
        el.scroll_into_view_if_needed = _human_el_scroll_into_view_if_needed


def _patch_page_element_handles_sync(
    page: Any, cfg: HumanConfig, cursor: _CursorState,
    raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any,
    stealth: Any, cdp_session: Any,
) -> None:
    """Patch page.query_selector, query_selector_all, wait_for_selector to return humanized ElementHandles (sync)."""
    _orig_qs = page.query_selector
    _orig_qsa = page.query_selector_all
    _orig_wfs = page.wait_for_selector

    def _patched_qs(selector: str, **kwargs: Any) -> Any:
        el = _orig_qs(selector, **kwargs)
        if el is not None:
            _patch_single_element_handle_sync(
                el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
            )
        return el

    def _patched_qsa(selector: str, **kwargs: Any) -> Any:
        els = _orig_qsa(selector, **kwargs)
        for el in els:
            _patch_single_element_handle_sync(
                el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
            )
        return els

    def _patched_wfs(selector: str, **kwargs: Any) -> Any:
        el = _orig_wfs(selector, **kwargs)
        if el is not None:
            _patch_single_element_handle_sync(
                el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
            )
        return el

    page.query_selector = _patched_qs
    page.query_selector_all = _patched_qsa
    page.wait_for_selector = _patched_wfs


def _patch_frames_sync(
    page: Any, cfg: HumanConfig, cursor: _CursorState,
    raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any,
) -> None:
    for frame in _iter_frames(page):
        _patch_single_frame_sync(frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals)

    orig_main_frame = getattr(page, "_original_main_frame", None)
    if orig_main_frame is None:
        try:
            _orig_goto = originals.goto

            def _frame_aware_goto(url: str, **kwargs: Any) -> Any:
                response = _orig_goto(url, **kwargs)
                # Invalidate isolated world after navigation
                stealth_world = getattr(page, '_stealth_world', None)
                if stealth_world is not None:
                    stealth_world.invalidate()
                for frame in _iter_frames(page):
                    if not getattr(frame, "_human_patched", False):
                        _patch_single_frame_sync(frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals)
                return response

            page.goto = _frame_aware_goto
            page._original_main_frame = True
        except Exception:
            pass


def _patch_single_frame_sync(
    frame: Any, page: Any, cfg: HumanConfig, cursor: _CursorState,
    raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any,
) -> None:
    if getattr(frame, "_human_patched", False):
        return
    frame._human_patched = True

    # Frame-scoped resolution: humanize via ``frame.locator(selector)`` so the
    # selector resolves in the sub-frame's own document, while the mouse stays
    # page-level (bounding_box() is already page/viewport-space). Mirrors the JS
    # wrapper's patchSingleFrame/moveToFrameSelector. #428.
    _orig_frame_click = frame.click
    _orig_frame_dblclick = frame.dblclick
    _orig_frame_hover = frame.hover
    _orig_frame_type = frame.type
    _orig_frame_fill = frame.fill
    _orig_frame_check = frame.check
    _orig_frame_uncheck = frame.uncheck
    _orig_frame_select_option = frame.select_option
    _orig_frame_press = frame.press
    _orig_frame_drag_and_drop = getattr(frame, 'drag_and_drop', None)

    stealth_world = getattr(page, '_stealth_world', None)
    cdp_session = None
    if stealth_world is not None:
        try:
            cdp_session = stealth_world.get_cdp_session()
        except Exception:
            pass

    def _native_kwargs(kwargs: dict) -> dict:
        # Native Playwright frame methods don't accept our ``human_config`` kwarg.
        return {k: v for k, v in kwargs.items() if k != "human_config"}

    def _deadline_remaining(kwargs: dict):
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0
        return lambda: max(0.0, (deadline - time.monotonic()) * 1000)

    def _frame_is_input(selector: str) -> bool:
        try:
            return bool(frame.locator(selector).first.evaluate(
                "el => { const t = el.tagName.toLowerCase();"
                " return t === 'input' || t === 'textarea'"
                " || el.getAttribute('contenteditable') === 'true'; }"
            ))
        except Exception:
            return False

    def _frame_is_focused(selector: str) -> bool:
        try:
            return bool(frame.locator(selector).first.evaluate(
                "el => el === document.activeElement"
            ))
        except Exception:
            return False

    def _move_to_frame_selector(selector: str, kwargs: dict, input_bias: bool, remaining_ms):
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        if call_cfg.idle_between_actions:
            human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
        loc = frame.locator(selector).first
        try:
            loc.scroll_into_view_if_needed(timeout=max(1, remaining_ms()))
        except Exception:
            pass
        try:
            box = loc.bounding_box(timeout=max(1, remaining_ms()))
        except Exception:
            box = None
        if not box:
            return None
        is_input = input_bias or _frame_is_input(selector)
        target = click_target(box, is_input, call_cfg)
        human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
        cursor.x = target.x
        cursor.y = target.y
        return call_cfg, is_input

    def _frame_click(selector: str, **kwargs: Any) -> None:
        remaining_ms = _deadline_remaining(kwargs)
        moved = _move_to_frame_selector(selector, kwargs, False, remaining_ms)
        if moved is None:
            _orig_frame_click(selector, **_native_kwargs(kwargs))
            return
        human_click(raw_mouse, moved[1], moved[0])

    def _frame_dblclick(selector: str, **kwargs: Any) -> None:
        remaining_ms = _deadline_remaining(kwargs)
        moved = _move_to_frame_selector(selector, kwargs, False, remaining_ms)
        if moved is None:
            _orig_frame_dblclick(selector, **_native_kwargs(kwargs))
            return
        raw_mouse.down(click_count=2)
        sleep_ms(rand(30, 60))
        raw_mouse.up(click_count=2)

    def _frame_hover(selector: str, **kwargs: Any) -> None:
        remaining_ms = _deadline_remaining(kwargs)
        moved = _move_to_frame_selector(selector, kwargs, False, remaining_ms)
        if moved is None:
            _orig_frame_hover(selector, **_native_kwargs(kwargs))

    def _frame_type(selector: str, text: str, **kwargs: Any) -> None:
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        sleep_ms(rand_range(call_cfg.field_switch_delay))
        _frame_click(selector, **kwargs)
        sleep_ms(rand(100, 250))
        try:
            human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp_session)
        except Exception:
            _orig_frame_type(selector, text, **_native_kwargs(kwargs))

    def _frame_fill(selector: str, value: str, **kwargs: Any) -> None:
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        sleep_ms(rand_range(call_cfg.field_switch_delay))
        _frame_click(selector, **kwargs)
        sleep_ms(rand(100, 250))
        originals.keyboard_press(_SELECT_ALL)
        sleep_ms(rand(30, 80))
        originals.keyboard_press("Backspace")
        sleep_ms(rand(50, 150))
        try:
            human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp_session)
        except Exception:
            _orig_frame_fill(selector, value, **_native_kwargs(kwargs))

    def _frame_check(selector: str, **kwargs: Any) -> None:
        try:
            checked = frame.locator(selector).first.is_checked()
        except Exception:
            _orig_frame_check(selector, **_native_kwargs(kwargs))
            return
        if not checked:
            try:
                _frame_click(selector, **kwargs)
            except Exception:
                _orig_frame_check(selector, **_native_kwargs(kwargs))

    def _frame_uncheck(selector: str, **kwargs: Any) -> None:
        try:
            checked = frame.locator(selector).first.is_checked()
        except Exception:
            _orig_frame_uncheck(selector, **_native_kwargs(kwargs))
            return
        if checked:
            try:
                _frame_click(selector, **kwargs)
            except Exception:
                _orig_frame_uncheck(selector, **_native_kwargs(kwargs))

    def _frame_select_option(selector: str, value: Any = None, **kwargs: Any) -> Any:
        _frame_hover(selector, **kwargs)
        sleep_ms(rand(100, 300))
        return _orig_frame_select_option(selector, value, **_native_kwargs(kwargs))

    def _frame_press(selector: str, key: str, **kwargs: Any) -> None:
        if not _frame_is_focused(selector):
            _frame_click(selector, **kwargs)
        sleep_ms(rand(50, 150))
        originals.keyboard_press(key)

    def _frame_clear(selector: str, **kwargs: Any) -> None:
        if not _frame_is_focused(selector):
            _frame_click(selector, **kwargs)
        sleep_ms(rand(50, 100))
        originals.keyboard_press(_SELECT_ALL)
        sleep_ms(rand(30, 80))
        originals.keyboard_press("Backspace")

    def _frame_drag_and_drop(source: str, target: str, **kwargs: Any) -> None:
        try:
            src_box = frame.locator(source).bounding_box()
            tgt_box = frame.locator(target).bounding_box()
        except Exception:
            src_box = tgt_box = None
        if src_box and tgt_box:
            sx = src_box['x'] + src_box['width'] / 2
            sy = src_box['y'] + src_box['height'] / 2
            tx = tgt_box['x'] + tgt_box['width'] / 2
            ty = tgt_box['y'] + tgt_box['height'] / 2
            page.mouse.move(sx, sy)
            sleep_ms(rand(100, 200))
            originals.mouse_down()
            sleep_ms(rand(80, 150))
            page.mouse.move(tx, ty)
            sleep_ms(rand(80, 150))
            originals.mouse_up()
        elif _orig_frame_drag_and_drop:
            _orig_frame_drag_and_drop(source, target, **kwargs)

    frame.click = _frame_click
    frame.dblclick = _frame_dblclick
    frame.hover = _frame_hover
    frame.type = _frame_type
    frame.fill = _frame_fill
    frame.check = _frame_check
    frame.uncheck = _frame_uncheck
    frame.select_option = _frame_select_option
    frame.press = _frame_press
    frame.clear = _frame_clear
    frame.drag_and_drop = _frame_drag_and_drop

    # --- Patch frame-level ElementHandle selectors ---
    _patch_frame_element_handles_sync(
        frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth_world, cdp_session
    )


def _patch_frame_element_handles_sync(
    frame: Any, page: Any, cfg: HumanConfig, cursor: _CursorState,
    raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any,
    stealth: Any, cdp_session: Any,
) -> None:
    """Patch frame.query_selector, query_selector_all, wait_for_selector (sync)."""
    _orig_qs = frame.query_selector
    _orig_qsa = frame.query_selector_all
    _orig_wfs = frame.wait_for_selector

    def _patched_qs(selector: str, **kwargs: Any) -> Any:
        el = _orig_qs(selector, **kwargs)
        if el is not None:
            _patch_single_element_handle_sync(
                el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
            )
        return el

    def _patched_qsa(selector: str, **kwargs: Any) -> Any:
        els = _orig_qsa(selector, **kwargs)
        for el in els:
            _patch_single_element_handle_sync(
                el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
            )
        return els

    def _patched_wfs(selector: str, **kwargs: Any) -> Any:
        el = _orig_wfs(selector, **kwargs)
        if el is not None:
            _patch_single_element_handle_sync(
                el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
            )
        return el

    frame.query_selector = _patched_qs
    frame.query_selector_all = _patched_qsa
    frame.wait_for_selector = _patched_wfs



def _iter_frames(page: Any):
    try:
        main = page.main_frame
        yield main
        for child in main.child_frames:
            yield child
    except Exception:
        pass


def patch_context(context: Any, cfg: HumanConfig) -> None:
    cursor = _CursorState()
    for page in context.pages:
        patch_page(page, cfg, cursor)
    context.on("page", lambda p: patch_page(p, cfg, _CursorState()) if not hasattr(p, '_original') else None)

    orig_new_page = context.new_page

    def _patched_new_page(**kwargs: Any) -> Any:
        page = orig_new_page(**kwargs)
        if not hasattr(page, '_original'):
            patch_page(page, cfg, _CursorState())
        return page

    context.new_page = _patched_new_page


def patch_browser(browser: Any, cfg: HumanConfig) -> None:
    for context in browser.contexts:
        patch_context(context, cfg)

    orig_new_context = browser.new_context

    def _patched_new_context(**kwargs: Any) -> Any:
        context = orig_new_context(**kwargs)
        patch_context(context, cfg)
        return context

    browser.new_context = _patched_new_context

    orig_new_page = browser.new_page

    def _patched_new_page(**kwargs: Any) -> Any:
        page = orig_new_page(**kwargs)
        if not hasattr(page, '_original'):
            patch_page(page, cfg, _CursorState())
        return page

    browser.new_page = _patched_new_page


# ============================================================================
# ASYNC patching
# ============================================================================


def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
    """Replace page methods with human-like implementations (async)."""
    originals = type("Originals", (), {
        "click": page.click,
        "type": page.type,
        "fill": page.fill,
        "goto": page.goto,
        "hover": page.hover,
        "dblclick": page.dblclick,
        "select_option": page.select_option,
        "mouse_move": page.mouse.move,
        "mouse_click": page.mouse.click,
        "mouse_wheel": page.mouse.wheel,
        "mouse_down": page.mouse.down,
        "mouse_up": page.mouse.up,
        "keyboard_type": page.keyboard.type,
        "keyboard_down": page.keyboard.down,
        "keyboard_up": page.keyboard.up,
        "keyboard_press": page.keyboard.press,
        "keyboard_insert_text": page.keyboard.insert_text,
    })()

    page._original = originals
    page._human_cfg = cfg
    page._human_cursor = cursor

    # --- Stealth infrastructure (lazy-initialized, async) ---
    stealth = _AsyncIsolatedWorld(page)
    page._stealth_world = stealth
    cdp_session_holder: list[Any] = [None]  # mutable container for closure
    page._cdp_session_holder = cdp_session_holder  # expose for frame-level patching

    async def _ensure_cdp() -> Any:
        if cdp_session_holder[0] is None:
            try:
                cdp_session_holder[0] = await stealth.get_cdp_session()
            except Exception:
                logger.debug("Could not create async CDP session")
        return cdp_session_holder[0]

    raw_mouse: AsyncRawMouse = type("_AsyncRawMouse", (), {
        "move": originals.mouse_move,
        "down": originals.mouse_down,
        "up": originals.mouse_up,
        "wheel": originals.mouse_wheel,
    })()

    raw_keyboard: AsyncRawKeyboard = type("_AsyncRawKeyboard", (), {
        "down": originals.keyboard_down,
        "up": originals.keyboard_up,
        "type": originals.keyboard_type,
        "insert_text": originals.keyboard_insert_text,
    })()

    page._human_raw_mouse = raw_mouse

    async def _ensure_cursor_init() -> None:
        if not cursor.initialized:
            cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1])
            cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1])
            await originals.mouse_move(cursor.x, cursor.y)
            cursor.initialized = True

    async def _human_goto(url: str, **kwargs: Any) -> Any:
        response = await originals.goto(url, **kwargs)
        # Invalidate isolated world after navigation
        stealth.invalidate()
        return response

    async def _human_click(selector: str, **kwargs: Any) -> None:
        await _ensure_cursor_init()
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        timeout = kwargs.get("timeout", 30000)
        force = kwargs.get("force", False)
        skip_checks = kwargs.pop("_skip_checks", False)
        deadline = time.monotonic() + timeout / 1000.0

        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)

        if not force and not skip_checks:
            await async_ensure_actionable(page, selector, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
        if call_cfg.idle_between_actions:
            await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
        box, cx, cy, did_scroll = await async_scroll_to_element(
            page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(),
        )
        cursor.x = cx
        cursor.y = cy
        is_input = await _async_is_input_element(page, selector)
        if not force and did_scroll:
            await async_ensure_stable(page, selector, timeout=_remaining_ms())
            box = await page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box
        target = click_target(box, is_input, call_cfg)
        if not force:
            await async_check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms())
        await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
        cursor.x = target.x
        cursor.y = target.y
        await async_human_click(raw_mouse, is_input, call_cfg)

    async def _human_dblclick(selector: str, **kwargs: Any) -> None:
        await _ensure_cursor_init()
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        timeout = kwargs.get("timeout", 30000)
        force = kwargs.get("force", False)
        deadline = time.monotonic() + timeout / 1000.0

        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)

        if not force:
            await async_ensure_actionable(page, selector, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
        if call_cfg.idle_between_actions:
            await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
        box, cx, cy, did_scroll = await async_scroll_to_element(
            page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(),
        )
        cursor.x = cx
        cursor.y = cy
        is_input = await _async_is_input_element(page, selector)
        if not force and did_scroll:
            await async_ensure_stable(page, selector, timeout=_remaining_ms())
            box = await page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box
        target = click_target(box, is_input, call_cfg)
        if not force:
            await async_check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms())
        await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
        cursor.x = target.x
        cursor.y = target.y
        await raw_mouse.down(click_count=2)
        await async_sleep_ms(rand(30, 60))
        await raw_mouse.up(click_count=2)

    async def _human_hover(selector: str, **kwargs: Any) -> None:
        await _ensure_cursor_init()
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        timeout = kwargs.get("timeout", 30000)
        force = kwargs.get("force", False)
        skip_checks = kwargs.pop("_skip_checks", False)
        deadline = time.monotonic() + timeout / 1000.0

        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)

        if not force and not skip_checks:
            await async_ensure_actionable(page, selector, CHECKS_HOVER, timeout=_remaining_ms(), force=force)
        if call_cfg.idle_between_actions:
            await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
        box, cx, cy, did_scroll = await async_scroll_to_element(
            page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(),
        )
        cursor.x = cx
        cursor.y = cy
        if not force and did_scroll:
            await async_ensure_stable(page, selector, timeout=_remaining_ms())
            box = await page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box
        target = click_target(box, False, call_cfg)
        if not force:
            await async_check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms())
        await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
        cursor.x = target.x
        cursor.y = target.y

    async def _human_type(selector: str, text: str, **kwargs: Any) -> None:
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        timeout = kwargs.get("timeout", 30000)
        force = kwargs.get("force", False)
        deadline = time.monotonic() + timeout / 1000.0

        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)

        if not force:
            await async_ensure_actionable(page, selector, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
        await async_sleep_ms(rand_range(call_cfg.field_switch_delay))
        await _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))
        await async_sleep_ms(rand(100, 250))
        cdp = await _ensure_cdp()
        await async_human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp)

    async def _human_fill(selector: str, value: str, **kwargs: Any) -> None:
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        timeout = kwargs.get("timeout", 30000)
        force = kwargs.get("force", False)
        deadline = time.monotonic() + timeout / 1000.0

        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)

        if not force:
            await async_ensure_actionable(page, selector, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
        await async_sleep_ms(rand_range(call_cfg.field_switch_delay))
        await _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))
        await async_sleep_ms(rand(100, 250))
        await originals.keyboard_press(_SELECT_ALL)
        await async_sleep_ms(rand(30, 80))
        await originals.keyboard_press("Backspace")
        await async_sleep_ms(rand(50, 150))
        cdp = await _ensure_cdp()
        await async_human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp)

    async def _human_check(selector: str, **kwargs: Any) -> None:
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0

        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)

        if not force:
            await async_ensure_actionable(page, selector, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
        try:
            checked = await page.is_checked(selector)
        except Exception:
            checked = False
        if not checked:
            await _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))

    async def _human_uncheck(selector: str, **kwargs: Any) -> None:
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0

        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)

        if not force:
            await async_ensure_actionable(page, selector, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
        try:
            checked = await page.is_checked(selector)
        except Exception:
            checked = True
        if checked:
            await _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))

    async def _human_press(selector: str, key: str, **kwargs: Any) -> None:
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0

        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)

        if not force:
            await async_ensure_actionable(page, selector, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
        if not await _async_is_selector_focused(page, selector):
            await _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))
        await async_sleep_ms(rand(50, 150))
        await originals.keyboard_press(key)

    async def _human_select_option(selector: str, value: Any = None, **kwargs: Any) -> Any:
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0

        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)

        if not force:
            await async_ensure_actionable(page, selector, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
        await _human_hover(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))
        await async_sleep_ms(rand(100, 300))
        pw_kwargs = {k: v for k, v in kwargs.items() if k not in ("human_config", "force")}
        return await originals.select_option(selector, value, **pw_kwargs)

    async def _human_mouse_move(x: float, y: float, **kwargs: Any) -> None:
        await _ensure_cursor_init()
        await async_human_move(raw_mouse, cursor.x, cursor.y, x, y, cfg)
        cursor.x = x
        cursor.y = y

    async def _human_mouse_click(x: float, y: float, **kwargs: Any) -> None:
        await _ensure_cursor_init()
        await async_human_move(raw_mouse, cursor.x, cursor.y, x, y, cfg)
        cursor.x = x
        cursor.y = y
        await async_human_click(raw_mouse, False, cfg)

    async def _human_keyboard_type(text: str, **kwargs: Any) -> None:
        cdp = await _ensure_cdp()
        await async_human_type(page, raw_keyboard, text, cfg, cdp_session=cdp)

    page.goto = _human_goto
    page.click = _human_click
    page.dblclick = _human_dblclick
    page.hover = _human_hover
    page.type = _human_type
    page.fill = _human_fill
    page.check = _human_check
    page.uncheck = _human_uncheck
    page.select_option = _human_select_option
    page.press = _human_press
    page.mouse.move = _human_mouse_move
    page.mouse.click = _human_mouse_click
    page.keyboard.type = _human_keyboard_type

    # --- Patch Frame-level methods (for sub-frames) ---
    _patch_frames_async(page, cfg, cursor, raw_mouse, raw_keyboard, originals)

    # --- Patch ElementHandle selectors (query_selector, query_selector_all, wait_for_selector) ---
    _patch_page_element_handles_async(page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder)

    # --- Patch async Locator class (class-level, runs once) ---
    _patch_locator_class_async()


# ============================================================================
# ASYNC ElementHandle patching
# ============================================================================


async def _async_is_input_element_handle(el: Any) -> bool:
    """Check if an ElementHandle is an input/textarea/contenteditable (async)."""
    try:
        return await el.evaluate(
            """(node) => {
                const tag = node.tagName ? node.tagName.toLowerCase() : '';
                return tag === 'input' || tag === 'textarea'
                    || node.getAttribute && node.getAttribute('contenteditable') === 'true';
            }"""
        )
    except Exception:
        return False


def _patch_single_element_handle_async(
    el: Any, page: Any, cfg: HumanConfig, cursor: _CursorState,
    raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any,
    stealth: Any, cdp_session_holder: Any,
) -> None:
    """Patch all interaction methods on an async Playwright ElementHandle."""
    if getattr(el, '_human_patched', False):
        return
    el._human_patched = True

    # Save originals
    _orig_click = el.click
    _orig_dblclick = el.dblclick
    _orig_hover = el.hover
    _orig_type = el.type
    _orig_fill = el.fill
    _orig_press = el.press
    _orig_select_option = el.select_option
    _orig_check = el.check
    _orig_uncheck = el.uncheck
    _orig_set_checked = getattr(el, 'set_checked', None)
    _orig_tap = el.tap
    _orig_focus = el.focus
    _orig_scroll_into_view = getattr(el, 'scroll_into_view_if_needed', None)

    # Nested selectors
    _orig_qs = el.query_selector
    _orig_qsa = el.query_selector_all
    _orig_wfs = el.wait_for_selector

    async def _patched_qs(selector: str, **kwargs: Any) -> Any:
        child = await _orig_qs(selector, **kwargs)
        if child is not None:
            _patch_single_element_handle_async(
                child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
            )
        return child

    async def _patched_qsa(selector: str, **kwargs: Any) -> Any:
        children = await _orig_qsa(selector, **kwargs)
        for child in children:
            _patch_single_element_handle_async(
                child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
            )
        return children

    async def _patched_wfs(selector: str, **kwargs: Any) -> Any:
        child = await _orig_wfs(selector, **kwargs)
        if child is not None:
            _patch_single_element_handle_async(
                child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
            )
        return child

    el.query_selector = _patched_qs
    el.query_selector_all = _patched_qsa
    el.wait_for_selector = _patched_wfs

    # Helper: move cursor to element (async). Accepts optional ``call_cfg`` so
    # per-call ``human_config`` overrides on type/fill carry through to mouse
    # timing. Also scrolls into view first so off-screen elements work
    # (#129, #172 follow-up).
    async def _move_to_element(call_cfg: HumanConfig = cfg):
        if not cursor.initialized:
            cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1])
            cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1])
            await originals.mouse_move(cursor.x, cursor.y)
            cursor.initialized = True

        # Scroll into view first — best-effort.
        async def _get_box():
            return await el.bounding_box()
        try:
            _, nx, ny, _ = await async_human_scroll_into_view(
                page, raw_mouse, _get_box,
                cursor.x, cursor.y, call_cfg,
            )
            cursor.x = nx
            cursor.y = ny
        except Exception:
            pass

        box = await el.bounding_box()
        if not box:
            return None

        is_inp = await _async_is_input_element_handle(el)
        target = click_target(box, is_inp, call_cfg)

        if call_cfg.idle_between_actions:
            await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)

        await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
        cursor.x = target.x
        cursor.y = target.y
        return {'box': box, 'is_inp': is_inp}

    async def _get_cdp():
        if cdp_session_holder[0] is None:
            try:
                cdp_session_holder[0] = await stealth.get_cdp_session()
            except Exception:
                pass
        return cdp_session_holder[0]

    # --- el.click() ---
    async def _human_el_click(**kwargs: Any) -> None:
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0
        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)
        if not force:
            await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
        info = await _move_to_element(call_cfg)
        if info is None:
            return await _orig_click(**kwargs)
        if not force:
            await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
        await async_human_click(raw_mouse, info['is_inp'], call_cfg)

    # --- el.dblclick() ---
    async def _human_el_dblclick(**kwargs: Any) -> None:
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0
        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)
        if not force:
            await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
        info = await _move_to_element(call_cfg)
        if info is None:
            return await _orig_dblclick(**kwargs)
        if not force:
            await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
        await raw_mouse.down(click_count=2)
        await async_sleep_ms(rand(30, 60))
        await raw_mouse.up(click_count=2)

    # --- el.hover() ---
    async def _human_el_hover(**kwargs: Any) -> None:
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0
        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)
        if not force:
            await async_ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=_remaining_ms(), force=force)
        info = await _move_to_element(call_cfg)
        if info is None:
            return await _orig_hover(**kwargs)

    # --- el.type() ---
    async def _human_el_type(text: str, **kwargs: Any) -> None:
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0
        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)
        if not force:
            await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
        info = await _move_to_element(call_cfg)
        if info is None:
            return await _orig_type(text, **kwargs)
        if not force:
            await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
        await async_human_click(raw_mouse, info['is_inp'], call_cfg)
        await async_sleep_ms(rand(100, 250))
        cdp = await _get_cdp()
        await async_human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp)

    # --- el.fill() ---
    async def _human_el_fill(value: str, **kwargs: Any) -> None:
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0
        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)
        if not force:
            await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
        info = await _move_to_element(call_cfg)
        if info is None:
            return await _orig_fill(value, **kwargs)
        if not force:
            await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
        await async_human_click(raw_mouse, info['is_inp'], call_cfg)
        await async_sleep_ms(rand(100, 250))
        await originals.keyboard_press(_SELECT_ALL)
        await async_sleep_ms(rand(30, 80))
        await originals.keyboard_press("Backspace")
        await async_sleep_ms(rand(50, 150))
        cdp = await _get_cdp()
        await async_human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp)

    # --- el.scroll_into_view_if_needed() ---
    async def _human_el_scroll_into_view_if_needed(**kwargs: Any) -> None:
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        if not cursor.initialized:
            cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1])
            cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1])
            try:
                await originals.mouse_move(cursor.x, cursor.y)
                cursor.initialized = True
            except Exception:
                pass

        async def _get_box():
            return await el.bounding_box()
        try:
            _, nx, ny, _ = await async_human_scroll_into_view(
                page, raw_mouse, _get_box,
                cursor.x, cursor.y, call_cfg,
            )
            cursor.x = nx
            cursor.y = ny
        except Exception:
            if _orig_scroll_into_view is not None:
                native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
                await _orig_scroll_into_view(**native_kwargs)

    # --- el.press() ---
    async def _human_el_press(key: str, **kwargs: Any) -> None:
        await async_sleep_ms(rand(20, 60))
        await originals.keyboard_down(key)
        await async_sleep_ms(rand_range(cfg.key_hold))
        await originals.keyboard_up(key)

    # --- el.select_option() ---
    async def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any:
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0
        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)
        if not force:
            await async_ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
        info = await _move_to_element()
        if info is None:
            return await _orig_select_option(value, **kwargs)
        await async_human_click(raw_mouse, False, cfg)
        await async_sleep_ms(rand(100, 300))
        return await _orig_select_option(value, **kwargs)

    # --- el.check() ---
    async def _human_el_check(**kwargs: Any) -> None:
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0
        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)
        if not force:
            await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
        try:
            if await el.is_checked():
                return
        except Exception:
            pass
        info = await _move_to_element()
        if info is None:
            return await _orig_check(**kwargs)
        if not force:
            await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
        await async_human_click(raw_mouse, info['is_inp'], cfg)

    # --- el.uncheck() ---
    async def _human_el_uncheck(**kwargs: Any) -> None:
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0
        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)
        if not force:
            await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
        try:
            if not await el.is_checked():
                return
        except Exception:
            pass
        info = await _move_to_element()
        if info is None:
            return await _orig_uncheck(**kwargs)
        if not force:
            await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
        await async_human_click(raw_mouse, info['is_inp'], cfg)

    # --- el.set_checked() ---
    async def _human_el_set_checked(checked: bool, **kwargs: Any) -> None:
        force = kwargs.get("force", False)
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0
        def _remaining_ms():
            return max(0, (deadline - time.monotonic()) * 1000)
        if not force:
            await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
        try:
            current = await el.is_checked()
            if current == checked:
                return
        except Exception:
            pass
        info = await _move_to_element()
        if info is None and _orig_set_checked:
            return await _orig_set_checked(checked, **kwargs)
        if info:
            if not force:
                await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
            await async_human_click(raw_mouse, info['is_inp'], cfg)

    # --- el.tap() ---
    async def _human_el_tap(**kwargs: Any) -> None:
        info = await _move_to_element()
        if info is None:
            return await _orig_tap(**kwargs)
        await async_human_click(raw_mouse, info['is_inp'], cfg)

    # --- el.focus() ---
    # FIX: move cursor humanly but use programmatic focus (no click side-effects).
    # Stock Playwright el.focus() never clicks — it just calls element.focus() in JS.
    # Clicking would trigger onclick, submit forms, navigate links, etc.
    async def _human_el_focus() -> None:
        await _move_to_element()  # human-like cursor movement (Bézier)
        await _orig_focus()       # programmatic focus, no click side-effects

    el.click = _human_el_click
    el.dblclick = _human_el_dblclick
    el.hover = _human_el_hover
    el.type = _human_el_type
    el.fill = _human_el_fill
    el.press = _human_el_press
    el.select_option = _human_el_select_option
    el.check = _human_el_check
    el.uncheck = _human_el_uncheck
    if _orig_set_checked is not None:
        el.set_checked = _human_el_set_checked
    el.tap = _human_el_tap
    el.focus = _human_el_focus
    if _orig_scroll_into_view is not None:
        el.scroll_into_view_if_needed = _human_el_scroll_into_view_if_needed


def _patch_page_element_handles_async(
    page: Any, cfg: HumanConfig, cursor: _CursorState,
    raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any,
    stealth: Any, cdp_session_holder: Any,
) -> None:
    """Patch page.query_selector, query_selector_all, wait_for_selector to return humanized ElementHandles (async)."""
    _orig_qs = page.query_selector
    _orig_qsa = page.query_selector_all
    _orig_wfs = page.wait_for_selector

    async def _patched_qs(selector: str, **kwargs: Any) -> Any:
        el = await _orig_qs(selector, **kwargs)
        if el is not None:
            _patch_single_element_handle_async(
                el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
            )
        return el

    async def _patched_qsa(selector: str, **kwargs: Any) -> Any:
        els = await _orig_qsa(selector, **kwargs)
        for el in els:
            _patch_single_element_handle_async(
                el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
            )
        return els

    async def _patched_wfs(selector: str, **kwargs: Any) -> Any:
        el = await _orig_wfs(selector, **kwargs)
        if el is not None:
            _patch_single_element_handle_async(
                el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
            )
        return el

    page.query_selector = _patched_qs
    page.query_selector_all = _patched_qsa
    page.wait_for_selector = _patched_wfs


def _patch_frames_async(
    page: Any, cfg: HumanConfig, cursor: _CursorState,
    raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any,
) -> None:
    for frame in _iter_frames(page):
        _patch_single_frame_async(frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals)

    orig_main_frame = getattr(page, "_original_main_frame", None)
    if orig_main_frame is None:
        try:
            _orig_goto = originals.goto

            async def _frame_aware_goto(url: str, **kwargs: Any) -> Any:
                response = await _orig_goto(url, **kwargs)
                # Invalidate isolated world after navigation
                stealth_world = getattr(page, '_stealth_world', None)
                if stealth_world is not None:
                    stealth_world.invalidate()
                for frame in _iter_frames(page):
                    if not getattr(frame, "_human_patched", False):
                        _patch_single_frame_async(frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals)
                return response

            page.goto = _frame_aware_goto
            page._original_main_frame = True
        except Exception:
            pass


def _patch_single_frame_async(
    frame: Any, page: Any, cfg: HumanConfig, cursor: _CursorState,
    raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any,
) -> None:
    if getattr(frame, "_human_patched", False):
        return
    frame._human_patched = True

    # Frame-scoped resolution (async): humanize via ``frame.locator(selector)`` so
    # the selector resolves in the sub-frame's own document, while the mouse stays
    # page-level. Mirrors the sync patcher / JS moveToFrameSelector. #428.
    _orig_frame_click = frame.click
    _orig_frame_dblclick = frame.dblclick
    _orig_frame_hover = frame.hover
    _orig_frame_type = frame.type
    _orig_frame_fill = frame.fill
    _orig_frame_check = frame.check
    _orig_frame_uncheck = frame.uncheck
    _orig_frame_select_option = frame.select_option
    _orig_frame_press = frame.press
    _orig_frame_drag_and_drop = getattr(frame, 'drag_and_drop', None)

    stealth_world = getattr(page, '_stealth_world', None)
    cdp_session_holder = getattr(page, '_cdp_session_holder', [None])

    async def _get_cdp():
        if cdp_session_holder[0] is None and stealth_world is not None:
            try:
                cdp_session_holder[0] = await stealth_world.get_cdp_session()
            except Exception:
                cdp_session_holder[0] = None
        return cdp_session_holder[0]

    def _native_kwargs(kwargs: dict) -> dict:
        return {k: v for k, v in kwargs.items() if k != "human_config"}

    def _deadline_remaining(kwargs: dict):
        timeout = kwargs.get("timeout", 30000)
        deadline = time.monotonic() + timeout / 1000.0
        return lambda: max(0.0, (deadline - time.monotonic()) * 1000)

    async def _frame_is_input(selector: str) -> bool:
        try:
            return bool(await frame.locator(selector).first.evaluate(
                "el => { const t = el.tagName.toLowerCase();"
                " return t === 'input' || t === 'textarea'"
                " || el.getAttribute('contenteditable') === 'true'; }"
            ))
        except Exception:
            return False

    async def _frame_is_focused(selector: str) -> bool:
        try:
            return bool(await frame.locator(selector).first.evaluate(
                "el => el === document.activeElement"
            ))
        except Exception:
            return False

    async def _move_to_frame_selector(selector: str, kwargs: dict, input_bias: bool, remaining_ms):
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        if call_cfg.idle_between_actions:
            await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
        loc = frame.locator(selector).first
        try:
            await loc.scroll_into_view_if_needed(timeout=max(1, remaining_ms()))
        except Exception:
            pass
        try:
            box = await loc.bounding_box(timeout=max(1, remaining_ms()))
        except Exception:
            box = None
        if not box:
            return None
        is_input = input_bias or await _frame_is_input(selector)
        target = click_target(box, is_input, call_cfg)
        await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
        cursor.x = target.x
        cursor.y = target.y
        return call_cfg, is_input

    async def _frame_click(selector: str, **kwargs: Any) -> None:
        remaining_ms = _deadline_remaining(kwargs)
        moved = await _move_to_frame_selector(selector, kwargs, False, remaining_ms)
        if moved is None:
            await _orig_frame_click(selector, **_native_kwargs(kwargs))
            return
        await async_human_click(raw_mouse, moved[1], moved[0])

    async def _frame_dblclick(selector: str, **kwargs: Any) -> None:
        remaining_ms = _deadline_remaining(kwargs)
        moved = await _move_to_frame_selector(selector, kwargs, False, remaining_ms)
        if moved is None:
            await _orig_frame_dblclick(selector, **_native_kwargs(kwargs))
            return
        await raw_mouse.down(click_count=2)
        await async_sleep_ms(rand(30, 60))
        await raw_mouse.up(click_count=2)

    async def _frame_hover(selector: str, **kwargs: Any) -> None:
        remaining_ms = _deadline_remaining(kwargs)
        moved = await _move_to_frame_selector(selector, kwargs, False, remaining_ms)
        if moved is None:
            await _orig_frame_hover(selector, **_native_kwargs(kwargs))

    async def _frame_type(selector: str, text: str, **kwargs: Any) -> None:
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        await async_sleep_ms(rand_range(call_cfg.field_switch_delay))
        await _frame_click(selector, **kwargs)
        await async_sleep_ms(rand(100, 250))
        try:
            cdp = await _get_cdp()
            await async_human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp)
        except Exception:
            await _orig_frame_type(selector, text, **_native_kwargs(kwargs))

    async def _frame_fill(selector: str, value: str, **kwargs: Any) -> None:
        call_cfg = merge_config(cfg, kwargs.get("human_config"))
        await async_sleep_ms(rand_range(call_cfg.field_switch_delay))
        await _frame_click(selector, **kwargs)
        await async_sleep_ms(rand(100, 250))
        await originals.keyboard_press(_SELECT_ALL)
        await async_sleep_ms(rand(30, 80))
        await originals.keyboard_press("Backspace")
        await async_sleep_ms(rand(50, 150))
        try:
            cdp = await _get_cdp()
            await async_human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp)
        except Exception:
            await _orig_frame_fill(selector, value, **_native_kwargs(kwargs))

    async def _frame_check(selector: str, **kwargs: Any) -> None:
        try:
            checked = await frame.locator(selector).first.is_checked()
        except Exception:
            await _orig_frame_check(selector, **_native_kwargs(kwargs))
            return
        if not checked:
            try:
                await _frame_click(selector, **kwargs)
            except Exception:
                await _orig_frame_check(selector, **_native_kwargs(kwargs))

    async def _frame_uncheck(selector: str, **kwargs: Any) -> None:
        try:
            checked = await frame.locator(selector).first.is_checked()
        except Exception:
            await _orig_frame_uncheck(selector, **_native_kwargs(kwargs))
            return
        if checked:
            try:
                await _frame_click(selector, **kwargs)
            except Exception:
                await _orig_frame_uncheck(selector, **_native_kwargs(kwargs))

    async def _frame_select_option(selector: str, value: Any = None, **kwargs: Any) -> Any:
        await _frame_hover(selector, **kwargs)
        await async_sleep_ms(rand(100, 300))
        return await _orig_frame_select_option(selector, value, **_native_kwargs(kwargs))

    async def _frame_press(selector: str, key: str, **kwargs: Any) -> None:
        if not await _frame_is_focused(selector):
            await _frame_click(selector, **kwargs)
        await async_sleep_ms(rand(50, 150))
        await originals.keyboard_press(key)

    async def _frame_clear(selector: str, **kwargs: Any) -> None:
        if not await _frame_is_focused(selector):
            await _frame_click(selector, **kwargs)
        await async_sleep_ms(rand(50, 100))
        await originals.keyboard_press(_SELECT_ALL)
        await async_sleep_ms(rand(30, 80))
        await originals.keyboard_press("Backspace")

    async def _frame_drag_and_drop(source: str, target: str, **kwargs: Any) -> None:
        try:
            src_box = await frame.locator(source).bounding_box()
            tgt_box = await frame.locator(target).bounding_box()
        except Exception:
            src_box = tgt_box = None
        if src_box and tgt_box:
            sx = src_box['x'] + src_box['width'] / 2
            sy = src_box['y'] + src_box['height'] / 2
            tx = tgt_box['x'] + tgt_box['width'] / 2
            ty = tgt_box['y'] + tgt_box['height'] / 2
            await page.mouse.move(sx, sy)
            await async_sleep_ms(rand(100, 200))
            await originals.mouse_down()
            await async_sleep_ms(rand(80, 150))
            await page.mouse.move(tx, ty)
            await async_sleep_ms(rand(80, 150))
            await originals.mouse_up()
        elif _orig_frame_drag_and_drop:
            await _orig_frame_drag_and_drop(source, target, **kwargs)

    frame.click = _frame_click
    frame.dblclick = _frame_dblclick
    frame.hover = _frame_hover
    frame.type = _frame_type
    frame.fill = _frame_fill
    frame.check = _frame_check
    frame.uncheck = _frame_uncheck
    frame.select_option = _frame_select_option
    frame.press = _frame_press
    frame.clear = _frame_clear
    frame.drag_and_drop = _frame_drag_and_drop

    # --- Patch frame-level ElementHandle selectors (async) ---
    _patch_frame_element_handles_async(
        frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth_world, cdp_session_holder
    )


def _patch_frame_element_handles_async(
    frame: Any, page: Any, cfg: HumanConfig, cursor: _CursorState,
    raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any,
    stealth: Any, cdp_session_holder: Any,
) -> None:
    """Patch frame.query_selector, query_selector_all, wait_for_selector (async)."""
    _orig_qs = frame.query_selector
    _orig_qsa = frame.query_selector_all
    _orig_wfs = frame.wait_for_selector

    async def _patched_qs(selector: str, **kwargs: Any) -> Any:
        el = await _orig_qs(selector, **kwargs)
        if el is not None:
            _patch_single_element_handle_async(
                el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
            )
        return el

    async def _patched_qsa(selector: str, **kwargs: Any) -> Any:
        els = await _orig_qsa(selector, **kwargs)
        for el in els:
            _patch_single_element_handle_async(
                el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
            )
        return els

    async def _patched_wfs(selector: str, **kwargs: Any) -> Any:
        el = await _orig_wfs(selector, **kwargs)
        if el is not None:
            _patch_single_element_handle_async(
                el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
            )
        return el

    frame.query_selector = _patched_qs
    frame.query_selector_all = _patched_qsa
    frame.wait_for_selector = _patched_wfs



def patch_context_async(context: Any, cfg: HumanConfig) -> None:
    cursor = _CursorState()
    for page in context.pages:
        patch_page_async(page, cfg, cursor)
    context.on("page", lambda p: patch_page_async(p, cfg, _CursorState()) if not hasattr(p, '_original') else None)

    orig_new_page = context.new_page

    async def _patched_new_page(**kwargs: Any) -> Any:
        page = await orig_new_page(**kwargs)
        if not hasattr(page, '_original'):
            patch_page_async(page, cfg, _CursorState())
        return page

    context.new_page = _patched_new_page


def patch_browser_async(browser: Any, cfg: HumanConfig) -> None:
    for context in browser.contexts:
        patch_context_async(context, cfg)

    orig_new_context = browser.new_context

    async def _patched_new_context(**kwargs: Any) -> Any:
        context = await orig_new_context(**kwargs)
        patch_context_async(context, cfg)
        return context

    browser.new_context = _patched_new_context

    orig_new_page = browser.new_page

    async def _patched_new_page(**kwargs: Any) -> Any:
        page = await orig_new_page(**kwargs)
        if not hasattr(page, '_original'):
            patch_page_async(page, cfg, _CursorState())
        return page

    browser.new_page = _patched_new_page
