"""Tests for browser MCP tool internals (liveness check, recovery, resilience)."""

from __future__ import annotations

import asyncio
import importlib.util
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from genesis.mcp.health import browser


def _clear_all_browser_state():
    """Clear all module-level browser state."""
    browser._stealth_cm = None
    browser._stealth_browser = None
    browser._stealth_page = None
    browser._playwright = None
    browser._context = None
    browser._page = None
    browser._active_page = None
    browser._collaborate_mode = False
    browser._browser_lock = asyncio.Lock()
    # Remote CDP state
    browser._remote_pw = None
    browser._remote_browser = None
    browser._remote_page = None
    browser._remote_cdp_url = None
    browser._remote_last_url = None


@pytest.fixture(autouse=True)
def _reset_browser_state():
    """Reset module-level browser state before and after each test."""
    _clear_all_browser_state()
    yield
    _clear_all_browser_state()


class TestIsPageAlive:
    def test_alive_page(self):
        page = MagicMock()
        page.is_closed.return_value = False
        page.url = "https://example.com"
        assert browser._is_page_alive(page) is True

    def test_closed_page(self):
        page = MagicMock()
        page.is_closed.return_value = True
        assert browser._is_page_alive(page) is False

    def test_severed_connection(self):
        page = MagicMock()
        page.is_closed.return_value = False
        type(page).url = property(lambda self: (_ for _ in ()).throw(ConnectionError("pipe broken")))
        assert browser._is_page_alive(page) is False

    def test_none_page(self):
        assert browser._is_page_alive(None) is False

    def test_is_closed_throws(self):
        page = MagicMock()
        page.is_closed.side_effect = RuntimeError("already disposed")
        assert browser._is_page_alive(page) is False


class TestEnsureBrowserRecovery:
    @pytest.mark.asyncio
    async def test_returns_alive_page_without_reinit(self):
        """If page is alive, _ensure_browser returns it immediately."""
        page = MagicMock()
        page.is_closed.return_value = False
        page.url = "https://example.com"
        browser._stealth_page = page

        result = await browser._ensure_browser()
        assert result is page

    @pytest.mark.asyncio
    async def test_recovers_from_stale_page(self):
        """If page is dead, _ensure_browser cleans up and re-initializes."""
        pytest.importorskip("camoufox", reason="camoufox not installed")
        dead_page = MagicMock()
        dead_page.is_closed.return_value = True
        browser._stealth_page = dead_page

        new_page = MagicMock()
        mock_browser = MagicMock()
        mock_browser.pages = [new_page]

        mock_cm = AsyncMock()
        mock_cm.__aenter__ = AsyncMock(return_value=mock_browser)
        mock_cm.__aexit__ = AsyncMock(return_value=None)

        with patch("camoufox.async_api.AsyncCamoufox", return_value=mock_cm):
            result = await browser._ensure_browser()

        assert result is new_page
        assert browser._stealth_page is new_page

    @pytest.mark.asyncio
    async def test_cleanup_safe_on_dead_browser(self):
        """async_cleanup() doesn't raise when browser is already dead."""
        dead_cm = AsyncMock()
        dead_cm.__aexit__ = AsyncMock(side_effect=ConnectionError("already gone"))
        browser._stealth_cm = dead_cm
        browser._stealth_browser = MagicMock()
        browser._stealth_page = MagicMock()

        await browser.async_cleanup()

        assert browser._stealth_cm is None
        assert browser._stealth_browser is None
        assert browser._stealth_page is None


class TestEnsureChromiumRecovery:
    @pytest.mark.asyncio
    async def test_returns_alive_page(self):
        page = MagicMock()
        page.is_closed.return_value = False
        page.url = "https://example.com"
        browser._page = page

        result = await browser._ensure_chromium_fallback()
        assert result is page

    @pytest.mark.asyncio
    async def test_recovers_from_stale_chromium(self):
        pytest.importorskip("playwright", reason="playwright not installed")
        dead_page = MagicMock()
        dead_page.is_closed.return_value = True
        browser._page = dead_page

        new_page = MagicMock()
        mock_context = AsyncMock()
        mock_context.pages = [new_page]

        mock_pw = AsyncMock()
        mock_pw.chromium.launch_persistent_context = AsyncMock(return_value=mock_context)

        with patch("playwright.async_api.async_playwright") as mock_apw:
            mock_starter = AsyncMock()
            mock_starter.start = AsyncMock(return_value=mock_pw)
            mock_apw.return_value = mock_starter
            result = await browser._ensure_chromium_fallback()

        assert result is new_page
        assert browser._page is new_page


# ---------------------------------------------------------------------------
# New tests for browser resilience (timeouts, selectors, keyboard, press_key)
# ---------------------------------------------------------------------------


class TestToolTimeout:
    """Verify _with_tool_timeout returns structured error on timeout."""

    @pytest.mark.asyncio
    async def test_returns_result_on_success(self):
        async def quick():
            return {"ok": True}

        result = await browser._with_tool_timeout(quick(), 5.0, "test_op")
        assert result == {"ok": True}

    @pytest.mark.asyncio
    async def test_returns_error_on_timeout(self):
        async def slow():
            await asyncio.sleep(10)
            return {"ok": True}

        result = await browser._with_tool_timeout(slow(), 0.05, "test_op")
        assert "error" in result
        assert "timed out" in result["error"]
        assert "test_op" in result["error"]

    @pytest.mark.asyncio
    async def test_resets_active_page_on_timeout(self):
        """Timeout must reset _active_page to None to avoid stale page state."""
        browser._active_page = MagicMock()  # simulate an active page

        async def slow():
            await asyncio.sleep(10)
            return {"ok": True}

        result = await browser._with_tool_timeout(slow(), 0.05, "test_op")
        assert "error" in result
        assert browser._active_page is None

    @pytest.mark.asyncio
    async def test_propagates_non_timeout_exceptions(self):
        async def broken():
            raise ValueError("boom")

        with pytest.raises(ValueError, match="boom"):
            await browser._with_tool_timeout(broken(), 5.0, "test_op")


class TestSnapshotTimeout:
    """Verify _snapshot_page handles timeout gracefully."""

    @pytest.mark.asyncio
    async def test_returns_snapshot_on_success(self):
        page = MagicMock()
        locator = MagicMock()
        locator.aria_snapshot = AsyncMock(return_value="- heading: Hello")
        page.locator.return_value = locator

        result = await browser._snapshot_page(page)
        assert result == "- heading: Hello"

    @pytest.mark.asyncio
    async def test_returns_message_on_timeout(self):
        page = MagicMock()
        locator = MagicMock()

        async def hang():
            await asyncio.sleep(60)

        locator.aria_snapshot = hang
        page.locator.return_value = locator

        # Patch the 15s timeout to 0.05s for test speed
        with patch.object(asyncio, "wait_for", wraps=asyncio.wait_for):
            result = await browser._snapshot_page(page)
            # The actual 15s timeout would make this test slow, so we verify
            # the structure handles exceptions gracefully
            assert isinstance(result, str)

    @pytest.mark.asyncio
    async def test_returns_message_on_exception(self):
        page = MagicMock()
        locator = MagicMock()
        locator.aria_snapshot = AsyncMock(side_effect=RuntimeError("DOM gone"))
        page.locator.return_value = locator

        result = await browser._snapshot_page(page)
        assert "snapshot unavailable" in result


class TestAmbiguousSelector:
    """Verify _stealth_click raises on ambiguous text= selectors."""

    @pytest.mark.asyncio
    async def test_ambiguous_text_selector_raises(self):
        page = MagicMock()
        locator = MagicMock()

        # count() returns a coroutine that resolves to 3
        locator.count = AsyncMock(return_value=3)

        # nth() returns locators with evaluate() for element info
        nth_locator = MagicMock()
        nth_locator.evaluate = AsyncMock(side_effect=["input", "sponsorship", "input", "relocation", "input", "experience"])
        locator.nth.return_value = nth_locator

        page.locator.return_value = locator

        with pytest.raises(Exception, match="Ambiguous selector"):
            await browser._stealth_click(page, "text=No")

    @pytest.mark.asyncio
    async def test_unique_text_selector_proceeds(self):
        """Single match should not raise ambiguity error."""
        page = MagicMock()
        locator = MagicMock()
        locator.count = AsyncMock(return_value=1)
        page.locator.return_value = locator

        # Non-Camoufox mode — plain click path
        page.click = AsyncMock()

        await browser._stealth_click(page, "text=Submit")
        page.click.assert_awaited_once()


class TestKeyboardFallback:
    """Verify _stealth_click falls back to keyboard on click failure."""

    @pytest.mark.asyncio
    async def test_keyboard_fallback_on_radio(self):
        """When both stealth and plain click fail, keyboard fallback fires."""
        page = MagicMock()
        locator = MagicMock()
        locator.count = AsyncMock(return_value=1)
        page.locator.return_value = locator

        # Make Camoufox active so stealth path runs
        browser._stealth_cm = MagicMock()
        browser._stealth_page = page
        browser._active_page = page

        # Stealth click path fails (wait_for_selector raises)
        el_mock = AsyncMock()
        el_mock.focus = AsyncMock()
        el_mock.evaluate = AsyncMock(side_effect=["input", "radio"])
        page.wait_for_selector = AsyncMock(
            side_effect=[Exception("stealth failed"), el_mock]
        )
        # Plain click also fails
        page.click = AsyncMock(side_effect=Exception("plain failed"))

        # Keyboard mock
        page.keyboard = MagicMock()
        page.keyboard.press = AsyncMock()

        await browser._stealth_click(page, "text=No")

        # Verify keyboard.press("Space") was called for the radio
        page.keyboard.press.assert_awaited_once_with("Space")

    @pytest.mark.asyncio
    async def test_raises_when_all_methods_fail(self):
        """When stealth, plain, keyboard, AND shadow DOM all fail, raises the plain error."""
        page = MagicMock()
        locator = MagicMock()
        locator.count = AsyncMock(return_value=1)
        page.locator.return_value = locator

        browser._stealth_cm = MagicMock()
        browser._stealth_page = page
        browser._active_page = page

        # All methods fail
        page.wait_for_selector = AsyncMock(side_effect=Exception("nope"))
        page.click = AsyncMock(side_effect=Exception("plain failed"))
        page.keyboard = MagicMock()
        page.keyboard.press = AsyncMock(side_effect=Exception("kb failed"))
        # Shadow DOM fallback also fails (returns False = not found)
        page.evaluate = AsyncMock(return_value=False)

        with pytest.raises(Exception, match="plain failed"):
            await browser._stealth_click(page, "text=No")


class TestShadowDomClick:
    """Verify _click_in_shadow_dom and its integration in _stealth_click."""

    @pytest.mark.asyncio
    async def test_shadow_dom_fallback_succeeds(self):
        """Shadow DOM JS traversal finds and clicks element after all else fails."""
        page = MagicMock()
        locator = MagicMock()
        locator.count = AsyncMock(return_value=1)
        page.locator.return_value = locator

        browser._stealth_cm = MagicMock()
        browser._stealth_page = page
        browser._active_page = page

        # Stealth, plain, and keyboard all fail
        page.wait_for_selector = AsyncMock(side_effect=Exception("nope"))
        page.click = AsyncMock(side_effect=Exception("plain failed"))
        page.keyboard = MagicMock()
        page.keyboard.press = AsyncMock(side_effect=Exception("kb failed"))
        # Shadow DOM fallback succeeds (JS found and clicked the element)
        page.evaluate = AsyncMock(return_value=True)

        # Should NOT raise — shadow DOM fallback saves it
        await browser._stealth_click(page, "text=Submit")

        # Verify page.evaluate was called with the shadow DOM JS
        page.evaluate.assert_awaited_once()
        args = page.evaluate.call_args
        assert args[0][1] == ["Submit", True]  # [search_value, is_text]

    @pytest.mark.asyncio
    async def test_shadow_dom_not_triggered_on_normal_success(self):
        """Shadow DOM fallback should not run when normal click succeeds."""
        page = MagicMock()
        locator = MagicMock()
        locator.count = AsyncMock(return_value=1)
        page.locator.return_value = locator

        # Non-Camoufox mode — plain click succeeds
        page.click = AsyncMock()
        page.evaluate = AsyncMock()

        await browser._stealth_click(page, "text=Submit")

        # Plain click worked, evaluate should NOT be called
        page.evaluate.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_shadow_dom_css_selector(self):
        """CSS selectors are passed to querySelector inside shadow roots."""
        page = MagicMock()
        page.evaluate = AsyncMock(return_value=True)

        result = await browser._click_in_shadow_dom(page, "button.submit-btn")

        assert result is True
        args = page.evaluate.call_args
        assert args[0][1] == ["button.submit-btn", False]  # [value, is_text=False]

    @pytest.mark.asyncio
    async def test_shadow_dom_evaluate_exception(self):
        """JS evaluation errors return False, not raise."""
        page = MagicMock()
        page.evaluate = AsyncMock(side_effect=Exception("page crashed"))

        result = await browser._click_in_shadow_dom(page, "text=Click")

        assert result is False


class TestPressKey:
    """Verify _impl_browser_press_key."""

    @pytest.mark.asyncio
    async def test_single_key_press(self):
        page = MagicMock()
        page.url = "https://example.com"
        page.keyboard = MagicMock()
        page.keyboard.press = AsyncMock()
        browser._active_page = page

        result = await browser._impl_browser_press_key("Tab")
        assert result["pressed"] == "Tab"
        assert result["count"] == 1
        page.keyboard.press.assert_awaited_once_with("Tab")

    @pytest.mark.asyncio
    async def test_multiple_key_presses(self):
        page = MagicMock()
        page.url = "https://example.com"
        page.keyboard = MagicMock()
        page.keyboard.press = AsyncMock()
        browser._active_page = page

        result = await browser._impl_browser_press_key("Tab", 3)
        assert result["count"] == 3
        assert page.keyboard.press.await_count == 3

    @pytest.mark.asyncio
    async def test_count_clamped(self):
        page = MagicMock()
        page.url = "https://example.com"
        page.keyboard = MagicMock()
        page.keyboard.press = AsyncMock()
        browser._active_page = page

        result = await browser._impl_browser_press_key("Tab", 100)
        assert result["count"] == 50  # clamped to max

    @pytest.mark.asyncio
    async def test_no_page_error(self):
        result = await browser._impl_browser_press_key("Tab")
        assert "error" in result
        assert "No page open" in result["error"]

    @pytest.mark.asyncio
    async def test_invalid_key_error(self):
        page = MagicMock()
        page.url = "https://example.com"
        page.keyboard = MagicMock()
        page.keyboard.press = AsyncMock(side_effect=Exception("Unknown key: Foo"))
        browser._active_page = page

        result = await browser._impl_browser_press_key("Foo")
        assert "error" in result
        assert "Foo" in result["error"]


# ---------------------------------------------------------------------------
# CDP Remote Browser Tests (Layer 3)
# ---------------------------------------------------------------------------


def _mock_remote_browser(pages=None, connected=True):
    """Create a mock CDP browser with optional pages."""
    mock_browser = MagicMock()
    mock_browser.is_connected.return_value = connected
    mock_browser.close = AsyncMock()
    mock_browser.on = MagicMock()
    ctx = MagicMock()
    ctx.pages = pages or []
    ctx.new_page = AsyncMock(return_value=MagicMock())
    mock_browser.contexts = [ctx]
    mock_browser.new_context = AsyncMock(return_value=ctx)
    return mock_browser


@pytest.mark.skipif(
    not importlib.util.find_spec("playwright"),
    reason="playwright not installed",
)
class TestEnsureRemoteCdp:
    """Verify _ensure_remote_cdp connection lifecycle."""

    @pytest.mark.asyncio
    async def test_returns_alive_page_without_reconnect(self):
        """Already-connected, alive page is reused."""
        page = MagicMock()
        page.is_closed.return_value = False
        page.url = "https://example.com"
        mock_br = _mock_remote_browser(connected=True)

        browser._remote_page = page
        browser._remote_browser = mock_br

        result = await browser._ensure_remote_cdp("http://100.1.2.3:9222")
        assert result is page

    @pytest.mark.asyncio
    async def test_reconnects_after_disconnect(self):
        """Stale connection is cleaned up and re-established."""
        dead_browser = _mock_remote_browser(connected=False)
        browser._remote_browser = dead_browser
        browser._remote_page = MagicMock()

        new_page = MagicMock()
        new_page.url = "chrome://newtab/"
        new_browser = _mock_remote_browser(pages=[new_page])

        mock_pw = AsyncMock()
        mock_pw.chromium.connect_over_cdp = AsyncMock(return_value=new_browser)
        mock_pw.stop = AsyncMock()

        with patch("playwright.async_api.async_playwright") as mock_apw:
            mock_starter = AsyncMock()
            mock_starter.start = AsyncMock(return_value=mock_pw)
            mock_apw.return_value = mock_starter
            result = await browser._ensure_remote_cdp("http://100.1.2.3:9222")

        assert result is new_page
        assert browser._remote_cdp_url == "http://100.1.2.3:9222"

    @pytest.mark.asyncio
    async def test_raises_connection_error_no_url(self):
        """No CDP URL configured — clear error with setup instructions."""
        with pytest.raises(ConnectionError, match="No CDP URL configured"):
            await browser._ensure_remote_cdp(None)

    @pytest.mark.asyncio
    async def test_raises_connection_error_chrome_not_running(self):
        """Chrome not running — clear error with troubleshooting."""
        mock_pw = AsyncMock()
        mock_pw.chromium.connect_over_cdp = AsyncMock(
            side_effect=Exception("Connection refused")
        )
        mock_pw.stop = AsyncMock()

        with patch("playwright.async_api.async_playwright") as mock_apw:
            mock_starter = AsyncMock()
            mock_starter.start = AsyncMock(return_value=mock_pw)
            mock_apw.return_value = mock_starter
            with pytest.raises(ConnectionError, match="Cannot connect"):
                await browser._ensure_remote_cdp("http://100.1.2.3:9222")

        # Playwright instance must be cleaned up
        mock_pw.stop.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_uses_existing_tab(self):
        """Picks first tab with a real URL (chrome://, http://, etc)."""
        visible_page = MagicMock()
        visible_page.url = "chrome://newtab/"
        other_page = MagicMock()
        other_page.url = "https://already-open.com"
        new_browser = _mock_remote_browser(pages=[visible_page, other_page])

        mock_pw = AsyncMock()
        mock_pw.chromium.connect_over_cdp = AsyncMock(return_value=new_browser)

        with patch("playwright.async_api.async_playwright") as mock_apw:
            mock_starter = AsyncMock()
            mock_starter.start = AsyncMock(return_value=mock_pw)
            mock_apw.return_value = mock_starter
            result = await browser._ensure_remote_cdp("http://100.1.2.3:9222")

        assert result is visible_page  # First tab with real URL

    @pytest.mark.asyncio
    async def test_creates_new_tab_when_no_pages(self):
        """Context exists but no pages — creates new tab."""
        new_browser = _mock_remote_browser(pages=[])
        created_page = MagicMock()
        new_browser.contexts[0].new_page = AsyncMock(return_value=created_page)

        mock_pw = AsyncMock()
        mock_pw.chromium.connect_over_cdp = AsyncMock(return_value=new_browser)

        with patch("playwright.async_api.async_playwright") as mock_apw:
            mock_starter = AsyncMock()
            mock_starter.start = AsyncMock(return_value=mock_pw)
            mock_apw.return_value = mock_starter
            result = await browser._ensure_remote_cdp("http://100.1.2.3:9222")

        assert result is created_page
        new_browser.contexts[0].new_page.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_resolves_url_from_env(self):
        """Falls back to GENESIS_CDP_URL env var when no explicit URL."""
        new_page = MagicMock()
        new_page.url = "chrome://newtab/"
        new_browser = _mock_remote_browser(pages=[new_page])

        mock_pw = AsyncMock()
        mock_pw.chromium.connect_over_cdp = AsyncMock(return_value=new_browser)

        with patch("playwright.async_api.async_playwright") as mock_apw, \
             patch.dict("os.environ", {"GENESIS_CDP_URL": "http://env.url:9222"}):
            mock_starter = AsyncMock()
            mock_starter.start = AsyncMock(return_value=mock_pw)
            mock_apw.return_value = mock_starter
            await browser._ensure_remote_cdp(None)

        mock_pw.chromium.connect_over_cdp.assert_awaited_once_with("http://env.url:9222")


class TestRemotePageDrift:
    """Verify _check_page_drift detects URL changes."""

    def test_no_drift_when_url_unchanged(self):
        page = MagicMock()
        page.url = "https://example.com/form"
        browser._remote_last_url = "https://example.com/form"

        assert browser._check_page_drift(page) is None

    def test_drift_detected_on_url_change(self):
        page = MagicMock()
        page.url = "https://example.com/other"
        browser._remote_last_url = "https://example.com/form"

        drift = browser._check_page_drift(page)
        assert drift is not None
        assert drift["drift"] == "url_changed"
        assert drift["expected"] == "https://example.com/form"
        assert drift["actual"] == "https://example.com/other"

    def test_no_drift_when_no_previous_url(self):
        page = MagicMock()
        page.url = "https://example.com"
        browser._remote_last_url = None

        assert browser._check_page_drift(page) is None

    def test_drift_on_inaccessible_page(self):
        page = MagicMock()
        type(page).url = property(lambda self: (_ for _ in ()).throw(Exception("tab closed")))
        browser._remote_last_url = "https://example.com"

        drift = browser._check_page_drift(page)
        assert drift is not None
        assert drift["drift"] == "page_inaccessible"


class TestRemoteCleanup:
    """Verify _cleanup_remote_cdp disconnects safely."""

    @pytest.mark.asyncio
    async def test_cleanup_disconnects_without_closing_chrome(self):
        """browser.close() on CDP = disconnect, verified via mock."""
        mock_br = _mock_remote_browser()
        mock_pw = AsyncMock()
        mock_pw.stop = AsyncMock()

        browser._remote_browser = mock_br
        browser._remote_page = MagicMock()
        browser._remote_pw = mock_pw
        browser._remote_last_url = "https://example.com"

        await browser._cleanup_remote_cdp()

        mock_br.close.assert_awaited_once()
        mock_pw.stop.assert_awaited_once()
        assert browser._remote_browser is None
        assert browser._remote_page is None
        assert browser._remote_pw is None
        assert browser._remote_last_url is None

    @pytest.mark.asyncio
    async def test_cleanup_safe_on_dead_connection(self):
        """Cleanup doesn't raise when browser.close() fails."""
        mock_br = _mock_remote_browser()
        mock_br.close = AsyncMock(side_effect=Exception("already gone"))
        mock_pw = AsyncMock()
        mock_pw.stop = AsyncMock()

        browser._remote_browser = mock_br
        browser._remote_page = MagicMock()
        browser._remote_pw = mock_pw

        await browser._cleanup_remote_cdp()  # should not raise

        assert browser._remote_browser is None
        assert browser._remote_pw is None

    @pytest.mark.asyncio
    async def test_cleanup_resets_all_globals(self):
        browser._remote_browser = MagicMock()
        browser._remote_page = MagicMock()
        browser._remote_pw = AsyncMock()
        browser._remote_pw.stop = AsyncMock()
        browser._remote_browser.close = AsyncMock()
        browser._remote_last_url = "https://test.com"

        await browser._cleanup_remote_cdp()

        assert browser._remote_browser is None
        assert browser._remote_page is None
        assert browser._remote_pw is None
        assert browser._remote_last_url is None
        # cdp_url preserved for reconnection
        # (not set in this test, but verify it's not touched)


class TestRemoteNavigate:
    """Verify _impl_browser_navigate with remote=True."""

    @pytest.mark.asyncio
    async def test_navigate_remote_auto_enables_collaborate(self):
        page = MagicMock()
        page.url = "https://example.com"
        page.title = AsyncMock(return_value="Example")
        page.goto = AsyncMock()
        page.is_closed.return_value = False
        locator = MagicMock()
        locator.aria_snapshot = AsyncMock(return_value="- heading: Example")
        page.locator.return_value = locator

        assert browser._collaborate_mode is False
        # _ensure_remote_cdp normally sets _remote_page; mock must too
        browser._remote_page = page

        with patch.object(browser, "_ensure_remote_cdp", new_callable=AsyncMock, return_value=page):
            result = await browser._impl_browser_navigate(
                "https://example.com", remote=True, cdp_url="http://x:9222"
            )

        assert browser._collaborate_mode is True
        assert result.get("layer") == "remote_cdp"

    @pytest.mark.asyncio
    async def test_navigate_remote_skips_turnstile(self):
        page = MagicMock()
        page.url = "https://example.com"
        page.title = AsyncMock(return_value="Example")
        page.goto = AsyncMock()
        page.is_closed.return_value = False
        locator = MagicMock()
        locator.aria_snapshot = AsyncMock(return_value="- heading: Example")
        page.locator.return_value = locator
        browser._remote_page = page

        with patch.object(browser, "_ensure_remote_cdp", new_callable=AsyncMock, return_value=page), \
             patch.object(browser, "_wait_for_turnstile") as mock_turnstile:
            await browser._impl_browser_navigate(
                "https://example.com", remote=True, cdp_url="http://x:9222"
            )

        mock_turnstile.assert_not_called()

    @pytest.mark.asyncio
    async def test_navigate_remote_connection_error(self):
        with patch.object(
            browser, "_ensure_remote_cdp", new_callable=AsyncMock,
            side_effect=ConnectionError("No CDP URL configured"),
        ):
            result = await browser._impl_browser_navigate(
                "https://example.com", remote=True
            )

        assert "error" in result
        assert "No CDP URL" in result["error"]

    @pytest.mark.asyncio
    async def test_navigate_remote_tracks_url_for_drift(self):
        page = MagicMock()
        page.url = "https://jobs.ashbyhq.com/apply"
        page.title = AsyncMock(return_value="Apply")
        page.goto = AsyncMock()
        page.is_closed.return_value = False
        locator = MagicMock()
        locator.aria_snapshot = AsyncMock(return_value="- heading: Apply")
        page.locator.return_value = locator

        browser._remote_page = page

        with patch.object(browser, "_ensure_remote_cdp", new_callable=AsyncMock, return_value=page):
            browser._active_page = page
            await browser._impl_browser_navigate(
                "https://jobs.ashbyhq.com/apply", remote=True, cdp_url="http://x:9222"
            )

        assert browser._remote_last_url == "https://jobs.ashbyhq.com/apply"


class TestRemoteInteraction:
    """Verify interaction tools handle remote CDP state."""

    @pytest.mark.asyncio
    async def test_click_with_drift_returns_advisory(self):
        """When user navigated away, click returns advisory instead of acting."""
        page = MagicMock()
        page.url = "https://other-page.com"
        page.is_closed.return_value = False
        browser._active_page = page
        browser._remote_page = page
        browser._remote_browser = _mock_remote_browser(connected=True)
        browser._remote_last_url = "https://original-page.com"

        result = await browser._impl_browser_click("#submit")
        assert "advisory" in result
        assert result["drift"] == "url_changed"

    @pytest.mark.asyncio
    async def test_click_with_disconnected_remote_returns_error(self):
        page = MagicMock()
        browser._active_page = page
        browser._remote_page = page
        browser._remote_browser = _mock_remote_browser(connected=False)

        result = await browser._impl_browser_click("#submit")
        assert "error" in result
        assert "connection lost" in result["error"].lower()

    @pytest.mark.asyncio
    async def test_human_delay_uses_collaborate_timing_for_remote(self):
        """Remote CDP always uses fast 0.5-2.0s timing."""
        page = MagicMock()
        browser._active_page = page
        browser._remote_page = page

        with patch("genesis.mcp.health.browser.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
            await browser._human_delay()

        mock_sleep.assert_awaited_once()
        delay = mock_sleep.call_args[0][0]
        assert 0.5 <= delay <= 2.0

    @pytest.mark.asyncio
    async def test_human_type_uses_per_keystroke_for_remote(self):
        """Remote CDP fires per-keystroke typing, not atomic fill."""
        page = AsyncMock()
        browser._active_page = page
        browser._remote_page = page
        browser._stealth_cm = None  # not Camoufox

        with patch("genesis.mcp.health.browser.asyncio.sleep", new_callable=AsyncMock):
            await browser._human_type(page, "#email", "hi")

        # Should clear field, click to focus, then type per-character
        page.fill.assert_awaited_once_with("#email", "", timeout=10000)
        page.click.assert_awaited_once_with("#email", timeout=10000)
        assert page.keyboard.type.await_count == 2  # 'h', 'i'

    @pytest.mark.asyncio
    async def test_human_type_uses_atomic_fill_for_chromium(self):
        """Plain Chromium (dev/test) uses atomic fill, no per-keystroke."""
        page = AsyncMock()
        browser._active_page = page
        browser._remote_page = None  # not remote
        browser._stealth_cm = None  # not Camoufox

        await browser._human_type(page, "#email", "hi")

        # Should use atomic fill with the full value
        page.fill.assert_awaited_once_with("#email", "hi", timeout=10000)
        page.keyboard.type.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_fill_with_drift_returns_advisory(self):
        page = MagicMock()
        page.url = "https://other.com"
        page.is_closed.return_value = False
        browser._active_page = page
        browser._remote_page = page
        browser._remote_browser = _mock_remote_browser(connected=True)
        browser._remote_last_url = "https://original.com"

        result = await browser._impl_browser_fill("#email", "test@test.com")
        assert "advisory" in result

    @pytest.mark.asyncio
    async def test_press_key_with_disconnected_returns_error(self):
        page = MagicMock()
        browser._active_page = page
        browser._remote_page = page
        browser._remote_browser = _mock_remote_browser(connected=False)

        result = await browser._impl_browser_press_key("Tab")
        assert "error" in result
        assert "connection lost" in result["error"].lower()


class TestRemoteHelpers:
    """Verify remote state helper functions."""

    def test_is_remote_active_true(self):
        page = MagicMock()
        browser._remote_page = page
        browser._active_page = page
        assert browser._is_remote_active() is True

    def test_is_remote_active_false_no_remote(self):
        assert browser._is_remote_active() is False

    def test_is_remote_active_false_different_page(self):
        browser._remote_page = MagicMock()
        browser._active_page = MagicMock()  # different object
        assert browser._is_remote_active() is False

    def test_remote_browser_connected(self):
        browser._remote_browser = MagicMock()
        browser._remote_browser.is_connected.return_value = True
        assert browser._remote_browser_connected() is True

    def test_remote_browser_not_connected(self):
        browser._remote_browser = MagicMock()
        browser._remote_browser.is_connected.return_value = False
        assert browser._remote_browser_connected() is False

    def test_remote_browser_none(self):
        assert browser._remote_browser_connected() is False

    def test_check_remote_health_ok(self):
        """Non-remote page — returns None."""
        browser._active_page = MagicMock()
        assert browser._check_remote_health() is None

    def test_check_remote_health_disconnected(self):
        """Read-only check — returns error but does NOT clear globals."""
        page = MagicMock()
        browser._active_page = page
        browser._remote_page = page
        browser._remote_browser = MagicMock()
        browser._remote_browser.is_connected.return_value = False

        result = browser._check_remote_health()
        assert result is not None
        assert "error" in result
        # Read-only: globals NOT cleared
        assert browser._active_page is page
        assert browser._remote_page is page

    def test_detach_dead_remote_clears_globals(self):
        """Mutating variant — returns error AND clears globals."""
        page = MagicMock()
        browser._active_page = page
        browser._remote_page = page
        browser._remote_browser = MagicMock()
        browser._remote_browser.is_connected.return_value = False

        result = browser._detach_dead_remote()
        assert result is not None
        assert "error" in result
        assert browser._active_page is None
        assert browser._remote_page is None

    def test_on_remote_disconnected_clears_state(self):
        page = MagicMock()
        browser._remote_browser = MagicMock()
        browser._remote_page = page
        browser._active_page = page

        browser._on_remote_disconnected()

        assert browser._remote_browser is None
        assert browser._remote_page is None
        assert browser._active_page is None

    def test_update_remote_url_tracks_after_action(self):
        """_update_remote_url syncs drift tracking after click/fill/js."""
        page = MagicMock()
        page.url = "https://new-page-after-submit.com"
        browser._remote_page = page
        browser._active_page = page
        browser._remote_last_url = "https://old-form-page.com"

        browser._update_remote_url()

        assert browser._remote_last_url == "https://new-page-after-submit.com"

    def test_update_remote_url_noop_when_not_remote(self):
        """_update_remote_url does nothing when not in remote mode."""
        browser._active_page = MagicMock()
        browser._remote_last_url = "https://old.com"

        browser._update_remote_url()

        assert browser._remote_last_url == "https://old.com"  # unchanged
