"""Tests for browser backend abstraction layer.

Note: Serialization, state management, and context manager tests have been
moved to test_backends_serialization.py to keep this file under 800 lines.
"""

from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
import selenium.common.exceptions

from graftpunk.backends import (
    BrowserBackend,
    get_backend,
    list_backends,
    register_backend,
)
from graftpunk.backends.selenium import SeleniumBackend


class TestBrowserBackendProtocol:
    """Tests for BrowserBackend Protocol."""

    def test_selenium_backend_implements_protocol(self) -> None:
        """SeleniumBackend should satisfy BrowserBackend protocol."""
        backend = SeleniumBackend(headless=True)
        assert isinstance(backend, BrowserBackend)

    def test_protocol_is_runtime_checkable(self) -> None:
        """Protocol should support isinstance checks."""
        # Create a mock that has all required methods
        mock = MagicMock(spec=SeleniumBackend)
        # Note: MagicMock with spec automatically gets all methods
        # but isinstance check requires actual attributes
        assert hasattr(mock, "start")
        assert hasattr(mock, "stop")
        assert hasattr(mock, "navigate")

    def test_cookie_type_exported(self) -> None:
        """Cookie TypedDict is exported from backends module."""
        from graftpunk.backends import Cookie

        # Verify Cookie can be used as a type hint
        cookie: Cookie = {"name": "test", "value": "val"}
        assert cookie["name"] == "test"
        assert cookie["value"] == "val"


class TestGetBackend:
    """Tests for get_backend factory function."""

    def test_get_selenium_backend(self) -> None:
        """Get selenium backend by name."""
        backend = get_backend("selenium", headless=True)
        assert isinstance(backend, SeleniumBackend)
        assert backend._headless is True

    def test_get_legacy_alias(self) -> None:
        """legacy is an alias for selenium."""
        backend = get_backend("legacy", headless=True)
        assert isinstance(backend, SeleniumBackend)

    def test_default_backend_is_selenium(self) -> None:
        """Default backend should be selenium."""
        backend = get_backend()
        assert isinstance(backend, SeleniumBackend)

    def test_unknown_backend_raises_error(self) -> None:
        """Unknown backend name raises ValueError."""
        with pytest.raises(ValueError, match="Unknown backend"):
            get_backend("nonexistent")

    def test_passes_kwargs_to_backend(self) -> None:
        """kwargs are passed to backend constructor."""
        backend = get_backend(
            "selenium",
            headless=False,
            use_stealth=False,
            default_timeout=30,
        )
        assert backend._headless is False
        assert backend._use_stealth is False
        assert backend._default_timeout == 30


class TestListBackends:
    """Tests for list_backends function."""

    def test_list_backends_returns_list(self) -> None:
        """list_backends returns a list."""
        backends = list_backends()
        assert isinstance(backends, list)

    def test_list_backends_includes_selenium(self) -> None:
        """selenium should be in available backends."""
        backends = list_backends()
        assert "selenium" in backends

    def test_list_backends_includes_legacy(self) -> None:
        """legacy alias should be in available backends."""
        backends = list_backends()
        assert "legacy" in backends

    def test_list_backends_is_sorted(self) -> None:
        """list_backends returns sorted list."""
        backends = list_backends()
        assert backends == sorted(backends)


class TestRegisterBackend:
    """Tests for register_backend function."""

    def test_register_duplicate_raises_error(self) -> None:
        """Registering existing name raises ValueError."""
        with pytest.raises(ValueError, match="already registered"):
            register_backend("selenium", "some.module:SomeClass")

    def test_register_invalid_path_raises_error(self) -> None:
        """Invalid module:class path raises ValueError."""
        with pytest.raises(ValueError, match="Expected format"):
            register_backend("invalid", "invalid_path_no_colon")

    def test_register_backend_success_and_retrieval(self) -> None:
        """Registered backend can be retrieved via get_backend()."""
        from graftpunk.backends import _BACKEND_REGISTRY

        # Register a custom backend pointing to an existing class
        test_name = "test_custom_backend_registration"
        register_backend(test_name, "graftpunk.backends.selenium:SeleniumBackend")

        try:
            # Verify it's in the registry
            assert test_name in _BACKEND_REGISTRY

            # Verify we can retrieve it
            backend = get_backend(test_name)
            assert isinstance(backend, SeleniumBackend)
        finally:
            # Clean up registry to not pollute other tests
            del _BACKEND_REGISTRY[test_name]


class TestSeleniumBackend:
    """Tests for SeleniumBackend implementation."""

    def test_init_stores_options(self) -> None:
        """Backend stores initialization options."""
        backend = SeleniumBackend(
            headless=True,
            use_stealth=True,
            default_timeout=30,
        )
        assert backend._headless is True
        assert backend._use_stealth is True
        assert backend._default_timeout == 30

    def test_init_with_profile_dir(self, tmp_path: Path) -> None:
        """Backend accepts profile_dir option."""
        profile = tmp_path / "test_profile"
        backend = SeleniumBackend(profile_dir=profile)
        assert backend._profile_dir == profile

    def test_is_running_false_before_start(self) -> None:
        """is_running is False before start() called."""
        backend = SeleniumBackend(headless=True)
        assert backend.is_running is False

    def test_backend_type_is_selenium(self) -> None:
        """BACKEND_TYPE should be 'selenium'."""
        assert SeleniumBackend.BACKEND_TYPE == "selenium"

    def test_repr_shows_status(self) -> None:
        """__repr__ shows backend status."""
        backend = SeleniumBackend(headless=True, use_stealth=True)
        assert "stealth" in repr(backend)
        assert "stopped" in repr(backend)

    def test_repr_shows_standard_mode(self) -> None:
        """__repr__ shows standard mode when not stealth."""
        backend = SeleniumBackend(use_stealth=False)
        assert "standard" in repr(backend)

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_start_creates_stealth_driver(self, mock_create: MagicMock) -> None:
        """start() creates stealth driver when use_stealth=True."""
        mock_driver = MagicMock()
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(headless=True, use_stealth=True)
        backend.start()

        mock_create.assert_called_once_with(headless=True, profile_dir=None)
        assert backend.is_running is True
        assert backend._driver is mock_driver

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_start_is_idempotent(self, mock_create: MagicMock) -> None:
        """Calling start() twice only creates driver once."""
        mock_create.return_value = MagicMock()

        backend = SeleniumBackend(use_stealth=True)
        backend.start()
        backend.start()  # Second call should be no-op

        mock_create.assert_called_once()

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_stop_quits_driver(self, mock_create: MagicMock) -> None:
        """stop() quits the driver."""
        mock_driver = MagicMock()
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()
        backend.stop()

        mock_driver.quit.assert_called_once()
        assert backend.is_running is False
        assert backend._driver is None

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_stop_is_idempotent(self, mock_create: MagicMock) -> None:
        """Calling stop() twice is safe."""
        mock_driver = MagicMock()
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()
        backend.stop()
        backend.stop()  # Second call should be no-op

        mock_driver.quit.assert_called_once()

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_navigate_calls_driver_get(self, mock_create: MagicMock) -> None:
        """navigate() calls driver.get()."""
        mock_driver = MagicMock()
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()
        backend.navigate("https://example.com")

        mock_driver.get.assert_called_once_with("https://example.com")

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_current_url_returns_driver_url(self, mock_create: MagicMock) -> None:
        """current_url returns driver's current_url."""
        mock_driver = MagicMock()
        mock_driver.current_url = "https://example.com/page"
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()

        assert backend.current_url == "https://example.com/page"

    def test_current_url_empty_when_not_running(self) -> None:
        """current_url returns empty string when not running."""
        backend = SeleniumBackend()
        assert backend.current_url == ""

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_get_cookies_returns_driver_cookies(self, mock_create: MagicMock) -> None:
        """get_cookies() returns driver's cookies."""
        mock_driver = MagicMock()
        mock_driver.get_cookies.return_value = [{"name": "session", "value": "abc123"}]
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()

        cookies = backend.get_cookies()
        assert len(cookies) == 1
        assert cookies[0]["name"] == "session"

    def test_get_cookies_empty_when_not_running(self) -> None:
        """get_cookies() returns empty list when not running."""
        backend = SeleniumBackend()
        assert backend.get_cookies() == []

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_set_cookies_adds_to_driver(self, mock_create: MagicMock) -> None:
        """set_cookies() adds cookies to driver."""
        mock_driver = MagicMock()
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()
        backend.set_cookies([{"name": "test", "value": "value"}])

        mock_driver.add_cookie.assert_called_once_with({"name": "test", "value": "value"})

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_get_user_agent(self, mock_create: MagicMock) -> None:
        """get_user_agent() executes JavaScript."""
        mock_driver = MagicMock()
        mock_driver.execute_script.return_value = "Mozilla/5.0 Test"
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()

        ua = backend.get_user_agent()
        assert ua == "Mozilla/5.0 Test"
        mock_driver.execute_script.assert_called_with("return navigator.userAgent")

    def test_get_user_agent_empty_when_not_running(self) -> None:
        """get_user_agent() returns empty string when not running."""
        backend = SeleniumBackend()
        assert backend.get_user_agent() == ""


class TestSeleniumBackendErrorHandling:
    """Tests for SeleniumBackend error handling."""

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_start_webdriver_exception_raises_browser_error(self, mock_create: MagicMock) -> None:
        """WebDriverException during start raises BrowserError."""
        from graftpunk.exceptions import BrowserError

        mock_create.side_effect = selenium.common.exceptions.WebDriverException("fail")

        backend = SeleniumBackend(use_stealth=True)
        with pytest.raises(BrowserError, match="Failed to start"):
            backend.start()

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_start_os_error_raises_browser_error(self, mock_create: MagicMock) -> None:
        """OSError during start raises BrowserError."""
        from graftpunk.exceptions import BrowserError

        mock_create.side_effect = OSError("Chrome not found")

        backend = SeleniumBackend(use_stealth=True)
        with pytest.raises(BrowserError, match="Failed to start"):
            backend.start()

    def test_start_import_error_raises_browser_error(self) -> None:
        """ImportError for stealth dependencies raises BrowserError with install hint."""
        from graftpunk.exceptions import BrowserError

        with patch(
            "graftpunk.backends.selenium.SeleniumBackend._start_stealth_driver"
        ) as mock_start:
            mock_start.side_effect = BrowserError(
                "Stealth dependencies not installed. Install with: pip install graftpunk[standard]"
            )

            backend = SeleniumBackend(use_stealth=True)
            with pytest.raises(BrowserError, match="Stealth dependencies not installed"):
                backend.start()

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_navigate_webdriver_exception_raises_browser_error(
        self, mock_create: MagicMock
    ) -> None:
        """WebDriverException during navigate raises BrowserError."""
        from graftpunk.exceptions import BrowserError

        mock_driver = MagicMock()
        mock_driver.get.side_effect = selenium.common.exceptions.WebDriverException("timeout")
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()
        with pytest.raises(BrowserError, match="Navigation failed"):
            backend.navigate("https://example.com")

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_stop_handles_quit_exception_gracefully(self, mock_create: MagicMock) -> None:
        """stop() handles WebDriverException from quit gracefully."""
        mock_driver = MagicMock()
        mock_driver.quit.side_effect = selenium.common.exceptions.WebDriverException(
            "already closed"
        )
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()
        # Should not raise
        backend.stop()
        assert backend.is_running is False

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_stop_handles_os_error_gracefully(self, mock_create: MagicMock) -> None:
        """stop() handles OSError from quit gracefully."""
        mock_driver = MagicMock()
        mock_driver.quit.side_effect = OSError("process died")
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()
        # Should not raise
        backend.stop()
        assert backend.is_running is False

    @patch("graftpunk.backends.selenium.LOG")
    @patch("graftpunk.stealth.create_stealth_driver")
    def test_stop_logs_debug_for_expected_webdriver_errors(
        self, mock_create: MagicMock, mock_log: MagicMock
    ) -> None:
        """stop() logs DEBUG for expected cleanup errors like 'session deleted'."""
        mock_driver = MagicMock()
        mock_driver.quit.side_effect = selenium.common.exceptions.WebDriverException(
            "session deleted because of page crash"
        )
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()
        backend.stop()

        # Should log at debug level for expected error
        mock_log.debug.assert_called()
        assert not mock_log.warning.called or all(
            "stop_unexpected" not in str(call) for call in mock_log.warning.call_args_list
        )

    @patch("graftpunk.backends.selenium.LOG")
    @patch("graftpunk.stealth.create_stealth_driver")
    def test_stop_logs_warning_for_unexpected_webdriver_errors(
        self, mock_create: MagicMock, mock_log: MagicMock
    ) -> None:
        """stop() logs WARNING for unexpected WebDriverException errors."""
        mock_driver = MagicMock()
        mock_driver.quit.side_effect = selenium.common.exceptions.WebDriverException(
            "some unexpected error that is not in the expected patterns"
        )
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()
        backend.stop()

        # Should log at warning level for unexpected error
        mock_log.warning.assert_called()

    @patch("graftpunk.backends.selenium.LOG")
    @patch("graftpunk.stealth.create_stealth_driver")
    def test_stop_logs_debug_for_expected_os_errors(
        self, mock_create: MagicMock, mock_log: MagicMock
    ) -> None:
        """stop() logs DEBUG for expected OS errors like 'no such process'."""
        mock_driver = MagicMock()
        mock_driver.quit.side_effect = OSError("No such process")
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()
        backend.stop()

        # Should log at debug level for expected OS error
        mock_log.debug.assert_called()

    @patch("graftpunk.backends.selenium.LOG")
    @patch("graftpunk.stealth.create_stealth_driver")
    def test_stop_logs_warning_for_unexpected_os_errors(
        self, mock_create: MagicMock, mock_log: MagicMock
    ) -> None:
        """stop() logs WARNING for unexpected OS errors."""
        mock_driver = MagicMock()
        mock_driver.quit.side_effect = OSError("disk full unexpected error")
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()
        backend.stop()

        # Should log at warning level for unexpected OS error
        mock_log.warning.assert_called()

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_navigate_auto_starts_browser(self, mock_create: MagicMock) -> None:
        """navigate() auto-starts browser if not running."""
        mock_driver = MagicMock()
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        assert backend.is_running is False

        backend.navigate("https://example.com")

        assert backend.is_running is True
        mock_driver.get.assert_called_once_with("https://example.com")

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_set_cookies_auto_starts_browser(self, mock_create: MagicMock) -> None:
        """set_cookies() auto-starts browser if not running."""
        mock_driver = MagicMock()
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        assert backend.is_running is False

        backend.set_cookies([{"name": "test", "value": "value"}])

        assert backend.is_running is True
        mock_driver.add_cookie.assert_called_once()

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_set_cookies_logs_warning_on_failure(self, mock_create: MagicMock) -> None:
        """set_cookies() logs warning when cookie add fails."""
        mock_driver = MagicMock()
        mock_driver.add_cookie.side_effect = selenium.common.exceptions.WebDriverException(
            "wrong domain"
        )
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()
        # Should not raise, but log warning
        backend.set_cookies([{"name": "test", "value": "value"}])

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_set_cookies_returns_success_count(self, mock_create: MagicMock) -> None:
        """set_cookies() returns number of cookies successfully set."""
        mock_driver = MagicMock()
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()
        result = backend.set_cookies(
            [
                {"name": "cookie1", "value": "val1"},
                {"name": "cookie2", "value": "val2"},
            ]
        )

        assert result == 2
        assert mock_driver.add_cookie.call_count == 2

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_set_cookies_returns_partial_count_on_failure(self, mock_create: MagicMock) -> None:
        """set_cookies() returns count of successfully set cookies when some fail."""
        mock_driver = MagicMock()
        # First cookie succeeds, second fails
        mock_driver.add_cookie.side_effect = [
            None,  # Success
            selenium.common.exceptions.WebDriverException("domain mismatch"),
        ]
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()
        result = backend.set_cookies(
            [
                {"name": "cookie1", "value": "val1"},
                {"name": "cookie2", "value": "val2"},
            ]
        )

        assert result == 1  # Only one succeeded

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_current_url_returns_empty_on_exception(self, mock_create: MagicMock) -> None:
        """current_url returns empty string on WebDriverException."""
        mock_driver = MagicMock()
        mock_driver.current_url = property(
            lambda self: (_ for _ in ()).throw(
                selenium.common.exceptions.WebDriverException("stale")
            )
        )
        # Simpler approach: make it a method that raises
        type(mock_driver).current_url = property(
            MagicMock(side_effect=selenium.common.exceptions.WebDriverException("stale"))
        )
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()

        assert backend.current_url == ""

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_page_title_returns_empty_on_exception(self, mock_create: MagicMock) -> None:
        """page_title returns empty string on WebDriverException."""
        mock_driver = MagicMock()
        type(mock_driver).title = property(
            MagicMock(side_effect=selenium.common.exceptions.WebDriverException("stale"))
        )
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()

        assert backend.page_title == ""

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_get_cookies_returns_empty_on_exception(self, mock_create: MagicMock) -> None:
        """get_cookies() returns empty list on WebDriverException."""
        mock_driver = MagicMock()
        mock_driver.get_cookies.side_effect = selenium.common.exceptions.WebDriverException(
            "browser closed"
        )
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()

        assert backend.get_cookies() == []

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_get_cookies_handles_driver_returning_none(self, mock_create: MagicMock) -> None:
        """get_cookies() returns empty list when driver returns None."""
        mock_driver = MagicMock()
        mock_driver.get_cookies.return_value = None
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()

        assert backend.get_cookies() == []

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_get_user_agent_handles_script_returning_none(self, mock_create: MagicMock) -> None:
        """get_user_agent() returns empty string when script returns None."""
        mock_driver = MagicMock()
        mock_driver.execute_script.return_value = None
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()

        assert backend.get_user_agent() == ""

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_get_user_agent_returns_empty_on_exception(self, mock_create: MagicMock) -> None:
        """get_user_agent() returns empty string on WebDriverException."""
        mock_driver = MagicMock()
        mock_driver.execute_script.side_effect = selenium.common.exceptions.WebDriverException(
            "execution failed"
        )
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()

        assert backend.get_user_agent() == ""


class TestSeleniumBackendStandardMode:
    """Tests for SeleniumBackend standard (non-stealth) mode."""

    @patch("selenium.webdriver.Chrome")
    @patch("graftpunk.backends.selenium.webdriver_manager.chrome.ChromeDriverManager")
    @patch("graftpunk.backends.selenium.get_chrome_version")
    def test_standard_mode_uses_webdriver_manager(
        self,
        mock_version: MagicMock,
        mock_manager: MagicMock,
        mock_chrome: MagicMock,
    ) -> None:
        """Standard mode uses webdriver-manager for chromedriver."""
        mock_version.return_value = "120"
        mock_manager.return_value.install.return_value = "/path/to/chromedriver"
        mock_chrome.return_value = MagicMock()

        backend = SeleniumBackend(use_stealth=False, headless=True)
        backend.start()

        mock_version.assert_called_once_with(major=True)
        mock_manager.assert_called_once_with(driver_version="120")
        assert backend.is_running is True

    @patch("selenium.webdriver.Chrome")
    @patch("graftpunk.backends.selenium.webdriver_manager.chrome.ChromeDriverManager")
    @patch("graftpunk.backends.selenium.get_chrome_version")
    def test_standard_mode_headless_argument(
        self,
        mock_version: MagicMock,
        mock_manager: MagicMock,
        mock_chrome: MagicMock,
    ) -> None:
        """Standard mode adds headless argument when headless=True."""
        mock_version.return_value = "120"
        mock_manager.return_value.install.return_value = "/path/to/chromedriver"
        mock_chrome.return_value = MagicMock()

        backend = SeleniumBackend(use_stealth=False, headless=True)
        backend.start()

        # Check that Chrome was called with options containing headless
        call_kwargs = mock_chrome.call_args[1]
        options = call_kwargs["options"]
        # Check arguments were added
        assert any("headless" in str(arg) for arg in options.arguments)

    @patch("graftpunk.backends.selenium.get_chrome_version")
    def test_standard_mode_chrome_detection_error(self, mock_version: MagicMock) -> None:
        """ChromeDriverError during version detection raises BrowserError."""
        from graftpunk.exceptions import BrowserError, ChromeDriverError

        mock_version.side_effect = ChromeDriverError("Chrome not found")

        backend = SeleniumBackend(use_stealth=False)
        with pytest.raises(BrowserError, match="Failed to detect Chrome"):
            backend.start()

    @patch("selenium.webdriver.Chrome")
    @patch("graftpunk.backends.selenium.webdriver_manager.chrome.ChromeDriverManager")
    @patch("graftpunk.backends.selenium.get_chrome_version")
    def test_standard_mode_respects_window_size_option(
        self,
        mock_version: MagicMock,
        mock_manager: MagicMock,
        mock_chrome: MagicMock,
    ) -> None:
        """Standard mode applies window_size option."""
        mock_version.return_value = "120"
        mock_manager.return_value.install.return_value = "/path/to/chromedriver"
        mock_chrome.return_value = MagicMock()

        backend = SeleniumBackend(use_stealth=False, window_size="1920,1080")
        backend.start()

        call_kwargs = mock_chrome.call_args[1]
        options = call_kwargs["options"]
        assert any("1920,1080" in str(arg) for arg in options.arguments)


class TestSeleniumBackendMissingCoverage:
    """Additional tests for SeleniumBackend coverage gaps."""

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_is_running_false_when_driver_none_but_started_true(
        self, mock_create: MagicMock
    ) -> None:
        """is_running returns False if driver is None even if _started is True."""
        mock_create.return_value = MagicMock()

        backend = SeleniumBackend(use_stealth=True)
        backend.start()
        assert backend.is_running is True

        # Simulate driver becoming None (crash scenario)
        backend._driver = None

        assert backend.is_running is False

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_page_source_returns_driver_source(self, mock_create: MagicMock) -> None:
        """page_source returns driver's page_source."""
        mock_driver = MagicMock()
        mock_driver.page_source = "<html><body>Test</body></html>"
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()

        assert backend.page_source == "<html><body>Test</body></html>"

    def test_page_source_empty_when_not_running(self) -> None:
        """page_source returns empty string when not running."""
        backend = SeleniumBackend()
        assert backend.page_source == ""

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_page_source_returns_empty_on_exception(self, mock_create: MagicMock) -> None:
        """page_source returns empty string on WebDriverException."""
        mock_driver = MagicMock()
        type(mock_driver).page_source = property(
            MagicMock(side_effect=selenium.common.exceptions.WebDriverException("stale"))
        )
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()

        assert backend.page_source == ""

    def test_page_title_empty_when_not_running(self) -> None:
        """page_title returns empty string when not running."""
        backend = SeleniumBackend()
        assert backend.page_title == ""

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_delete_all_cookies_calls_driver(self, mock_create: MagicMock) -> None:
        """delete_all_cookies() calls driver.delete_all_cookies()."""
        mock_driver = MagicMock()
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()
        backend.delete_all_cookies()

        mock_driver.delete_all_cookies.assert_called_once()

    def test_delete_all_cookies_safe_when_not_running(self) -> None:
        """delete_all_cookies() is safe when not running."""
        backend = SeleniumBackend()
        # Should not raise and should return True (no-op success)
        result = backend.delete_all_cookies()
        assert result is True

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_delete_all_cookies_returns_true_on_success(self, mock_create: MagicMock) -> None:
        """delete_all_cookies() returns True when successful."""
        mock_driver = MagicMock()
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()
        result = backend.delete_all_cookies()

        assert result is True
        mock_driver.delete_all_cookies.assert_called_once()

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_delete_all_cookies_returns_false_on_failure(self, mock_create: MagicMock) -> None:
        """delete_all_cookies() returns False when WebDriverException occurs."""
        mock_driver = MagicMock()
        mock_driver.delete_all_cookies.side_effect = selenium.common.exceptions.WebDriverException(
            "fail"
        )
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()
        result = backend.delete_all_cookies()

        assert result is False

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_delete_all_cookies_handles_exception_gracefully(self, mock_create: MagicMock) -> None:
        """delete_all_cookies() handles WebDriverException gracefully."""
        mock_driver = MagicMock()
        mock_driver.delete_all_cookies.side_effect = selenium.common.exceptions.WebDriverException(
            "fail"
        )
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()
        # Should not raise
        backend.delete_all_cookies()

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_driver_starts_if_not_running(self, mock_create: MagicMock) -> None:
        """driver property starts browser if not running."""
        mock_driver = MagicMock()
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        assert backend.is_running is False

        _ = backend.driver

        assert backend.is_running is True
        mock_create.assert_called_once()


class TestGetBackendImportError:
    """Tests for get_backend() import error handling."""

    def test_get_backend_import_error_provides_helpful_message(self) -> None:
        """ImportError during backend loading provides helpful message."""
        with patch("graftpunk.backends.import_module") as mock_import:
            mock_import.side_effect = ImportError("No module named 'nodriver'")

            with pytest.raises(ImportError, match="requires additional dependencies"):
                get_backend("nodriver")

    def test_get_backend_attribute_error_provides_helpful_message(self) -> None:
        """AttributeError during class lookup provides helpful message."""
        with patch("graftpunk.backends.import_module") as mock_import:
            mock_import.return_value = MagicMock(spec=[])  # Empty spec, no attributes
            with pytest.raises(ImportError, match="class.*not found"):
                get_backend("selenium")


class TestSeleniumBackendMalformedUrls:
    """Tests for navigate() with malformed URLs."""

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_navigate_with_empty_url(self, mock_create: MagicMock) -> None:
        """navigate() with empty URL passes to driver (driver handles validation)."""
        from graftpunk.exceptions import BrowserError

        mock_driver = MagicMock()
        mock_driver.get.side_effect = selenium.common.exceptions.WebDriverException("invalid URL")
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()

        with pytest.raises(BrowserError, match="Navigation failed"):
            backend.navigate("")

    @patch("graftpunk.stealth.create_stealth_driver")
    def test_navigate_with_malformed_url(self, mock_create: MagicMock) -> None:
        """navigate() with malformed URL raises BrowserError from driver."""
        from graftpunk.exceptions import BrowserError

        mock_driver = MagicMock()
        mock_driver.get.side_effect = selenium.common.exceptions.WebDriverException(
            "invalid argument: 'url' must be a valid URL"
        )
        mock_create.return_value = mock_driver

        backend = SeleniumBackend(use_stealth=True)
        backend.start()

        with pytest.raises(BrowserError, match="Navigation failed"):
            backend.navigate("not-a-valid-url")


class TestSeleniumExpectedStopPatterns:
    """Parameterized tests for all expected stop patterns."""

    @pytest.mark.parametrize(
        "pattern",
        [
            "unable to connect to renderer",
            "no such window handle",
            "chrome not reachable",
            "session deleted because of page crash",
            "target window already closed",
        ],
    )
    def test_expected_webdriver_pattern_detection(self, pattern: str) -> None:
        """All expected WebDriver stop patterns are correctly detected."""
        backend = SeleniumBackend()
        assert backend._is_expected_webdriver_stop_error(pattern) is True

    @pytest.mark.parametrize(
        "pattern",
        [
            "UNABLE TO CONNECT",  # Uppercase
            "No Such Window",  # Mixed case
            "Chrome Not Reachable",  # Title case
        ],
    )
    def test_expected_webdriver_pattern_case_insensitive(self, pattern: str) -> None:
        """WebDriver pattern matching is case-insensitive."""
        backend = SeleniumBackend()
        assert backend._is_expected_webdriver_stop_error(pattern) is True

    @pytest.mark.parametrize(
        "pattern",
        [
            "no such process",
            "broken pipe error",
        ],
    )
    def test_expected_os_pattern_detection(self, pattern: str) -> None:
        """All expected OS stop patterns are correctly detected."""
        backend = SeleniumBackend()
        assert backend._is_expected_os_stop_error(pattern) is True

    @pytest.mark.parametrize(
        "pattern",
        [
            "random error",
            "disk full",
            "permission denied",
        ],
    )
    def test_unexpected_webdriver_pattern_detection(self, pattern: str) -> None:
        """Unexpected WebDriver patterns are correctly identified."""
        backend = SeleniumBackend()
        assert backend._is_expected_webdriver_stop_error(pattern) is False

    @pytest.mark.parametrize(
        "pattern",
        [
            "random error",
            "disk full",
            "permission denied",
        ],
    )
    def test_unexpected_os_pattern_detection(self, pattern: str) -> None:
        """Unexpected OS patterns are correctly identified."""
        backend = SeleniumBackend()
        assert backend._is_expected_os_stop_error(pattern) is False
