"""
Browse-Now CLI - Browser automation for AI agents.

Provides browser control via the Nowledge Memory Exchange extension.
Uses argparse + Rich for CLI (matching nmem-cli pattern).

Quick start:
    browse-now open <url>        # Navigate to page
    browse-now snapshot -i       # Get interactive elements with refs
    browse-now click @e1         # Click element by ref
    browse-now fill @e2 "text"   # Fill input by ref
"""

import argparse
import json as json_module
import os
import re
import sys
import time
from importlib.metadata import version
from typing import Any, Optional

import httpx
from rich import box
from rich.console import Console
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.table import Table

from .ws_client import WSResponse

DEFAULT_API_URL = "http://127.0.0.1:14242"
DEFAULT_WS_URL = "ws://127.0.0.1:14242/browser-bridge/ws"

console = Console()
_json_mode = False
_browser_id: str | None = None


def set_json_mode(enabled: bool) -> None:
    global _json_mode
    _json_mode = enabled


def is_json_mode() -> bool:
    return _json_mode


def set_browser_id(browser_id: str | None) -> None:
    global _browser_id
    _browser_id = browser_id


def get_browser_id() -> str | None:
    return _browser_id


def output_json(data: Any) -> None:
    print(json_module.dumps(data, indent=2, default=str, ensure_ascii=False))


def get_api_url() -> str:
    return os.environ.get("NOWLEDGE_API_URL", DEFAULT_API_URL).rstrip("/")


def get_api_key() -> str:
    return (
        os.environ.get("NOWLEDGE_API_KEY")
        or os.environ.get("NMEM_API_KEY")
        or ""
    ).strip()


def get_auth_headers() -> dict[str, str]:
    token = get_api_key()
    if not token:
        return {}
    return {"Authorization": f"Bearer {token}"}


def get_ws_url() -> str:
    api_url = get_api_url()
    return api_url.replace("http://", "ws://").replace("https://", "wss://") + "/browser-bridge/ws"


def escape_rich_markup(text: str) -> str:
    """Escape Rich markup characters in text (brackets)."""
    return text.replace("[", r"\[")


def print_error(title: str, message: str, hint: str | None = None) -> None:
    if is_json_mode():
        return
    # Escape brackets in message to prevent Rich from interpreting CSS selectors as markup
    safe_message = escape_rich_markup(message)
    console.print(f"[bold red]x[/bold red] [bold]{title}[/bold]")
    console.print(f"  [dim]{safe_message}[/dim]")
    if hint:
        console.print(f"  [dim cyan]Hint: {hint}[/dim cyan]")


def print_success(message: str, detail: str | None = None) -> None:
    if is_json_mode():
        return
    if detail:
        console.print(f"[green]ok[/green] {message}: [cyan]{detail}[/cyan]")
    else:
        console.print(f"[green]ok[/green] {message}")


def print_action_hint(hint: str) -> None:
    """Print a hint suggesting the next action (vision-first workflow guidance)."""
    if is_json_mode():
        return
    console.print(f"  [dim cyan]→ {hint}[/dim cyan]")


def is_ref(selector: str) -> bool:
    """Check if selector is a ref (e.g., @e1, e1, @e123)."""
    return bool(re.match(r"^@?e\d+$", selector))


# ═══════════════════════════════════════════════════════════════════════════════
# API Client (HTTP fallback)
# ═══════════════════════════════════════════════════════════════════════════════


def api_get(endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
    url = f"{get_api_url()}{endpoint}"
    try:
        response = httpx.get(url, params=params, headers=get_auth_headers(), timeout=30.0)
        response.raise_for_status()
        return response.json()
    except httpx.ConnectError:
        if is_json_mode():
            output_json(
                {
                    "error": "connection_failed",
                    "message": f"Cannot connect to {get_api_url()}",
                }
            )
        else:
            print_error(
                "Connection Failed",
                f"Cannot reach {get_api_url()}",
                "Make sure Nowledge Mem is running",
            )
        sys.exit(1)
    except httpx.HTTPStatusError as e:
        if is_json_mode():
            output_json({"error": "api_error", "status_code": e.response.status_code})
        else:
            print_error(f"API Error ({e.response.status_code})", "Request failed")
        sys.exit(1)


def api_post(endpoint: str, data: dict[str, Any]) -> dict[str, Any]:
    url = f"{get_api_url()}{endpoint}"
    try:
        response = httpx.post(url, json=data, headers=get_auth_headers(), timeout=60.0)
        response.raise_for_status()
        return response.json()
    except httpx.ConnectError:
        if is_json_mode():
            output_json(
                {
                    "error": "connection_failed",
                    "message": f"Cannot connect to {get_api_url()}",
                }
            )
        else:
            print_error("Connection Failed", f"Cannot reach {get_api_url()}")
        sys.exit(1)
    except httpx.HTTPStatusError as e:
        if is_json_mode():
            output_json({"error": "api_error", "status_code": e.response.status_code})
        else:
            print_error(f"API Error ({e.response.status_code})", "Request failed")
        sys.exit(1)


# ═══════════════════════════════════════════════════════════════════════════════
# WebSocket Client
# ═══════════════════════════════════════════════════════════════════════════════


def create_ws_client(timeout: float = 30.0):
    """Create WebSocket client for browse-now commands.

    Args:
        timeout: Request timeout in seconds. Default 30s, use 60s for screenshot.
    """
    from .ws_client import BrowseNowWSClientSync

    return BrowseNowWSClientSync(ws_url=get_ws_url(), browser_id=get_browser_id(), timeout=timeout)


# ═══════════════════════════════════════════════════════════════════════════════
# Commands: Navigation
# ═══════════════════════════════════════════════════════════════════════════════


def cmd_open(url: str, wait: str = "load", isolated: bool = True, timeout: int = 10) -> None:
    """Navigate to a URL.

    By default, opens in a dedicated "Browse-Now" tab group (isolated mode)
    so agent browsing doesn't interfere with user's normal browsing.
    Use --no-isolated to use the current active tab instead.

    Args:
        url: URL to navigate to
        wait: When to consider navigation complete
        isolated: Use isolated agent tab (default True)
        timeout: Page load timeout in seconds (default 10)
    """
    # Use longer timeout for WebSocket client (matches page load timeout)
    ws_timeout = float(timeout) + 5.0  # Add buffer for network overhead

    if not is_json_mode():
        msg = f"[cyan]Opening {url}"
        if not isolated:
            msg += " (shared tab)"
        if timeout != 10:
            msg += f" (timeout: {timeout}s)"
        msg += "...[/cyan]"
        with Progress(
            SpinnerColumn(),
            TextColumn(msg),
            console=console,
            transient=True,
        ) as p:
            p.add_task("", total=None)
            ws_client = create_ws_client(timeout=ws_timeout)
            result = ws_client.navigate(
                url, wait_until=wait, use_agent_tab=isolated, timeout=timeout
            )
    else:
        ws_client = create_ws_client(timeout=ws_timeout)
        result = ws_client.navigate(url, wait_until=wait, use_agent_tab=isolated, timeout=timeout)

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success:
            data = result.data or {}
            detail = data.get("url", url)
            if isolated and data.get("agentTabId"):
                detail += f" [dim](agent tab {data.get('agentTabId')})[/dim]"
            print_success("Navigated", detail)
            if data.get("warning"):
                console.print(f"  [yellow]⚠ {data['warning']}[/yellow]")
            print_action_hint("Take screenshot to see page: browse-now screenshot /tmp/page.png")
        else:
            print_error("Navigation Failed", result.error or "Unknown error")


def cmd_back() -> None:
    """Go back in browser history."""
    ws_client = create_ws_client()
    result = ws_client.send_command("back", {})

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success:
            data = result.data or {}
            url = data.get("url", "")
            previous_url = data.get("previousUrl", "")
            if url and url != previous_url:
                print_success("Navigated back", url)
            elif data.get("error"):
                console.print(f"[yellow]ok[/yellow] {data.get('error')}")
            else:
                print_success("Navigated back")
        else:
            print_error("Back Failed", result.error or "Unknown error")


def cmd_forward() -> None:
    """Go forward in browser history."""
    ws_client = create_ws_client()
    result = ws_client.send_command("forward", {})

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success:
            data = result.data or {}
            url = data.get("url", "")
            if url:
                print_success("Navigated forward", url)
            else:
                print_success("Navigated forward")
        else:
            print_error("Forward Failed", result.error or "Unknown error")


def cmd_reload() -> None:
    """Reload the current page."""
    ws_client = create_ws_client()
    result = ws_client.send_command("reload", {})

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success:
            print_success("Page reloaded")
        else:
            print_error("Reload Failed", result.error or "Unknown error")


# ═══════════════════════════════════════════════════════════════════════════════
# Commands: Snapshot (Page Analysis)
# ═══════════════════════════════════════════════════════════════════════════════


def cmd_snapshot(
    interactive: bool = False,
    compact: bool = False,
    depth: int = 10,
    coords: bool = False,
    selector: str | None = None,
    with_css: bool = False,
    wait_ms: int = 0,
    max_elements: int = 0,
) -> None:
    """Capture page snapshot with element refs.

    Args:
        interactive: Only include interactive elements
        compact: Compact output (no panel border)
        depth: Maximum depth to traverse
        coords: Include element coordinates
        selector: Scope snapshot to CSS selector
        with_css: Include CSS selectors for each element
        wait_ms: Wait milliseconds before snapshot (for late-loading content)
        max_elements: Limit output to first N elements (0 = no limit)
    """
    if not is_json_mode():
        wait_msg = f" (waiting {wait_ms}ms)" if wait_ms > 0 else ""
        with Progress(
            SpinnerColumn(),
            TextColumn(f"[cyan]Capturing snapshot{wait_msg}...[/cyan]"),
            console=console,
            transient=True,
        ) as p:
            p.add_task("", total=None)
            ws_client = create_ws_client()
            result = ws_client.snapshot(
                interactive=interactive,
                max_depth=depth,
                include_coordinates=coords,
                selector=selector,
                with_css=with_css,
                wait_ms=wait_ms,
            )
    else:
        ws_client = create_ws_client()
        result = ws_client.snapshot(
            interactive=interactive,
            max_depth=depth,
            include_coordinates=coords,
            selector=selector,
            with_css=with_css,
            wait_ms=wait_ms,
        )

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success and result.data:
            data = result.data
            tree = data.get("tree", "")
            refs = data.get("refs", {})
            url = data.get("url", "")
            title = data.get("title", "")

            console.print(f"[dim]URL: {url}[/dim]")
            console.print(f"[dim]Title: {title}[/dim]")
            console.print(f"[dim]Refs: {len(refs)} elements[/dim]")
            if with_css:
                console.print(f"[dim]CSS selectors included (use with click -S)[/dim]")

            # Warn if results are sparse (site may use custom components without accessibility)
            if len(refs) < 10 and interactive:
                console.print()
                console.print("[yellow]⚠ Few interactive elements found.[/yellow]")
                console.print(
                    "[dim]  This site may use custom components without proper accessibility.[/dim]"
                )
                console.print(
                    "[dim]  Try: snapshot (without -i), or use -s to scope to specific area.[/dim]"
                )
            console.print()

            # Apply --max limit if set
            truncated = False
            if max_elements > 0:
                lines = tree.split("\n")
                # Count lines that contain interactive refs [eN] - v2.0.58 format
                import re

                ref_lines = [l for l in lines if re.search(r"\[e\d+\]", l)]
                if len(ref_lines) > max_elements:
                    # Find the line index of the Nth ref element
                    ref_count = 0
                    cutoff_idx = len(lines)
                    for i, line in enumerate(lines):
                        if re.search(r"\[e\d+\]", line):
                            ref_count += 1
                            if ref_count >= max_elements:
                                cutoff_idx = i + 1
                                break
                    tree = "\n".join(lines[:cutoff_idx])
                    truncated = True
                    console.print(
                        f"[dim](showing first {max_elements} of {len(ref_lines)} elements, use -m 0 for all)[/dim]"
                    )
                    console.print()

            if compact:
                # Escape Rich markup - brackets interfere with ref format [e1]
                console.print(tree, markup=False, highlight=False)
            else:
                # Escape tree content for Rich Panel
                from rich.text import Text

                tree_text = Text(tree)
                console.print(
                    Panel(
                        tree_text,
                        title="Page Snapshot" + (" (truncated)" if truncated else ""),
                        border_style="blue",
                    )
                )
        else:
            print_error("Snapshot Failed", result.error or "Unknown error")


# ═══════════════════════════════════════════════════════════════════════════════
# Commands: Skill (Agent Workflow Guide)
# ═══════════════════════════════════════════════════════════════════════════════

SKILL_GUIDE = """
# browse-now: Browser Automation for AI Agents

- **Refs (click @eN)** → 99%+ reliable for interaction targeting (v2.0.82)
- **find "query"** → Natural language element search
- **Text (click -T)** → 85% reliable, fallback for dialogs
- **Note:** Ref reliability means the click targets the intended element. It does **not** bypass login/paywalls/anti-bot. Always verify navigation with `get url`.

---

## Two Discovery Methods

| Method | Use Case | Example |
|--------|----------|---------|
| `snapshot -i` | "What's on this page?" | Get ALL refs, understand structure |
| `find "query"` | "Where is element X?" | Search by keywords |

Choose based on your need:
- Unknown page → `snapshot -i` first
- Looking for specific element → `find "search button"`
- Need exact text match → `click -T "删除"` (text-based click)

---

## Primary Workflow (Refs)

```
1. snapshot -i          → Get refs [e1], [e2], [e3]...
2. Analyze refs         → Find what you need (button "更多" [e5])
3. click @e5            → Click by REF
4. wait 1-2s            → Let navigation / SPA route update
5. get url / get title  → Confirm you reached the intended page
6. If page changed      → snapshot -i again, get new refs
```

---

## Alternative: Natural Language Find

```bash
# When you know what you're looking for:
browse-now find "search button"     # Find by keywords
browse-now find "flight price book" # Multiple keywords
browse-now find "订票" -v           # Chinese, with scores

# Results return refs that work with click!
# Found: 2 element(s) for "订票"
#   [e23] [button role=button] 订票 (score: 3)
#   [e45] [link role=link] 订票指南 (score: 3)
#
# Then click directly using the ref:
browse-now click @e23
```

---

## Getting Started

```bash
browse-now open <url>            # Open URL (isolated agent tab)
browse-now snapshot -i           # Get interactive elements with refs
# OR
browse-now find "login button"   # Find specific element → click @eN
```

---

## Click Methods (by reliability)

```bash
# 1. By ref (99%+ reliable) - from snapshot or find (v2.0.82)
browse-now click @e2

# 2. By CSS selector (90%) - when you know the selector
browse-now click -S "button.submit"

# 3. By text (85%) - fallback for dialogs
browse-now click -T "确定"
```

---

## Dialogs & Popups

```bash
browse-now snapshot -i
# → button "取消" [e107]
# → button "确定" [e108]

browse-now click @e108           # By ref
browse-now click -T "确定"       # Or by text
```

---

## Input Methods

```bash
# fill: Clear field + type (MOST RELIABLE for search boxes, forms)
browse-now fill @e3 "search query"       # By ref
browse-now fill @e3 "query" --submit     # Fill + submit form

# type --active: Type into whatever is focused (after click/tab)
browse-now click @e3                     # Focus element first
browse-now type --active "extra text"    # Append to focused input

# type: Append text without clearing
browse-now type @e3 " more text"         # Append by ref
```

**When to use which:**
- `fill @eN "text"` → First choice for search boxes, login forms
- `type --active "text"` → After click, when fill doesn't work (rare)
- `type @eN "text"` → Append to existing text without clearing

---

## Autocomplete Dropdowns

Some dynamic dropdowns (like airport selectors) don't appear in the
accessibility tree. Use text-based click for these:

```bash
# 1. Fill the input to trigger autocomplete
browse-now fill @e32 "Shanghai"

# 2. Dropdown appears but NOT in snapshot (dynamic content)
# Use text-based click instead:
browse-now click -T "SHA Shanghai All airports"
```

---

## Reading Content

```bash
# Full page text (articles, search results, etc.)
browse-now get page-text                 # All text
browse-now get page-text --max-chars 3000  # Limit to 3000 chars

# Specific element text
browse-now get text @e5                  # Text of element e5

# Visual analysis (image-heavy sites)
browse-now screenshot /tmp/p.png         # Capture for vision LLM

# Scroll to load more content
browse-now scroll down 900               # ~1 page down
browse-now scroll down                   # 500px down (default)
```

---

## Complex Sites (e.g., ctrip.com)

For sites with many elements, use find + page-text:

```bash
# Step 1: Understand the page content
browse-now get page-text --max-chars 5000

# Step 2: Find specific elements
browse-now find "flight price book" -v
#   [e78] [button role=button] 订票 (score: 4.5)

# Step 3: Click using ref from find
browse-now click @e78
```

---

## Decision Tree

```
1. What's on this page?
   → snapshot -i (get all refs)

2. Content in images? (xiaohongshu, weibo, etc.)
   → screenshot /tmp/p.png (visual analysis)

3. Looking for specific element?
   → find "description" (natural language) → click @eN
   → click -T "exact text" (text-based click)

4. Have ref [eN] from snapshot or find?
   → click @eN ✓ (99%+ reliable)

5. Know CSS selector?
   → click -S "selector" ✓

6. Sparse results? (< 10 elements)
   → screenshot for visual reference
   → click -T "visible text"
```

---

## Image-Heavy / Gated Sites (xiaohongshu, weibo, etc.)

Many social media sites show content as images, not text. Also, some sites may restrict access (login / anti-bot / tokenized links).

Recommended workflow:

```bash
browse-now open "https://www.xiaohongshu.com"
browse-now snapshot -i                   # textbox "搜索小红书" [e3]
browse-now fill @e3 "旅游攻略" --submit   # Fill + submit
browse-now wait 2
browse-now snapshot -i                   # Find post links (refs)

browse-now click @e64                    # Open post
browse-now wait 2
browse-now get url                       # Confirm you are on the content page (e.g., /explore/...)

# If content is image-heavy:
browse-now screenshot /tmp/post.png       # Visual analysis required

# If you land on a 404 / login wall:
# The click likely succeeded but access is restricted.
# Ask user to log in, or try another entry link that includes auth tokens.
```

**Tip**: `snapshot` gives refs for navigation, but actual content (photos, infographics) is visual.
Always use `get url`/`get title` after a click to detect redirects/blocks early.

---

## Command Quick Reference

| Category | Command | Notes |
|----------|---------|-------|
| **Navigate** | `open <url>` | Isolated agent tab |
| | `back`, `forward`, `reload` | Browser history |
| **Discover** | `snapshot -i` | Get ALL refs |
| | `find "query"` | Natural language search |
| | `screenshot /tmp/p.png` | Visual analysis |
| **Click** | `click @e5` | By ref (99%+, v2.0.82) |
| | `click -S "sel"` | By CSS selector (90%) |
| | `click -T "text"` | By visible text (85%) |
| **Input** | `fill @e3 "text"` | Clear + type |
| | `type --active "text"` | Type into focused element |
| | `press Enter` | Keyboard key |
| **Info** | `get page-text` | Full page text |
| | `get text @e5` | Element text |
| **Other** | `hover @e5`, `scroll down 500` | Interaction |

---

## Examples

### Simple Form (Google search)
```bash
browse-now open https://google.com
browse-now snapshot -i           # textbox "Search" [e1]
browse-now fill @e1 "AI news" --submit
```

### E-commerce Search (taobao.com, jd.com)
```bash
browse-now open https://www.taobao.com
browse-now snapshot -i           # textbox "..." [e34]
browse-now fill @e34 "RTX 3090" --submit   # Fill + submit in one step
browse-now snapshot -i           # Product links [e45], [e67]...
browse-now click @e45            # Open product
```

### Social Media Search (xiaohongshu.com)
```bash
browse-now open https://www.xiaohongshu.com
browse-now snapshot -i           # textbox "搜索小红书" [e3]
browse-now fill @e3 "旅游攻略" --submit    # Fill + submit
browse-now snapshot -i           # Posts: [e50], [e64], [e77]...
browse-now click @e64            # Open post
browse-now screenshot /tmp/p.png # Content is in images!
```

### Menu Interaction (weibo, etc.)
```bash
browse-now snapshot -i           # Find "更多" [e45]
browse-now click @e45            # Open menu
browse-now snapshot -i           # "删除" [e67]
browse-now click @e67            # Delete
```

### Flight Search (ctrip.com)
```bash
browse-now open https://flights.ctrip.com/...
browse-now get page-text         # Understand content
browse-now find "出发城市"        # → [e12] textbox
browse-now click @e12
browse-now type --active "北京"
browse-now click -T "北京首都国际机场"  # Autocomplete dropdown
```

---

## Key Insight

**Discovery strategy:**
- New page → `snapshot -i` (understand structure, get refs)
- Specific target → `find "query"` (natural language search)
- Image content → `screenshot` (visual analysis)

**Click strategy (by reliability):**
1. `click @eN` (99%+ reliable, v2.0.82)
2. `click -S "selector"` (when CSS selector known)
3. `click -T "text"` (fallback for dialogs, dropdowns)
"""


def cmd_skill() -> None:
    """Display the browse-now skill guide for AI agents."""
    if is_json_mode():
        output_json({"skill_guide": SKILL_GUIDE.strip()})
    else:
        from rich.text import Text

        # Use Text to prevent Rich from interpreting [eN] refs as markup
        skill_text = Text(SKILL_GUIDE.strip())
        console.print(
            Panel(
                skill_text,
                title="[bold cyan]browse-now Skill Guide[/bold cyan]",
                subtitle="For AI Agents",
                border_style="cyan",
            )
        )


# ═══════════════════════════════════════════════════════════════════════════════
# Commands: Interactions
# ═══════════════════════════════════════════════════════════════════════════════


def cmd_click(
    target: str | None = None,
    css_selector: str | None = None,
    text: str | None = None,
    exact: bool = False,
    within: str | None = None,
    verbose: bool = False,
    retry_count: int = 0,
) -> None:
    """Click an element by ref, CSS selector, or visible text.

    Args:
        target: Element ref (@e1) or CSS selector
        css_selector: Direct CSS selector (bypasses ref lookup, no snapshot needed)
        text: Click element by visible text content (e.g., "删除", "Submit")
        exact: When using --text, require exact text match
        within: When using --text, scope search to CSS selector
        verbose: When using --text, show all candidates found before clicking
        retry_count: Number of times to retry if click fails (v2.0.56)
    """
    ws_client = create_ws_client()

    # Track what we're clicking for display purposes
    click_display = None
    result: Optional[WSResponse] = None  # Will hold the click result
    max_attempts = retry_count + 1  # retry_count=0 means 1 attempt total

    for attempt in range(max_attempts):
        if attempt > 0:
            # Show retry message
            if not is_json_mode():
                console.print(f"  [yellow]Retry {attempt}/{retry_count}...[/yellow]")
            import time

            time.sleep(0.5)  # Brief delay between retries

        # Priority: --text > --selector > target
        if text:
            # Click by visible text
            click_display = f'text:"{text}"'
            params: dict = {"text": text, "exact": exact}
            if within:
                params["within"] = within
                click_display += f' within "{within}"'
            if verbose:
                params["verbose"] = True
            result = ws_client.send_command("click_by_text", params)
        else:
            # Click by ref or selector
            click_target = css_selector if css_selector else target

            if not click_target:
                if is_json_mode():
                    output_json(
                        {
                            "success": False,
                            "error": "Must provide target ref, --selector, or --text",
                        }
                    )
                else:
                    print_error("Click Failed", "Must provide target ref, --selector, or --text")
                return

            click_display = click_target
            result = ws_client.click(click_target)

        # Check if we should retry (v2.0.56)
        if result.success:
            break  # Success - exit retry loop
        elif attempt < max_attempts - 1:
            # Failed but have retries left - continue loop
            continue
        # else: Last attempt failed - will process error below

    # Type narrowing: result is always assigned in the loop above
    if result is None:
        # This should never happen, but type checker needs this check
        raise RuntimeError("result should always be assigned in the loop")
    data = result.data or {}

    if is_json_mode():
        output_json(result.model_dump())
    else:
        # Show candidates if verbose mode was used
        candidates = data.get("candidates", [])
        if verbose and candidates:
            console.print(f"[dim]Found {len(candidates)} candidate(s):[/dim]")
            for i, c in enumerate(candidates[:5]):  # Show top 5
                tag = c.get("tag", "?")
                ctext = c.get("text", "")
                spec = c.get("specificity", 0)
                marker = " ← clicked" if i == 0 else ""
                console.print(f"  [{tag}] {ctext} (score: {spec}){marker}")
            if len(candidates) > 5:
                console.print(f"  [dim]... and {len(candidates) - 5} more[/dim]")

        if result.success:
            element = data.get("element", click_display)
            navigated_to = data.get("navigatedTo")

            # v2.0.83: Show stale refs warning
            stale_warning = data.get("staleWarning")
            if stale_warning:
                console.print(f"[yellow]⚠ Stale refs:[/yellow] {stale_warning}")

            if navigated_to:
                print_success("Clicked", f"{element} → {navigated_to}")
            else:
                print_success("Clicked", element)

            # Check if form was submitted via fallback
            if data.get("submittedForm"):
                console.print("  [dim]Click fallback triggered[/dim]")
            # Check if a new tab was opened
            if data.get("newTabOpened"):
                new_tab = data["newTabOpened"]
                console.print(
                    f"  [yellow]⚠ New tab opened:[/yellow] {new_tab.get('url', 'unknown')}"
                )
                tab_id = new_tab.get("tabId", "unknown")
                default_hint = f"Use agent-tab set {tab_id}"
                console.print(f"  [dim]Hint: {new_tab.get('hint', default_hint)}[/dim]")
        else:
            error_msg = result.error or "Unknown error"
            print_error("Click Failed", error_msg)


def cmd_fill(
    target: str | None,
    text: str,
    no_clear: bool = False,
    submit: bool = False,
    css_selector: str | None = None,
) -> None:
    """Fill an input element (clear and type).

    Args:
        target: Element ref (@e1) - required unless using --selector
        text: Text to fill
        no_clear: Don't clear field before typing
        submit: Submit form after filling
        css_selector: Direct CSS selector (bypasses ref lookup, no snapshot needed)
    """
    # Determine fill target - prefer explicit selector over ref
    fill_target = css_selector if css_selector else target

    if not fill_target:
        if is_json_mode():
            output_json(
                {
                    "success": False,
                    "error": "Must provide either target ref or --selector",
                }
            )
        else:
            print_error("Fill Failed", "Must provide either target ref or --selector")
        return

    ws_client = create_ws_client()
    clear = not no_clear
    # Use WebSocket for both refs and CSS selectors
    result = ws_client.fill(fill_target, text, clear=clear, submit=submit)

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success:
            data = result.data or {}
            element = data.get("element", fill_target)
            print_success("Filled", element)
        else:
            print_error("Fill Failed", result.error or "Unknown error")


def cmd_type(
    target: str | None, text: str, css_selector: str | None = None, active: bool = False
) -> None:
    """Type text without clearing (append to existing).

    Args:
        target: Element ref (@e1) or CSS selector
        text: Text to type
        css_selector: Direct CSS selector
        active: Type into currently focused element (vision-first workflow)
    """
    if active:
        # Vision-first workflow: type into whatever element currently has focus
        ws_client = create_ws_client()
        result = ws_client.send_command("type_active", {"text": text})

        if is_json_mode():
            output_json(result.model_dump())
        else:
            if result.success:
                data = result.data or {}
                focused = data.get("focusedElement", {})
                tag = focused.get("tag", "element")
                print_success(
                    "Typed", f'"{text[:30]}{"..." if len(text) > 30 else ""}" into <{tag}>'
                )
            else:
                # Provide helpful error message with recovery hint
                error_msg = result.error or "Unknown error"
                data = result.data or {}
                focused = data.get("focusedElement", {})

                if (
                    "not editable" in error_msg.lower()
                    or "no element is focused" in error_msg.lower()
                ):
                    print_error("Type Failed", error_msg)
                    console.print()
                    console.print(
                        "  [dim cyan]Recovery: Use snapshot to find the input ref[/dim cyan]"
                    )
                    console.print("    [dim]browse-now snapshot -i       # Find input ref[/dim]")
                    console.print('    [dim]browse-now fill @eN "text"  # Fill by ref[/dim]')
                else:
                    print_error("Type Failed", error_msg)
    else:
        cmd_fill(target, text, no_clear=True, submit=False, css_selector=css_selector)


def cmd_press(key: str, target: str | None = None) -> None:
    """Press a keyboard key.

    Args:
        key: Key to press (e.g., 'Enter', 'Tab', 'Control+a')
        target: Optional element ref or CSS selector to focus before pressing
    """
    ws_client = create_ws_client()
    params: dict = {"key": key}
    if target:
        params["target"] = target

    result = ws_client.send_command("press", params)

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success:
            data = result.data or {}
            target_element = data.get("targetElement")
            if target_element:
                print_success("Pressed", f"{key} on {target_element}")
            else:
                print_success("Pressed", key)
            # Check if form was submitted via fallback
            if data.get("submittedForm"):
                console.print("  [dim]Form submitted via fallback[/dim]")
            # Check if a new tab was opened
            if data.get("newTabOpened"):
                new_tab = data["newTabOpened"]
                console.print(
                    f"  [yellow]⚠ New tab opened:[/yellow] {new_tab.get('url', 'unknown')}"
                )
                tab_id = new_tab.get("tabId", "unknown")
                default_hint = f"Use agent-tab set {tab_id}"
                console.print(f"  [dim]Hint: {new_tab.get('hint', default_hint)}[/dim]")

            # Action hint for Enter/Return which may change page state
            if key.lower() in ("enter", "return"):
                print_action_hint(
                    "Screenshot to see result: browse-now screenshot /tmp/after_press.png"
                )
        else:
            print_error("Press Failed", result.error or "Unknown error")


def cmd_hover(target: str | None = None, at_coords: str | None = None) -> None:
    """Hover over an element.

    v2.0.54: Uses CDP mouse simulation for reliable hover on Vue/React components.
    Supports both element reference and coordinate-based hover.
    """
    ws_client = create_ws_client()

    # Priority: --at > target
    if at_coords:
        # Coordinate-based hover (vision-first workflow)
        try:
            parts = at_coords.replace(" ", "").split(",")
            if len(parts) != 2:
                print_error("Invalid Coordinates", "Expected X,Y format (e.g., 450,320 or 0.5,0.4)")
                return
            x_val = float(parts[0])
            y_val = float(parts[1])
        except ValueError:
            print_error("Invalid Coordinates", "Expected numbers (e.g., 450,320 or 0.5,0.4)")
            return

        result = ws_client.send_command(
            "hover_at",
            {
                "x": x_val,
                "y": y_val,
                "normalized": x_val <= 1.0 and y_val <= 1.0 and x_val >= 0 and y_val >= 0,
            },
        )

        if is_json_mode():
            output_json(result.model_dump())
        else:
            if result.success:
                # Format display coordinates
                if x_val <= 1.0 and y_val <= 1.0 and x_val >= 0 and y_val >= 0:
                    hover_display = f"({x_val * 100:.0f}%, {y_val * 100:.0f}%)"
                else:
                    hover_display = f"({x_val:.0f}, {y_val:.0f})"
                print_success("Hovering at", hover_display)
                if result.data:
                    hit = result.data.get("hit", {})
                    if hit:
                        tag = hit.get("tag", "?")
                        text = hit.get("text", "")[:30]
                        console.print(f'  [dim]Hit: <{tag}> "{text}"[/dim]')
                print_action_hint(
                    "Screenshot to see result: browse-now screenshot /tmp/after_hover.png"
                )
            else:
                print_error("Hover Failed", result.error or "Unknown error")
    elif target:
        # Element-based hover
        result = ws_client.send_command("hover", {"target": target})

        if is_json_mode():
            output_json(result.model_dump())
        else:
            if result.success:
                print_success("Hovering over", target)
                print_action_hint(
                    "Screenshot to see result: browse-now screenshot /tmp/after_hover.png"
                )
            else:
                print_error("Hover Failed", result.error or "Unknown error")
    else:
        print_error("Hover Failed", "No target specified. Use element ref (@e5) or --at X,Y")


def cmd_scroll(direction_or_x: str, amount_or_y: int | None = None) -> None:
    """Scroll the page.

    Supports two modes:
    - Direction-based: scroll <direction> [amount]  (e.g., scroll down 500)
    - Pixel delta: scroll <x> <y>  (e.g., scroll 0 900)
    """
    ws_client = create_ws_client()

    # Check if first arg is a number (pixel delta mode) or direction word
    try:
        x = int(direction_or_x)
        # First arg is a number - pixel delta mode
        y = amount_or_y if amount_or_y is not None else 0
        result = ws_client.send_command("scroll", {"x": x, "y": y, "mode": "pixel"})
        scroll_desc = f"({x}, {y})"
    except ValueError:
        # First arg is direction word
        direction = direction_or_x
        amount = amount_or_y if amount_or_y is not None else 500
        result = ws_client.send_command("scroll", {"direction": direction, "amount": amount})
        scroll_desc = f"{direction} {amount}px"

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success:
            print_success("Scrolled", scroll_desc)
        else:
            print_error("Scroll Failed", result.error or "Unknown error")


# ═══════════════════════════════════════════════════════════════════════════════
# Commands: Get Information
# ═══════════════════════════════════════════════════════════════════════════════


def cmd_get_text(target: str) -> None:
    """Get element text content (or value for inputs)."""
    ws_client = create_ws_client()
    result = ws_client.send_command("get_text", {"target": target})

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success and result.data:
            data = result.data
            # For inputs, show value; for others, show text
            value = data.get("value")
            text = data.get("text", "")
            if value is not None:
                # It's an input element - show value
                console.print(f"[dim]value:[/dim] {value}")
                if text:
                    console.print(f"[dim]text:[/dim] {text}")
            else:
                console.print(text)
        else:
            print_error("Get Text Failed", result.error or "Unknown error")


def cmd_find(
    query: str, limit: int = 10, within: str | None = None, verbose: bool = False
) -> None:
    """Natural language search over accessibility tree.

    Searches for elements matching a natural language query by checking
    text content, ARIA labels, roles, placeholders, and form labels.
    Results are ranked by keyword match score.

    Args:
        query: Natural language query (space-separated keywords)
        limit: Maximum results to return (default: 10)
        within: Scope search to CSS selector or ref (e.g., ".form", "@e5")
        verbose: Include match score and matched keywords
    """
    ws_client = create_ws_client()
    params: dict = {"query": query, "limit": limit, "verbose": verbose}
    if within:
        params["within"] = within
    result = ws_client.send_command("find", params)

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success and result.data:
            matches = result.data.get("matches", [])
            if not matches:
                scope_hint = f' within "{within}"' if within else ""
                print_error(
                    "Find",
                    f'No elements found matching query: "{query}"{scope_hint}',
                )
                return

            # v2.0.83: Show stale refs warning
            stale_warning = result.data.get("staleWarning")
            if stale_warning:
                console.print(f"[yellow]⚠ Stale refs:[/yellow] {stale_warning}")

            scope_hint = f' within "{within}"' if within else ""
            print_success("Found", f'{len(matches)} element(s) for "{query}"{scope_hint}')
            for m in matches:
                ref = m.get("ref", "?")
                tag = m.get("tag", "?")
                mtext = m.get("text", "")
                role = m.get("role", "")
                selector = m.get("selector", "")
                score = m.get("score")
                matched_keywords = m.get("matchedKeywords", [])

                role_info = f" role={role}" if role else ""
                score_info = f" (score: {score})" if score else ""
                # Use markup=False to prevent Rich from interpreting [eN] as color tags
                console.print(f"  {ref} [{tag}{role_info}] {mtext}{score_info}", markup=False)
                if verbose and matched_keywords:
                    console.print(f"    [dim]matched: {', '.join(matched_keywords)}[/dim]")
                if selector:
                    console.print(f"    [dim]selector: {selector}[/dim]")
        else:
            # v2.0.83: Show stale refs warning even on failure
            if result.data:
                stale_warning = result.data.get("staleWarning")
                if stale_warning:
                    console.print(f"[yellow]⚠ Stale refs:[/yellow] {stale_warning}")
            print_error("Find Failed", result.error or "Unknown error")


def cmd_get_title() -> None:
    """Get page title."""
    ws_client = create_ws_client()
    result = ws_client.send_command("get_title", {})

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success and result.data:
            console.print(result.data.get("title", ""))
        else:
            print_error("Get Title Failed", result.error or "Unknown error")


def cmd_get_url() -> None:
    """Get current URL."""
    ws_client = create_ws_client()
    result = ws_client.send_command("get_url", {})

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success and result.data:
            console.print(result.data.get("url", ""))
        else:
            print_error("Get URL Failed", result.error or "Unknown error")


def cmd_get_page_text(max_chars: int = 50000) -> None:
    """Get readable text from entire page (HTML stripped).

    Extracts visible text content from the page body, filtering out:
    - Script/style/noscript content
    - Hidden elements
    - Empty text nodes

    Useful for extracting content from authenticated pages that
    WebFetch cannot access.

    Args:
        max_chars: Maximum characters to return (default: 50000)
    """
    ws_client = create_ws_client()
    result = ws_client.send_command("get_page_text", {"maxChars": max_chars})

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success and result.data:
            text = result.data.get("text", "")
            char_count = result.data.get("charCount", len(text))
            truncated = result.data.get("truncated", False)

            # Print the text content
            console.print(text)

            # Show info about truncation
            if truncated:
                console.print(f"\n[dim]({char_count} chars, truncated at {max_chars})[/dim]")
            else:
                console.print(f"\n[dim]({char_count} chars)[/dim]")
        else:
            print_error("Get Page Text Failed", result.error or "Unknown error")


# ═══════════════════════════════════════════════════════════════════════════════
# Commands: Screenshot
# ═══════════════════════════════════════════════════════════════════════════════


def cmd_screenshot(path: str | None = None, full: bool = False, element: str | None = None) -> None:
    """Take a screenshot with viewport metadata for coordinate mapping."""
    import base64

    # Use longer timeout for screenshot (60s) due to large response size
    ws_client = create_ws_client(timeout=60.0)
    result = ws_client.send_command("screenshot", {"full": full, "element": element})

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success:
            data = result.data or {}
            base64_data = data.get("data", "")
            width = data.get("width")
            height = data.get("height")
            dpr = data.get("devicePixelRatio")

            if path and base64_data:
                # Decode base64 and save to file
                try:
                    image_data = base64.b64decode(base64_data)
                    # Expand ~ and resolve path
                    expanded_path = os.path.expanduser(path)
                    # Create parent directories if needed
                    parent_dir = os.path.dirname(expanded_path)
                    if parent_dir and not os.path.exists(parent_dir):
                        os.makedirs(parent_dir)
                    with open(expanded_path, "wb") as f:
                        f.write(image_data)
                    # Show success with viewport info for coordinate mapping
                    viewport_info = ""
                    if width and height:
                        viewport_info = f" (viewport: {width}x{height}"
                        if dpr and dpr != 1:
                            viewport_info += f", dpr: {dpr}"
                        viewport_info += ")"
                    print_success("Screenshot saved", f"{expanded_path}{viewport_info}")
                except Exception as e:
                    print_error("Screenshot Save Failed", str(e))
            elif base64_data:
                # No path provided - show preview of base64 with viewport info
                viewport_info = ""
                if width and height:
                    viewport_info = f" [viewport: {width}x{height}]"
                console.print(
                    f"[dim]Base64 data ({len(base64_data)} chars){viewport_info}: {base64_data[:50]}...[/dim]"
                )
            else:
                print_error("Screenshot Failed", "No image data returned")
        else:
            print_error("Screenshot Failed", result.error or "Unknown error")


# ═══════════════════════════════════════════════════════════════════════════════
# Commands: Wait
# ═══════════════════════════════════════════════════════════════════════════════


def cmd_wait(
    target: str, timeout: int = 30000, wait_text: str | None = None, ms_mode: bool = False
) -> None:
    """Wait for element, text, or time."""
    import re

    # Check if target is a time duration (number, Ns, Nms)
    time_match = re.match(r"^(\d+(?:\.\d+)?)(s|ms)?$", target)
    if time_match:
        value = float(time_match.group(1))
        unit = time_match.group(2)

        # Determine milliseconds
        if unit == "ms":
            wait_ms = int(value)
        elif unit == "s":
            wait_ms = int(value * 1000)
        elif ms_mode:
            # --ms flag: treat bare number as milliseconds
            wait_ms = int(value)
        else:
            # Default: treat bare number as seconds
            wait_ms = int(value * 1000)

        time.sleep(wait_ms / 1000)
        if is_json_mode():
            output_json({"success": True, "waited_ms": wait_ms})
        else:
            if wait_ms >= 1000:
                print_success(f"Waited {wait_ms / 1000:.1f}s")
            else:
                print_success(f"Waited {wait_ms}ms")
        return

    ws_client = create_ws_client()
    payload: dict[str, Any] = {"target": target, "timeout": timeout}
    if wait_text:
        payload["text"] = wait_text

    result = ws_client.send_command("wait", payload)

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success:
            print_success("Element found", target)
        else:
            print_error("Wait Timeout", result.error or "Element not found")


# ═══════════════════════════════════════════════════════════════════════════════
# Commands: Tabs
# ═══════════════════════════════════════════════════════════════════════════════


def cmd_tabs() -> None:
    """List open browser tabs."""
    ws_client = create_ws_client()
    result = ws_client.tabs_list()

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success and result.data:
            tabs = result.data.get("tabs", [])
            agent_tab_id = result.data.get("agentTabId")
            if not tabs:
                console.print("[dim]No tabs found[/dim]")
                return

            table = Table(box=box.SIMPLE, header_style="bold", show_edge=False)
            table.add_column("ID", style="cyan")
            table.add_column("", style="green")
            table.add_column("Title")
            table.add_column("URL", style="dim")

            for tab in tabs:
                tab_id = tab.get("id")
                # Show markers: * = active browser tab, → = agent tab
                marker = ""
                if tab_id == agent_tab_id:
                    marker = "→"
                elif tab.get("active"):
                    marker = "*"
                title = tab.get("title", "")[:40]
                url = tab.get("url", "")[:60]
                table.add_row(str(tab_id or ""), marker, title, url)

            console.print(table)
            if agent_tab_id:
                console.print(f"[dim]→ = agent tab, * = active browser tab[/dim]")
        else:
            print_error("Tabs Failed", result.error or "Unknown error")


def cmd_switch(tab_id: int) -> None:
    """Switch to a specific tab by ID."""
    ws_client = create_ws_client()
    result = ws_client.tabs_switch(tab_id)

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success:
            print_success("Switched to tab", str(tab_id))
        else:
            print_error("Switch Failed", result.error or "Unknown error")


# ═══════════════════════════════════════════════════════════════════════════════
# Commands: Browser Management
# ═══════════════════════════════════════════════════════════════════════════════


def cmd_browsers() -> None:
    """List connected browser extensions."""
    try:
        if not is_json_mode():
            with Progress(
                SpinnerColumn(),
                TextColumn("[cyan]Checking browsers...[/cyan]"),
                console=console,
                transient=True,
            ) as p:
                p.add_task("", total=None)
                ws_client = create_ws_client()
                result = ws_client.list_browsers()
        else:
            ws_client = create_ws_client()
            result = ws_client.list_browsers()

        if is_json_mode():
            output_json(result.model_dump())
        else:
            if result.count == 0:
                console.print("[yellow]No browsers connected[/yellow]")
                console.print(
                    "[dim]Make sure the Nowledge Memory Exchange extension is installed and active.[/dim]"
                )
            else:
                table = Table(box=box.SIMPLE, header_style="bold", show_edge=False)
                table.add_column("ID", style="cyan")
                table.add_column("Name", style="green")
                table.add_column("Version")
                table.add_column("Connected At", style="dim")

                for browser in result.browsers:
                    connected_at = browser.connected_at[:19] if browser.connected_at else ""
                    table.add_row(
                        browser.browser_id,
                        browser.browser_name,
                        browser.extension_version,
                        connected_at,
                    )

                console.print(table)
    except Exception as e:
        error_msg = str(e)
        # Distinguish between connection errors and WebSocket protocol errors
        if "connect" in error_msg.lower() or "refused" in error_msg.lower():
            # Server not running
            if is_json_mode():
                output_json(
                    {
                        "error": "server_not_running",
                        "message": "Cannot connect to browser bridge server",
                    }
                )
            else:
                print_error(
                    "Server Not Running",
                    f"Cannot connect to {get_api_url()}",
                    "Make sure Nowledge Mem is running",
                )
        elif "no close frame" in error_msg.lower() or "websocket" in error_msg.lower():
            # WebSocket issue - server running but communication failed
            if is_json_mode():
                output_json({"error": "websocket_error", "message": error_msg})
            else:
                print_error(
                    "WebSocket Error",
                    error_msg,
                    "Try running 'browse-now status' to check connection",
                )
        else:
            if is_json_mode():
                output_json({"error": "connection_error", "message": error_msg})
            else:
                print_error("Connection Error", error_msg)


def cmd_status() -> None:
    """Get browser bridge status."""
    ws_client = create_ws_client()
    result = ws_client.status()

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success and result.data:
            data = result.data
            table = Table(box=box.SIMPLE, header_style="bold", show_edge=False, show_header=False)
            table.add_column("Property", style="cyan")
            table.add_column("Value")
            table.add_row(
                "Connected",
                "[green]Yes[/green]" if data.get("connected") else "[red]No[/red]",
            )
            table.add_row("Version", data.get("version", "N/A"))
            table.add_row("Extension ID", data.get("extensionId", "N/A"))
            if data.get("activeTab"):
                table.add_row("Active Tab", data["activeTab"].get("title", "N/A"))
                table.add_row("URL", data["activeTab"].get("url", "N/A"))
            console.print(table)
        else:
            print_error("Status Failed", result.error or "Unknown error")


# ═══════════════════════════════════════════════════════════════════════════════
# Commands: JavaScript Evaluation
# ═══════════════════════════════════════════════════════════════════════════════


def cmd_evaluate(js_code: str) -> None:
    """Execute JavaScript in the page context."""
    ws_client = create_ws_client()
    result = ws_client.send_command("evaluate", {"code": js_code})

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success and result.data:
            data = result.data
            # Check inner success (from the evaluate function in browser)
            if data.get("success", True):
                eval_result = data.get("result")
                if eval_result is not None:
                    console.print(Panel(str(eval_result), title="JS Result"))
                else:
                    console.print(Panel("[dim]undefined[/dim]", title="JS Result"))
            else:
                print_error("Evaluate Failed", data.get("error", "Unknown error"))
        else:
            print_error("Evaluate Failed", result.error or "Unknown error")


# ═══════════════════════════════════════════════════════════════════════════════
# Commands: Agent Tab Management
# ═══════════════════════════════════════════════════════════════════════════════


def cmd_agent_tab_init(url: str | None = None, color: str = "cyan") -> None:
    """Initialize isolated agent tab with tab group."""
    ws_client = create_ws_client()
    params: dict[str, str] = {}
    if url:
        params["url"] = url
    if color:
        params["groupColor"] = color

    result = ws_client.send_command("agent-tab-init", params)

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success and result.data:
            data = result.data
            if data.get("active"):
                console.print(f"[green]ok[/green] Agent tab initialized")
                console.print(f"  [dim]Tab ID: {data.get('tabId')}[/dim]")
                console.print(f"  [dim]Group ID: {data.get('groupId')}[/dim]")
                if data.get("url"):
                    console.print(f"  [dim]URL: {data.get('url')}[/dim]")
            else:
                print_error("Agent Tab Init Failed", "Could not create agent tab")
        else:
            print_error("Agent Tab Init Failed", result.error or "Unknown error")


def cmd_agent_tab_status() -> None:
    """Get agent tab status."""
    ws_client = create_ws_client()
    result = ws_client.send_command("agent-tab-status", {})

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success and result.data:
            data = result.data
            if data.get("active"):
                console.print("[green]Agent tab is active[/green]")
                console.print(f"  [dim]Tab ID: {data.get('tabId')}[/dim]")
                if data.get("groupId"):
                    console.print(f"  [dim]Group ID: {data.get('groupId')}[/dim]")
                if data.get("url"):
                    console.print(f"  [dim]URL: {data.get('url')}[/dim]")
                if data.get("title"):
                    console.print(f"  [dim]Title: {data.get('title')}[/dim]")
            else:
                console.print("[dim]No agent tab active[/dim]")
                console.print(
                    "[dim]Use 'browse-now open <url>' to create one (isolated by default)[/dim]"
                )
        else:
            print_error("Agent Tab Status Failed", result.error or "Unknown error")


def cmd_agent_tab_close() -> None:
    """Close agent tab."""
    ws_client = create_ws_client()
    result = ws_client.send_command("agent-tab-close", {})

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success:
            print_success("Agent tab closed")
        else:
            print_error("Agent Tab Close Failed", result.error or "Unknown error")


def cmd_agent_tab_set(tab_id: int) -> None:
    """Set agent tab to an existing tab by ID."""
    ws_client = create_ws_client()
    result = ws_client.send_command("agent-tab-set", {"tabId": tab_id})

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success:
            data = result.data or {}
            url = data.get("url", "")
            title = data.get("title", "")
            new_tab_id = data.get("tabId", tab_id)
            print_success(f"Agent tab set to tab {new_tab_id}")
            if title:
                console.print(f"  Title: {title}")
            if url:
                console.print(f"  URL: {url}")
        else:
            error_msg = result.error or (result.data or {}).get("error", "Unknown error")
            print_error("Agent Tab Set Failed", error_msg)


# ═══════════════════════════════════════════════════════════════════════════════
# Argument Parser
# ═══════════════════════════════════════════════════════════════════════════════


def create_parser() -> argparse.ArgumentParser:
    epilog = """
THE WORKFLOW

  1. browse-now open <url>           # Open page (isolated agent tab)
  2. browse-now snapshot -i          # Get interactive elements with refs
  3. browse-now click @e5            # Click by ref
  4. browse-now fill @e3 "text"      # Fill input by ref
  5. (If page changed) → snapshot -i again (refs reset each snapshot)

EXAMPLES
  # Search a website (fill + submit)
  browse-now open https://www.taobao.com
  browse-now snapshot -i             # textbox "..." [e34]
  browse-now fill @e34 "RTX 3090" --submit   # Fill + submit in one step

  # Find + click (natural language search)
  browse-now find "search button"    # Returns refs: [e5], [e10]...
  browse-now click @e5               # Click by ref from find

  # Click by text (fallback for dialogs/dropdowns)
  browse-now click -T "Submit"       # By visible text
  browse-now click -T "确认"         # Works with Chinese

  # Read content
  browse-now get page-text           # Full page text
  browse-now get text @e5            # Text of specific element
  browse-now screenshot /tmp/p.png   # Visual (for image-heavy sites)

  # Scroll and navigate
  browse-now scroll down 900         # Scroll down ~1 page (900px)
  browse-now back                    # Go back in history

RELIABILITY
  Refs (click @eN)     99%+  ← Primary method (v2.0.82)
  Text (click -T)      85%   ← Fallback for dialogs/menus

GLOBAL FLAGS (must come BEFORE command)
  -j, --json                     JSON output for programmatic use
  -b, --browser BROWSER_ID       Target specific browser

ENVIRONMENT
  NOWLEDGE_API_URL               Override API URL (default: http://127.0.0.1:14242)
  NOWLEDGE_API_KEY               Optional API key (Bearer auth)

  Browser bridge endpoints are local-only. Access Anywhere is not supported for browse-now.

For full guide: browse-now skill
"""

    parser = argparse.ArgumentParser(
        prog="browse-now",
        description="Browser automation CLI for AI agents",
        epilog=epilog,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    try:
        package_version = version("browse-now")
    except Exception:
        # Fallback if package is not installed (e.g., during development)
        package_version = "unknown"
    parser.add_argument(
        "-v", "--version", action="version", version=f"browse-now {package_version}"
    )
    parser.add_argument("--api-url", help="API server URL")
    parser.add_argument("-j", "--json", action="store_true", help="JSON output")
    parser.add_argument(
        "-b",
        "--browser",
        dest="browser_id",
        help="Target browser ID (for multi-browser)",
    )
    parser.add_argument(
        "--api-key",
        help="API key for Bearer auth",
    )

    subparsers = parser.add_subparsers(dest="command", metavar="COMMAND")

    # Navigation commands
    p = subparsers.add_parser("open", help="Navigate to URL (isolated by default)")
    p.add_argument("url", help="URL to navigate to")
    p.add_argument(
        "-w",
        "--wait",
        choices=["load", "domcontentloaded", "networkidle"],
        default="load",
    )
    p.add_argument(
        "--no-isolated",
        action="store_true",
        dest="no_isolated",
        help="Use current active tab instead of isolated agent tab",
    )
    p.add_argument(
        "-t",
        "--timeout",
        type=int,
        default=10,
        dest="open_timeout",
        help="Page load timeout in seconds (default: 10, use 30-60 for heavy sites)",
    )

    subparsers.add_parser("back", help="Go back in browser history")
    subparsers.add_parser("forward", help="Go forward in browser history")
    subparsers.add_parser("reload", help="Reload the current page")

    # Snapshot - core command for page analysis
    p = subparsers.add_parser(
        "snapshot",
        help="Capture accessibility tree with refs (@e1, @e2...) for click/fill",
        description="""
Capture the page's accessibility tree with element references.

WORKFLOW:
  1. snapshot -i          → Get interactive elements with refs
  2. click @e5            → Click element by ref
  3. fill @e3 "text"      → Fill input by ref

TIPS:
  - Works best on accessible sites (Google, Wikipedia, news sites)
  - For SPAs/custom components, use -s to scope to specific area
  - Use -w 1000 if content loads late
  - If few elements found, use 'screenshot' for vision-capable LLMs
        """,
    )
    p.add_argument(
        "-i",
        "--interactive",
        action="store_true",
        help="Interactive elements only: buttons, links, inputs (recommended)",
    )
    p.add_argument("-c", "--compact", action="store_true", help="Compact output (no panel border)")
    p.add_argument("-d", "--depth", type=int, default=10, help="Max tree depth (default: 10)")
    p.add_argument("--coords", action="store_true", help="Include element coordinates")
    p.add_argument(
        "-s",
        "--selector",
        help="Scope to CSS selector (e.g., -s 'main', -s '#content')",
    )
    p.add_argument(
        "--with-css",
        action="store_true",
        dest="with_css",
        help="Include CSS selectors (for click -S)",
    )
    p.add_argument(
        "-w",
        "--wait",
        type=int,
        default=0,
        dest="wait_ms",
        help="Extra wait ms after network idle (for late-loading JS)",
    )
    p.add_argument(
        "-m",
        "--max",
        type=int,
        default=0,
        dest="max_elements",
        help="Show first N elements only (0 = all)",
    )

    # Interactions
    p = subparsers.add_parser(
        "click",
        help="Click element by ref (recommended), text, or selector",
        description="""Click an element. Methods in order of reliability:

1. By ref (99%+):   click @e5            ← Primary method
   Requires: snapshot -i first to get refs

2. By text (85%):   click -T "Submit"    ← Fallback for dialogs
   Works with Chinese: click -T "确认"

3. By selector:     click -S "btn.submit" ← When you know CSS

EXAMPLES:
  browse-now snapshot -i           # Get refs
  browse-now click @e5             # Click by ref
  browse-now click -T "Delete"     # Click by visible text""",
    )
    p.add_argument("target", nargs="?", help="Element ref (@e1) or CSS selector")
    p.add_argument(
        "-S",
        "--selector",
        dest="css_selector",
        help="CSS selector for direct click (bypasses ref lookup, no snapshot needed)",
    )
    p.add_argument(
        "-T",
        "--text",
        dest="click_text",
        help="Click by visible text content (e.g., --text '删除')",
    )
    p.add_argument(
        "--exact",
        action="store_true",
        help="When using --text, require exact text match",
    )
    p.add_argument(
        "-W",
        "--within",
        dest="click_within",
        help="When using --text, scope search to CSS selector (e.g., '.dropdown-menu')",
    )
    p.add_argument(
        "-V",
        "--verbose",
        dest="click_verbose",
        action="store_true",
        help="When using --text, show all candidates found before clicking",
    )
    p.add_argument(
        "--retry",
        dest="retry_count",
        type=int,
        default=0,
        metavar="N",
        help="Retry click up to N times if it fails. Use for flaky elements.",
    )

    p = subparsers.add_parser(
        "find",
        help="Natural language search",
        description="""Search for elements using natural language keywords.

Matches against text content, ARIA labels, roles, placeholders, and form labels.
Results are ranked by keyword match score.

This is the Claude Chrome Extension parity command - use natural language
queries like "flight search form origin destination date" to find elements.

EXAMPLES:
  browse-now find "search button"             # Find search-related buttons
  browse-now find "origin city input field"   # Find form fields
  browse-now find "submit order" --within ".checkout"  # Scoped search
  browse-now find "delete confirm" -v         # Verbose with scores""",
    )
    p.add_argument("query", help="Natural language query (space-separated keywords)")
    p.add_argument("--limit", type=int, default=10, help="Max results (default: 10)")
    p.add_argument(
        "-W",
        "--within",
        dest="find_within",
        help="Scope search to CSS selector or ref (e.g., '.form', '@e5')",
    )
    p.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Include match scores and matched keywords",
    )

    p = subparsers.add_parser(
        "fill",
        help="Fill an input element (clear and type)",
        description=(
            "Clear an input field and type new text.\n\n"
            "EXAMPLES:\n"
            "  browse-now fill @e3 \"search query\"         # Fill by ref\n"
            "  browse-now fill @e3 \"query\" --submit       # Fill and submit form\n"
            "  browse-now fill -S \"input[name='q']\" \"hi\" # Fill by CSS selector"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p.add_argument("target", nargs="?", help="Element ref (@e1) - optional if using -S")
    p.add_argument("text", help="Text to fill")
    p.add_argument("--no-clear", action="store_true", help="Don't clear field before typing")
    p.add_argument("-s", "--submit", action="store_true", help="Submit form after filling")
    p.add_argument(
        "-S",
        "--selector",
        dest="css_selector",
        help="CSS selector for direct fill (bypasses ref lookup, e.g., input[name='ss'])",
    )

    p = subparsers.add_parser(
        "type",
        help="Type text without clearing (supports --active for focused element)",
        description="""Type text into an element without clearing existing content.

EXAMPLES:
  browse-now type @e5 "hello"      # Type into element by ref
  browse-now type --active "text"  # Type into currently focused element""",
    )
    p.add_argument("target", nargs="?", help="Element ref - optional if using -S or --active")
    p.add_argument("text", help="Text to type")
    p.add_argument(
        "-S",
        "--selector",
        dest="css_selector",
        help="CSS selector for direct type (bypasses ref lookup)",
    )
    p.add_argument(
        "-A",
        "--active",
        action="store_true",
        help="Type into currently focused element",
    )

    p = subparsers.add_parser("press", help="Press a keyboard key")
    p.add_argument("key", help="Key to press (Enter, Escape, Tab, Control+a, etc.)")
    p.add_argument(
        "--target",
        "-t",
        dest="press_target",
        help="Element ref or CSS selector to focus before pressing (e.g., --target @e4)",
    )

    p = subparsers.add_parser(
        "hover",
        help="Hover over an element",
        description="""Hover over an element to reveal hidden UI (menus, buttons, dropdowns).

v2.0.54: Uses CDP mouse simulation for reliable hover on Vue/React components.

EXAMPLES:
  browse-now hover @e5              # Hover by element reference
  browse-now hover --at 0.5,0.3     # Hover by coordinates (vision-first workflow)
  browse-now hover --at 450,320     # Hover by pixel coordinates

WEIBO DELETE WORKFLOW:
  browse-now hover @e15             # Hover over post to reveal "..." menu
  browse-now screenshot /tmp/menu.png
  browse-now click -T "..."         # Click menu button
  browse-now screenshot /tmp/dropdown.png
  browse-now click -T "删除"         # Click delete""",
    )
    p.add_argument("target", nargs="?", help="Element ref or CSS selector")
    p.add_argument(
        "--at",
        dest="at_coords",
        metavar="X,Y",
        help="Hover at coordinates. Pixels (450,320) or normalized (0.5,0.4 = 50%%,40%%)",
    )

    p = subparsers.add_parser(
        "scroll",
        help="Scroll the page (direction-based or pixel delta)",
        description=(
            "Scroll the page.\n\n"
            "EXAMPLES:\n"
            "  browse-now scroll down           # Scroll down 500px (default)\n"
            "  browse-now scroll down 1000      # Scroll down 1000px\n"
            "  browse-now scroll up 300         # Scroll up 300px\n"
            "  browse-now scroll 0 900          # Pixel delta mode (x=0, y=900)\n\n"
            "TIP: A typical viewport is ~900px tall. Use 'scroll down 900'\n"
            "to scroll roughly one full page."
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p.add_argument(
        "direction_or_x",
        metavar="DIR_OR_X",
        help="Direction (up/down/left/right) OR x-pixels for delta mode",
    )
    p.add_argument(
        "amount_or_y",
        type=int,
        nargs="?",
        default=None,
        metavar="AMT_OR_Y",
        help="Pixels to scroll (default: 500) OR y-pixels for delta mode",
    )

    # Get information
    p = subparsers.add_parser(
        "get",
        help="Get page info (text, title, url, page-text)",
        description=(
            "Get information from the current page.\n\n"
            "SUBCOMMANDS:\n"
            "  text @e5           Read text content of a specific element\n"
            "  text \"#article\"    Read text by CSS selector\n"
            "  page-text          Read ALL visible text on the page\n"
            "  title              Get page title\n"
            "  url                Get current URL\n\n"
            "EXAMPLES:\n"
            "  browse-now get page-text                # Full page text\n"
            "  browse-now get page-text --max-chars 3000  # Limit output\n"
            "  browse-now get text @e5                 # Specific element text\n"
            "  browse-now get title                    # Page title\n"
            "  browse-now get url                      # Current URL"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    get_subs = p.add_subparsers(dest="get_action", metavar="WHAT")

    g = get_subs.add_parser("text", help="Get element text (supports @eN refs and CSS selectors)")
    g.add_argument("target", help="Ref from snapshot (@e5) or CSS selector (#id, .class)")

    get_subs.add_parser("title", help="Get page title")
    get_subs.add_parser("url", help="Get current URL")

    g = get_subs.add_parser(
        "page-text",
        help="Get readable text from entire page (articles, search results, etc.)",
    )
    g.add_argument(
        "--max-chars", type=int, default=50000, help="Maximum characters (default: 50000)"
    )

    # Screenshot
    p = subparsers.add_parser(
        "screenshot",
        help="Take a screenshot (for vision-capable LLMs when snapshot is sparse)",
        description="""Capture a screenshot of the current page.

Use as fallback when 'snapshot -i' returns sparse results (e.g., on SPAs,
custom components, or sites without proper accessibility).

Vision-capable LLMs (Claude, GPT-4o, Gemini) can analyze the screenshot
to understand page content that accessibility tree misses.""",
    )
    p.add_argument("path", nargs="?", help="Output file path (optional, prints base64 if omitted)")
    p.add_argument(
        "--full", action="store_true", help="Full page screenshot (captures scrolled content)"
    )
    p.add_argument(
        "-e", "--element", help="Screenshot specific element by ref (@e5) or CSS selector"
    )

    # Wait
    p = subparsers.add_parser("wait", help="Wait for element, text, or time")
    p.add_argument(
        "target",
        nargs="?",
        default="body",
        help="Element ref, CSS selector, or time (e.g., 2, 2s, 2000ms). Time defaults to seconds.",
    )
    p.add_argument("-t", "--timeout", type=int, default=30000, help="Timeout in milliseconds")
    p.add_argument("--text", help="Wait for specific text in element (uses body if no target)")
    p.add_argument(
        "--ms",
        action="store_true",
        help="Interpret numeric target as milliseconds instead of seconds",
    )

    # Tabs
    subparsers.add_parser("tabs", help="List open browser tabs")

    p = subparsers.add_parser("switch", help="Switch to a specific tab")
    p.add_argument("tab_id", type=int, help="Tab ID to switch to")

    # Browser management
    subparsers.add_parser("browsers", help="List connected browser extensions")
    subparsers.add_parser("status", help="Get browser bridge status")

    # Evaluate
    p = subparsers.add_parser("evaluate", help="Execute JavaScript in page context")
    p.add_argument("code", help="JavaScript code to execute")

    # Skill guide for AI agents
    subparsers.add_parser(
        "skill",
        help="Display workflow guide for AI agents",
        description="""Show the complete browse-now skill guide.

This guide teaches AI agents the critical workflow patterns for reliable
browser automation, especially on complex UIs like Weibo, Xiaohongshu, etc.

KEY LESSON: Always take a screenshot after any action that changes the page.""",
    )

    # Agent tab management
    p = subparsers.add_parser("agent-tab", help="Manage isolated agent tab")
    agent_tab_subs = p.add_subparsers(dest="agent_tab_action", metavar="ACTION")

    at_init = agent_tab_subs.add_parser("init", help="Initialize agent tab with tab group")
    at_init.add_argument("url", nargs="?", help="Initial URL (optional)")
    at_init.add_argument(
        "-c",
        "--color",
        default="cyan",
        choices=[
            "grey",
            "blue",
            "red",
            "yellow",
            "green",
            "pink",
            "purple",
            "cyan",
            "orange",
        ],
        help="Tab group color",
    )

    agent_tab_subs.add_parser("status", help="Check if agent tab is active")
    agent_tab_subs.add_parser("close", help="Close agent tab")

    at_set = agent_tab_subs.add_parser("set", help="Set agent tab to an existing tab by ID")
    at_set.add_argument("tab_id", type=int, help="Tab ID to set as agent tab")

    return parser


def main() -> int:
    parser = create_parser()

    # Use parse_known_args to handle -j/--json appearing after command
    # This is more user-friendly than strict argument ordering
    args, unknown = parser.parse_known_args()

    # Check for -j/--json in unknown args (placed after command)
    json_in_unknown = "-j" in unknown or "--json" in unknown
    if json_in_unknown:
        unknown = [a for a in unknown if a not in ("-j", "--json")]
        set_json_mode(True)

    # If there are still unknown args, show error
    if unknown:
        parser.error(f"unrecognized arguments: {' '.join(unknown)}")

    if args.api_url:
        os.environ["NOWLEDGE_API_URL"] = args.api_url
    if getattr(args, "api_key", None):
        os.environ["NOWLEDGE_API_KEY"] = args.api_key
    if getattr(args, "json", False):
        set_json_mode(True)
    if getattr(args, "browser_id", None):
        set_browser_id(args.browser_id)

    cmd = args.command

    try:
        if cmd == "open":
            # Isolated is default (True), --no-isolated sets it to False
            isolated = not getattr(args, "no_isolated", False)
            timeout = getattr(args, "open_timeout", 10)
            cmd_open(args.url, args.wait, isolated, timeout)
        elif cmd == "back":
            cmd_back()
        elif cmd == "forward":
            cmd_forward()
        elif cmd == "reload":
            cmd_reload()
        elif cmd == "snapshot":
            cmd_snapshot(
                args.interactive,
                args.compact,
                args.depth,
                args.coords,
                getattr(args, "selector", None),
                getattr(args, "with_css", False),
                getattr(args, "wait_ms", 0),
                getattr(args, "max_elements", 0),
            )
        elif cmd == "click":
            cmd_click(
                getattr(args, "target", None),
                getattr(args, "css_selector", None),
                getattr(args, "click_text", None),
                getattr(args, "exact", False),
                getattr(args, "click_within", None),
                getattr(args, "click_verbose", False),
                getattr(args, "retry_count", 0),
            )
        elif cmd == "find":
            cmd_find(
                args.query,
                getattr(args, "limit", 10),
                getattr(args, "find_within", None),
                getattr(args, "verbose", False),
            )
        elif cmd == "fill":
            cmd_fill(
                getattr(args, "target", None),
                args.text,
                args.no_clear,
                args.submit,
                getattr(args, "css_selector", None),
            )
        elif cmd == "type":
            cmd_type(
                getattr(args, "target", None),
                args.text,
                getattr(args, "css_selector", None),
                getattr(args, "active", False),
            )
        elif cmd == "press":
            cmd_press(args.key, getattr(args, "press_target", None))
        elif cmd == "hover":
            cmd_hover(getattr(args, "target", None), getattr(args, "at_coords", None))
        elif cmd == "scroll":
            cmd_scroll(args.direction_or_x, args.amount_or_y)
        elif cmd == "get":
            action = args.get_action
            if action == "text":
                cmd_get_text(args.target)
            elif action == "title":
                cmd_get_title()
            elif action == "url":
                cmd_get_url()
            elif action == "page-text":
                cmd_get_page_text(getattr(args, "max_chars", 50000))
            else:
                parser.parse_args(["get", "--help"])
        elif cmd == "screenshot":
            cmd_screenshot(args.path, args.full, args.element)
        elif cmd == "wait":
            cmd_wait(
                args.target, args.timeout, getattr(args, "text", None), getattr(args, "ms", False)
            )
        elif cmd == "tabs":
            cmd_tabs()
        elif cmd == "switch":
            cmd_switch(args.tab_id)
        elif cmd == "browsers":
            cmd_browsers()
        elif cmd == "status":
            cmd_status()
        elif cmd == "evaluate":
            cmd_evaluate(args.code)
        elif cmd == "skill":
            cmd_skill()
        elif cmd == "agent-tab":
            action = args.agent_tab_action
            if action == "init":
                cmd_agent_tab_init(getattr(args, "url", None), getattr(args, "color", "cyan"))
            elif action == "status":
                cmd_agent_tab_status()
            elif action == "close":
                cmd_agent_tab_close()
            elif action == "set":
                cmd_agent_tab_set(args.tab_id)
            else:
                parser.parse_args(["agent-tab", "--help"])
        else:
            if is_json_mode():
                output_json({"error": "no_command", "hint": "Use browse-now --help"})
            else:
                parser.print_help()
            return 0
    except ImportError as e:
        if is_json_mode():
            output_json({"error": "missing_dependency", "message": str(e)})
        else:
            print_error("Missing Dependency", str(e), "Install with: pip install websockets")
        return 1
    except Exception as e:
        if is_json_mode():
            output_json({"error": "unexpected_error", "message": str(e)})
        else:
            print_error("Error", str(e))
        return 1

    return 0


if __name__ == "__main__":
    sys.exit(main())
