"""
Unit + integration tests for the humanize layer.

Fast unit tests (config, Bézier math, mocks) are proper test_ functions
that pytest discovers automatically.

Browser-dependent tests are marked @pytest.mark.slow and skipped in CI
unless explicitly requested (pytest -m slow).

Can also run directly: python tests/test_humanize_unit.py
"""
import math
import time
import sys
import asyncio
import pytest
from unittest.mock import MagicMock


def _mock_el_evaluate(is_input=False):
    """Mock evaluate that returns is_input for tagName checks and {hit: True} for pointer events."""
    def _eval(js, *args, **kwargs):
        if isinstance(js, str) and "elementFromPoint" in js:
            return {"hit": True}
        return is_input
    return MagicMock(side_effect=_eval)


def _async_mock_el_evaluate(is_input=False):
    """Async version of _mock_el_evaluate."""
    from unittest.mock import AsyncMock
    async def _eval(js, *args, **kwargs):
        if isinstance(js, str) and "elementFromPoint" in js:
            return {"hit": True}
        return is_input
    return AsyncMock(side_effect=_eval)


# =========================================================================
# Helper: ensure Locator class is patched before mock tests
# =========================================================================

def _ensure_locator_patched():
    import cloakbrowser.human as h
    h._locator_sync_patched = False
    h._patch_locator_class_sync()


# =========================================================================
# Helper: fake RawMouse for Bézier tests
# =========================================================================

class _FakeRawMouse:
    def __init__(self):
        self.moves = []
    def move(self, x, y, **kw):
        self.moves.append((x, y))
    def down(self, **kw):
        pass
    def up(self, **kw):
        pass
    def wheel(self, dx, dy):
        pass


# =========================================================================
# 1. Config resolution
# =========================================================================

class TestConfigResolution:
    def test_default_config_resolves(self):
        from cloakbrowser.human.config import resolve_config, HumanConfig
        cfg = resolve_config("default", None)
        assert isinstance(cfg, HumanConfig)
        assert cfg.mouse_min_steps > 0
        assert cfg.mouse_max_steps > cfg.mouse_min_steps
        assert len(cfg.initial_cursor_x) == 2
        assert len(cfg.initial_cursor_y) == 2
        assert cfg.typing_delay > 0

    def test_careful_config_resolves(self):
        from cloakbrowser.human.config import resolve_config
        cfg = resolve_config("careful", None)
        default_cfg = resolve_config("default", None)
        assert cfg.mouse_min_steps > 0
        assert cfg.typing_delay >= default_cfg.typing_delay

    def test_custom_override(self):
        from cloakbrowser.human.config import resolve_config
        cfg = resolve_config("default", {"mouse_min_steps": 100, "mouse_max_steps": 200})
        assert cfg.mouse_min_steps == 100
        assert cfg.mouse_max_steps == 200

    def test_invalid_preset_raises(self):
        from cloakbrowser.human.config import resolve_config
        with pytest.raises(ValueError, match="Unknown humanize preset"):
            resolve_config("nonexistent", None)

    def test_rand_within_bounds(self):
        from cloakbrowser.human.config import rand, rand_range
        for _ in range(200):
            v = rand(10, 20)
            assert 10 <= v <= 20
        for _ in range(200):
            v = rand_range([5, 15])
            assert 5 <= v <= 15

    def test_sleep_ms_timing(self):
        from cloakbrowser.human.config import sleep_ms
        t0 = time.time()
        sleep_ms(50)
        elapsed = (time.time() - t0) * 1000
        assert elapsed >= 40
        assert elapsed < 200


# =========================================================================
# 2. Bézier math
# =========================================================================

class TestBezierMath:
    def test_generates_multiple_points(self):
        from cloakbrowser.human.mouse import human_move
        from cloakbrowser.human.config import resolve_config
        cfg = resolve_config("default", None)
        raw = _FakeRawMouse()
        human_move(raw, 0, 0, 500, 300, cfg)
        assert len(raw.moves) >= 10
        last_x, last_y = raw.moves[-1]
        assert abs(last_x - 500) < 10
        assert abs(last_y - 300) < 10

    def test_smoothness_no_large_jumps(self):
        from cloakbrowser.human.mouse import human_move
        from cloakbrowser.human.config import resolve_config
        cfg = resolve_config("default", None)
        raw = _FakeRawMouse()
        human_move(raw, 0, 0, 400, 400, cfg)
        total_dist = math.sqrt(400**2 + 400**2)
        max_jump = total_dist * 0.5
        for i in range(1, len(raw.moves)):
            dx = raw.moves[i][0] - raw.moves[i-1][0]
            dy = raw.moves[i][1] - raw.moves[i-1][1]
            assert math.sqrt(dx*dx + dy*dy) < max_jump

    def test_short_distance(self):
        from cloakbrowser.human.mouse import human_move
        from cloakbrowser.human.config import resolve_config
        cfg = resolve_config("default", None)
        raw = _FakeRawMouse()
        human_move(raw, 100, 100, 103, 102, cfg)
        assert len(raw.moves) >= 1

    def test_not_straight_line(self):
        from cloakbrowser.human.mouse import human_move
        from cloakbrowser.human.config import resolve_config
        cfg = resolve_config("default", None)
        max_dev = 0
        for _ in range(5):
            raw = _FakeRawMouse()
            human_move(raw, 0, 0, 500, 0, cfg)
            dev = max(abs(y) for _, y in raw.moves)
            if dev > max_dev:
                max_dev = dev
        assert max_dev > 0.5

    def test_click_target_within_box(self):
        from cloakbrowser.human.mouse import click_target
        from cloakbrowser.human.config import resolve_config
        cfg = resolve_config("default", None)
        box = {"x": 100, "y": 200, "width": 150, "height": 40}
        for _ in range(50):
            t = click_target(box, False, cfg)
            assert 100 <= t.x <= 250
            assert 200 <= t.y <= 240

    def test_click_target_input_mode(self):
        from cloakbrowser.human.mouse import click_target
        from cloakbrowser.human.config import resolve_config
        cfg = resolve_config("default", None)
        box = {"x": 50, "y": 50, "width": 200, "height": 30}
        for _ in range(20):
            t = click_target(box, True, cfg)
            assert 50 <= t.x <= 250
            assert 50 <= t.y <= 80


# =========================================================================
# 3. Async compatibility
# =========================================================================

class TestAsyncCompat:
    def test_async_modules_import(self):
        from cloakbrowser.human.mouse_async import AsyncRawMouse, async_human_move
        from cloakbrowser.human.keyboard_async import AsyncRawKeyboard, async_human_type
        from cloakbrowser.human.scroll_async import async_scroll_to_element
        from cloakbrowser.human import patch_page_async, patch_browser_async, patch_context_async
        assert callable(async_human_move)
        assert callable(async_human_type)
        assert callable(async_scroll_to_element)

    def test_async_locator_patch(self):
        import cloakbrowser.human as h
        h._locator_async_patched = False
        h._patch_locator_class_async()
        assert h._locator_async_patched
        from playwright.async_api._generated import Locator as AsyncLocator
        assert 'humanized' in AsyncLocator.fill.__name__

    def test_async_sleep_is_coroutine(self):
        from cloakbrowser.human.config import async_sleep_ms
        import asyncio
        assert asyncio.iscoroutinefunction(async_sleep_ms)

    def test_patch_page_async_does_not_crash(self):
        """patch_page_async must not raise NameError for missing definitions."""
        import cloakbrowser.human as h
        from cloakbrowser.human import _CursorState
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock, AsyncMock

        cfg = resolve_config("default", {"idle_between_actions": False})
        cursor = _CursorState()
        cursor.initialized = True
        cursor.x = 100
        cursor.y = 100

        page = MagicMock()
        page.click = AsyncMock()
        page.dblclick = AsyncMock()
        page.hover = AsyncMock()
        page.type = AsyncMock()
        page.fill = AsyncMock()
        page.goto = AsyncMock()
        page.check = AsyncMock()
        page.uncheck = AsyncMock()
        page.select_option = AsyncMock()
        page.press = AsyncMock()
        page.is_checked = AsyncMock(return_value=False)
        page.viewport_size = {"width": 1280, "height": 720}
        page.evaluate = AsyncMock(return_value={"hit": True})
        page.context.new_cdp_session = AsyncMock(side_effect=Exception("no cdp"))
        page.mouse = MagicMock()
        page.mouse.move = AsyncMock()
        page.mouse.click = AsyncMock()
        page.mouse.wheel = AsyncMock()
        page.mouse.down = AsyncMock()
        page.mouse.up = AsyncMock()
        page.keyboard = MagicMock()
        page.keyboard.type = AsyncMock()
        page.keyboard.down = AsyncMock()
        page.keyboard.up = AsyncMock()
        page.keyboard.press = AsyncMock()
        page.keyboard.insert_text = AsyncMock()
        page.query_selector = AsyncMock(return_value=None)
        page.query_selector_all = AsyncMock(return_value=[])
        page.wait_for_selector = AsyncMock(return_value=None)
        page.main_frame = MagicMock()
        page.main_frame.return_value = MagicMock()
        page.main_frame.return_value.child_frames = MagicMock(return_value=[])
        page.main_frame.child_frames = MagicMock(return_value=[])

        h.patch_page_async(page, cfg, cursor)

        assert hasattr(page, '_original')
        assert page.select_option != AsyncMock


# =========================================================================
# 4. Focus check — press / clear / pressSequentially
# =========================================================================

class TestFocusCheck:
    def test_press_skips_click_when_focused(self):
        _ensure_locator_patched()
        from unittest.mock import MagicMock, patch as mock_patch
        page = MagicMock()
        page._original = MagicMock()
        page._human_cfg = MagicMock()
        page._human_cfg.idle_between_actions = False

        with mock_patch("cloakbrowser.human._is_selector_focused", return_value=True):
            from playwright.sync_api._generated import Locator
            loc = MagicMock()
            loc.page = page
            loc._impl_obj = MagicMock()
            loc._impl_obj._selector = "#test"
            Locator.press(loc, "Enter")

        page.click.assert_not_called()

    def test_press_clicks_when_not_focused(self):
        _ensure_locator_patched()
        from unittest.mock import MagicMock, patch as mock_patch
        page = MagicMock()
        page._original = MagicMock()
        page._human_cfg = MagicMock()
        page._human_cfg.idle_between_actions = False

        with mock_patch("cloakbrowser.human._is_selector_focused", return_value=False):
            from playwright.sync_api._generated import Locator
            loc = MagicMock()
            loc.page = page
            loc._impl_obj = MagicMock()
            loc._impl_obj._selector = "#test"
            Locator.press(loc, "Enter")

        page.click.assert_called_with("#test")


# =========================================================================
# 5. check/uncheck idle
# =========================================================================

class TestCheckUncheckIdle:
    def test_check_calls_idle_when_enabled(self):
        _ensure_locator_patched()
        from unittest.mock import MagicMock, patch as mock_patch
        from cloakbrowser.human.config import resolve_config
        cfg = resolve_config("default", {"idle_between_actions": True, "idle_between_duration": [50, 100]})

        page = MagicMock()
        page._original = MagicMock()
        page._original.mouse_move = MagicMock()
        page._human_cfg = cfg

        idle_called = {"n": 0}
        def fake_idle(*a, **kw):
            idle_called["n"] += 1

        from playwright.sync_api._generated import Locator
        loc = MagicMock()
        loc.page = page
        loc._impl_obj = MagicMock()
        loc._impl_obj._selector = "#checkbox"
        loc.is_checked = MagicMock(return_value=False)

        with mock_patch("cloakbrowser.human.human_idle", fake_idle):
            Locator.check(loc)

        assert idle_called["n"] >= 1

    def test_uncheck_calls_idle_when_enabled(self):
        _ensure_locator_patched()
        from unittest.mock import MagicMock, patch as mock_patch
        from cloakbrowser.human.config import resolve_config
        cfg = resolve_config("default", {"idle_between_actions": True, "idle_between_duration": [50, 100]})

        page = MagicMock()
        page._original = MagicMock()
        page._original.mouse_move = MagicMock()
        page._human_cfg = cfg

        idle_called = {"n": 0}
        def fake_idle(*a, **kw):
            idle_called["n"] += 1

        from playwright.sync_api._generated import Locator
        loc = MagicMock()
        loc.page = page
        loc._impl_obj = MagicMock()
        loc._impl_obj._selector = "#checkbox"
        loc.is_checked = MagicMock(return_value=True)

        with mock_patch("cloakbrowser.human.human_idle", fake_idle):
            Locator.uncheck(loc)

        assert idle_called["n"] >= 1


# =========================================================================
# 6. Frame patching completeness
# =========================================================================

class TestFramePatching:
    def test_all_11_methods_patched(self):
        from cloakbrowser.human import _patch_single_frame_sync, _CursorState
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock

        cfg = resolve_config("default", None)
        cursor = _CursorState()
        page = MagicMock()
        page._original = MagicMock()
        frame = MagicMock()
        frame._human_patched = False

        _patch_single_frame_sync(frame, page, cfg, cursor, MagicMock(), MagicMock(), page._original)

        expected = ['click', 'dblclick', 'hover', 'type', 'fill',
                    'check', 'uncheck', 'select_option', 'press',
                    'clear', 'drag_and_drop']
        for method in expected:
            fn = getattr(frame, method)
            assert not isinstance(fn, MagicMock), f"frame.{method} was not patched"


# =========================================================================
# 6b. iframe humanize routing (#428)
# =========================================================================

def _make_frame_for_routing(is_input=False, box=None):
    """A MagicMock frame whose locator resolves to a real bounding box."""
    from cloakbrowser.human import _patch_single_frame_sync, _CursorState
    from cloakbrowser.human.config import resolve_config
    cfg = resolve_config("default", None)
    cursor = _CursorState()
    page = MagicMock()
    page._original = MagicMock()
    page._stealth_world = None          # skip CDP path
    frame = MagicMock()
    frame._human_patched = False
    loc_first = frame.locator.return_value.first
    loc_first.bounding_box.return_value = box if box is not None else {
        "x": 10.0, "y": 10.0, "width": 40.0, "height": 20.0}
    loc_first.evaluate.return_value = is_input
    loc_first.is_checked.return_value = False
    orig_click = frame.click                                   # capture pre-patch
    _patch_single_frame_sync(frame, page, cfg, cursor, MagicMock(), MagicMock(), MagicMock())
    return frame, page, orig_click


class TestFrameHumanizedRouting:
    def test_frame_click_resolves_in_frame_not_page(self):
        from unittest.mock import patch as mock_patch
        frame, page, _ = _make_frame_for_routing()
        with mock_patch("cloakbrowser.human.human_move") as mv, \
             mock_patch("cloakbrowser.human.human_click") as clk:
            frame.click("#btn")
        # resolved through the frame's own locator, moved + clicked the real mouse,
        # and NEVER re-dispatched to page.click (the #428 bug)
        frame.locator.assert_any_call("#btn")
        assert mv.called and clk.called
        page.click.assert_not_called()

    def test_frame_click_falls_back_to_native_when_no_box(self):
        from unittest.mock import patch as mock_patch
        frame, page, orig_click = _make_frame_for_routing(box=False)  # bounding_box -> falsy
        with mock_patch("cloakbrowser.human.human_move") as mv, \
             mock_patch("cloakbrowser.human.human_click") as clk:
            frame.click("#btn")
        assert not mv.called and not clk.called
        orig_click.assert_called_once()          # native frame.click used
        page.click.assert_not_called()


def _make_locator_for_routing(frame_kind):
    """Build a MagicMock locator + page.frames for _route_target testing.

    frame_kind: 'main' | 'patched_sub' | 'unpatched_sub'
    Returns (loc, page, child_frame).
    """
    _ensure_locator_patched()
    page = MagicMock()
    page._original = MagicMock()
    main = MagicMock()
    child = MagicMock()
    child._human_patched = (frame_kind == "patched_sub")
    page.main_frame = main
    page.frames = [main, child]
    loc = MagicMock()
    loc.page = page
    loc._impl_obj._selector = "#btn"
    if frame_kind == "main":
        loc._impl_obj._frame = main._impl_obj
    else:
        loc._impl_obj._frame = child._impl_obj
    return loc, page, child


class TestLocatorSubframeRouting:
    def test_subframe_locator_routes_to_owning_frame(self):
        from playwright.sync_api._generated import Locator
        loc, page, child = _make_locator_for_routing("patched_sub")
        Locator.click(loc)
        child.click.assert_called_once()
        assert child.click.call_args[0][0] == "#btn"
        page.click.assert_not_called()

    def test_mainframe_locator_routes_to_page(self):
        from playwright.sync_api._generated import Locator
        loc, page, child = _make_locator_for_routing("main")
        Locator.click(loc)
        page.click.assert_called_once()
        assert page.click.call_args[0][0] == "#btn"
        child.click.assert_not_called()

    def test_unpatched_subframe_falls_back_to_native(self):
        from playwright.sync_api._generated import Locator
        loc, page, child = _make_locator_for_routing("unpatched_sub")
        # native Locator.click is invoked on the mock; neither humanized path runs
        Locator.click(loc)
        child.click.assert_not_called()
        page.click.assert_not_called()


class TestLocatorSubframeRoutingAsync:
    @pytest.mark.asyncio
    async def test_async_subframe_locator_routes_to_owning_frame(self):
        import cloakbrowser.human as h
        from unittest.mock import AsyncMock
        h._locator_async_patched = False
        h._patch_locator_class_async()
        from playwright.async_api._generated import Locator as AsyncLocator

        page = MagicMock()
        page._original = MagicMock()
        main = MagicMock()
        child = MagicMock()
        child._human_patched = True
        child.click = AsyncMock()
        page.main_frame = main
        page.frames = [main, child]
        loc = MagicMock()
        loc.page = page
        loc._impl_obj._selector = "#btn"
        loc._impl_obj._frame = child._impl_obj

        await AsyncLocator.click(loc)
        child.click.assert_awaited_once()
        assert child.click.call_args[0][0] == "#btn"


class TestFrameClickArgContract:
    """#428 guard: _frame_click must call human_click(raw, is_input, cfg) in order.
    The routing tests mock human_click, so a swap would slip through."""

    def test_frame_click_passes_is_input_bool_and_cfg(self):
        from unittest.mock import patch as mock_patch
        from cloakbrowser.human.config import HumanConfig
        frame, page, _ = _make_frame_for_routing(is_input=False)  # button
        with mock_patch("cloakbrowser.human.human_move"), \
             mock_patch("cloakbrowser.human.human_click") as clk:
            frame.click("#btn")
        args = clk.call_args[0]
        assert isinstance(args[1], bool) and args[1] is False   # is_input
        assert isinstance(args[2], HumanConfig)                 # cfg

    def test_frame_click_marks_is_input_true_for_input(self):
        from unittest.mock import patch as mock_patch
        frame, page, _ = _make_frame_for_routing(is_input=True)
        with mock_patch("cloakbrowser.human.human_move"), \
             mock_patch("cloakbrowser.human.human_click") as clk:
            frame.click("#inp")
        assert clk.call_args[0][1] is True


class TestFrameFillFocus:
    def test_frame_fill_clicks_before_typing(self):
        from unittest.mock import patch as mock_patch
        frame, page, _ = _make_frame_for_routing(is_input=True)
        with mock_patch("cloakbrowser.human.human_move"), \
             mock_patch("cloakbrowser.human.human_click"), \
             mock_patch("cloakbrowser.human.human_type") as htype:
            frame.fill("#inp", "hello")
        assert htype.called and htype.call_args[0][2] == "hello"

    def test_frame_fill_falls_back_when_type_raises(self):
        from unittest.mock import patch as mock_patch
        frame, page, _ = _make_frame_for_routing(is_input=True)
        with mock_patch("cloakbrowser.human.human_move"), \
             mock_patch("cloakbrowser.human.human_click"), \
             mock_patch("cloakbrowser.human.human_type", side_effect=RuntimeError("boom")):
            frame.fill("#inp", "hello")  # must not raise — native fallback


# =========================================================================
# 7. drag_to safety
# =========================================================================

class TestDragToSafety:
    def test_handles_missing_original(self):
        _ensure_locator_patched()
        from playwright.sync_api._generated import Locator
        from unittest.mock import MagicMock

        page = MagicMock()
        page._original = None

        source_loc = MagicMock()
        source_loc.page = page
        source_loc._impl_obj = MagicMock()
        source_loc._impl_obj._selector = "#src"
        source_loc.bounding_box = MagicMock(return_value={"x": 10, "y": 10, "width": 50, "height": 50})

        target_loc = MagicMock()
        target_loc.page = page
        target_loc._impl_obj = MagicMock()
        target_loc._impl_obj._selector = "#tgt"
        target_loc.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 50, "height": 50})

        try:
            Locator.drag_to(source_loc, target_loc)
        except AttributeError:
            pytest.fail("drag_to crashed without page._original")


# =========================================================================
# 8. Page config persistence
# =========================================================================

class TestPageConfigPersistence:
    def test_resolve_config_has_all_fields(self):
        from cloakbrowser.human.config import resolve_config
        cfg = resolve_config("default")
        required = ["mouse_min_steps", "mouse_max_steps", "typing_delay",
                    "initial_cursor_x", "initial_cursor_y", "idle_between_actions",
                    "idle_between_duration", "field_switch_delay",
                    "mistype_chance", "mistype_delay_notice", "mistype_delay_correct"]
        for field in required:
            assert hasattr(cfg, field), f"Config missing field: {field}"


# =========================================================================
# 9. Mistype config
# =========================================================================

class TestMistypeConfig:
    def test_default_mistype_chance(self):
        from cloakbrowser.human.config import resolve_config
        cfg = resolve_config("default")
        assert 0 < cfg.mistype_chance < 1
        assert len(cfg.mistype_delay_notice) == 2
        assert len(cfg.mistype_delay_correct) == 2

    def test_careful_mistype_higher(self):
        from cloakbrowser.human.config import resolve_config
        default = resolve_config("default")
        careful = resolve_config("careful")
        assert careful.mistype_chance >= default.mistype_chance


# =========================================================================
# 10. Select-all platform detection
# =========================================================================

class TestSelectAllPlatform:
    def test_select_all_constant_exists(self):
        from cloakbrowser.human import _SELECT_ALL
        assert _SELECT_ALL in ("Meta+a", "Control+a")

    def test_select_all_matches_platform(self):
        import sys
        from cloakbrowser.human import _SELECT_ALL
        if sys.platform == "darwin":
            assert _SELECT_ALL == "Meta+a"
        else:
            assert _SELECT_ALL == "Control+a"


# =========================================================================
# 11. Non-ASCII keyboard input
# =========================================================================

class TestNonAsciiKeyboard:
    def test_cyrillic_uses_insert_text(self):
        from cloakbrowser.human.keyboard import human_type
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock

        cfg = resolve_config("default", {"mistype_chance": 0})
        page = MagicMock()
        raw = MagicMock()

        down_keys = []
        inserted = []
        raw.down = MagicMock(side_effect=lambda k: down_keys.append(k))
        raw.up = MagicMock()
        raw.insert_text = MagicMock(side_effect=lambda t: inserted.append(t))

        human_type(page, raw, "Привет", cfg)

        assert "".join(inserted) == "Привет"
        for k in down_keys:
            assert ord(k[0]) < 128 or k in ("Shift", "Backspace")

    def test_mixed_ascii_cyrillic(self):
        from cloakbrowser.human.keyboard import human_type
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock

        cfg = resolve_config("default", {"mistype_chance": 0})
        page = MagicMock()
        raw = MagicMock()

        down_keys = []
        inserted = []
        raw.down = MagicMock(side_effect=lambda k: down_keys.append(k))
        raw.up = MagicMock()
        raw.insert_text = MagicMock(side_effect=lambda t: inserted.append(t))

        human_type(page, raw, "Hi Мир", cfg)

        assert "H" in down_keys
        assert "i" in down_keys
        assert "М" in "".join(inserted)

    def test_cjk_uses_insert_text(self):
        from cloakbrowser.human.keyboard import human_type
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock

        cfg = resolve_config("default", {"mistype_chance": 0})
        page = MagicMock()
        raw = MagicMock()

        inserted = []
        raw.down = MagicMock()
        raw.up = MagicMock()
        raw.insert_text = MagicMock(side_effect=lambda t: inserted.append(t))

        human_type(page, raw, "你好", cfg)

        assert "".join(inserted) == "你好"

    def test_mistype_only_ascii(self):
        from cloakbrowser.human.keyboard import human_type
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock

        cfg = resolve_config("default", {"mistype_chance": 1.0})
        page = MagicMock()
        raw = MagicMock()

        down_keys = []
        raw.down = MagicMock(side_effect=lambda k: down_keys.append(k))
        raw.up = MagicMock()
        raw.insert_text = MagicMock()

        human_type(page, raw, "AБ", cfg)

        assert "Backspace" in down_keys

    def test_no_error_on_cyrillic(self):
        from cloakbrowser.human.keyboard import human_type
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock

        cfg = resolve_config("default", {"mistype_chance": 0})
        page = MagicMock()
        raw = MagicMock()
        raw.down = MagicMock()
        raw.up = MagicMock()
        raw.insert_text = MagicMock()

        # Should not raise
        human_type(page, raw, "Тест кириллицы", cfg)


class TestNonAsciiKeyboardAsync:
    @pytest.mark.asyncio
    async def test_async_cyrillic_uses_insert_text(self):
        from cloakbrowser.human.keyboard_async import async_human_type
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock, AsyncMock

        cfg = resolve_config("default", {"mistype_chance": 0})
        page = MagicMock()
        raw = MagicMock()

        inserted = []
        raw.down = AsyncMock()
        raw.up = AsyncMock()
        raw.insert_text = AsyncMock(side_effect=lambda t: inserted.append(t))

        await async_human_type(page, raw, "Привет", cfg)

        assert "".join(inserted) == "Привет"



# =========================================================================
# SLOW TESTS — require browser (skipped in CI unless pytest -m slow)
# =========================================================================

@pytest.mark.slow
class TestBrowserFill:
    def test_fill_clears_existing(self):
        from cloakbrowser import launch
        browser = launch(headless=False, humanize=True)
        page = browser.new_page()
        page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
        time.sleep(1)
        page.locator('#searchInput').type('initial text')
        time.sleep(0.5)
        page.locator('#searchInput').fill('replaced text')
        time.sleep(0.5)
        val = page.locator('#searchInput').input_value()
        assert val == 'replaced text'
        assert 'initial' not in val
        browser.close()

    def test_fill_timing_humanized(self):
        from cloakbrowser import launch
        browser = launch(headless=False, humanize=True)
        page = browser.new_page()
        page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
        time.sleep(1)
        t0 = time.time()
        page.locator('#searchInput').fill('Human speed test')
        elapsed_ms = int((time.time() - t0) * 1000)
        assert elapsed_ms > 1000
        browser.close()

    def test_clear_empties_field(self):
        from cloakbrowser import launch
        browser = launch(headless=False, humanize=True)
        page = browser.new_page()
        page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
        time.sleep(1)
        page.locator('#searchInput').fill('some text')
        time.sleep(0.5)
        page.locator('#searchInput').clear()
        time.sleep(0.5)
        val = page.locator('#searchInput').input_value()
        assert val == ''
        browser.close()


# =========================================================================
# 6c. iframe humanize end-to-end (#428) — real same-origin iframe over HTTP
# =========================================================================

import threading
import http.server
import socketserver
import contextlib

_IFRAME_PARENT = (
    b"<html><body><h1>parent</h1>"
    b"<iframe name='myframe' src='/child.html' width=400 height=250></iframe>"
    b"</body></html>"
)
_IFRAME_CHILD = (
    b"<html><body>"
    b"<button id='btn' onclick=\"this.textContent='CLICKED'\">Click Me</button>"
    b"<input id='inp'>"
    b"</body></html>"
)


@contextlib.contextmanager
def _iframe_server():
    """Serve a parent page + same-origin child page with a button/input."""
    class _H(http.server.BaseHTTPRequestHandler):
        def do_GET(self):
            body = _IFRAME_CHILD if self.path.startswith("/child") else _IFRAME_PARENT
            self.send_response(200)
            self.send_header("Content-Type", "text/html")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

        def log_message(self, *a):
            pass

    srv = socketserver.TCPServer(("127.0.0.1", 0), _H)
    port = srv.server_address[1]
    threading.Thread(target=srv.serve_forever, daemon=True).start()
    try:
        yield f"http://127.0.0.1:{port}/"
    finally:
        srv.shutdown()


@pytest.mark.slow
class TestBrowserIframeHumanize:
    """Regression for #428: humanize=True must interact with elements inside
    sub-frames instead of misrouting to the top document."""

    def test_humanized_click_inside_iframe(self):
        from cloakbrowser import launch
        with _iframe_server() as url:
            browser = launch(headless=True, humanize=True)
            try:
                page = browser.new_page()
                page.goto(url, wait_until="networkidle")
                frame = page.frame(name="myframe")
                assert frame is not None
                # the #428 case: a Locator obtained from a sub-frame
                frame.locator("#btn").click(timeout=5000)
                assert frame.locator("#btn").text_content() == "CLICKED"
            finally:
                browser.close()

    def test_humanized_fill_inside_iframe(self):
        from cloakbrowser import launch
        with _iframe_server() as url:
            browser = launch(headless=True, humanize=True)
            try:
                page = browser.new_page()
                page.goto(url, wait_until="networkidle")
                frame = page.frame(name="myframe")
                frame.locator("#inp").fill("hello", timeout=5000)
                assert frame.locator("#inp").input_value() == "hello"
            finally:
                browser.close()

    def test_native_control_click_inside_iframe(self):
        """Control: humanize=False must also work (parity)."""
        from cloakbrowser import launch
        with _iframe_server() as url:
            browser = launch(headless=True, humanize=False)
            try:
                page = browser.new_page()
                page.goto(url, wait_until="networkidle")
                frame = page.frame(name="myframe")
                frame.locator("#btn").click(timeout=5000)
                assert frame.locator("#btn").text_content() == "CLICKED"
            finally:
                browser.close()


@pytest.mark.slow
class TestBrowserIframeHumanizeAsync:
    @pytest.mark.asyncio
    async def test_async_humanized_click_inside_iframe(self):
        from cloakbrowser import launch_async
        with _iframe_server() as url:
            browser = await launch_async(headless=True, humanize=True)
            try:
                page = await browser.new_page()
                await page.goto(url, wait_until="networkidle")
                frame = page.frame(name="myframe")
                assert frame is not None
                await frame.locator("#btn").click(timeout=5000)
                assert await frame.locator("#btn").text_content() == "CLICKED"
            finally:
                await browser.close()


@pytest.mark.slow
class TestBrowserPatching:
    def test_page_has_original(self):
        from cloakbrowser import launch
        browser = launch(headless=False, humanize=True)
        page = browser.new_page()
        assert hasattr(page, '_original')
        assert hasattr(page, '_human_cfg')
        browser.close()

    def test_locator_methods_patched(self):
        from cloakbrowser import launch
        browser = launch(headless=False, humanize=True)
        page = browser.new_page()
        from playwright.sync_api._generated import Locator
        methods = ['fill', 'click', 'type', 'dblclick', 'hover', 'check', 'uncheck',
                   'set_checked', 'select_option', 'press', 'press_sequentially',
                   'tap', 'drag_to', 'clear']
        for method in methods:
            fn = getattr(Locator, method)
            assert 'humanized' in fn.__name__, f"{method} not patched"
        browser.close()

    def test_non_humanized_page_normal(self):
        from playwright.sync_api import sync_playwright
        with sync_playwright() as p:
            browser = p.chromium.launch(headless=True)
            page = browser.new_page()
            assert not hasattr(page, '_original')
            browser.close()

    def test_page_human_cfg_persists(self):
        from cloakbrowser import launch
        browser = launch(headless=False, humanize=True)
        page = browser.new_page()
        assert page._human_cfg is not None
        assert hasattr(page._human_cfg, 'idle_between_actions')
        assert hasattr(page._human_cfg, 'mistype_chance')
        browser.close()


@pytest.mark.slow
class TestBrowserBotDetection:
    PROXY = None

    def test_behavioral_checks_pass(self):
        from cloakbrowser import launch
        browser = launch(headless=False, humanize=True, proxy=self.PROXY, geoip=True)
        page = browser.new_page()
        page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions',
                   wait_until='domcontentloaded')
        time.sleep(3)
        page.locator('#email').click()
        time.sleep(0.3)
        page.locator('#email').fill('test@example.com')
        time.sleep(0.5)
        page.locator('#password').click()
        time.sleep(0.3)
        page.locator('#password').fill('SecurePass!123')
        time.sleep(0.5)
        page.locator('#loginForm button[type="submit"]').click()
        time.sleep(5)
        body = page.locator('body').text_content()
        assert '"superHumanSpeed": true' not in body
        assert '"suspiciousClientSideBehavior": true' not in body
        browser.close()

    def test_form_timing(self):
        from cloakbrowser import launch
        browser = launch(headless=False, humanize=True, proxy=self.PROXY, geoip=True)
        page = browser.new_page()
        page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions',
                   wait_until='domcontentloaded')
        time.sleep(2)
        t0 = time.time()
        page.locator('#email').fill('test@example.com')
        page.locator('#password').fill('MyPassword!99')
        page.locator('#loginForm button[type="submit"]').click()
        elapsed_ms = int((time.time() - t0) * 1000)
        time.sleep(3)
        assert elapsed_ms > 3000
        browser.close()


@pytest.mark.slow
class TestAsyncEndToEnd:
    @pytest.mark.asyncio
    async def test_async_launch_click_fill(self):
        """launch_async(humanize=True) — async page.click and page.fill work end-to-end."""
        from cloakbrowser import launch_async
        
        browser = await launch_async(headless=False, humanize=True)
        page = await browser.new_page()
        assert hasattr(page, '_original'), "async page not patched"
        assert hasattr(page, '_human_cfg'), "async page missing _human_cfg"

        await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
        await asyncio.sleep(1)

        t0 = time.time()
        await page.locator('#searchInput').fill('async test')
        elapsed_ms = int((time.time() - t0) * 1000)
        assert elapsed_ms > 500, f"async fill too fast: {elapsed_ms}ms"

        val = await page.locator('#searchInput').input_value()
        assert val == 'async test', f"async fill wrong value: {val}"

        await browser.close()


# =========================================================================
# 12. ElementHandle patching — SYNC
# =========================================================================

class TestElementHandlePatchingSync:
    """Test that ElementHandle objects returned by query_selector etc. are humanized."""

    def test_patch_single_element_handle_marks_patched(self):
        from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock

        cfg = resolve_config("default", None)
        cursor = _CursorState()
        cursor.initialized = True
        cursor.x = 100
        cursor.y = 100
        page = MagicMock()
        page._original = MagicMock()
        el = MagicMock()
        el._human_patched = False
        el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
        el.evaluate = _mock_el_evaluate(is_input=True)
        el.is_checked = MagicMock(return_value=False)
        el.query_selector = MagicMock(return_value=None)
        el.query_selector_all = MagicMock(return_value=[])
        el.wait_for_selector = MagicMock(return_value=None)

        raw_mouse = MagicMock()
        raw_keyboard = MagicMock()

        _patch_single_element_handle_sync(
            el, page, cfg, cursor, raw_mouse, raw_keyboard, page._original, None, None
        )

        assert el._human_patched is True

    def test_element_handle_click_calls_human_move(self):
        from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock

        cfg = resolve_config("default", {"idle_between_actions": False})
        cursor = _CursorState()
        cursor.initialized = True
        cursor.x = 100
        cursor.y = 100
        page = MagicMock()
        page._original = MagicMock()

        el = MagicMock()
        el._human_patched = False
        el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
        el.evaluate = _mock_el_evaluate(is_input=False)
        el.is_checked = MagicMock(return_value=False)
        el.query_selector = MagicMock(return_value=None)
        el.query_selector_all = MagicMock(return_value=[])
        el.wait_for_selector = MagicMock(return_value=None)

        raw_mouse = MagicMock()
        raw_mouse.move = MagicMock()
        raw_mouse.down = MagicMock()
        raw_mouse.up = MagicMock()
        raw_mouse.wheel = MagicMock()
        raw_keyboard = MagicMock()

        _patch_single_element_handle_sync(
            el, page, cfg, cursor, raw_mouse, raw_keyboard, page._original, None, None
        )

        # Call the patched click
        el.click()

        # Should call raw_mouse.move (Bezier path) and then down/up
        assert raw_mouse.move.called
        assert raw_mouse.down.called
        assert raw_mouse.up.called

    def test_element_handle_hover_moves_cursor_without_click(self):
        from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock

        cfg = resolve_config("default", {"idle_between_actions": False})
        cursor = _CursorState()
        cursor.initialized = True
        cursor.x = 50
        cursor.y = 50
        page = MagicMock()
        page._original = MagicMock()

        el = MagicMock()
        el._human_patched = False
        el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
        el.evaluate = _mock_el_evaluate(is_input=False)
        el.is_checked = MagicMock(return_value=False)
        el.query_selector = MagicMock(return_value=None)
        el.query_selector_all = MagicMock(return_value=[])
        el.wait_for_selector = MagicMock(return_value=None)

        raw_mouse = MagicMock()
        raw_mouse.move = MagicMock()
        raw_mouse.down = MagicMock()
        raw_mouse.up = MagicMock()
        raw_mouse.wheel = MagicMock()
        raw_keyboard = MagicMock()

        _patch_single_element_handle_sync(
            el, page, cfg, cursor, raw_mouse, raw_keyboard, page._original, None, None
        )

        el.hover()

        # Move should be called, but NOT down/up (hover, not click)
        assert raw_mouse.move.called
        assert not raw_mouse.down.called

    def test_element_handle_type_calls_human_type(self):
        from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock

        cfg = resolve_config("default", {"idle_between_actions": False, "mistype_chance": 0})
        cursor = _CursorState()
        cursor.initialized = True
        cursor.x = 50
        cursor.y = 50
        page = MagicMock()
        originals = MagicMock()
        page._original = originals

        el = MagicMock()
        el._human_patched = False
        el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
        el.evaluate = _mock_el_evaluate(is_input=True)  # is input
        el.is_checked = MagicMock(return_value=False)
        el.query_selector = MagicMock(return_value=None)
        el.query_selector_all = MagicMock(return_value=[])
        el.wait_for_selector = MagicMock(return_value=None)

        raw_mouse = MagicMock()
        raw_mouse.move = MagicMock()
        raw_mouse.down = MagicMock()
        raw_mouse.up = MagicMock()
        raw_mouse.wheel = MagicMock()
        raw_keyboard = MagicMock()
        raw_keyboard.down = MagicMock()
        raw_keyboard.up = MagicMock()
        raw_keyboard.insert_text = MagicMock()

        _patch_single_element_handle_sync(
            el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, None, None
        )

        el.type("hello")

        # Mouse moved + clicked (to focus), then keyboard used
        assert raw_mouse.move.called
        assert raw_mouse.down.called  # click to focus the input
        # Keyboard events should have fired (down/up for ASCII chars)
        assert raw_keyboard.down.called or raw_keyboard.insert_text.called

    def test_element_handle_fill_clears_and_types(self):
        from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock, call

        cfg = resolve_config("default", {"idle_between_actions": False, "mistype_chance": 0})
        cursor = _CursorState()
        cursor.initialized = True
        cursor.x = 50
        cursor.y = 50
        page = MagicMock()
        originals = MagicMock()
        page._original = originals

        pressed_keys = []
        originals.keyboard_press = MagicMock(side_effect=lambda k: pressed_keys.append(k))

        el = MagicMock()
        el._human_patched = False
        el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
        el.evaluate = _mock_el_evaluate(is_input=True)
        el.is_checked = MagicMock(return_value=False)
        el.query_selector = MagicMock(return_value=None)
        el.query_selector_all = MagicMock(return_value=[])
        el.wait_for_selector = MagicMock(return_value=None)

        raw_mouse = MagicMock()
        raw_mouse.move = MagicMock()
        raw_mouse.down = MagicMock()
        raw_mouse.up = MagicMock()
        raw_mouse.wheel = MagicMock()
        raw_keyboard = MagicMock()
        raw_keyboard.down = MagicMock()
        raw_keyboard.up = MagicMock()
        raw_keyboard.insert_text = MagicMock()

        _patch_single_element_handle_sync(
            el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, None, None
        )

        el.fill("replaced")

        # Should have pressed Select-All and Backspace to clear
        import sys
        expected_select = "Meta+a" if sys.platform == "darwin" else "Control+a"
        assert expected_select in pressed_keys
        assert "Backspace" in pressed_keys

    def test_element_handle_no_double_patching(self):
        from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock

        cfg = resolve_config("default", None)
        cursor = _CursorState()
        page = MagicMock()
        page._original = MagicMock()
        el = MagicMock()
        el._human_patched = False
        el.query_selector = MagicMock(return_value=None)
        el.query_selector_all = MagicMock(return_value=[])
        el.wait_for_selector = MagicMock(return_value=None)

        _patch_single_element_handle_sync(
            el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None
        )

        # Save patched click
        first_click = el.click

        # Try to patch again
        _patch_single_element_handle_sync(
            el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None
        )

        # Should be the same — no double wrap
        assert el.click is first_click

    def test_nested_query_selector_returns_patched_handle(self):
        from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock

        cfg = resolve_config("default", None)
        cursor = _CursorState()
        page = MagicMock()
        page._original = MagicMock()

        child = MagicMock()
        child._human_patched = False
        child.bounding_box = MagicMock(return_value={"x": 10, "y": 10, "width": 50, "height": 30})
        child.evaluate = MagicMock(return_value=False)
        child.is_checked = MagicMock(return_value=False)
        child.query_selector = MagicMock(return_value=None)
        child.query_selector_all = MagicMock(return_value=[])
        child.wait_for_selector = MagicMock(return_value=None)

        el = MagicMock()
        el._human_patched = False
        el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
        el.evaluate = _mock_el_evaluate(is_input=False)
        el.is_checked = MagicMock(return_value=False)
        el.query_selector = MagicMock(return_value=child)
        el.query_selector_all = MagicMock(return_value=[])
        el.wait_for_selector = MagicMock(return_value=None)

        _patch_single_element_handle_sync(
            el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None
        )

        result = el.query_selector("span")
        assert result._human_patched is True

    def test_page_query_selector_patched(self):
        from cloakbrowser.human import _patch_page_element_handles_sync, _patch_single_element_handle_sync, _CursorState
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock

        cfg = resolve_config("default", None)
        cursor = _CursorState()

        el = MagicMock()
        el._human_patched = False
        el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
        el.evaluate = _mock_el_evaluate(is_input=False)
        el.is_checked = MagicMock(return_value=False)
        el.query_selector = MagicMock(return_value=None)
        el.query_selector_all = MagicMock(return_value=[])
        el.wait_for_selector = MagicMock(return_value=None)

        page = MagicMock()
        page._original = MagicMock()
        page.query_selector = MagicMock(return_value=el)
        page.query_selector_all = MagicMock(return_value=[el])
        page.wait_for_selector = MagicMock(return_value=el)

        _patch_page_element_handles_sync(
            page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None
        )

        result = page.query_selector("#test")
        assert result._human_patched is True

    def test_page_query_selector_all_patches_all(self):
        from cloakbrowser.human import _patch_page_element_handles_sync, _CursorState
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock

        cfg = resolve_config("default", None)
        cursor = _CursorState()

        def make_el():
            e = MagicMock()
            e._human_patched = False
            e.bounding_box = MagicMock(return_value={"x": 10, "y": 10, "width": 50, "height": 30})
            e.evaluate = MagicMock(return_value=False)
            e.is_checked = MagicMock(return_value=False)
            e.query_selector = MagicMock(return_value=None)
            e.query_selector_all = MagicMock(return_value=[])
            e.wait_for_selector = MagicMock(return_value=None)
            return e

        el1, el2, el3 = make_el(), make_el(), make_el()

        page = MagicMock()
        page._original = MagicMock()
        page.query_selector = MagicMock(return_value=None)
        page.query_selector_all = MagicMock(return_value=[el1, el2, el3])
        page.wait_for_selector = MagicMock(return_value=None)

        _patch_page_element_handles_sync(
            page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None
        )

        results = page.query_selector_all("div")
        for r in results:
            assert r._human_patched is True

    def test_wait_for_selector_patched(self):
        from cloakbrowser.human import _patch_page_element_handles_sync, _CursorState
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock

        cfg = resolve_config("default", None)
        cursor = _CursorState()

        el = MagicMock()
        el._human_patched = False
        el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
        el.evaluate = _mock_el_evaluate(is_input=False)
        el.is_checked = MagicMock(return_value=False)
        el.query_selector = MagicMock(return_value=None)
        el.query_selector_all = MagicMock(return_value=[])
        el.wait_for_selector = MagicMock(return_value=None)

        page = MagicMock()
        page._original = MagicMock()
        page.query_selector = MagicMock(return_value=None)
        page.query_selector_all = MagicMock(return_value=[])
        page.wait_for_selector = MagicMock(return_value=el)

        _patch_page_element_handles_sync(
            page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None
        )

        result = page.wait_for_selector("#test")
        assert result._human_patched is True

    def test_element_handle_all_methods_patched(self):
        """Verify all expected interaction methods are replaced."""
        from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock

        cfg = resolve_config("default", None)
        cursor = _CursorState()
        page = MagicMock()
        page._original = MagicMock()

        el = MagicMock()
        el._human_patched = False
        el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
        el.evaluate = _mock_el_evaluate(is_input=False)
        el.is_checked = MagicMock(return_value=False)
        el.query_selector = MagicMock(return_value=None)
        el.query_selector_all = MagicMock(return_value=[])
        el.wait_for_selector = MagicMock(return_value=None)
        el.set_checked = MagicMock()  # ensure it exists

        _patch_single_element_handle_sync(
            el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None
        )

        expected_methods = ['click', 'dblclick', 'hover', 'type', 'fill', 'press',
                            'select_option', 'check', 'uncheck', 'set_checked',
                            'tap', 'focus', 'query_selector', 'query_selector_all',
                            'wait_for_selector']
        for method in expected_methods:
            fn = getattr(el, method)
            assert not isinstance(fn, MagicMock), f"el.{method} was not patched"


# =========================================================================
# 13. ElementHandle patching — ASYNC
# =========================================================================

class TestElementHandlePatchingAsync:
    @pytest.mark.asyncio
    async def test_async_element_handle_click(self):
        from cloakbrowser.human import _patch_single_element_handle_async, _CursorState
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock, AsyncMock

        cfg = resolve_config("default", {"idle_between_actions": False})
        cursor = _CursorState()
        cursor.initialized = True
        cursor.x = 100
        cursor.y = 100

        page = MagicMock()
        originals = MagicMock()
        originals.mouse_move = AsyncMock()
        page._original = originals

        el = MagicMock()
        el._human_patched = False
        el.bounding_box = AsyncMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
        el.evaluate = _async_mock_el_evaluate(is_input=False)
        el.is_checked = AsyncMock(return_value=False)
        el.wait_for_element_state = AsyncMock()
        el.query_selector = AsyncMock(return_value=None)
        el.query_selector_all = AsyncMock(return_value=[])
        el.wait_for_selector = AsyncMock(return_value=None)

        raw_mouse = MagicMock()
        raw_mouse.move = AsyncMock()
        raw_mouse.down = AsyncMock()
        raw_mouse.up = AsyncMock()
        raw_mouse.wheel = AsyncMock()
        raw_keyboard = MagicMock()
        raw_keyboard.down = AsyncMock()
        raw_keyboard.up = AsyncMock()
        raw_keyboard.insert_text = AsyncMock()

        stealth = MagicMock()
        stealth.get_cdp_session = AsyncMock(return_value=None)

        _patch_single_element_handle_async(
            el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, [None]
        )

        await el.click()

        assert raw_mouse.move.called
        assert raw_mouse.down.called
        assert raw_mouse.up.called

    @pytest.mark.asyncio
    async def test_async_page_query_selector_patched(self):
        from cloakbrowser.human import _patch_page_element_handles_async, _CursorState
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock, AsyncMock

        cfg = resolve_config("default", None)
        cursor = _CursorState()

        el = MagicMock()
        el._human_patched = False
        el.bounding_box = AsyncMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
        el.evaluate = _async_mock_el_evaluate(is_input=False)
        el.is_checked = AsyncMock(return_value=False)
        el.wait_for_element_state = AsyncMock()
        el.query_selector = AsyncMock(return_value=None)
        el.query_selector_all = AsyncMock(return_value=[])
        el.wait_for_selector = AsyncMock(return_value=None)

        page = MagicMock()
        page._original = MagicMock()
        page.query_selector = AsyncMock(return_value=el)
        page.query_selector_all = AsyncMock(return_value=[el])
        page.wait_for_selector = AsyncMock(return_value=el)

        stealth = MagicMock()
        stealth.get_cdp_session = AsyncMock(return_value=None)

        _patch_page_element_handles_async(
            page, cfg, cursor, MagicMock(), MagicMock(), page._original, stealth, [None]
        )

        result = await page.query_selector("#test")
        assert result._human_patched is True


# =========================================================================
# 14. SLOW: Browser ElementHandle end-to-end
# =========================================================================

@pytest.mark.slow
class TestBrowserElementHandle:
    def test_query_selector_click_humanized(self):
        """page.query_selector() returns a patched handle — el.click() uses human curves."""
        from cloakbrowser import launch
        browser = launch(headless=False, humanize=True)
        page = browser.new_page()
        page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
        time.sleep(1)

        el = page.query_selector('#searchInput')
        assert el is not None
        assert getattr(el, '_human_patched', False), "ElementHandle not patched"

        t0 = time.time()
        el.click()
        click_ms = int((time.time() - t0) * 1000)
        assert click_ms > 100, f"ElementHandle click too fast: {click_ms}ms (not humanized)"
        browser.close()

    def test_query_selector_type_humanized(self):
        """el.type() should type character-by-character with human timing."""
        from cloakbrowser import launch
        browser = launch(headless=False, humanize=True)
        page = browser.new_page()
        page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
        time.sleep(1)

        el = page.query_selector('#searchInput')
        assert el is not None

        t0 = time.time()
        el.type('ElementHandle test')
        type_ms = int((time.time() - t0) * 1000)
        assert type_ms > 1000, f"ElementHandle type too fast: {type_ms}ms"

        val = page.locator('#searchInput').input_value()
        assert val == 'ElementHandle test'
        browser.close()

    def test_query_selector_fill_humanized(self):
        """el.fill() should clear + type with human timing."""
        from cloakbrowser import launch
        browser = launch(headless=False, humanize=True)
        page = browser.new_page()
        page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
        time.sleep(1)

        el = page.query_selector('#searchInput')
        el.type('initial')
        time.sleep(0.3)

        t0 = time.time()
        el.fill('replaced')
        fill_ms = int((time.time() - t0) * 1000)
        assert fill_ms > 500, f"ElementHandle fill too fast: {fill_ms}ms"

        val = page.locator('#searchInput').input_value()
        assert val == 'replaced'
        browser.close()

    def test_query_selector_all_returns_patched(self):
        """page.query_selector_all() returns all handles patched."""
        from cloakbrowser import launch
        browser = launch(headless=False, humanize=True)
        page = browser.new_page()
        page.goto('https://the-internet.herokuapp.com/checkboxes', wait_until='domcontentloaded')
        time.sleep(1)

        els = page.query_selector_all('input[type="checkbox"]')
        assert len(els) >= 2
        for el in els:
            assert getattr(el, '_human_patched', False), "ElementHandle not patched"
        browser.close()

    def test_query_selector_hover_humanized(self):
        """el.hover() should move cursor with human Bezier curve."""
        from cloakbrowser import launch
        browser = launch(headless=False, humanize=True)
        page = browser.new_page()
        page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
        time.sleep(1)

        el = page.query_selector('#searchInput')
        t0 = time.time()
        el.hover()
        hover_ms = int((time.time() - t0) * 1000)
        assert hover_ms > 50, f"ElementHandle hover too fast: {hover_ms}ms"
        browser.close()


@pytest.mark.slow
class TestAsyncElementHandle:
    @pytest.mark.asyncio
    async def test_async_query_selector_click(self):
        from cloakbrowser import launch_async
        
        browser = await launch_async(headless=False, humanize=True)
        page = await browser.new_page()
        await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
        await asyncio.sleep(1)

        el = await page.query_selector('#searchInput')
        assert el is not None
        assert getattr(el, '_human_patched', False), "Async ElementHandle not patched"

        t0 = time.time()
        await el.click()
        click_ms = int((time.time() - t0) * 1000)
        assert click_ms > 100, f"Async ElementHandle click too fast: {click_ms}ms"

        await browser.close()


# =========================================================================
# 15. Per-call timeout forwarding (issue #137)
# =========================================================================

class TestPerCallTimeoutForwarding:
    """page.click('#x', timeout=5000) must forward 5000 to bounding_box(),
    not silently use the hardcoded 2000ms in scroll."""

    def test_get_element_box_default_timeout(self):
        """Default timeout matches Playwright's 30000ms."""
        from cloakbrowser.human.scroll import _get_element_box
        from unittest.mock import MagicMock

        page = MagicMock()
        loc = MagicMock()
        loc.bounding_box = MagicMock(return_value={"x": 0, "y": 0, "width": 1, "height": 1})
        page.locator = MagicMock(return_value=MagicMock(first=loc))

        _get_element_box(page, "#x")
        loc.bounding_box.assert_called_once_with(timeout=30000)

    def test_get_element_box_custom_timeout(self):
        """Caller can pass a custom timeout that overrides the default."""
        from cloakbrowser.human.scroll import _get_element_box
        from unittest.mock import MagicMock

        page = MagicMock()
        loc = MagicMock()
        loc.bounding_box = MagicMock(return_value={"x": 0, "y": 0, "width": 1, "height": 1})
        page.locator = MagicMock(return_value=MagicMock(first=loc))

        _get_element_box(page, "#x", timeout=5000)
        loc.bounding_box.assert_called_once_with(timeout=5000)

    def test_scroll_to_element_forwards_timeout(self):
        """scroll_to_element passes timeout through to bounding_box()."""
        from cloakbrowser.human.scroll import scroll_to_element
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock

        cfg = resolve_config("default", None)
        page = MagicMock()
        page.viewport_size = {"width": 1280, "height": 720}
        loc = MagicMock()
        # Already in viewport so we don't actually scroll — just verify
        # the timeout was forwarded on the first bounding_box() call.
        loc.bounding_box = MagicMock(return_value={"x": 100, "y": 200, "width": 50, "height": 30})
        page.locator = MagicMock(return_value=MagicMock(first=loc))

        raw = MagicMock()
        scroll_to_element(page, raw, "#x", 0, 0, cfg, timeout=7500)
        loc.bounding_box.assert_called_with(timeout=7500)

    def test_page_click_forwards_timeout_kwarg(self):
        """page.click(selector, timeout=...) reaches scroll_to_element.

        Patches scroll_to_element module-side via monkey-patching the
        cloakbrowser.human module attribute used by patch_page.
        """
        import cloakbrowser.human as h
        from cloakbrowser.human import _CursorState
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock, patch

        cfg = resolve_config("default", {"idle_between_actions": False})
        cursor = _CursorState()
        cursor.initialized = True
        cursor.x = 100
        cursor.y = 100

        # Build a minimal page mock
        page = MagicMock()
        page.click = MagicMock()
        page.dblclick = MagicMock()
        page.hover = MagicMock()
        page.type = MagicMock()
        page.fill = MagicMock()
        page.goto = MagicMock()
        page.is_checked = MagicMock(return_value=False)
        page.viewport_size = {"width": 1280, "height": 720}
        page.evaluate = MagicMock(return_value={"hit": True})
        page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
        page.mouse = MagicMock()
        page.keyboard = MagicMock()
        page.query_selector = MagicMock(return_value=None)
        page.query_selector_all = MagicMock(return_value=[])
        page.wait_for_selector = MagicMock(return_value=None)
        page.main_frame = MagicMock()
        page.main_frame.child_frames = []

        captured = {}
        def fake_scroll(page_arg, raw, selector, cx, cy, cfg_arg, timeout=30000):
            captured["timeout"] = timeout
            return ({"x": 100, "y": 100, "width": 50, "height": 30}, cx, cy, False)

        with patch.object(h, "scroll_to_element", side_effect=fake_scroll), \
             patch.object(h, "ensure_actionable"):
            h.patch_page(page, cfg, cursor)
            page.click("#slow-button", timeout=5000)

        assert 4900 <= captured.get("timeout", 0) <= 5000, f"expected ~5000, got {captured}"


# =========================================================================
# 16. Per-call human_config override (typing speed customization)
# =========================================================================

class TestPerCallHumanConfigOverride:
    """page.type('#email', text, human_config={'typing_delay': 30}) lets users
    override typing speed (and any other HumanConfig field) on a per-call
    basis without re-patching the page."""

    def test_merge_config_creates_new_instance(self):
        from cloakbrowser.human.config import resolve_config, merge_config

        base = resolve_config("default", None)
        merged = merge_config(base, {"typing_delay": 30})

        assert merged.typing_delay == 30
        assert base.typing_delay != 30  # not mutated
        # Non-overridden fields are preserved
        assert merged.mouse_min_steps == base.mouse_min_steps

    def test_merge_config_none_returns_base(self):
        from cloakbrowser.human.config import resolve_config, merge_config

        base = resolve_config("default", None)
        merged = merge_config(base, None)
        assert merged is base

    def test_merge_config_ignores_unknown_keys(self):
        from cloakbrowser.human.config import resolve_config, merge_config

        base = resolve_config("default", None)
        # ``not_a_real_field`` is silently dropped — callers shouldn't crash
        # if they pass typos or future field names.
        merged = merge_config(base, {"typing_delay": 30, "not_a_real_field": 99})
        assert merged.typing_delay == 30

    def test_page_type_uses_per_call_typing_delay(self):
        """page.type(..., human_config={'typing_delay': 30}) reaches human_type
        with cfg.typing_delay == 30 even when patch was done with default 70."""
        import cloakbrowser.human as h
        from cloakbrowser.human import _CursorState
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock, patch

        cfg = resolve_config("default", {
            "idle_between_actions": False,
            "field_switch_delay": (0, 1),
        })
        assert cfg.typing_delay == 70  # baseline

        cursor = _CursorState()
        cursor.initialized = True
        cursor.x = 100
        cursor.y = 100

        page = MagicMock()
        page.click = MagicMock()
        page.dblclick = MagicMock()
        page.hover = MagicMock()
        page.type = MagicMock()
        page.fill = MagicMock()
        page.goto = MagicMock()
        page.is_checked = MagicMock(return_value=False)
        page.viewport_size = {"width": 1280, "height": 720}
        page.evaluate = MagicMock(return_value={"hit": True})
        page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
        page.mouse = MagicMock()
        page.keyboard = MagicMock()
        page.query_selector = MagicMock(return_value=None)
        page.query_selector_all = MagicMock(return_value=[])
        page.wait_for_selector = MagicMock(return_value=None)
        page.main_frame = MagicMock()
        page.main_frame.child_frames = []

        captured = {}
        def fake_human_type(page_arg, raw, text, cfg_arg, cdp_session=None):
            captured["typing_delay"] = cfg_arg.typing_delay
            captured["mistype_chance"] = cfg_arg.mistype_chance

        def fake_scroll(*args, **kwargs):
            return ({"x": 100, "y": 100, "width": 50, "height": 30}, 100, 100, False)

        with patch.object(h, "human_type", side_effect=fake_human_type), \
             patch.object(h, "scroll_to_element", side_effect=fake_scroll), \
             patch.object(h, "ensure_actionable"), \
             patch.object(h, "check_pointer_events"):
            h.patch_page(page, cfg, cursor)
            page.type(
                "#email", "hi",
                human_config={"typing_delay": 30, "mistype_chance": 0},
            )

        assert captured["typing_delay"] == 30
        assert captured["mistype_chance"] == 0
        assert cfg.typing_delay == 70

    def test_page_fill_uses_per_call_typing_delay(self):
        """Same as type, but for fill (which also clears the field first)."""
        import cloakbrowser.human as h
        from cloakbrowser.human import _CursorState
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock, patch

        cfg = resolve_config("default", {
            "idle_between_actions": False,
            "field_switch_delay": (0, 1),
        })
        cursor = _CursorState()
        cursor.initialized = True
        cursor.x = 100
        cursor.y = 100

        page = MagicMock()
        page.viewport_size = {"width": 1280, "height": 720}
        page.is_checked = MagicMock(return_value=False)
        page.evaluate = MagicMock(return_value={"hit": True})
        page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
        page.mouse = MagicMock()
        page.keyboard = MagicMock()
        page.query_selector = MagicMock(return_value=None)
        page.query_selector_all = MagicMock(return_value=[])
        page.wait_for_selector = MagicMock(return_value=None)
        page.main_frame = MagicMock()
        page.main_frame.child_frames = []

        captured = {}
        def fake_human_type(page_arg, raw, text, cfg_arg, cdp_session=None):
            captured["typing_delay"] = cfg_arg.typing_delay

        def fake_scroll(*args, **kwargs):
            return ({"x": 100, "y": 100, "width": 50, "height": 30}, 100, 100, False)

        with patch.object(h, "human_type", side_effect=fake_human_type), \
             patch.object(h, "scroll_to_element", side_effect=fake_scroll), \
             patch.object(h, "ensure_actionable"), \
             patch.object(h, "check_pointer_events"):
            h.patch_page(page, cfg, cursor)
            page.fill("#password", "secret", human_config={"typing_delay": 150})

        assert captured["typing_delay"] == 150

    def test_element_handle_type_uses_per_call_human_config(self):
        """el.type(text, human_config={...}) merges per-call overrides on the
        ElementHandle path (which doesn't go through page.type)."""
        from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
        from cloakbrowser.human.config import resolve_config
        import cloakbrowser.human as h
        from unittest.mock import MagicMock, patch

        cfg = resolve_config("default", {
            "idle_between_actions": False,
            "field_switch_delay": (0, 1),
        })
        cursor = _CursorState()
        cursor.initialized = True
        cursor.x = 50
        cursor.y = 50

        page = MagicMock()
        page.viewport_size = {"width": 1280, "height": 720}
        page._original = MagicMock()

        el = MagicMock()
        el._human_patched = False
        el.bounding_box = MagicMock(
            return_value={"x": 200, "y": 200, "width": 100, "height": 30}
        )
        el.evaluate = _mock_el_evaluate(is_input=True)
        el.is_checked = MagicMock(return_value=False)
        el.query_selector = MagicMock(return_value=None)
        el.query_selector_all = MagicMock(return_value=[])
        el.wait_for_selector = MagicMock(return_value=None)
        el.scroll_into_view_if_needed = MagicMock()

        raw_mouse = MagicMock()
        raw_keyboard = MagicMock()

        captured = {}
        def fake_human_type(page_arg, raw, text, cfg_arg, cdp_session=None):
            captured["typing_delay"] = cfg_arg.typing_delay

        with patch.object(h, "human_type", side_effect=fake_human_type):
            _patch_single_element_handle_sync(
                el, page, cfg, cursor, raw_mouse, raw_keyboard,
                page._original, None, None,
            )
            el.type("abc", human_config={"typing_delay": 25})

        assert captured["typing_delay"] == 25


# =========================================================================
# 17. scroll_into_view_if_needed humanization
# =========================================================================

class TestScrollIntoViewIfNeeded:
    """scroll_into_view_if_needed should run through the same
    accelerate → cruise → decelerate → overshoot wheel sequence as page.click—
    not Playwright's instant-snap default."""

    def test_human_scroll_into_view_skips_when_in_viewport(self):
        """Already-visible elements: no wheel events, just return."""
        from cloakbrowser.human.scroll import human_scroll_into_view
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock

        cfg = resolve_config("default", None)
        page = MagicMock()
        page.viewport_size = {"width": 1280, "height": 720}
        raw = MagicMock()
        # Box is dead-center of viewport — squarely in scroll_target_zone
        in_view_box = {"x": 200, "y": 300, "width": 50, "height": 30}

        box, cx, cy, did_scroll = human_scroll_into_view(
            page, raw, lambda: in_view_box, 0, 0, cfg,
        )
        assert not did_scroll, "In-viewport elements shouldn't report scrolling"
        assert box == in_view_box
        assert not raw.wheel.called, "In-viewport elements shouldn't trigger wheel events"

    def test_human_scroll_into_view_scrolls_when_below_fold(self):
        """Below-fold elements: wheel events fire, eventually box becomes visible."""
        from cloakbrowser.human.scroll import human_scroll_into_view
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock

        cfg = resolve_config("default", {
            "scroll_overshoot_chance": 0,        # deterministic
            "scroll_pre_move_delay": (0, 1),
            "scroll_pause_fast": (0, 1),
            "scroll_pause_slow": (0, 1),
            "scroll_settle_delay": (0, 1),
        })
        page = MagicMock()
        page.viewport_size = {"width": 1280, "height": 720}
        raw = MagicMock()

        # First box is far below the fold; subsequent boxes "come into view"
        # so the loop terminates after a few wheel bursts.
        boxes = [
            {"x": 200, "y": 2000, "width": 50, "height": 30},
            {"x": 200, "y": 1500, "width": 50, "height": 30},
            {"x": 200, "y": 1000, "width": 50, "height": 30},
            {"x": 200, "y": 400, "width": 50, "height": 30},   # in viewport
            {"x": 200, "y": 400, "width": 50, "height": 30},
            {"x": 200, "y": 400, "width": 50, "height": 30},
        ]
        idx = {"i": 0}
        def get_box():
            i = min(idx["i"], len(boxes) - 1)
            idx["i"] += 1
            return boxes[i]

        human_scroll_into_view(page, raw, get_box, 0, 0, cfg)
        assert raw.wheel.called, "Below-fold scroll should produce wheel events"

    def test_element_handle_scroll_into_view_if_needed_humanized(self):
        """el.scroll_into_view_if_needed() routes through human_scroll_into_view."""
        from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
        from cloakbrowser.human.config import resolve_config
        import cloakbrowser.human as h
        from unittest.mock import MagicMock, patch

        cfg = resolve_config("default", None)
        cursor = _CursorState()
        cursor.initialized = True
        cursor.x = 50
        cursor.y = 50

        page = MagicMock()
        page.viewport_size = {"width": 1280, "height": 720}
        page._original = MagicMock()

        el = MagicMock()
        el._human_patched = False
        el.bounding_box = MagicMock(
            return_value={"x": 200, "y": 200, "width": 50, "height": 30}
        )
        el.evaluate = _mock_el_evaluate(is_input=False)
        el.is_checked = MagicMock(return_value=False)
        el.query_selector = MagicMock(return_value=None)
        el.query_selector_all = MagicMock(return_value=[])
        el.wait_for_selector = MagicMock(return_value=None)
        # Make sure the original method exists so the patch is wired up
        el.scroll_into_view_if_needed = MagicMock()

        called = {"count": 0}
        def fake(*args, **kwargs):
            called["count"] += 1
            return ({"x": 200, "y": 200, "width": 50, "height": 30}, 100, 100, False)

        with patch.object(h, "human_scroll_into_view", side_effect=fake):
            _patch_single_element_handle_sync(
                el, page, cfg, cursor, MagicMock(), MagicMock(),
                page._original, None, None,
            )
            el.scroll_into_view_if_needed()

        assert called["count"] >= 1, "humanized scroll helper was never called"

    def test_locator_scroll_into_view_if_needed_humanized(self):
        """Locator.scroll_into_view_if_needed() also goes through humanized scroll."""
        import cloakbrowser.human as h
        from cloakbrowser.human import _CursorState
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock, patch

        # Patch Locator class fresh
        _ensure_locator_patched()

        from playwright.sync_api._generated import Locator

        cfg = resolve_config("default", None)
        cursor = _CursorState()
        cursor.x = 50
        cursor.y = 50
        cursor.initialized = True

        page = MagicMock()
        page._original = MagicMock()
        page._human_cfg = cfg
        page._human_cursor = cursor
        page._human_raw_mouse = MagicMock()
        page.viewport_size = {"width": 1280, "height": 720}

        # Build a Locator-like object satisfying the patched method
        loc = MagicMock(spec=Locator)
        loc.page = page
        impl_obj = MagicMock()
        impl_obj._selector = "#x"
        loc._impl_obj = impl_obj
        loc.bounding_box = MagicMock(
            return_value={"x": 100, "y": 100, "width": 50, "height": 30}
        )

        called = {"count": 0, "cfg": None}
        def fake(*args, **kwargs):
            called["count"] += 1
            # cfg is the 6th positional arg (page, raw, get_box, cx, cy, cfg)
            called["cfg"] = args[5] if len(args) >= 6 else kwargs.get("cfg")
            return ({"x": 100, "y": 100, "width": 50, "height": 30}, 200, 200, False)

        with patch.object(h, "human_scroll_into_view", side_effect=fake):
            Locator.scroll_into_view_if_needed(
                loc, human_config={"scroll_overshoot_chance": 0.5},
            )

        assert called["count"] == 1
        # Per-call override merged into the cfg passed downstream
        assert called["cfg"].scroll_overshoot_chance == 0.5
        # Cursor was updated from the helper's return value
        assert cursor.x == 200 and cursor.y == 200


# =========================================================================
# Issue #307: frame/page click timeout should not multiply
# =========================================================================

class TestTimeoutBudget307:
    """Verify timeout budget is shared across sequential operations."""

    def test_page_click_total_time_within_budget(self):
        """page.click on a missing element should not exceed ~1x the timeout."""
        import cloakbrowser.human as h
        from cloakbrowser.human import _CursorState
        from cloakbrowser.human.config import resolve_config
        from unittest.mock import MagicMock, patch

        TIMEOUT_MS = 500
        cfg = resolve_config("default", {"idle_between_actions": False})
        cursor = _CursorState()
        cursor.initialized = True
        cursor.x = 100
        cursor.y = 100

        page = MagicMock()
        page.click = MagicMock()
        page.dblclick = MagicMock()
        page.hover = MagicMock()
        page.type = MagicMock()
        page.fill = MagicMock()
        page.goto = MagicMock()
        page.is_checked = MagicMock(return_value=False)
        page.viewport_size = {"width": 1280, "height": 720}
        page.evaluate = MagicMock(return_value={"hit": True})
        page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
        page.mouse = MagicMock()
        page.keyboard = MagicMock()
        page.query_selector = MagicMock(return_value=None)
        page.query_selector_all = MagicMock(return_value=[])
        page.wait_for_selector = MagicMock(return_value=None)
        page.main_frame = MagicMock()
        page.main_frame.child_frames = []

        loc = MagicMock()
        loc.wait_for = MagicMock(side_effect=lambda **kw: time.sleep(kw.get("timeout", 30000) / 1000.0))
        loc.is_visible = MagicMock(return_value=False)
        loc.first = loc
        page.locator = MagicMock(return_value=loc)

        h.patch_page(page, cfg, cursor)

        start = time.monotonic()
        try:
            page.click("#does-not-exist", timeout=TIMEOUT_MS)
        except Exception:
            pass
        elapsed_ms = (time.monotonic() - start) * 1000

        assert elapsed_ms < TIMEOUT_MS * 1.8, (
            f"expected <{TIMEOUT_MS * 1.8}ms, got {elapsed_ms:.0f}ms"
        )


class TestPointerEventsFailOpen:
    """The pointer-events check must fail open: when it cannot run (evaluate /
    bounding_box throws -> result None), proceed with the click instead of
    blocking it until the timeout expires."""

    def test_handle_failopen_returns_on_evaluate_error(self):
        from cloakbrowser.human.actionability import check_pointer_events_handle
        el = MagicMock()
        el.bounding_box = MagicMock(side_effect=Exception("stale handle"))
        el.evaluate = MagicMock(side_effect=Exception("execution context destroyed"))
        start = time.monotonic()
        check_pointer_events_handle(MagicMock(), el, 100, 100, timeout=2000)  # must not raise
        elapsed_ms = (time.monotonic() - start) * 1000
        assert elapsed_ms < 500, f"fail-open should return promptly, took {elapsed_ms:.0f}ms"

    def test_locator_failopen_returns_on_evaluate_error(self):
        from cloakbrowser.human.actionability import check_pointer_events
        page = MagicMock()
        loc = MagicMock()
        loc.first = loc
        loc.bounding_box = MagicMock(side_effect=Exception("no element"))
        loc.evaluate = MagicMock(side_effect=Exception("no element"))
        page.locator = MagicMock(return_value=loc)
        start = time.monotonic()
        check_pointer_events(page, "#x", 100, 100, timeout=2000)  # must not raise
        elapsed_ms = (time.monotonic() - start) * 1000
        assert elapsed_ms < 500, f"fail-open should return promptly, took {elapsed_ms:.0f}ms"

    def test_handle_still_raises_when_covered(self):
        """A genuine 'covered' result (not None) must still raise — fail-open
        only applies when the check could not be determined."""
        from cloakbrowser.human.actionability import (
            check_pointer_events_handle, ElementNotReceivingEventsError,
        )
        el = MagicMock()
        el.bounding_box = MagicMock(return_value={"x": 0, "y": 0, "width": 10, "height": 10})
        el.evaluate = MagicMock(return_value={"hit": False, "covering": "DIV"})
        with pytest.raises(ElementNotReceivingEventsError):
            check_pointer_events_handle(MagicMock(), el, 5, 5, timeout=200)

    def test_async_handle_failopen_returns_on_evaluate_error(self):
        from cloakbrowser.human.actionability_async import async_check_pointer_events_handle
        from unittest.mock import AsyncMock
        el = MagicMock()
        el.bounding_box = AsyncMock(side_effect=Exception("stale handle"))
        el.evaluate = AsyncMock(side_effect=Exception("execution context destroyed"))
        start = time.monotonic()
        asyncio.run(async_check_pointer_events_handle(MagicMock(), el, 100, 100, timeout=2000))
        elapsed_ms = (time.monotonic() - start) * 1000
        assert elapsed_ms < 500, f"fail-open should return promptly, took {elapsed_ms:.0f}ms"


# =========================================================================
# Direct runner (backwards compat)
# =========================================================================

if __name__ == "__main__":
    sys.exit(pytest.main([__file__, "-v", "--tb=short", "-x"]))
