"""Unit tests for agent_browser tools.

All subprocess calls are mocked — no agent-browser binary or real browser needed.
"""

import asyncio
import json
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from backend.copilot.model import ChatSession

from .agent_browser import (
    BrowserActTool,
    BrowserNavigateTool,
    BrowserScreenshotTool,
    _ensure_session,
    _has_local_session,
    _restore_browser_state,
    _save_browser_state,
    close_browser_session,
)
from .models import (
    BrowserActResponse,
    BrowserNavigateResponse,
    BrowserScreenshotResponse,
    ErrorResponse,
    ResponseType,
)

# ---------------------------------------------------------------------------
# Autouse fixture — mock state persistence helpers for all existing tests.
# Dedicated tests for these helpers call the *original* functions directly
# (imported above), which are not affected by monkeypatch on the module attr.
# ---------------------------------------------------------------------------


@pytest.fixture(autouse=True)
def _reset_and_mock_state(monkeypatch):
    """Reset session cache and mock state persistence for all tests."""
    from . import agent_browser as _mod

    _mod._alive_sessions.clear()
    _mod._session_locks.clear()
    monkeypatch.setattr(
        "backend.copilot.tools.agent_browser._ensure_session", AsyncMock()
    )
    monkeypatch.setattr(
        "backend.copilot.tools.agent_browser._fire_and_forget_save", MagicMock()
    )
    yield
    _mod._alive_sessions.clear()
    _mod._session_locks.clear()


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def make_session(session_id: str = "test-session-123") -> ChatSession:
    return MagicMock(spec=ChatSession, session_id=session_id)


def _run_result(rc: int = 0, stdout: str = "", stderr: str = "") -> tuple:
    return (rc, stdout, stderr)


# ---------------------------------------------------------------------------
# SSRF protection via shared validate_url_host (backend.util.request)
# ---------------------------------------------------------------------------

# Patch target: validate_url_host is imported directly into agent_browser's
# module scope.
_VALIDATE_URL = "backend.copilot.tools.agent_browser.validate_url_host"


class TestSsrfViaValidateUrl:
    """Verify that browser_navigate uses validate_url_host for SSRF protection.

    We mock validate_url_host itself (not the low-level socket) so these tests
    exercise the integration point, not the internals of request.py
    (which has its own thorough test suite in request_test.py).
    """

    def setup_method(self):
        self.tool = BrowserNavigateTool()
        self.session = make_session()

    @pytest.mark.asyncio
    async def test_blocked_ip_returns_blocked_url_error(self):
        """validate_url_host raises ValueError → tool returns blocked_url ErrorResponse."""
        with patch(_VALIDATE_URL, new_callable=AsyncMock) as mock_validate:
            mock_validate.side_effect = ValueError(
                "Access to blocked IP 10.0.0.1 is not allowed."
            )
            result = await self.tool._execute(
                user_id="user1", session=self.session, url="http://internal.corp"
            )
        assert isinstance(result, ErrorResponse)
        assert result.error == "blocked_url"
        assert "10.0.0.1" in result.message

    @pytest.mark.asyncio
    async def test_invalid_scheme_returns_blocked_url_error(self):
        with patch(_VALIDATE_URL, new_callable=AsyncMock) as mock_validate:
            mock_validate.side_effect = ValueError("Scheme 'ftp' is not allowed.")
            result = await self.tool._execute(
                user_id="user1", session=self.session, url="ftp://example.com"
            )
        assert isinstance(result, ErrorResponse)
        assert result.error == "blocked_url"

    @pytest.mark.asyncio
    async def test_unresolvable_host_returns_blocked_url_error(self):
        with patch(_VALIDATE_URL, new_callable=AsyncMock) as mock_validate:
            mock_validate.side_effect = ValueError(
                "Unable to resolve IP address for hostname bad.invalid"
            )
            result = await self.tool._execute(
                user_id="user1", session=self.session, url="https://bad.invalid"
            )
        assert isinstance(result, ErrorResponse)
        assert result.error == "blocked_url"

    @pytest.mark.asyncio
    async def test_validate_url_host_called_without_trusted_hostnames(self):
        """Confirms no trusted-hostnames bypass is granted — all URLs are validated."""
        with patch(_VALIDATE_URL, new_callable=AsyncMock) as mock_validate:
            mock_validate.return_value = (object(), False, ["1.2.3.4"])
            with patch(
                "backend.copilot.tools.agent_browser._run",
                new_callable=AsyncMock,
                return_value=_run_result(rc=0),
            ):
                with patch(
                    "backend.copilot.tools.agent_browser._snapshot",
                    new_callable=AsyncMock,
                    return_value="",
                ):
                    await self.tool._execute(
                        user_id="user1",
                        session=self.session,
                        url="https://example.com",
                    )
        mock_validate.assert_called_once_with("https://example.com")


# ---------------------------------------------------------------------------
# BrowserNavigateTool metadata
# ---------------------------------------------------------------------------


class TestAgentBrowserAvailability:
    """is_available reflects whether the agent-browser CLI binary is installed."""

    def test_available_when_binary_found(self):
        with patch("shutil.which", return_value="/usr/local/bin/agent-browser"):
            assert BrowserNavigateTool().is_available is True
            assert BrowserActTool().is_available is True
            assert BrowserScreenshotTool().is_available is True

    def test_unavailable_when_binary_missing(self):
        with patch("shutil.which", return_value=None):
            assert BrowserNavigateTool().is_available is False
            assert BrowserActTool().is_available is False
            assert BrowserScreenshotTool().is_available is False


class TestBrowserNavigateMetadata:
    def setup_method(self):
        self.tool = BrowserNavigateTool()

    def test_name(self):
        assert self.tool.name == "browser_navigate"

    def test_requires_auth(self):
        assert self.tool.requires_auth is True

    def test_parameters_has_url_required(self):
        params = self.tool.parameters
        assert "url" in params["properties"]
        assert "url" in params["required"]

    def test_parameters_has_wait_for(self):
        params = self.tool.parameters
        assert "wait_for" in params["properties"]

    def test_as_openai_tool(self):
        ot = self.tool.as_openai_tool()
        assert ot["type"] == "function"
        assert ot["function"]["name"] == "browser_navigate"


# ---------------------------------------------------------------------------
# BrowserNavigateTool execution
# ---------------------------------------------------------------------------


class TestBrowserNavigateExecute:
    def setup_method(self):
        self.tool = BrowserNavigateTool()
        self.session = make_session()

    @pytest.mark.asyncio
    async def test_missing_url_returns_error(self):
        result = await self.tool._execute(user_id="user1", session=self.session, url="")
        assert isinstance(result, ErrorResponse)
        assert result.error == "missing_url"

    @pytest.mark.asyncio
    async def test_ssrf_blocked_url_returns_error(self):
        with patch(_VALIDATE_URL, new_callable=AsyncMock) as mock_validate:
            mock_validate.side_effect = ValueError(
                "Access to blocked IP 10.0.0.1 is not allowed."
            )
            result = await self.tool._execute(
                user_id="user1", session=self.session, url="http://internal.corp"
            )
        assert isinstance(result, ErrorResponse)
        assert result.error == "blocked_url"

    @pytest.mark.asyncio
    async def test_navigation_failure_returns_error(self):
        with patch(
            _VALIDATE_URL,
            new_callable=AsyncMock,
            return_value=(object(), False, ["1.2.3.4"]),
        ):
            with patch(
                "backend.copilot.tools.agent_browser._run",
                new_callable=AsyncMock,
            ) as mock_run:
                mock_run.return_value = _run_result(rc=1, stderr="timeout")
                result = await self.tool._execute(
                    user_id="user1",
                    session=self.session,
                    url="https://example.com",
                )
        assert isinstance(result, ErrorResponse)
        assert result.error == "navigation_failed"

    @pytest.mark.asyncio
    async def test_successful_navigation(self):
        with patch(
            _VALIDATE_URL,
            new_callable=AsyncMock,
            return_value=(object(), False, ["1.2.3.4"]),
        ):
            with patch(
                "backend.copilot.tools.agent_browser._run",
                new_callable=AsyncMock,
            ) as mock_run:
                # open → success; wait → success; get title; get url
                mock_run.side_effect = [
                    _run_result(rc=0),  # open
                    _run_result(rc=0),  # wait
                    _run_result(rc=0, stdout="Example Domain"),  # get title
                    _run_result(rc=0, stdout="https://example.com"),  # get url
                ]
                with patch(
                    "backend.copilot.tools.agent_browser._snapshot",
                    new_callable=AsyncMock,
                    return_value="[snapshot]",
                ):
                    result = await self.tool._execute(
                        user_id="user1",
                        session=self.session,
                        url="https://example.com",
                    )

        assert isinstance(result, BrowserNavigateResponse)
        assert result.type == ResponseType.BROWSER_NAVIGATE
        assert result.url == "https://example.com"
        assert result.title == "Example Domain"
        assert result.snapshot == "[snapshot]"

    @pytest.mark.asyncio
    async def test_session_id_used_as_session_name(self):
        """Ensure session.session_id is passed as --session-name."""
        session = make_session("my-unique-session")
        captured_calls = []

        async def fake_run(session_name, *args, **kwargs):
            captured_calls.append((session_name, args))
            return _run_result(rc=0)

        with patch(
            _VALIDATE_URL,
            new_callable=AsyncMock,
            return_value=(object(), False, ["1.2.3.4"]),
        ):
            with patch(
                "backend.copilot.tools.agent_browser._run", side_effect=fake_run
            ):
                with patch(
                    "backend.copilot.tools.agent_browser._snapshot",
                    new_callable=AsyncMock,
                    return_value="",
                ):
                    await self.tool._execute(
                        user_id="user1",
                        session=session,
                        url="https://example.com",
                    )

        for session_name, _ in captured_calls:
            assert session_name == "my-unique-session"


# ---------------------------------------------------------------------------
# BrowserActTool metadata
# ---------------------------------------------------------------------------


class TestBrowserActMetadata:
    def setup_method(self):
        self.tool = BrowserActTool()

    def test_name(self):
        assert self.tool.name == "browser_act"

    def test_requires_auth(self):
        assert self.tool.requires_auth is True

    def test_parameters_has_action_required(self):
        params = self.tool.parameters
        assert "action" in params["properties"]
        assert "action" in params["required"]

    def test_action_enum_values(self):
        actions = self.tool.parameters["properties"]["action"]["enum"]
        expected = {
            "click",
            "dblclick",
            "fill",
            "type",
            "scroll",
            "hover",
            "press",
            "check",
            "uncheck",
            "select",
            "wait",
            "back",
            "forward",
            "reload",
        }
        assert set(actions) == expected


# ---------------------------------------------------------------------------
# BrowserActTool execution — input validation
# ---------------------------------------------------------------------------


class TestBrowserActValidation:
    def setup_method(self):
        self.tool = BrowserActTool()
        self.session = make_session()

    @pytest.mark.asyncio
    async def test_missing_action_returns_error(self):
        result = await self.tool._execute(
            user_id="user1", session=self.session, action=""
        )
        assert isinstance(result, ErrorResponse)
        assert result.error == "missing_action"

    @pytest.mark.asyncio
    async def test_click_without_target_returns_error(self):
        result = await self.tool._execute(
            user_id="user1", session=self.session, action="click", target=""
        )
        assert isinstance(result, ErrorResponse)
        assert result.error == "missing_target"

    @pytest.mark.asyncio
    async def test_fill_without_value_returns_error(self):
        result = await self.tool._execute(
            user_id="user1",
            session=self.session,
            action="fill",
            target="@e1",
            value="",
        )
        assert isinstance(result, ErrorResponse)
        assert result.error == "missing_params"

    @pytest.mark.asyncio
    async def test_press_without_value_returns_error(self):
        result = await self.tool._execute(
            user_id="user1", session=self.session, action="press", value=""
        )
        assert isinstance(result, ErrorResponse)
        assert result.error == "missing_value"

    @pytest.mark.asyncio
    async def test_unsupported_action_returns_error(self):
        result = await self.tool._execute(
            user_id="user1", session=self.session, action="teleport"
        )
        assert isinstance(result, ErrorResponse)
        assert result.error == "invalid_action"


# ---------------------------------------------------------------------------
# BrowserActTool execution — successful actions
# ---------------------------------------------------------------------------


class TestBrowserActExecute:
    def setup_method(self):
        self.tool = BrowserActTool()
        self.session = make_session()

    async def _act(self, **kwargs):
        with patch(
            "backend.copilot.tools.agent_browser._run",
            new_callable=AsyncMock,
        ) as mock_run:
            mock_run.return_value = _run_result(rc=0, stdout="https://example.com")
            with patch(
                "backend.copilot.tools.agent_browser._snapshot",
                new_callable=AsyncMock,
                return_value="[updated-snapshot]",
            ):
                result = await self.tool._execute(
                    user_id="user1", session=self.session, **kwargs
                )
        return result

    @pytest.mark.asyncio
    async def test_click_success(self):
        result = await self._act(action="click", target="@e3")
        assert isinstance(result, BrowserActResponse)
        assert result.type == ResponseType.BROWSER_ACT
        assert result.action == "click"
        assert result.snapshot == "[updated-snapshot]"

    @pytest.mark.asyncio
    async def test_fill_success(self):
        result = await self._act(action="fill", target="@e1", value="hello@test.com")
        assert isinstance(result, BrowserActResponse)
        assert "fill" in result.message
        # value must NOT appear in the message — it may contain credentials
        assert "hello@test.com" not in result.message

    @pytest.mark.asyncio
    async def test_back_success(self):
        result = await self._act(action="back")
        assert isinstance(result, BrowserActResponse)

    @pytest.mark.asyncio
    async def test_scroll_default_direction(self):
        result = await self._act(action="scroll")
        assert isinstance(result, BrowserActResponse)

    @pytest.mark.asyncio
    async def test_scroll_up(self):
        result = await self._act(action="scroll", direction="up")
        assert isinstance(result, BrowserActResponse)

    @pytest.mark.asyncio
    async def test_press_enter(self):
        result = await self._act(action="press", value="Enter")
        assert isinstance(result, BrowserActResponse)

    @pytest.mark.asyncio
    async def test_action_failure_returns_error(self):
        with patch(
            "backend.copilot.tools.agent_browser._run",
            new_callable=AsyncMock,
            return_value=_run_result(rc=1, stderr="element not found"),
        ):
            result = await self.tool._execute(
                user_id="user1", session=self.session, action="click", target="@e99"
            )
        assert isinstance(result, ErrorResponse)
        assert result.error == "action_failed"

    @pytest.mark.asyncio
    async def test_select_success(self):
        result = await self._act(action="select", target="@e5", value="option1")
        assert isinstance(result, BrowserActResponse)

    @pytest.mark.asyncio
    async def test_check_success(self):
        result = await self._act(action="check", target="@e2")
        assert isinstance(result, BrowserActResponse)

    @pytest.mark.asyncio
    async def test_uncheck_success(self):
        result = await self._act(action="uncheck", target="@e2")
        assert isinstance(result, BrowserActResponse)

    @pytest.mark.asyncio
    async def test_dblclick_success(self):
        result = await self._act(action="dblclick", target="@e4")
        assert isinstance(result, BrowserActResponse)
        assert result.action == "dblclick"

    @pytest.mark.asyncio
    async def test_type_success(self):
        """type appends text without clearing the field first (unlike fill)."""
        result = await self._act(action="type", target="@e1", value="hello")
        assert isinstance(result, BrowserActResponse)
        assert result.action == "type"

    @pytest.mark.asyncio
    async def test_wait_selector_success(self):
        """wait with a CSS selector waits for the element to appear."""
        result = await self._act(action="wait", target=".my-element")
        assert isinstance(result, BrowserActResponse)
        assert result.action == "wait"

    @pytest.mark.asyncio
    async def test_wait_ms_success(self):
        """wait with a numeric string pauses for the given milliseconds."""
        result = await self._act(action="wait", target="1000")
        assert isinstance(result, BrowserActResponse)

    @pytest.mark.asyncio
    async def test_dblclick_without_target_returns_error(self):
        result = await self.tool._execute(
            user_id="user1", session=self.session, action="dblclick", target=""
        )
        assert isinstance(result, ErrorResponse)
        assert result.error == "missing_target"

    @pytest.mark.asyncio
    async def test_type_without_value_returns_error(self):
        result = await self.tool._execute(
            user_id="user1",
            session=self.session,
            action="type",
            target="@e1",
            value="",
        )
        assert isinstance(result, ErrorResponse)
        assert result.error == "missing_params"

    @pytest.mark.asyncio
    async def test_wait_without_target_returns_error(self):
        result = await self.tool._execute(
            user_id="user1", session=self.session, action="wait", target=""
        )
        assert isinstance(result, ErrorResponse)
        assert result.error == "missing_target"


# ---------------------------------------------------------------------------
# BrowserScreenshotTool metadata
# ---------------------------------------------------------------------------


class TestBrowserScreenshotMetadata:
    def setup_method(self):
        self.tool = BrowserScreenshotTool()

    def test_name(self):
        assert self.tool.name == "browser_screenshot"

    def test_requires_auth(self):
        assert self.tool.requires_auth is True

    def test_parameters_are_optional(self):
        params = self.tool.parameters
        # No required fields — both annotate and filename are optional
        assert "required" not in params or len(params.get("required", [])) == 0


# ---------------------------------------------------------------------------
# BrowserScreenshotTool execution
# ---------------------------------------------------------------------------


class TestBrowserScreenshotExecute:
    def setup_method(self):
        self.tool = BrowserScreenshotTool()
        self.session = make_session()

    @pytest.mark.asyncio
    async def test_screenshot_failure_returns_error(self):

        fake_fd, fake_path = 5, "/tmp/fake.png"
        with patch("tempfile.mkstemp", return_value=(fake_fd, fake_path)):
            with patch("os.close"):
                with patch(
                    "backend.copilot.tools.agent_browser._run",
                    new_callable=AsyncMock,
                    return_value=_run_result(rc=1, stderr="no browser"),
                ):
                    with patch("os.unlink"):
                        result = await self.tool._execute(
                            user_id="user1",
                            session=self.session,
                        )
        assert isinstance(result, ErrorResponse)
        assert result.error == "screenshot_failed"

    @pytest.mark.asyncio
    async def test_screenshot_success(self):
        import os
        import tempfile

        from .workspace_files import WorkspaceWriteResponse

        png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100

        # Build a real WorkspaceWriteResponse to satisfy the isinstance check
        write_resp = WorkspaceWriteResponse(
            message="ok",
            file_id="file-abc-123",
            name="test.png",
            path="/workspace/test.png",
            mime_type="image/png",
            size_bytes=len(png_bytes),
            download_url="workspace://file-abc-123#image/png",
            session_id="test-session-123",
        )

        fd, path = tempfile.mkstemp(suffix=".png")
        os.close(fd)
        with open(path, "wb") as f:
            f.write(png_bytes)

        try:
            with patch("tempfile.mkstemp", return_value=(fd, path)):
                with patch("os.close"):
                    with patch(
                        "backend.copilot.tools.agent_browser._run",
                        new_callable=AsyncMock,
                        return_value=_run_result(rc=0),
                    ):
                        # Patch WriteWorkspaceFileTool in the workspace_files module
                        # (the lazy import resolves to this target)
                        with patch(
                            "backend.copilot.tools.workspace_files.WriteWorkspaceFileTool._execute",
                            new_callable=AsyncMock,
                            return_value=write_resp,
                        ):
                            with patch("os.unlink"):
                                result = await self.tool._execute(
                                    user_id="user1",
                                    session=self.session,
                                    filename="test.png",
                                    annotate=True,
                                )
        finally:
            try:
                os.unlink(path)
            except OSError:
                pass  # Best-effort test cleanup; not critical if temp file lingers.

        assert isinstance(result, BrowserScreenshotResponse)
        assert result.type == ResponseType.BROWSER_SCREENSHOT
        assert result.file_id == "file-abc-123"
        assert result.filename == "test.png"

    @pytest.mark.asyncio
    @pytest.mark.parametrize(
        "input_val,expected",
        [
            ("true", True),
            ("false", False),
            ("0", False),
            (True, True),
            (False, False),
        ],
    )
    async def test_annotate_string_coercion(self, input_val, expected):
        """OpenAI function-call payloads may send annotate as a string."""
        import os
        import tempfile

        from .workspace_files import WorkspaceWriteResponse

        png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
        write_resp = WorkspaceWriteResponse(
            message="ok",
            file_id="file-abc-123",
            name="shot.png",
            path="/workspace/shot.png",
            mime_type="image/png",
            size_bytes=len(png_bytes),
            download_url="workspace://file-abc-123#image/png",
            session_id="test-session-123",
        )

        fd, path = tempfile.mkstemp(suffix=".png")
        os.close(fd)
        with open(path, "wb") as f:
            f.write(png_bytes)

        try:
            with patch("tempfile.mkstemp", return_value=(fd, path)):
                with patch("os.close"):
                    with patch(
                        "backend.copilot.tools.agent_browser._run",
                        new_callable=AsyncMock,
                        return_value=_run_result(rc=0),
                    ) as mock_run:
                        with patch(
                            "backend.copilot.tools.workspace_files.WriteWorkspaceFileTool._execute",
                            new_callable=AsyncMock,
                            return_value=write_resp,
                        ):
                            with patch("os.unlink"):
                                result = await self.tool._execute(
                                    user_id="user1",
                                    session=self.session,
                                    annotate=input_val,
                                )
        finally:
            try:
                os.unlink(path)
            except OSError:
                pass

        assert isinstance(result, BrowserScreenshotResponse)
        # Verify the --annotate flag was passed (or not) to agent-browser
        run_calls = mock_run.call_args_list
        screenshot_call = [c for c in run_calls if "screenshot" in c.args]
        assert len(screenshot_call) == 1
        if expected:
            assert "--annotate" in screenshot_call[0].args
        else:
            assert "--annotate" not in screenshot_call[0].args


# ---------------------------------------------------------------------------
# BaseTool auth check (inherits to all browser tools)
# ---------------------------------------------------------------------------


class TestAuthCheck:
    @pytest.mark.asyncio
    async def test_navigate_requires_login_if_no_user(self):
        tool = BrowserNavigateTool()
        session = make_session()
        # BaseTool.execute checks requires_auth before calling _execute
        result = await tool.execute(
            user_id=None,
            session=session,
            tool_call_id="call-1",
            url="https://example.com",
        )
        assert isinstance(result.output, str)
        data = json.loads(result.output)
        assert data["type"] == ResponseType.NEED_LOGIN

    @pytest.mark.asyncio
    async def test_act_requires_login_if_no_user(self):
        tool = BrowserActTool()
        session = make_session()
        result = await tool.execute(
            user_id=None,
            session=session,
            tool_call_id="call-2",
            action="click",
            target="@e1",
        )
        assert isinstance(result.output, str)
        data = json.loads(result.output)
        assert data["type"] == ResponseType.NEED_LOGIN

    @pytest.mark.asyncio
    async def test_screenshot_requires_login_if_no_user(self):
        tool = BrowserScreenshotTool()
        session = make_session()
        result = await tool.execute(
            user_id=None,
            session=session,
            tool_call_id="call-3",
        )
        assert isinstance(result.output, str)
        data = json.loads(result.output)
        assert data["type"] == ResponseType.NEED_LOGIN


# ---------------------------------------------------------------------------
# _snapshot helper
# ---------------------------------------------------------------------------


class TestSnapshot:
    @pytest.mark.asyncio
    async def test_snapshot_truncation(self):
        from .agent_browser import _MAX_SNAPSHOT_CHARS, _snapshot

        big_text = "x" * (_MAX_SNAPSHOT_CHARS + 1000)
        with patch(
            "backend.copilot.tools.agent_browser._run",
            new_callable=AsyncMock,
            return_value=_run_result(rc=0, stdout=big_text),
        ):
            result = await _snapshot("test-session")
        assert len(result) <= _MAX_SNAPSHOT_CHARS
        assert "truncated" in result.lower()

    @pytest.mark.asyncio
    async def test_snapshot_failure(self):
        from .agent_browser import _snapshot

        with patch(
            "backend.copilot.tools.agent_browser._run",
            new_callable=AsyncMock,
            return_value=_run_result(rc=1, stderr="daemon died"),
        ):
            result = await _snapshot("test-session")
        assert "snapshot failed" in result

    @pytest.mark.asyncio
    async def test_snapshot_short_content_not_truncated(self):
        from .agent_browser import _snapshot

        short = "button @e1 Submit"
        with patch(
            "backend.copilot.tools.agent_browser._run",
            new_callable=AsyncMock,
            return_value=_run_result(rc=0, stdout=short),
        ):
            result = await _snapshot("test-session")
        assert result == short
        assert "truncated" not in result


# ---------------------------------------------------------------------------
# _run helper (timeout and FileNotFoundError)
# ---------------------------------------------------------------------------


class TestRunHelper:
    @pytest.mark.asyncio
    async def test_run_timeout(self):
        from .agent_browser import _run

        mock_proc = MagicMock()
        mock_proc.returncode = None  # Simulate a process that is still running
        mock_proc.kill = MagicMock()
        mock_proc.communicate = AsyncMock(return_value=(b"", b""))

        with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
            with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError):
                rc, stdout, stderr = await _run(
                    "sess", "open", "https://x.com", timeout=1
                )

        assert rc == 1
        assert "timed out" in stderr
        mock_proc.kill.assert_called_once()
        mock_proc.communicate.assert_called()

    @pytest.mark.asyncio
    async def test_run_agent_browser_not_installed(self):
        from .agent_browser import _run

        with patch(
            "asyncio.create_subprocess_exec",
            side_effect=FileNotFoundError("No such file"),
        ):
            rc, stdout, stderr = await _run("sess", "snapshot", "-i")

        assert rc == 1
        assert "agent-browser" in stderr
        assert "not installed" in stderr


# ---------------------------------------------------------------------------
# _has_local_session
# ---------------------------------------------------------------------------


class TestHasLocalSession:
    @pytest.mark.asyncio
    async def test_returns_true_when_daemon_alive(self):
        with patch(
            "backend.copilot.tools.agent_browser._run",
            new_callable=AsyncMock,
            return_value=_run_result(rc=0, stdout="https://example.com"),
        ):
            assert await _has_local_session("sess-1") is True

    @pytest.mark.asyncio
    async def test_returns_false_when_daemon_dead(self):
        with patch(
            "backend.copilot.tools.agent_browser._run",
            new_callable=AsyncMock,
            return_value=_run_result(rc=1, stderr="no session"),
        ):
            assert await _has_local_session("sess-1") is False


# ---------------------------------------------------------------------------
# _save_browser_state
# ---------------------------------------------------------------------------

_GET_MANAGER = "backend.copilot.tools.agent_browser.get_workspace_manager"


def _make_mock_manager():
    mgr = AsyncMock()
    mgr.write_file = AsyncMock()
    mgr.read_file = AsyncMock()
    mgr.get_file_info_by_path = AsyncMock(return_value=None)
    return mgr


class TestSaveBrowserState:
    @pytest.mark.asyncio
    async def test_writes_json_to_workspace(self):
        session = make_session("save-sess")
        cookies_json = json.dumps(
            [{"name": "sid", "value": "abc", "domain": ".example.com", "path": "/"}]
        )
        storage_json = json.dumps({"theme": "dark"})

        mock_mgr = _make_mock_manager()

        with patch(
            "backend.copilot.tools.agent_browser._run",
            new_callable=AsyncMock,
        ) as mock_run:
            mock_run.side_effect = [
                _run_result(rc=0, stdout="https://example.com"),  # get url
                _run_result(rc=0, stdout=cookies_json),  # cookies get --json
                _run_result(rc=0, stdout=storage_json),  # storage local --json
            ]
            with patch(_GET_MANAGER, new_callable=AsyncMock, return_value=mock_mgr):
                await _save_browser_state("save-sess", "user1", session)

        mock_mgr.write_file.assert_called_once()
        call_kwargs = mock_mgr.write_file.call_args
        written = json.loads(call_kwargs.kwargs["content"].decode("utf-8"))
        assert written["url"] == "https://example.com"
        assert written["cookies"] == [
            {"name": "sid", "value": "abc", "domain": ".example.com", "path": "/"}
        ]
        assert written["local_storage"] == {"theme": "dark"}
        assert call_kwargs.kwargs["overwrite"] is True
        assert call_kwargs.kwargs["filename"] == "._browser_state.json"

    @pytest.mark.asyncio
    async def test_error_is_swallowed(self):
        session = make_session("err-sess")
        with patch(
            "backend.copilot.tools.agent_browser._run",
            new_callable=AsyncMock,
            side_effect=RuntimeError("boom"),
        ):
            # Should not raise
            await _save_browser_state("err-sess", "user1", session)


# ---------------------------------------------------------------------------
# _restore_browser_state
# ---------------------------------------------------------------------------


class TestRestoreBrowserState:
    @pytest.mark.asyncio
    async def test_restores_url_cookies_and_storage(self):
        session = make_session("restore-sess")
        state = {
            "url": "https://example.com/page",
            "cookies": [
                {"name": "sid", "value": "abc", "domain": ".example.com", "path": "/"},
                {
                    "name": "token",
                    "value": "xyz",
                    "domain": ".example.com",
                    "path": "/api",
                },
            ],
            "local_storage": {"theme": "dark", "lang": "en"},
        }

        mock_mgr = _make_mock_manager()
        mock_mgr.get_file_info_by_path.return_value = MagicMock(id="file-1")
        mock_mgr.read_file.return_value = json.dumps(state).encode("utf-8")

        captured_cmds: list[tuple] = []

        async def fake_run(session_name, *args, **kwargs):
            captured_cmds.append((session_name, args))
            return _run_result(rc=0)

        with patch("backend.copilot.tools.agent_browser._run", side_effect=fake_run):
            with patch(_GET_MANAGER, new_callable=AsyncMock, return_value=mock_mgr):
                result = await _restore_browser_state("restore-sess", "user1", session)

        assert result is True
        # First call: open URL
        assert captured_cmds[0] == (
            "restore-sess",
            ("open", "https://example.com/page"),
        )
        # Second: wait for load
        assert captured_cmds[1][1] == ("wait", "--load", "load")
        # Then cookies (2 cookies)
        cookie_cmds = [
            c for c in captured_cmds if len(c[1]) > 1 and c[1][0] == "cookies"
        ]
        assert len(cookie_cmds) == 2
        assert cookie_cmds[0][1] == (
            "cookies",
            "set",
            "sid",
            "abc",
            "--domain",
            ".example.com",
            "--path",
            "/",
        )
        assert cookie_cmds[1][1] == (
            "cookies",
            "set",
            "token",
            "xyz",
            "--domain",
            ".example.com",
            "--path",
            "/api",
        )
        # Then localStorage (2 entries)
        storage_cmds = [
            c
            for c in captured_cmds
            if len(c[1]) > 2 and c[1][:2] == ("storage", "local")
        ]
        assert len(storage_cmds) == 2

    @pytest.mark.asyncio
    async def test_no_saved_state_returns_true(self):
        session = make_session("fresh-sess")
        mock_mgr = _make_mock_manager()
        mock_mgr.get_file_info_by_path.return_value = None

        with patch(_GET_MANAGER, new_callable=AsyncMock, return_value=mock_mgr):
            with patch(
                "backend.copilot.tools.agent_browser._run",
                new_callable=AsyncMock,
            ) as mock_run:
                result = await _restore_browser_state("fresh-sess", "user1", session)

        assert result is True
        mock_run.assert_not_called()

    @pytest.mark.asyncio
    async def test_empty_url_skips_navigation(self):
        """State with url='' should skip navigation and still restore cookies/storage."""
        session = make_session("empty-url-sess")
        state = {
            "url": "",
            "cookies": [
                {"name": "c1", "value": "v1", "domain": ".example.com", "path": "/"},
            ],
            "local_storage": {"k": "v"},
        }

        mock_mgr = _make_mock_manager()
        mock_mgr.get_file_info_by_path.return_value = MagicMock(id="f-1")
        mock_mgr.read_file.return_value = json.dumps(state).encode("utf-8")

        captured_cmds: list[tuple] = []

        async def track_run(session_name, *args, **kwargs):
            captured_cmds.append((session_name, args))
            return _run_result(rc=0)

        with patch("backend.copilot.tools.agent_browser._run", side_effect=track_run):
            with patch(_GET_MANAGER, new_callable=AsyncMock, return_value=mock_mgr):
                result = await _restore_browser_state(
                    "empty-url-sess", "user1", session
                )

        assert result is True
        # Should NOT have called "open" — no URL to navigate to
        open_cmds = [c for c in captured_cmds if c[1][:1] == ("open",)]
        assert open_cmds == []
        # But cookies and storage should still be restored
        cookie_cmds = [c for c in captured_cmds if c[1][:2] == ("cookies", "set")]
        storage_cmds = [
            c
            for c in captured_cmds
            if len(c[1]) > 2 and c[1][:2] == ("storage", "local")
        ]
        assert len(cookie_cmds) == 1
        assert len(storage_cmds) == 1

    @pytest.mark.asyncio
    async def test_error_returns_false(self):
        session = make_session("err-sess")
        with patch(
            _GET_MANAGER,
            new_callable=AsyncMock,
            side_effect=RuntimeError("db down"),
        ):
            result = await _restore_browser_state("err-sess", "user1", session)

        assert result is False


# ---------------------------------------------------------------------------
# _ensure_session
# ---------------------------------------------------------------------------


class TestEnsureSession:
    @pytest.mark.asyncio
    async def test_skips_probe_when_cached(self):
        from . import agent_browser as _mod

        session = make_session("cached-sess")
        _mod._alive_sessions.add("cached-sess")

        with patch(
            "backend.copilot.tools.agent_browser._run",
            new_callable=AsyncMock,
        ) as mock_run:
            await _ensure_session("cached-sess", "user1", session)

        mock_run.assert_not_called()

    @pytest.mark.asyncio
    async def test_adds_to_cache_when_daemon_alive(self):
        from . import agent_browser as _mod

        session = make_session("alive-sess")
        with patch(
            "backend.copilot.tools.agent_browser._has_local_session",
            new_callable=AsyncMock,
            return_value=True,
        ):
            await _ensure_session("alive-sess", "user1", session)

        assert "alive-sess" in _mod._alive_sessions

    @pytest.mark.asyncio
    async def test_restores_and_caches_when_daemon_dead(self):
        from . import agent_browser as _mod

        session = make_session("dead-sess")
        with patch(
            "backend.copilot.tools.agent_browser._has_local_session",
            new_callable=AsyncMock,
            return_value=False,
        ):
            with patch(
                "backend.copilot.tools.agent_browser._restore_browser_state",
                new_callable=AsyncMock,
                return_value=True,
            ) as mock_restore:
                await _ensure_session("dead-sess", "user1", session)

        mock_restore.assert_called_once_with("dead-sess", "user1", session)
        assert "dead-sess" in _mod._alive_sessions

    @pytest.mark.asyncio
    async def test_no_cache_when_restore_fails(self):
        """Failed restore should not cache the session as alive."""
        from . import agent_browser as _mod

        session = make_session("fail-sess")
        with patch(
            "backend.copilot.tools.agent_browser._has_local_session",
            new_callable=AsyncMock,
            return_value=False,
        ):
            with patch(
                "backend.copilot.tools.agent_browser._restore_browser_state",
                new_callable=AsyncMock,
                return_value=False,
            ):
                await _ensure_session("fail-sess", "user1", session)

        assert "fail-sess" not in _mod._alive_sessions

    @pytest.mark.asyncio
    async def test_concurrent_calls_restore_once(self):
        """Two concurrent _ensure_session calls should only restore once."""
        session = make_session("race-sess")
        with patch(
            "backend.copilot.tools.agent_browser._has_local_session",
            new_callable=AsyncMock,
            return_value=False,
        ):
            with patch(
                "backend.copilot.tools.agent_browser._restore_browser_state",
                new_callable=AsyncMock,
                return_value=True,
            ) as mock_restore:
                await asyncio.gather(
                    _ensure_session("race-sess", "user1", session),
                    _ensure_session("race-sess", "user1", session),
                )

        mock_restore.assert_called_once()


# ---------------------------------------------------------------------------
# close_browser_session
# ---------------------------------------------------------------------------


class TestCloseBrowserSession:
    @pytest.mark.asyncio
    async def test_calls_close_command(self):
        from . import agent_browser as _mod

        _mod._alive_sessions.add("close-sess")
        with patch(
            "backend.copilot.tools.agent_browser._run",
            new_callable=AsyncMock,
            return_value=_run_result(rc=0),
        ) as mock_run:
            await close_browser_session("close-sess")

        mock_run.assert_called_once_with("close-sess", "close", timeout=10)
        assert "close-sess" not in _mod._alive_sessions
        assert "close-sess" not in _mod._session_locks

    @pytest.mark.asyncio
    async def test_deletes_state_file_when_user_id_provided(self):
        mock_mgr = _make_mock_manager()
        mock_mgr.get_file_info_by_path.return_value = MagicMock(id="state-file-1")
        mock_mgr.delete_file = AsyncMock(return_value=True)

        with patch(_GET_MANAGER, new_callable=AsyncMock, return_value=mock_mgr):
            with patch(
                "backend.copilot.tools.agent_browser._run",
                new_callable=AsyncMock,
                return_value=_run_result(rc=0),
            ):
                await close_browser_session("close-sess", user_id="user1")

        mock_mgr.get_file_info_by_path.assert_called_once_with("._browser_state.json")
        mock_mgr.delete_file.assert_called_once_with("state-file-1")

    @pytest.mark.asyncio
    async def test_skips_state_cleanup_without_user_id(self):
        with patch(_GET_MANAGER, new_callable=AsyncMock) as mock_get_mgr:
            with patch(
                "backend.copilot.tools.agent_browser._run",
                new_callable=AsyncMock,
                return_value=_run_result(rc=0),
            ):
                await close_browser_session("close-sess")

        mock_get_mgr.assert_not_called()

    @pytest.mark.asyncio
    async def test_state_file_not_found_skips_delete(self):
        """If get_file_info_by_path returns None, delete_file should not be called."""
        mock_mgr = _make_mock_manager()
        mock_mgr.get_file_info_by_path.return_value = None
        mock_mgr.delete_file = AsyncMock()

        with patch(_GET_MANAGER, new_callable=AsyncMock, return_value=mock_mgr):
            with patch(
                "backend.copilot.tools.agent_browser._run",
                new_callable=AsyncMock,
                return_value=_run_result(rc=0),
            ):
                await close_browser_session("close-sess", user_id="user1")

        mock_mgr.get_file_info_by_path.assert_called_once_with("._browser_state.json")
        mock_mgr.delete_file.assert_not_called()

    @pytest.mark.asyncio
    async def test_error_is_swallowed(self):
        with patch(
            "backend.copilot.tools.agent_browser._run",
            new_callable=AsyncMock,
            side_effect=RuntimeError("gone"),
        ):
            # Should not raise
            await close_browser_session("bad-sess")


# ---------------------------------------------------------------------------
# Integration: save/restore called from _execute methods
# ---------------------------------------------------------------------------


class TestStatePersistenceIntegration:
    """Verify _execute methods call _ensure_session and _fire_and_forget_save."""

    def setup_method(self):
        self.session = make_session("int-sess")

    @pytest.mark.asyncio
    async def test_navigate_calls_ensure_and_save(self, monkeypatch):
        mock_ensure = AsyncMock()
        mock_save = MagicMock()
        monkeypatch.setattr(
            "backend.copilot.tools.agent_browser._ensure_session", mock_ensure
        )
        monkeypatch.setattr(
            "backend.copilot.tools.agent_browser._fire_and_forget_save", mock_save
        )

        tool = BrowserNavigateTool()
        with patch(
            _VALIDATE_URL,
            new_callable=AsyncMock,
            return_value=(object(), False, ["1.2.3.4"]),
        ):
            with patch(
                "backend.copilot.tools.agent_browser._run",
                new_callable=AsyncMock,
                return_value=_run_result(rc=0),
            ):
                with patch(
                    "backend.copilot.tools.agent_browser._snapshot",
                    new_callable=AsyncMock,
                    return_value="",
                ):
                    result = await tool._execute(
                        user_id="user1",
                        session=self.session,
                        url="https://example.com",
                    )

        assert isinstance(result, BrowserNavigateResponse)
        mock_ensure.assert_called_once_with("int-sess", "user1", self.session)
        mock_save.assert_called_once_with("int-sess", "user1", self.session)

    @pytest.mark.asyncio
    async def test_navigate_no_save_on_error(self, monkeypatch):
        mock_save = MagicMock()
        monkeypatch.setattr(
            "backend.copilot.tools.agent_browser._fire_and_forget_save", mock_save
        )

        tool = BrowserNavigateTool()
        with patch(
            _VALIDATE_URL,
            new_callable=AsyncMock,
            return_value=(object(), False, ["1.2.3.4"]),
        ):
            with patch(
                "backend.copilot.tools.agent_browser._run",
                new_callable=AsyncMock,
                return_value=_run_result(rc=1, stderr="fail"),
            ):
                result = await tool._execute(
                    user_id="user1",
                    session=self.session,
                    url="https://example.com",
                )

        assert isinstance(result, ErrorResponse)
        mock_save.assert_not_called()

    @pytest.mark.asyncio
    async def test_navigate_skips_persistence_without_user_id(self, monkeypatch):
        mock_ensure = AsyncMock()
        mock_save = MagicMock()
        monkeypatch.setattr(
            "backend.copilot.tools.agent_browser._ensure_session", mock_ensure
        )
        monkeypatch.setattr(
            "backend.copilot.tools.agent_browser._fire_and_forget_save", mock_save
        )

        tool = BrowserNavigateTool()
        with patch(
            _VALIDATE_URL,
            new_callable=AsyncMock,
            return_value=(object(), False, ["1.2.3.4"]),
        ):
            with patch(
                "backend.copilot.tools.agent_browser._run",
                new_callable=AsyncMock,
                return_value=_run_result(rc=0),
            ):
                with patch(
                    "backend.copilot.tools.agent_browser._snapshot",
                    new_callable=AsyncMock,
                    return_value="",
                ):
                    result = await tool._execute(
                        user_id=None,
                        session=self.session,
                        url="https://example.com",
                    )

        assert isinstance(result, BrowserNavigateResponse)
        mock_ensure.assert_not_called()
        mock_save.assert_not_called()

    @pytest.mark.asyncio
    async def test_act_calls_ensure_and_save(self, monkeypatch):
        mock_ensure = AsyncMock()
        mock_save = MagicMock()
        monkeypatch.setattr(
            "backend.copilot.tools.agent_browser._ensure_session", mock_ensure
        )
        monkeypatch.setattr(
            "backend.copilot.tools.agent_browser._fire_and_forget_save", mock_save
        )

        tool = BrowserActTool()
        with patch(
            "backend.copilot.tools.agent_browser._run",
            new_callable=AsyncMock,
            return_value=_run_result(rc=0, stdout="https://example.com"),
        ):
            with patch(
                "backend.copilot.tools.agent_browser._snapshot",
                new_callable=AsyncMock,
                return_value="",
            ):
                result = await tool._execute(
                    user_id="user1",
                    session=self.session,
                    action="click",
                    target="@e1",
                )

        assert isinstance(result, BrowserActResponse)
        mock_ensure.assert_called_once()
        mock_save.assert_called_once()

    @pytest.mark.asyncio
    async def test_screenshot_calls_ensure_and_save(self, monkeypatch):
        import os
        import tempfile

        from .workspace_files import WorkspaceWriteResponse

        mock_ensure = AsyncMock()
        mock_save = MagicMock()
        monkeypatch.setattr(
            "backend.copilot.tools.agent_browser._ensure_session", mock_ensure
        )
        monkeypatch.setattr(
            "backend.copilot.tools.agent_browser._fire_and_forget_save", mock_save
        )

        png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
        write_resp = WorkspaceWriteResponse(
            message="ok",
            file_id="f-1",
            name="s.png",
            path="/workspace/s.png",
            mime_type="image/png",
            size_bytes=len(png_bytes),
            download_url="workspace://f-1#image/png",
            session_id="int-sess",
        )

        fd, path = tempfile.mkstemp(suffix=".png")
        os.close(fd)
        with open(path, "wb") as f:
            f.write(png_bytes)

        try:
            tool = BrowserScreenshotTool()
            with patch("tempfile.mkstemp", return_value=(fd, path)):
                with patch("os.close"):
                    with patch(
                        "backend.copilot.tools.agent_browser._run",
                        new_callable=AsyncMock,
                        return_value=_run_result(rc=0),
                    ):
                        with patch(
                            "backend.copilot.tools.workspace_files.WriteWorkspaceFileTool._execute",
                            new_callable=AsyncMock,
                            return_value=write_resp,
                        ):
                            with patch("os.unlink"):
                                result = await tool._execute(
                                    user_id="user1",
                                    session=self.session,
                                )
        finally:
            try:
                os.unlink(path)
            except OSError:
                pass

        assert isinstance(result, BrowserScreenshotResponse)
        mock_ensure.assert_called_once()
        mock_save.assert_called_once()


# ---------------------------------------------------------------------------
# SSRF protection in _restore_browser_state
# ---------------------------------------------------------------------------


class TestRestoreSsrf:
    @pytest.mark.asyncio
    async def test_blocked_url_returns_false(self):
        """Saved URL pointing at a private IP should be blocked."""
        session = make_session("ssrf-sess")
        state = {
            "url": "http://169.254.169.254/metadata",
            "cookies": [],
            "local_storage": {},
        }

        mock_mgr = _make_mock_manager()
        mock_mgr.get_file_info_by_path.return_value = MagicMock(id="f-1")
        mock_mgr.read_file.return_value = json.dumps(state).encode("utf-8")

        with patch(_GET_MANAGER, new_callable=AsyncMock, return_value=mock_mgr):
            with patch(
                _VALIDATE_URL,
                new_callable=AsyncMock,
                side_effect=ValueError("blocked"),
            ):
                with patch(
                    "backend.copilot.tools.agent_browser._run",
                    new_callable=AsyncMock,
                ) as mock_run:
                    result = await _restore_browser_state("ssrf-sess", "user1", session)

        assert result is False
        # Should NOT have called _run at all — URL was blocked before open
        mock_run.assert_not_called()


# ---------------------------------------------------------------------------
# Partial-failure tests for save/restore
# ---------------------------------------------------------------------------


class TestPartialFailures:
    """Test mixed success/failure scenarios in save and restore."""

    @pytest.mark.asyncio
    async def test_save_partial_export_failure(self):
        """If cookies export fails but URL and storage succeed, state is still saved."""
        session = make_session("partial-save")
        mock_mgr = _make_mock_manager()

        with patch(
            "backend.copilot.tools.agent_browser._run",
            new_callable=AsyncMock,
        ) as mock_run:
            mock_run.side_effect = [
                _run_result(rc=0, stdout="https://example.com"),  # get url
                _run_result(rc=1, stderr="cookie export error"),  # cookies get fails
                _run_result(rc=0, stdout='{"theme":"dark"}'),  # storage local
            ]
            with patch(_GET_MANAGER, new_callable=AsyncMock, return_value=mock_mgr):
                await _save_browser_state("partial-save", "user1", session)

        # Should still write state — with empty cookies list
        mock_mgr.write_file.assert_called_once()
        written = json.loads(
            mock_mgr.write_file.call_args.kwargs["content"].decode("utf-8")
        )
        assert written["url"] == "https://example.com"
        assert written["cookies"] == []  # Failed export → empty
        assert written["local_storage"] == {"theme": "dark"}

    @pytest.mark.asyncio
    async def test_restore_cookies_fail_url_succeeds(self):
        """If some cookies fail to restore, the overall restore still succeeds."""
        session = make_session("partial-restore")
        state = {
            "url": "https://example.com",
            "cookies": [
                {"name": "good", "value": "1", "domain": ".example.com", "path": "/"},
                {"name": "bad", "value": "2", "domain": ".example.com", "path": "/"},
            ],
            "local_storage": {"key": "val"},
        }

        mock_mgr = _make_mock_manager()
        mock_mgr.get_file_info_by_path.return_value = MagicMock(id="f-1")
        mock_mgr.read_file.return_value = json.dumps(state).encode("utf-8")

        call_count = 0

        async def selective_fail(session_name, *args, **kwargs):
            nonlocal call_count
            call_count += 1
            # First two calls: open URL + wait for load → succeed
            if args[:1] == ("open",) or args[:1] == ("wait",):
                return _run_result(rc=0)
            # First cookie set succeeds, second fails
            if args[:2] == ("cookies", "set"):
                if args[2] == "bad":
                    return _run_result(rc=1, stderr="cookie set error")
                return _run_result(rc=0)
            # Storage always succeeds
            return _run_result(rc=0)

        with patch(
            "backend.copilot.tools.agent_browser._run", side_effect=selective_fail
        ):
            with patch(_GET_MANAGER, new_callable=AsyncMock, return_value=mock_mgr):
                with patch(
                    _VALIDATE_URL,
                    new_callable=AsyncMock,
                    return_value=(object(), False, ["1.2.3.4"]),
                ):
                    result = await _restore_browser_state(
                        "partial-restore", "user1", session
                    )

        # Restore still returns True — individual cookie failures are non-fatal
        assert result is True

    @pytest.mark.asyncio
    async def test_restore_url_fails_returns_false(self):
        """If the URL navigation itself fails, restore returns False."""
        session = make_session("url-fail-sess")
        state = {
            "url": "https://example.com",
            "cookies": [],
            "local_storage": {},
        }

        mock_mgr = _make_mock_manager()
        mock_mgr.get_file_info_by_path.return_value = MagicMock(id="f-1")
        mock_mgr.read_file.return_value = json.dumps(state).encode("utf-8")

        with patch(
            "backend.copilot.tools.agent_browser._run",
            new_callable=AsyncMock,
            return_value=_run_result(rc=1, stderr="navigation failed"),
        ):
            with patch(_GET_MANAGER, new_callable=AsyncMock, return_value=mock_mgr):
                with patch(
                    _VALIDATE_URL,
                    new_callable=AsyncMock,
                    return_value=(object(), False, ["1.2.3.4"]),
                ):
                    result = await _restore_browser_state(
                        "url-fail-sess", "user1", session
                    )

        assert result is False

    @pytest.mark.asyncio
    async def test_save_workspace_write_fails(self):
        """If workspace write raises, save swallows the error."""
        session = make_session("write-fail")
        mock_mgr = _make_mock_manager()
        mock_mgr.write_file.side_effect = RuntimeError("storage down")

        with patch(
            "backend.copilot.tools.agent_browser._run",
            new_callable=AsyncMock,
        ) as mock_run:
            mock_run.side_effect = [
                _run_result(rc=0, stdout="https://example.com"),  # get url
                _run_result(rc=0, stdout="[]"),  # cookies get --json
                _run_result(rc=0, stdout="{}"),  # storage local --json
            ]
            with patch(_GET_MANAGER, new_callable=AsyncMock, return_value=mock_mgr):
                # Should not raise
                await _save_browser_state("write-fail", "user1", session)

        # write_file was actually called and its RuntimeError was swallowed
        mock_mgr.write_file.assert_called_once()
