"""Browser tool for MindRoom.

This exposes a single ``browser`` function with an ``action`` parameter.
"""
# ruff: noqa: N803, A002

from __future__ import annotations

import asyncio
import json
import os
import re
import shutil
import time
from dataclasses import dataclass, field
from itertools import count
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
from uuid import uuid4

from agno.media import Image
from agno.tools import Toolkit
from agno.tools.function import ToolResult
from playwright.async_api import BrowserContext, ConsoleMessage, Dialog, Page, Playwright, async_playwright
from playwright.async_api import Error as PlaywrightError

from mindroom.browser_fetch_guard import continue_or_abort_browser_fetch
from mindroom.custom_tools.desktop_attachment import (
    register_runtime_screenshot_attachment,
    screenshot_attachment_result_fields,
)
from mindroom.desktop.client import desktop_response_router
from mindroom.desktop.media import download_encrypted_screenshot
from mindroom.desktop.playwright_mcp import browser_action_requires_control
from mindroom.desktop.protocol import MAX_COMMAND_TTL_MS, DesktopCommand
from mindroom.logging_config import get_logger
from mindroom.matrix.olm_to_device import PinnedMatrixDevice
from mindroom.server_fetch_url import validate_server_fetch_url
from mindroom.tool_system.runtime_context import get_tool_runtime_context

if TYPE_CHECKING:
    from mindroom.constants import RuntimePaths

_DEFAULT_PROFILE = "mindroom"
_DEFAULT_SNAPSHOT_LIMIT = 200
_DEFAULT_AI_SNAPSHOT_MAX_CHARS = 12_000
_DEFAULT_TIMEOUT_MS = 30_000
_MAX_CONSOLE_ENTRIES = 200
_VIEWPORT_WIDTH = 1280
_VIEWPORT_HEIGHT = 720
_PLAYWRIGHT_INSTALL_COMMAND = "uv run playwright install chromium"

logger = get_logger(__name__)

_BROWSER_ACTIONS = {
    "actions",
    "status",
    "start",
    "stop",
    "profiles",
    "tabs",
    "open",
    "focus",
    "close",
    "snapshot",
    "screenshot",
    "navigate",
    "console",
    "pdf",
    "upload",
    "dialog",
    "act",
    "help",
}

_ACT_REQUEST_KINDS = (
    "click",
    "type",
    "press",
    "hover",
    "drag",
    "select",
    "fill",
    "resize",
    "wait",
    "evaluate",
    "close",
)

_BROWSER_ACTION_TABLE = (
    {"action": "status", "description": "Report whether the selected browser profile is running."},
    {"action": "start", "description": "Start the selected browser profile."},
    {"action": "stop", "description": "Stop the selected browser profile."},
    {"action": "profiles", "description": "List known browser profiles."},
    {"action": "tabs", "description": "List tabs for the selected browser profile."},
    {"action": "open", "description": "Open targetUrl in a new tab."},
    {"action": "focus", "description": "Host target only: focus targetId; desktop does not support focus."},
    {
        "action": "close",
        "description": "Close targetId or the active host tab; desktop closes its current extension tab.",
    },
    {"action": "snapshot", "description": "Capture a model-friendly page snapshot and element refs."},
    {
        "action": "screenshot",
        "description": "Capture a screenshot; host retains it while desktop removes its transient scratch file.",
    },
    {
        "action": "navigate",
        "description": "Navigate targetId or the active host tab, or the current desktop extension tab, to targetUrl.",
    },
    {"action": "console", "description": "Read collected console entries."},
    {"action": "pdf", "description": "Save the current page as a PDF."},
    {
        "action": "upload",
        "description": "Upload paths through a host selector or ref, or through the active desktop file chooser.",
    },
    {"action": "dialog", "description": "Arm how the next browser dialog should be handled."},
    {"action": "act", "description": "Run a browser interaction described by request.kind."},
    {"action": "help", "description": "Return this browser action and request-kind table."},
    {"action": "actions", "description": "Alias for help."},
)

_ACT_REQUEST_DESCRIPTION = (
    "Act request object for action='act'. Set request.kind to one of: click, type, press, hover, drag, select, "
    "fill, resize, wait, evaluate, close. "
    "Common shapes: click {kind, ref, doubleClick?, button?, modifiers?}; "
    "type {kind, ref, text, submit?, slowly?}; press {kind, key}; "
    "hover {kind, ref}; drag {kind, startRef, endRef}; "
    "select {kind, ref, values}; fill {kind, fields:[{ref|selector,value}]}; "
    "resize {kind, width, height}; wait {kind, timeMs?|text?|textGone?}; "
    "evaluate {kind, fn, ref?}; close {kind}. Refs come from action='snapshot'."
)

_SNAPSHOT_JS = """
({ selector, limit, depth, interactiveOnly }) => {
  const root = selector ? document.querySelector(selector) : (document.body || document.documentElement);
  if (!root) return [];

  const maxItems = Math.max(1, Number.isFinite(limit) ? Number(limit) : 200);
  const maxDepth = Math.max(1, Number.isFinite(depth) ? Number(depth) : 12);

  const isVisible = (el) => {
    if (!(el instanceof HTMLElement)) return false;
    const style = window.getComputedStyle(el);
    if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false;
    const rect = el.getBoundingClientRect();
    return rect.width > 0 && rect.height > 0;
  };

  const isInteractive = (el) => {
    const tag = el.tagName.toLowerCase();
    if (["a", "button", "input", "select", "textarea", "summary", "option", "label"].includes(tag)) return true;
    if (el.hasAttribute("contenteditable")) return true;
    if (el.hasAttribute("onclick")) return true;
    const role = (el.getAttribute("role") || "").trim();
    return role.length > 0;
  };

  const inferRole = (el) => {
    const explicit = (el.getAttribute("role") || "").trim();
    if (explicit) return explicit;
    const tag = el.tagName.toLowerCase();
    if (tag === "a") return "link";
    if (tag === "button") return "button";
    if (tag === "input") return "input";
    if (tag === "select") return "select";
    if (tag === "textarea") return "textbox";
    return "";
  };

  const selectorFor = (el) => {
    if (el.id) return `#${CSS.escape(el.id)}`;
    const parts = [];
    let node = el;
    let safety = 0;
    while (node && node.nodeType === Node.ELEMENT_NODE && safety < 12) {
      let part = node.tagName.toLowerCase();
      const parent = node.parentElement;
      if (parent) {
        const siblings = Array.from(parent.children).filter((sib) => sib.tagName === node.tagName);
        if (siblings.length > 1) {
          const idx = siblings.indexOf(node) + 1;
          part += `:nth-of-type(${idx})`;
        }
      }
      parts.unshift(part);
      if (node.id) {
        parts[0] = `#${CSS.escape(node.id)}`;
        break;
      }
      node = parent;
      safety += 1;
    }
    return parts.join(" > ");
  };

  const depthFromRoot = (el) => {
    let d = 0;
    let node = el;
    while (node && node !== root) {
      d += 1;
      node = node.parentElement;
      if (d > 200) break;
    }
    return d;
  };

  const candidates = [root, ...root.querySelectorAll("*")];
  const rows = [];
  for (const el of candidates) {
    if (!(el instanceof HTMLElement)) continue;
    if (!isVisible(el)) continue;
    if (depthFromRoot(el) > maxDepth) continue;
    if (interactiveOnly && !isInteractive(el)) continue;
    const text = (
      el.getAttribute("aria-label") ||
      el.getAttribute("alt") ||
      el.getAttribute("title") ||
      el.innerText ||
      el.textContent ||
      el.getAttribute("value") ||
      ""
    ).replace(/\\s+/g, " ").trim();
    rows.push({
      selector: selectorFor(el),
      role: inferRole(el),
      name: text.slice(0, 140),
      tag: el.tagName.toLowerCase(),
      type: (el.getAttribute("type") || "").trim(),
    });
    if (rows.length >= maxItems) break;
  }
  return rows;
}
"""


@dataclass
class _BrowserTabState:
    """State for one browser tab."""

    target_id: str
    page: Page
    refs: dict[str, str] = field(default_factory=dict)
    pending_dialog: dict[str, Any] | None = None
    console: list[dict[str, Any]] = field(default_factory=list)


@dataclass
class _BrowserProfileState:
    """State for one browser profile."""

    playwright: Playwright
    context: BrowserContext
    tabs: dict[str, _BrowserTabState] = field(default_factory=dict)
    active_target_id: str | None = None


def _clean_str(value: object) -> str | None:
    """Normalize a value to a stripped string."""
    if not isinstance(value, str):
        return None
    cleaned = value.strip()
    return cleaned or None


def _profile_dir(runtime_paths: RuntimePaths, profile_name: str) -> Path:
    """Return the persistent Playwright profile directory for one browser profile."""
    normalized_profile = _clean_str(profile_name) or _DEFAULT_PROFILE
    profile_slug = re.sub(r"[^a-zA-Z0-9._+-]+", "_", normalized_profile).strip("_") or _DEFAULT_PROFILE
    _profile_dir = (runtime_paths.storage_root / "browser-profiles" / profile_slug).resolve()
    _profile_dir.mkdir(parents=True, exist_ok=True)
    _profile_dir.chmod(0o700)
    return _profile_dir


def _persistent_launch_kwargs(
    runtime_paths: RuntimePaths,
    profile_name: str,
    *,
    headless: bool,
    executable_override: str | None = None,
) -> dict[str, Any]:
    """Return the shared persistent-context launch kwargs for one browser profile."""
    executable = (
        executable_override
        or runtime_paths.env_value("BROWSER_EXECUTABLE_PATH")
        or shutil.which("chromium")
        or shutil.which("google-chrome-stable")
    )
    launch_kwargs: dict[str, Any] = {
        "headless": headless,
        # Block service workers because stale Cinny SW state after redeploy is a sharper risk than offline support.
        # Revisit if PWA targets matter.
        "service_workers": "block",
        "user_data_dir": str(_profile_dir(runtime_paths, profile_name)),
        "viewport": {"height": _VIEWPORT_HEIGHT, "width": _VIEWPORT_WIDTH},
    }
    if executable:
        launch_kwargs["executable_path"] = executable
    return launch_kwargs


def _clear_stale_singleton_locks(_profile_dir: Path) -> None:
    """Best-effort cleanup for stale Chromium singleton lock symlinks."""
    for entry_name in ("SingletonLock", "SingletonCookie", "SingletonSocket"):
        entry = _profile_dir / entry_name
        try:
            if not entry.is_symlink():
                continue
            target = entry.readlink()
            match = re.fullmatch(r".+-(\d+)", target.name)
            if match is None:
                continue
            pid = int(match.group(1))
            try:
                os.kill(pid, 0)
            except ProcessLookupError:
                entry.unlink()
        except OSError as exc:
            logger.warning(
                "Failed to clean Chromium singleton lock",
                entry=str(entry),
                error=str(exc),
            )


def _browser_help_payload(action: str) -> dict[str, Any]:
    """Return a compact browser action discovery payload."""
    return {
        "action": action,
        "actions": sorted(_BROWSER_ACTIONS),
        "actKinds": list(_ACT_REQUEST_KINDS),
        "actRequestDescription": _ACT_REQUEST_DESCRIPTION,
        "actionTable": list(_BROWSER_ACTION_TABLE),
        "status": "ok",
    }


def _unknown_browser_action_message(action: str) -> str:
    """Return an actionable unknown-action error for browser callers."""
    valid_actions = ", ".join(sorted(_BROWSER_ACTIONS))
    return (
        f"Unknown action: {action}. Valid actions: {valid_actions}. "
        "For click/type/press/hover/drag/select/fill/resize/wait/evaluate/close interactions, "
        "use action='act' with request.kind='click' (or another act kind). "
        "Use action='help' to inspect the full table."
    )


def _desktop_browser_parameters(  # noqa: C901, PLR0911, PLR0912, PLR0915
    action: str,
    *,
    target_url: str | None,
    target_id: str | None,
    max_chars: int | None,
    selector: str | None,
    depth: int | None,
    full_page: bool | None,
    ref: str | None,
    element: str | None,
    image_type: str | None,
    level: str | None,
    paths: list[str] | None,
    accept: bool | None,
    prompt_text: str | None,
    request: dict[str, Any] | None,
    allow_private_networks: bool,
) -> dict[str, object]:
    if target_id is not None:
        msg = (
            "Browser target=desktop does not support targetId because Playwright MCP tab indices can change; "
            "operate the current tab or open a new one."
        )
        raise ValueError(msg)
    if action in {"status", "start", "stop", "profiles", "tabs"}:
        return {}
    if action in {"open", "navigate"}:
        normalized_url = _clean_str(target_url)
        if normalized_url is None:
            msg = f"targetUrl required for action={action}"
            raise ValueError(msg)
        normalized_url = validate_server_fetch_url(normalized_url, allow_private_networks=allow_private_networks)
        parameters: dict[str, object] = {"targetUrl": normalized_url}
        return parameters
    if action == "focus":
        msg = "Browser target=desktop does not support focus because Playwright MCP exposes only mutable tab indices."
        raise ValueError(msg)
    if action == "close":
        return {}
    if action == "snapshot":
        parameters: dict[str, object] = {}
        normalized_selector = _clean_str(selector)
        if normalized_selector is not None:
            parameters["selector"] = normalized_selector
        if isinstance(depth, int) and not isinstance(depth, bool) and depth > 0:
            parameters["depth"] = depth
        if isinstance(max_chars, int) and not isinstance(max_chars, bool) and max_chars > 0:
            parameters["maxChars"] = max_chars
        return parameters
    if action == "screenshot":
        parameters: dict[str, object] = {"fullPage": bool(full_page)}
        for key, value in (
            ("ref", _clean_str(ref)),
            ("element", _clean_str(element)),
            ("type", _clean_str(image_type)),
        ):
            if value is not None:
                parameters[key] = value
        return parameters
    if action == "console":
        parameters: dict[str, object] = {}
        normalized_level = _clean_str(level)
        if normalized_level is not None:
            parameters["level"] = normalized_level
        return parameters
    if action == "pdf":
        return {}
    if action == "upload":
        if not paths:
            msg = "paths required for action=upload"
            raise ValueError(msg)
        if ref is not None or element is not None:
            msg = "Browser target=desktop upload does not support ref or element; use the active file chooser."
            raise ValueError(msg)
        parameters: dict[str, object] = {"paths": paths}
        return parameters
    if action == "dialog":
        parameters: dict[str, object] = {"accept": bool(accept)}
        normalized_prompt = _clean_str(prompt_text)
        if normalized_prompt is not None:
            parameters["promptText"] = normalized_prompt
        return parameters
    if action == "act":
        if not isinstance(request, dict):
            msg = "request required for action=act"
            raise ValueError(msg)
        if request.get("targetId") is not None:
            msg = (
                "Browser target=desktop does not support request.targetId because Playwright MCP tab indices can "
                "change; operate the current tab or open a new one."
            )
            raise ValueError(msg)
        return {"request": request}
    msg = f"Unsupported desktop browser action: {action}"
    raise ValueError(msg)


def _playwright_cache_root(expected_executable_path: Path | None) -> Path | None:
    """Return the Playwright browser cache root for an executable path."""
    if expected_executable_path is None:
        return None
    for parent in expected_executable_path.parents:
        if parent.name == "ms-playwright":
            return parent
    return None


def _playwright_revision_dir_name(expected_executable_path: Path | None) -> str | None:
    """Return the expected Playwright revision directory from an executable path."""
    if expected_executable_path is None:
        return None
    for parent in expected_executable_path.parents:
        if re.fullmatch(r"(chromium|chromium_headless_shell)-\d+", parent.name):
            return parent.name
    return None


def _installed_playwright_chromium_revisions(cache_root: Path | None) -> list[str]:
    """List locally cached Playwright Chromium revisions."""
    if cache_root is None or not cache_root.is_dir():
        return []
    return sorted(
        entry.name
        for entry in cache_root.iterdir()
        if entry.is_dir() and re.fullmatch(r"(chromium|chromium_headless_shell)-\d+", entry.name)
    )


def _friendly_playwright_browser_error_message(exc: PlaywrightError) -> str | None:
    """Translate Playwright's install banner into MindRoom-specific guidance."""
    raw_message = str(exc)
    expected_path: Path | None = None
    executable_match = re.search(r"Executable doesn't exist at (?P<path>[^\n]+)", raw_message)
    if executable_match is not None:
        expected_path = Path(executable_match.group("path").strip()).expanduser()
    elif "Looks like Playwright was just installed or updated" not in raw_message:
        return None

    cache_root = _playwright_cache_root(expected_path)
    expected_revision = _playwright_revision_dir_name(expected_path)
    installed_revisions = _installed_playwright_chromium_revisions(cache_root)

    expected_text = (
        f"Expected Chromium runtime revision {expected_revision}"
        if expected_revision is not None
        else "Expected the Chromium runtime revision pinned by the installed Playwright package"
    )
    cache_text = f" in {cache_root}" if cache_root is not None else ""
    installed_text = (
        f" Cached Chromium revisions: {', '.join(installed_revisions)}."
        if installed_revisions
        else " No cached Chromium runtime for that revision was found."
    )
    return (
        f"{expected_text}{cache_text}.{installed_text} Run `{_PLAYWRIGHT_INSTALL_COMMAND}` from the MindRoom checkout."
    )


class _BrowserFunctionNotRegisteredError(RuntimeError):
    """Raised when the BrowserTools entrypoint is missing after Toolkit registration."""

    _MESSAGE = "Browser function was not registered"

    def __init__(self) -> None:
        super().__init__(self._MESSAGE)


class BrowserTools(Toolkit):
    """Browser control for MindRoom agents."""

    def __init__(
        self,
        runtime_paths: RuntimePaths,
        *,
        output_dir: Path | str | None = None,
        allow_private_networks: bool = False,
        default_target: Literal["host", "desktop"] = "host",
        device_user_id: str | None = None,
        device_id: str | None = None,
        device_ed25519: str | None = None,
        timeout_seconds: float = 90.0,
    ) -> None:
        super().__init__(name="browser", tools=[self.browser])
        self._runtime_paths = runtime_paths
        self._allow_private_networks = allow_private_networks
        self._default_target = self._validated_default_target(default_target)
        self._desktop_target = self._configured_desktop_target(
            device_user_id=device_user_id,
            device_id=device_id,
            device_ed25519=device_ed25519,
        )
        if self._default_target == "desktop" and self._desktop_target is None:
            msg = "Browser default_target=desktop requires device_user_id, device_id, and device_ed25519."
            raise ValueError(msg)
        if isinstance(timeout_seconds, bool) or not 1 <= timeout_seconds <= MAX_COMMAND_TTL_MS / 1000:
            msg = f"timeout_seconds must be between 1 and {MAX_COMMAND_TTL_MS // 1000}."
            raise ValueError(msg)
        self._timeout_seconds = float(timeout_seconds)
        self._command_session_id = uuid4().hex
        self._command_sequences = count()
        self._profiles: dict[str, _BrowserProfileState] = {}
        self._lock = asyncio.Lock()
        self._configured_output_dir = Path(output_dir).expanduser().resolve() if output_dir is not None else None
        if self._configured_output_dir is not None:
            self._configured_output_dir.mkdir(parents=True, exist_ok=True)
        self._close_task: asyncio.Task[None] | None = None
        self._describe_browser_schema()

    def _describe_browser_schema(self) -> None:
        """Attach explicit model-facing descriptions for browser action routing."""
        function = self.async_functions.get("browser")
        if function is None:
            raise _BrowserFunctionNotRegisteredError
        function.process_entrypoint(strict=False)
        parameters = dict(function.parameters)
        properties = dict(parameters.get("properties") or {})

        action_schema = dict(properties.get("action") or {})
        action_schema["description"] = (
            f"Browser action. Valid actions: {', '.join(sorted(_BROWSER_ACTIONS))}. "
            "Use action='help' to inspect the full table."
        )
        action_schema["enum"] = sorted(_BROWSER_ACTIONS)
        properties["action"] = action_schema

        request_schema = dict(properties.get("request") or {})
        request_schema["description"] = _ACT_REQUEST_DESCRIPTION
        properties["request"] = request_schema

        target_schema = dict(properties.get("target") or {})
        target_schema["description"] = (
            "Execution target. Use host for MindRoom's own browser profile or desktop for the pinned local "
            "Playwright extension in the user's existing profile."
        )
        target_schema["enum"] = ["host", "desktop"]
        properties["target"] = target_schema

        target_id_schema = dict(properties.get("targetId") or {})
        target_id_schema["description"] = (
            "Opaque host-browser tab ID. The desktop target operates only the current tab and rejects targetId because "
            "Playwright MCP exposes mutable numeric indices."
        )
        properties["targetId"] = target_id_schema

        attachment_schema = dict(properties.get("returnAttachment") or {})
        attachment_schema["description"] = (
            "For action=screenshot with target=desktop, return a turn-scoped att_* handle so matrix_message can "
            "send the captured image without saving plaintext to disk."
        )
        properties["returnAttachment"] = attachment_schema

        parameters["properties"] = properties
        function.parameters = parameters

    async def _close_profiles(self) -> None:
        """Close all active browser profiles."""
        for profile_name in list(self._profiles.keys()):
            await self._stop_profile(profile_name)

    def close(self) -> None:
        """Close toolkit resources."""
        try:
            loop = asyncio.get_running_loop()
        except RuntimeError:
            asyncio.run(self._close_profiles())
            return
        self._close_task = loop.create_task(self._close_profiles())

    async def browser(  # noqa: C901, PLR0911, PLR0912, PLR0915
        self,
        action: str,
        target: str | None = None,
        node: str | None = None,
        profile: str | None = None,
        targetUrl: str | None = None,
        targetId: str | None = None,
        limit: int | None = None,
        maxChars: int | None = None,
        mode: str | None = None,
        snapshotFormat: str | None = None,
        refs: str | None = None,
        interactive: bool | None = None,
        compact: bool | None = None,
        depth: int | None = None,
        selector: str | None = None,
        frame: str | None = None,
        labels: bool | None = None,
        fullPage: bool | None = None,
        ref: str | None = None,
        element: str | None = None,
        type: str | None = None,
        returnAttachment: bool = False,
        level: str | None = None,
        paths: list[str] | None = None,
        inputRef: str | None = None,
        timeoutMs: int | None = None,
        accept: bool | None = None,
        promptText: str | None = None,
        request: dict[str, Any] | None = None,
    ) -> str | ToolResult:
        """Control browser state and actions.

        Args:
            action: Browser action (status/start/stop/profiles/tabs/open/focus/close/snapshot/screenshot/navigate/console/pdf/upload/dialog/act/help/actions)
            target: Browser target location: ``host`` or the configured ``desktop`` Matrix device.
            node: Node id compatibility field; unsupported in MindRoom runtime.
            profile: Host-target browser profile name (defaults to ``mindroom``).
            targetUrl: URL for ``open`` and ``navigate`` actions.
            targetId: Opaque host-browser tab id. Unsupported for the desktop target, which operates the current tab.
            limit: Host-target snapshot item limit.
            maxChars: Snapshot text limit.
            mode: Host-target snapshot mode (supports ``efficient``).
            snapshotFormat: Host-target snapshot format (``ai`` or ``aria``).
            refs: Host-target snapshot refs mode (accepted for compatibility).
            interactive: Host-target snapshot interactive filtering.
            compact: Host-target snapshot compact mode hint.
            depth: Snapshot traversal depth.
            selector: Root selector for snapshot.
            frame: Host-target frame selector (accepted for compatibility; ignored for now).
            labels: Host-target snapshot label hint (accepted for compatibility; ignored for now).
            fullPage: Full-page capture for screenshots.
            ref: Snapshot ref id or CSS selector.
            element: CSS selector for element-specific actions.
            type: Screenshot type (``png`` or ``jpeg``).
            returnAttachment: For desktop screenshots, expose an ephemeral handle that matrix_message can send.
            level: Console log level filter.
            paths: Upload file paths.
            inputRef: Host-target upload input selector or ref.
            timeoutMs: Host-target timeout for upload/dialog actions.
            accept: Whether to accept dialog.
            promptText: Prompt text for dialog accept.
            request: Act request object.

        Returns:
            JSON-encoded result payload.

        """
        normalized_action = action.strip().lower()
        if normalized_action not in _BROWSER_ACTIONS:
            msg = _unknown_browser_action_message(action)
            raise ValueError(msg)
        if normalized_action in {"actions", "help"}:
            return json.dumps(_browser_help_payload(normalized_action), sort_keys=True)
        if not isinstance(returnAttachment, bool):
            msg = "returnAttachment must be a boolean."
            raise TypeError(msg)
        if returnAttachment and normalized_action != "screenshot":
            msg = "returnAttachment is only supported for action=screenshot."
            raise ValueError(msg)

        resolved_target = self._resolve_target(target=target, node=node)
        if returnAttachment and resolved_target != "desktop":
            msg = "returnAttachment requires target=desktop."
            raise ValueError(msg)
        if resolved_target == "desktop":
            unsupported = {
                "compact": compact,
                "frame": frame,
                "inputRef": inputRef,
                "interactive": interactive,
                "labels": labels,
                "limit": limit,
                "mode": mode,
                "profile": profile,
                "refs": refs,
                "snapshotFormat": snapshotFormat,
                "timeoutMs": timeoutMs,
            }
            provided_unsupported = sorted(
                name for name, value in unsupported.items() if value is not None and value is not False
            )
            if provided_unsupported:
                msg = f"Browser target=desktop does not support: {', '.join(provided_unsupported)}."
                raise ValueError(msg)
            return await self._desktop_browser(
                action=normalized_action,
                target_url=targetUrl,
                target_id=targetId,
                max_chars=maxChars,
                selector=selector,
                depth=depth,
                full_page=fullPage,
                ref=ref,
                element=element,
                image_type=type,
                return_attachment=returnAttachment,
                level=level,
                paths=paths,
                accept=accept,
                prompt_text=promptText,
                request=request,
            )
        profile_name = _clean_str(profile) or _DEFAULT_PROFILE

        if normalized_action == "status":
            return json.dumps(await self._status_payload(profile_name), sort_keys=True)
        if normalized_action == "start":
            state = await self._ensure_profile(profile_name)
            payload = await self._profile_status(profile_name, state)
            payload["action"] = "start"
            return json.dumps(payload, sort_keys=True)
        if normalized_action == "stop":
            await self._stop_profile(profile_name)
            return json.dumps({"action": "stop", "profile": profile_name, "status": "ok"}, sort_keys=True)
        if normalized_action == "profiles":
            return json.dumps(await self._profiles_payload(profile_name), sort_keys=True)
        if normalized_action == "tabs":
            state = await self._ensure_profile(profile_name)
            return json.dumps(await self._tabs_payload(profile_name, state), sort_keys=True)
        if normalized_action == "open":
            target_url = _clean_str(targetUrl)
            if target_url is None:
                msg = "targetUrl required for action=open"
                raise ValueError(msg)
            target_url = validate_server_fetch_url(target_url, allow_private_networks=self._allow_private_networks)
            return json.dumps(await self._open_tab(profile_name, target_url), sort_keys=True)
        if normalized_action == "focus":
            target_id = _clean_str(targetId)
            if target_id is None:
                msg = "targetId required for action=focus"
                raise ValueError(msg)
            return json.dumps(await self._focus_tab(profile_name, target_id), sort_keys=True)
        if normalized_action == "close":
            return json.dumps(await self._close_tab(profile_name, _clean_str(targetId)), sort_keys=True)
        if normalized_action == "snapshot":
            return json.dumps(
                await self._snapshot(
                    profile_name=profile_name,
                    target_id=_clean_str(targetId),
                    snapshot_format=_clean_str(snapshotFormat),
                    limit=limit,
                    max_chars=maxChars,
                    mode=_clean_str(mode),
                    refs_mode=_clean_str(refs),
                    interactive=interactive,
                    compact=compact,
                    depth=depth,
                    selector=_clean_str(selector),
                    frame=_clean_str(frame),
                    labels=labels,
                ),
                sort_keys=True,
            )
        if normalized_action == "screenshot":
            return json.dumps(
                await self._screenshot(
                    profile_name=profile_name,
                    target_id=_clean_str(targetId),
                    full_page=bool(fullPage),
                    ref=_clean_str(ref),
                    element=_clean_str(element),
                    image_type=_clean_str(type),
                ),
                sort_keys=True,
            )
        if normalized_action == "navigate":
            target_url = _clean_str(targetUrl)
            if target_url is None:
                msg = "targetUrl required for action=navigate"
                raise ValueError(msg)
            target_url = validate_server_fetch_url(target_url, allow_private_networks=self._allow_private_networks)
            return json.dumps(
                await self._navigate(profile_name, target_url, _clean_str(targetId)),
                sort_keys=True,
            )
        if normalized_action == "console":
            return json.dumps(
                await self._console(
                    profile_name=profile_name,
                    target_id=_clean_str(targetId),
                    level=_clean_str(level),
                ),
                sort_keys=True,
            )
        if normalized_action == "pdf":
            return json.dumps(await self._pdf(profile_name, _clean_str(targetId)), sort_keys=True)
        if normalized_action == "upload":
            if not paths:
                msg = "paths required for action=upload"
                raise ValueError(msg)
            return json.dumps(
                await self._upload(
                    profile_name=profile_name,
                    target_id=_clean_str(targetId),
                    paths=paths,
                    ref=_clean_str(ref),
                    input_ref=_clean_str(inputRef),
                    element=_clean_str(element),
                    timeout_ms=timeoutMs,
                ),
                sort_keys=True,
            )
        if normalized_action == "dialog":
            return json.dumps(
                await self._dialog(
                    profile_name=profile_name,
                    target_id=_clean_str(targetId),
                    accept=bool(accept),
                    prompt_text=_clean_str(promptText),
                    timeout_ms=timeoutMs,
                ),
                sort_keys=True,
            )
        if normalized_action == "act":
            if not isinstance(request, dict):
                msg = "request required for action=act"
                raise ValueError(msg)
            return json.dumps(
                await self._act(
                    profile_name=profile_name,
                    request=request,
                    fallback_target_id=_clean_str(targetId),
                ),
                sort_keys=True,
            )

        msg = f"Unhandled action: {action}"
        raise ValueError(msg)

    @staticmethod
    def _validate_target(*, target: str | None, node: str | None) -> None:
        normalized_target = _clean_str(target)
        if node is not None and normalized_target not in {None, "node"}:
            msg = "node parameter is not supported in MindRoom."
            raise ValueError(msg)
        if normalized_target in {"sandbox", "node"} or node is not None:
            msg = "MindRoom browser tool does not support sandbox or node targets."
            raise ValueError(msg)
        if normalized_target not in {None, "host", "desktop"}:
            msg = f"Unsupported target: {target}"
            raise ValueError(msg)

    def _resolve_target(self, *, target: str | None, node: str | None) -> str:
        self._validate_target(target=target, node=node)
        return _clean_str(target) or self._default_target

    @staticmethod
    def _validated_default_target(default_target: str) -> str:
        normalized = default_target.strip().lower()
        if normalized not in {"host", "desktop"}:
            msg = "Browser default_target must be host or desktop."
            raise ValueError(msg)
        return normalized

    @staticmethod
    def _configured_desktop_target(
        *,
        device_user_id: str | None,
        device_id: str | None,
        device_ed25519: str | None,
    ) -> PinnedMatrixDevice | None:
        values = (device_user_id, device_id, device_ed25519)
        if all(value is None for value in values):
            return None
        if any(value is None or not value.strip() for value in values):
            msg = "Browser desktop target requires device_user_id, device_id, and device_ed25519 together."
            raise ValueError(msg)
        assert device_user_id is not None
        assert device_id is not None
        assert device_ed25519 is not None
        return PinnedMatrixDevice(user_id=device_user_id, device_id=device_id, ed25519=device_ed25519)

    async def _desktop_browser(
        self,
        *,
        action: str,
        target_url: str | None,
        target_id: str | None,
        max_chars: int | None,
        selector: str | None,
        depth: int | None,
        full_page: bool | None,
        ref: str | None,
        element: str | None,
        image_type: str | None,
        return_attachment: bool,
        level: str | None,
        paths: list[str] | None,
        accept: bool | None,
        prompt_text: str | None,
        request: dict[str, Any] | None,
    ) -> str | ToolResult:
        target = self._desktop_target
        if target is None:
            msg = "Browser target=desktop requires configured Matrix desktop device identity."
            raise ValueError(msg)
        context = get_tool_runtime_context()
        if context is None:
            msg = "Browser target=desktop requires a live Matrix runtime context."
            raise ValueError(msg)
        parameters = _desktop_browser_parameters(
            action,
            target_url=target_url,
            target_id=target_id,
            max_chars=max_chars,
            selector=selector,
            depth=depth,
            full_page=full_page,
            ref=ref,
            element=element,
            image_type=image_type,
            level=level,
            paths=paths,
            accept=accept,
            prompt_text=prompt_text,
            request=request,
            allow_private_networks=self._allow_private_networks,
        )
        now_ms = round(time.time() * 1000)
        command = DesktopCommand(
            request_id=uuid4().hex,
            session_id=self._command_session_id,
            sequence=next(self._command_sequences),
            issued_at_ms=now_ms,
            expires_at_ms=now_ms + round(self._timeout_seconds * 1000),
            action="browser_control" if browser_action_requires_control(action) else "browser_observe",
            requester_id=context.requester_id,
            agent_name=context.agent_name,
            parameters={"browser_action": action, "browser_parameters": parameters},
        )
        response = await desktop_response_router(context.client).request(
            target,
            command,
            timeout_seconds=self._timeout_seconds,
        )
        if not response.ok:
            raise ValueError(response.error or "Desktop Playwright browser request failed.")
        result_payload = dict(response.result)
        if response.screenshot is None:
            return json.dumps(result_payload, sort_keys=True, ensure_ascii=False)
        image_bytes = await download_encrypted_screenshot(
            context.client,
            response.screenshot,
            timeout_seconds=self._timeout_seconds,
        )
        if return_attachment:
            attachment = register_runtime_screenshot_attachment(
                context,
                response.screenshot,
                filename_prefix="browser-screenshot",
            )
            result_payload.update(screenshot_attachment_result_fields(attachment))
        return ToolResult(
            content=json.dumps(result_payload, sort_keys=True, ensure_ascii=False),
            images=[Image(content=image_bytes, mime_type=response.screenshot.mime_type)],
        )

    async def _status_payload(self, profile_name: str) -> dict[str, Any]:
        async with self._lock:
            state = self._profiles.get(profile_name)
            if state is None:
                return {"action": "status", "profile": profile_name, "running": False, "status": "ok", "tabs": []}
            return await self._profile_status(profile_name, state)

    async def _profiles_payload(self, selected_profile: str) -> dict[str, Any]:
        async with self._lock:
            running = sorted(self._profiles.keys())
        advertised = sorted({_DEFAULT_PROFILE, "chrome", *running})
        return {
            "action": "profiles",
            "profiles": advertised,
            "running_profiles": running,
            "selected_profile": selected_profile,
            "status": "ok",
        }

    async def _profile_status(self, profile_name: str, state: _BrowserProfileState) -> dict[str, Any]:
        tabs = await self._tab_list(state)
        return {
            "action": "status",
            "activeTargetId": state.active_target_id,
            "profile": profile_name,
            "running": True,
            "status": "ok",
            "tabCount": len(tabs),
            "tabs": tabs,
        }

    async def _tab_list(self, state: _BrowserProfileState) -> list[dict[str, Any]]:
        payload_tabs: list[dict[str, Any]] = []
        stale: list[str] = []
        for target_id, tab in state.tabs.items():
            if tab.page.is_closed():
                stale.append(target_id)
                continue
            title = await tab.page.title()
            payload_tabs.append(
                {
                    "active": target_id == state.active_target_id,
                    "targetId": target_id,
                    "title": title,
                    "url": tab.page.url,
                },
            )
        for target_id in stale:
            self._remove_tab(state, target_id)
        return payload_tabs

    async def _tabs_payload(self, profile_name: str, state: _BrowserProfileState) -> dict[str, Any]:
        tabs = await self._tab_list(state)
        return {
            "action": "tabs",
            "activeTargetId": state.active_target_id,
            "profile": profile_name,
            "status": "ok",
            "tabs": tabs,
        }

    async def _open_tab(self, profile_name: str, target_url: str) -> dict[str, Any]:
        state = await self._ensure_profile(profile_name)
        page = await state.context.new_page()
        target_id = self._register_tab(state, page)
        await page.goto(target_url, wait_until="domcontentloaded", timeout=_DEFAULT_TIMEOUT_MS)
        state.active_target_id = target_id
        return {
            "action": "open",
            "profile": profile_name,
            "status": "ok",
            "targetId": target_id,
            "title": await page.title(),
            "url": page.url,
        }

    async def _focus_tab(self, profile_name: str, target_id: str) -> dict[str, Any]:
        state = await self._ensure_profile(profile_name)
        if target_id not in state.tabs or state.tabs[target_id].page.is_closed():
            msg = f"tab not found: {target_id}"
            raise ValueError(msg)
        state.active_target_id = target_id
        page = state.tabs[target_id].page
        return {
            "action": "focus",
            "profile": profile_name,
            "status": "ok",
            "targetId": target_id,
            "title": await page.title(),
            "url": page.url,
        }

    async def _close_tab(self, profile_name: str, target_id: str | None) -> dict[str, Any]:
        state = await self._ensure_profile(profile_name)
        resolved_target_id, tab = await self._resolve_tab(state, target_id)
        await tab.page.close()
        self._remove_tab(state, resolved_target_id)
        return {
            "action": "close",
            "profile": profile_name,
            "status": "ok",
            "targetId": resolved_target_id,
        }

    async def _navigate(self, profile_name: str, target_url: str, target_id: str | None) -> dict[str, Any]:
        state = await self._ensure_profile(profile_name)
        resolved_target_id, tab = await self._resolve_tab(state, target_id)
        await tab.page.goto(target_url, wait_until="domcontentloaded", timeout=_DEFAULT_TIMEOUT_MS)
        state.active_target_id = resolved_target_id
        return {
            "action": "navigate",
            "profile": profile_name,
            "status": "ok",
            "targetId": resolved_target_id,
            "title": await tab.page.title(),
            "url": tab.page.url,
        }

    async def _console(self, profile_name: str, target_id: str | None, level: str | None) -> dict[str, Any]:
        state = await self._ensure_profile(profile_name)
        if target_id is not None:
            _, tab = await self._resolve_tab(state, target_id)
            entries = tab.console
        else:
            entries = [entry for tab in state.tabs.values() for entry in tab.console]
        if level is not None:
            entries = [entry for entry in entries if entry.get("level") == level]
        return {
            "action": "console",
            "entries": entries[-_MAX_CONSOLE_ENTRIES:],
            "level": level,
            "profile": profile_name,
            "status": "ok",
            "targetId": target_id,
        }

    async def _pdf(self, profile_name: str, target_id: str | None) -> dict[str, Any]:
        state = await self._ensure_profile(profile_name)
        resolved_target_id, tab = await self._resolve_tab(state, target_id)
        output_path = self._next_output_path("pdf")
        await tab.page.pdf(path=str(output_path))
        return {
            "action": "pdf",
            "path": str(output_path),
            "profile": profile_name,
            "status": "ok",
            "targetId": resolved_target_id,
        }

    async def _upload(
        self,
        *,
        profile_name: str,
        target_id: str | None,
        paths: list[str],
        ref: str | None,
        input_ref: str | None,
        element: str | None,
        timeout_ms: int | None,
    ) -> dict[str, Any]:
        state = await self._ensure_profile(profile_name)
        resolved_target_id, tab = await self._resolve_tab(state, target_id)
        selector = self._resolve_selector(tab, input_ref or ref or element)
        if selector is None:
            msg = "upload requires inputRef, ref, or element"
            raise ValueError(msg)
        normalized_paths = [str(self._resolve_upload_path(path)) for path in paths]
        locator = tab.page.locator(selector).first
        await locator.set_input_files(normalized_paths, timeout=timeout_ms or _DEFAULT_TIMEOUT_MS)
        return {
            "action": "upload",
            "paths": normalized_paths,
            "profile": profile_name,
            "selector": selector,
            "status": "ok",
            "targetId": resolved_target_id,
        }

    async def _dialog(
        self,
        *,
        profile_name: str,
        target_id: str | None,
        accept: bool,
        prompt_text: str | None,
        timeout_ms: int | None,
    ) -> dict[str, Any]:
        state = await self._ensure_profile(profile_name)
        resolved_target_id, tab = await self._resolve_tab(state, target_id)
        tab.pending_dialog = {
            "accept": accept,
            "promptText": prompt_text,
            "timeoutMs": timeout_ms,
        }
        return {
            "accept": accept,
            "action": "dialog",
            "armed": True,
            "profile": profile_name,
            "promptText": prompt_text,
            "status": "ok",
            "targetId": resolved_target_id,
            "timeoutMs": timeout_ms,
        }

    async def _screenshot(
        self,
        *,
        profile_name: str,
        target_id: str | None,
        full_page: bool,
        ref: str | None,
        element: str | None,
        image_type: str | None,
    ) -> dict[str, Any]:
        state = await self._ensure_profile(profile_name)
        resolved_target_id, tab = await self._resolve_tab(state, target_id)
        resolved_type = "jpeg" if image_type == "jpeg" else "png"
        output_path = self._next_output_path("jpg" if resolved_type == "jpeg" else "png")
        selector = self._resolve_selector(tab, element or ref)
        if selector is None:
            await tab.page.screenshot(path=str(output_path), type=resolved_type, full_page=full_page)
        else:
            await tab.page.locator(selector).first.screenshot(path=str(output_path), type=resolved_type)
        return {
            "action": "screenshot",
            "fullPage": full_page,
            "path": str(output_path),
            "profile": profile_name,
            "selector": selector,
            "status": "ok",
            "targetId": resolved_target_id,
            "type": resolved_type,
        }

    async def _snapshot(
        self,
        *,
        profile_name: str,
        target_id: str | None,
        snapshot_format: str | None,
        limit: int | None,
        max_chars: int | None,
        mode: str | None,
        refs_mode: str | None,
        interactive: bool | None,
        compact: bool | None,
        depth: int | None,
        selector: str | None,
        frame: str | None,
        labels: bool | None,
    ) -> dict[str, Any]:
        state = await self._ensure_profile(profile_name)
        resolved_target_id, tab = await self._resolve_tab(state, target_id)
        if frame is not None:
            del frame  # Not implemented in local Playwright path yet.
        if labels is not None:
            del labels  # Label overlays are currently not implemented.

        fmt = "aria" if snapshot_format == "aria" else "ai"
        resolved_limit = max(1, limit) if isinstance(limit, int) and limit > 0 else _DEFAULT_SNAPSHOT_LIMIT
        resolved_depth = max(1, depth) if isinstance(depth, int) and depth > 0 else 12
        interactive_only = interactive if isinstance(interactive, bool) else True
        rows = await tab.page.evaluate(
            _SNAPSHOT_JS,
            {
                "depth": resolved_depth,
                "interactiveOnly": interactive_only,
                "limit": resolved_limit,
                "selector": selector,
            },
        )
        if not isinstance(rows, list):
            rows = []

        ref_entries: list[dict[str, Any]] = []
        tab.refs = {}
        for index, row in enumerate(rows, start=1):
            if not isinstance(row, dict):
                continue
            selector_value = _clean_str(row.get("selector"))
            if selector_value is None:
                continue
            ref_id = f"e{index}"
            tab.refs[ref_id] = selector_value
            ref_entries.append(
                {
                    "name": _clean_str(row.get("name")) or "",
                    "ref": ref_id,
                    "role": _clean_str(row.get("role")) or "",
                    "selector": selector_value,
                    "tag": _clean_str(row.get("tag")) or "",
                    "type": _clean_str(row.get("type")) or "",
                },
            )

        state.active_target_id = resolved_target_id
        title = await tab.page.title()
        if fmt == "aria":
            return {
                "action": "snapshot",
                "count": len(ref_entries),
                "format": "aria",
                "interactive": interactive_only,
                "mode": mode,
                "profile": profile_name,
                "refs": refs_mode or "role",
                "status": "ok",
                "targetId": resolved_target_id,
                "title": title,
                "url": tab.page.url,
                "elements": ref_entries,
            }

        lines: list[str] = [f"URL: {tab.page.url}", f"Title: {title}", "Elements:"]
        for entry in ref_entries:
            role_or_tag = entry["role"] or entry["tag"] or "element"
            name = entry["name"]
            base = f"[{entry['ref']}] {role_or_tag}"
            lines.append(f"{base}: {name}" if name else base)
        snapshot_text = "\n".join(lines if not compact else lines[:2] + lines[3:])
        resolved_max_chars = self._resolve_max_chars(max_chars=max_chars, mode=mode)
        if resolved_max_chars is not None and len(snapshot_text) > resolved_max_chars:
            snapshot_text = snapshot_text[:resolved_max_chars].rstrip() + "\n…"

        return {
            "action": "snapshot",
            "count": len(ref_entries),
            "format": "ai",
            "interactive": interactive_only,
            "mode": mode,
            "profile": profile_name,
            "refs": refs_mode or "role",
            "snapshot": snapshot_text,
            "status": "ok",
            "targetId": resolved_target_id,
            "title": title,
            "url": tab.page.url,
        }

    @staticmethod
    def _resolve_max_chars(*, max_chars: int | None, mode: str | None) -> int | None:
        if isinstance(max_chars, int):
            return max_chars if max_chars > 0 else None
        if mode == "efficient":
            return None
        return _DEFAULT_AI_SNAPSHOT_MAX_CHARS

    async def _act(  # noqa: C901, PLR0911, PLR0912, PLR0915
        self,
        *,
        profile_name: str,
        request: dict[str, Any],
        fallback_target_id: str | None,
    ) -> dict[str, Any]:
        state = await self._ensure_profile(profile_name)
        requested_target_id = _clean_str(request.get("targetId")) or fallback_target_id
        resolved_target_id, tab = await self._resolve_tab(state, requested_target_id)
        kind = _clean_str(request.get("kind"))
        if kind is None:
            msg = "request.kind required"
            raise ValueError(msg)

        if kind == "click":
            selector = self._resolve_selector(tab, _clean_str(request.get("ref")))
            if selector is None:
                msg = "click requires request.ref"
                raise ValueError(msg)
            click_kwargs: dict[str, Any] = {}
            if request.get("doubleClick") is True:
                click_kwargs["click_count"] = 2
            button = _clean_str(request.get("button"))
            if button is not None:
                click_kwargs["button"] = button
            modifiers = request.get("modifiers")
            if isinstance(modifiers, list):
                click_kwargs["modifiers"] = [str(modifier) for modifier in modifiers]
            await tab.page.locator(selector).first.click(**click_kwargs)
            return self._act_result(profile_name, resolved_target_id, kind)

        if kind == "type":
            selector = self._resolve_selector(tab, _clean_str(request.get("ref")))
            if selector is None:
                msg = "type requires request.ref"
                raise ValueError(msg)
            text = str(request.get("text", ""))
            locator = tab.page.locator(selector).first
            if request.get("slowly") is True:
                await locator.type(text, delay=75)
            else:
                await locator.fill(text)
            if request.get("submit") is True:
                await locator.press("Enter")
            return self._act_result(profile_name, resolved_target_id, kind, text=text)

        if kind == "press":
            key = _clean_str(request.get("key"))
            if key is None:
                msg = "press requires request.key"
                raise ValueError(msg)
            await tab.page.keyboard.press(key)
            return self._act_result(profile_name, resolved_target_id, kind, key=key)

        if kind == "hover":
            selector = self._resolve_selector(tab, _clean_str(request.get("ref")))
            if selector is None:
                msg = "hover requires request.ref"
                raise ValueError(msg)
            await tab.page.locator(selector).first.hover()
            return self._act_result(profile_name, resolved_target_id, kind)

        if kind == "drag":
            start_selector = self._resolve_selector(tab, _clean_str(request.get("startRef")))
            end_selector = self._resolve_selector(tab, _clean_str(request.get("endRef")))
            if start_selector is None or end_selector is None:
                msg = "drag requires request.startRef and request.endRef"
                raise ValueError(msg)
            await tab.page.drag_and_drop(start_selector, end_selector)
            return self._act_result(profile_name, resolved_target_id, kind)

        if kind == "select":
            selector = self._resolve_selector(tab, _clean_str(request.get("ref")))
            values = request.get("values")
            if selector is None or not isinstance(values, list) or not values:
                msg = "select requires request.ref and request.values"
                raise ValueError(msg)
            await tab.page.select_option(selector, [str(value) for value in values])
            return self._act_result(profile_name, resolved_target_id, kind, values=values)

        if kind == "fill":
            fields = request.get("fields")
            if not isinstance(fields, list) or not fields:
                msg = "fill requires request.fields"
                raise ValueError(msg)
            updated: list[dict[str, str]] = []
            for field in fields:
                if not isinstance(field, dict):
                    continue
                selector = self._resolve_selector(
                    tab,
                    _clean_str(field.get("ref")) or _clean_str(field.get("selector")),
                )
                if selector is None:
                    continue
                value = str(field.get("value", ""))
                await tab.page.locator(selector).first.fill(value)
                updated.append({"selector": selector, "value": value})
            if not updated:
                msg = "fill requires at least one field with a valid ref or selector"
                raise ValueError(msg)
            return self._act_result(profile_name, resolved_target_id, kind, fields=updated)

        if kind == "resize":
            width = request.get("width")
            height = request.get("height")
            if not isinstance(width, int) or not isinstance(height, int) or width <= 0 or height <= 0:
                msg = "resize requires positive integer request.width and request.height"
                raise ValueError(msg)
            await tab.page.set_viewport_size({"height": height, "width": width})
            return self._act_result(profile_name, resolved_target_id, kind, width=width, height=height)

        if kind == "wait":
            time_ms = request.get("timeMs")
            text = _clean_str(request.get("text"))
            text_gone = _clean_str(request.get("textGone"))
            if isinstance(time_ms, int) and time_ms >= 0:
                await tab.page.wait_for_timeout(time_ms)
            elif text is not None:
                await tab.page.wait_for_selector(f"text={text}", state="visible", timeout=_DEFAULT_TIMEOUT_MS)
            elif text_gone is not None:
                await tab.page.wait_for_selector(f"text={text_gone}", state="detached", timeout=_DEFAULT_TIMEOUT_MS)
            else:
                await tab.page.wait_for_timeout(500)
            return self._act_result(profile_name, resolved_target_id, kind)

        if kind == "evaluate":
            fn = _clean_str(request.get("fn"))
            if fn is None:
                msg = "evaluate requires request.fn"
                raise ValueError(msg)
            selector = self._resolve_selector(tab, _clean_str(request.get("ref")))
            result = await tab.page.evaluate(fn) if selector is None else await tab.page.eval_on_selector(selector, fn)
            return self._act_result(profile_name, resolved_target_id, kind, result=result)

        if kind == "close":
            await tab.page.close()
            self._remove_tab(state, resolved_target_id)
            return self._act_result(profile_name, resolved_target_id, kind)

        msg = f"Unsupported act kind: {kind}"
        raise ValueError(msg)

    @staticmethod
    def _act_result(profile_name: str, target_id: str, kind: str, **extra: object) -> dict[str, Any]:
        payload: dict[str, Any] = {
            "action": "act",
            "kind": kind,
            "profile": profile_name,
            "status": "ok",
            "targetId": target_id,
        }
        payload.update(extra)
        return payload

    async def _ensure_profile(self, profile_name: str) -> _BrowserProfileState:
        async with self._lock:
            state = self._profiles.get(profile_name)
            if state is not None:
                return state

            playwright = await async_playwright().start()
            launch_kwargs = _persistent_launch_kwargs(self._runtime_paths, profile_name, headless=True)
            user_data_dir = Path(str(launch_kwargs["user_data_dir"]))
            _clear_stale_singleton_locks(user_data_dir)
            try:
                context = await playwright.chromium.launch_persistent_context(**launch_kwargs)
            except PlaywrightError as exc:
                await playwright.stop()
                friendly_message = _friendly_playwright_browser_error_message(exc)
                if friendly_message is not None:
                    raise RuntimeError(friendly_message) from exc
                raise
            except Exception:
                await playwright.stop()
                raise
            await context.route(
                "**/*",
                lambda route: continue_or_abort_browser_fetch(
                    route,
                    allow_private_networks=self._allow_private_networks,
                ),
            )
            state = _BrowserProfileState(playwright=playwright, context=context)
            self._profiles[profile_name] = state

            for page in context.pages:
                target_id = self._register_tab(state, page)
                if state.active_target_id is None:
                    state.active_target_id = target_id
            if state.active_target_id is None:
                page = await context.new_page()
                target_id = self._register_tab(state, page)
                state.active_target_id = target_id
            return state

    async def _stop_profile(self, profile_name: str) -> None:
        async with self._lock:
            state = self._profiles.pop(profile_name, None)
            if state is None:
                return
            await state.context.close()
            await state.playwright.stop()

    async def _resolve_tab(
        self,
        state: _BrowserProfileState,
        target_id: str | None,
    ) -> tuple[str, _BrowserTabState]:
        resolved_target_id = target_id or state.active_target_id
        if resolved_target_id is not None:
            tab = state.tabs.get(resolved_target_id)
            if tab is not None and not tab.page.is_closed():
                state.active_target_id = resolved_target_id
                return resolved_target_id, tab
        for candidate_id, tab in state.tabs.items():
            if not tab.page.is_closed():
                state.active_target_id = candidate_id
                return candidate_id, tab
        page = await state.context.new_page()
        candidate_id = self._register_tab(state, page)
        state.active_target_id = candidate_id
        return candidate_id, state.tabs[candidate_id]

    def _register_tab(self, state: _BrowserProfileState, page: Page) -> str:
        target_id = uuid4().hex[:8]
        tab = _BrowserTabState(target_id=target_id, page=page)
        state.tabs[target_id] = tab
        page.on("console", lambda message: self._record_console(tab, message))
        page.on("dialog", lambda dialog: asyncio.create_task(self._handle_dialog(tab, dialog)))
        page.on("close", lambda _: self._remove_tab(state, target_id))
        return target_id

    @staticmethod
    def _record_console(tab: _BrowserTabState, message: ConsoleMessage) -> None:
        entry = {
            "level": message.type,
            "location": message.location,
            "text": message.text,
        }
        tab.console.append(entry)
        if len(tab.console) > _MAX_CONSOLE_ENTRIES:
            del tab.console[:-_MAX_CONSOLE_ENTRIES]

    async def _handle_dialog(self, tab: _BrowserTabState, dialog: Dialog) -> None:
        behavior = tab.pending_dialog
        if behavior is None:
            await dialog.dismiss()
            return
        tab.pending_dialog = None
        if behavior.get("accept"):
            await dialog.accept(str(behavior.get("promptText") or ""))
            return
        await dialog.dismiss()

    @staticmethod
    def _resolve_selector(tab: _BrowserTabState, ref_or_selector: str | None) -> str | None:
        if ref_or_selector is None:
            return None
        return tab.refs.get(ref_or_selector, ref_or_selector)

    def _next_output_path(self, extension: str) -> Path:
        return self._resolve_output_dir() / f"{uuid4().hex}.{extension}"

    def _resolve_output_dir(self) -> Path:
        """Return the directory used for browser artifacts."""
        if self._configured_output_dir is not None:
            return self._configured_output_dir

        context = get_tool_runtime_context()
        storage_root = (
            context.storage_path
            if context is not None and context.storage_path is not None
            else self._runtime_paths.storage_root
        )
        output_dir = (storage_root / "browser").resolve()
        output_dir.mkdir(parents=True, exist_ok=True)
        return output_dir

    def _browser_upload_roots(self) -> tuple[Path, ...]:
        """Return roots whose files can be read by browser upload."""
        context = get_tool_runtime_context()
        if self._configured_output_dir is not None:
            roots = [self._configured_output_dir.resolve()]
        elif context is not None and context.storage_path is not None:
            roots = [(context.storage_path / "browser").resolve()]
        else:
            roots = [(self._runtime_paths.storage_root / "browser").resolve()]
        if context is not None and context.storage_path is not None:
            roots.append(context.storage_path.resolve())
        return tuple(roots)

    def _resolve_upload_path(self, path: str) -> Path:
        """Resolve and confine one browser upload path."""
        resolved = Path(path).expanduser().resolve()
        if not resolved.is_file():
            msg = f"upload path must be an existing file: {path}"
            raise ValueError(msg)
        roots = self._browser_upload_roots()
        if any(resolved.is_relative_to(root) for root in roots):
            return resolved
        root_list = ", ".join(str(root) for root in roots)
        msg = f"upload path '{path}' resolves to '{resolved}', outside browser upload root(s): {root_list}"
        raise ValueError(msg)

    @staticmethod
    def _remove_tab(state: _BrowserProfileState, target_id: str) -> None:
        state.tabs.pop(target_id, None)
        if state.active_target_id == target_id:
            state.active_target_id = next(iter(state.tabs.keys()), None)
