"""
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)


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, 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, 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)
            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:
            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

Matches Claude Chrome Extension design:
- **Refs (click @eN)** → 95%+ reliable, primary method
- **Text (click -T)** → 85% reliable, fallback for dialogs
- Simple: `snapshot -i` → `click @eN` → repeat

---

## The Workflow

```
1. snapshot -i          → Get refs [e1], [e2], [e3]...
2. Analyze refs         → Find what you need (button "更多" [e5])
3. click @e5            → Click by REF
4. If page changes      → snapshot -i again, get new refs
5. Repeat
```

---

## Getting Started

```bash
browse-now open <url>            # Open URL (isolated agent tab by default)
browse-now snapshot -i           # Get interactive elements with refs
```

---

## Primary: Click by Ref (95%+ Reliable)

```bash
browse-now snapshot -i           # Get interactive elements
# Output:
#   textbox "搜索" [e1]
#   button "发送" [e2]
#   generic "更多" [e3]

browse-now click @e2             # Click by ref
browse-now fill @e1 "text"       # Fill input (clears first)
browse-now type @e1 "more"       # Type without clearing
```

Refs stay valid until page changes. Click multiple refs without re-snapshotting!

---

## Fallback: Click by Text

```bash
browse-now click -T "Submit"     # Click element with text
browse-now click -T "删除"       # Works with Chinese
browse-now click -T "确定"       # Dialog buttons
```

---

## Dialogs & Popups

Use the same methods - dialogs appear in snapshot:

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

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

---

## Sparse Results? Use Screenshot + Text

If snapshot returns few elements (< 10), the site may have poor accessibility:

```bash
browse-now snapshot -i           # Few refs found
browse-now screenshot /tmp/p.png # Take screenshot for analysis
# Read visible text from screenshot, then:
browse-now click -T "登录"       # Click by visible text
```

---

## Decision Tree

```
1. snapshot -i
   Found [eN]? → click @eN ✓

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

3. Has unique visible text?
   YES → click -T "text" ✓

4. Re-snapshot (late load?)
   Use: snapshot -i -w 1000  (wait 1s)
   Found now? → click @eN ✓

5. Ask user (after 2-3 attempts)
```

---

## Command Quick Reference

| Category | Command | Notes |
|----------|---------|-------|
| **Navigate** | `open <url>` | Isolated agent tab by default |
| | `back`, `forward`, `reload` | Browser history |
| **Snapshot** | `snapshot -i` | Interactive elements with refs |
| | `screenshot /tmp/p.png` | Visual reference for sparse results |
| **Click** | `click @e5` | By ref (95%+ reliable) |
| | `click -T "text"` | By visible text (85% reliable) |
| **Input** | `fill @e3 "text"` | Clear + type |
| | `type @e3 "more"` | Append without clearing |
| | `press Enter` | Keyboard key |
| **Info** | `get text @e5` | Element text content |
| | `get page-text` | Full page text (auth'd content) |
| | `get url`, `get title` | Page metadata |
| **Wait** | `wait 2s` | Wait time |
| | `wait @e5` | Wait for element |
| **Other** | `hover @e5` | Reveal hidden UI |
| | `scroll down 500` | Scroll page |
| | `tabs`, `switch N` | Tab management |

---

## Examples

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

### Weibo Delete Flow
```bash
browse-now snapshot -i           # Find "更多" [e45]
browse-now click @e45            # Open menu
browse-now snapshot -i           # "删除" [e67]
browse-now click @e67            # Delete
browse-now snapshot -i           # "确定" [e89]
browse-now click @e89            # Confirm
```

### Extract Content (Authenticated Page)
```bash
browse-now open https://app.example.com   # Already logged in
browse-now get page-text --max-chars 10000
```

---

## Key Insight

Reliability comes from:
1. Using refs (`@eN`), not coordinates
2. Re-snapshotting after page changes
3. Text-based (`-T`) as fallback for dialogs
4. Screenshot for visual reference when accessibility is poor

Just: `snapshot -i` → `click @eN` → repeat.
"""


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")

            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_text(
    text: str, exact: bool = False, limit: int = 10, within: str | None = None
) -> None:
    """Find visible elements by text content.

    Useful for discovering menu items, buttons, and links by their visible text.
    Returns elements sorted by specificity (most specific matches first).

    Args:
        text: Text to search for (e.g., "删除", "Submit")
        exact: Require exact text match (default: partial match)
        limit: Maximum results to return (default: 10)
        within: Scope search to CSS selector (e.g., ".dropdown-menu")
    """
    ws_client = create_ws_client()
    params: dict = {"text": text, "exact": exact, "limit": limit}
    if within:
        params["within"] = within
    result = ws_client.send_command("find_text", 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 Text",
                    f'No visible elements found with text: "{text}"{scope_hint}',
                )
                return

            scope_hint = f' within "{within}"' if within else ""
            print_success("Found", f'{len(matches)} element(s) with text "{text}"{scope_hint}')
            for m in matches:
                tag = m.get("tag", "?")
                mtext = m.get("text", "")
                selector = m.get("selector", "")
                role = m.get("role", "")
                role_info = f" role={role}" if role else ""
                console.print(f"  [{tag}{role_info}] {mtext}")
                if selector:
                    console.print(f"    [dim]selector: {selector}[/dim]")
        else:
            print_error("Find Text Failed", result.error or "Unknown error")


def cmd_find_role(role: str, limit: int = 10) -> None:
    """Find visible elements by ARIA role.

    Searches for elements with matching role attribute or implicit ARIA role.
    Useful for finding buttons, links, textboxes, etc. by semantic role.

    Args:
        role: ARIA role to search for (e.g., "button", "link", "textbox")
        limit: Maximum results to return (default: 10)
    """
    ws_client = create_ws_client()
    result = ws_client.send_command("find_role", {"role": role, "limit": limit})

    if is_json_mode():
        output_json(result.model_dump())
    else:
        if result.success and result.data:
            matches = result.data.get("matches", [])
            if not matches:
                print_error("Find Role", f'No visible elements found with role: "{role}"')
                return

            print_success("Found", f'{len(matches)} element(s) with role "{role}"')
            for m in matches:
                tag = m.get("tag", "?")
                mtext = m.get("text", "")
                selector = m.get("selector", "")
                console.print(f"  [{tag}] {mtext}")
                if selector:
                    console.print(f"    [dim]selector: {selector}[/dim]")
        else:
            print_error("Find Role Failed", result.error or "Unknown error")


def cmd_find_label(
    label: str, exact: bool = False, limit: int = 10, include_placeholder: bool = False
) -> None:
    """Find form elements by their associated label.

    Searches for inputs, selects, and textareas that have a label with matching text.
    Also checks aria-label and aria-labelledby attributes.
    With --include-placeholder, also matches placeholder and title attributes.

    Args:
        label: Label text to search for (e.g., "Username", "Email")
        exact: Require exact label match (default: partial match)
        limit: Maximum results to return (default: 10)
        include_placeholder: Also match placeholder and title attributes
    """
    ws_client = create_ws_client()
    params: dict = {"label": label, "exact": exact, "limit": limit}
    if include_placeholder:
        params["includePlaceholder"] = True
    result = ws_client.send_command("find_label", 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:
                hint = "" if include_placeholder else " (try --include-placeholder)"
                print_error(
                    "Find Label",
                    f'No visible elements found with label: "{label}"{hint}',
                )
                return

            print_success("Found", f'{len(matches)} element(s) with label "{label}"')
            for m in matches:
                tag = m.get("tag", "?")
                mtext = m.get("text", "")
                selector = m.get("selector", "")
                label_text = m.get("labelText", "")
                label_source = m.get("labelSource", "")
                source_info = f" ({label_source})" if label_source else ""
                console.print(f"  [{tag}] {mtext}")
                if label_text:
                    console.print(f"    [dim]label: {label_text}{source_info}[/dim]")
                if selector:
                    console.print(f"    [dim]selector: {selector}[/dim]")
        else:
            print_error("Find Label 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", [])
            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("Active", style="green")
            table.add_column("Title")
            table.add_column("URL", style="dim")

            for tab in tabs:
                active = "*" if tab.get("active") else ""
                title = tab.get("title", "")[:40]
                url = tab.get("url", "")[:60]
                table.add_row(str(tab.get("id", "")), active, title, url)

            console.print(table)
        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 (Matches Claude Chrome Extension)

  1. browse-now open <url>           # Open page (isolated agent tab)
  2. browse-now snapshot -i          # Get refs [e1], [e2]...
  3. browse-now click @e5            # Click by ref
  4. browse-now fill @e3 "text"      # Fill by ref
  5. (If page changed) → snapshot -i again

EXAMPLES
  # Search form
  browse-now snapshot -i && browse-now fill @e1 "query" --submit

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

  # Weibo delete flow (all refs)
  browse-now snapshot -i             # Find "更多" [e45]
  browse-now click @e45              # Click menu
  browse-now snapshot -i             # Find "删除" [e67]
  browse-now click @e67              # Click delete

  # Extract authenticated content
  browse-now get page-text           # Full page text from logged-in page

RELIABILITY
  Refs (click @eN)     95%+  ← Primary method
  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://localhost:14242)

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)",
    )

    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 (95%+ reliable):  click @e5
   Requires: snapshot -i first to get refs

2. By text (85% reliable):  click -T "Submit"
   Fallback when ref isn't clear

3. By selector (advanced):  click -S "button.submit"
   Power users only, fragile

RECOMMENDED WORKFLOW:
  browse-now snapshot -i    # Get refs
  browse-now click @e5      # Click by ref""",
    )
    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-text", help="Find visible elements by text content")
    p.add_argument("text", help="Text to search for (e.g., '删除', 'Submit')")
    p.add_argument("--exact", action="store_true", help="Require exact text match")
    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 (e.g., '.dropdown-menu')",
    )

    p = subparsers.add_parser("find-role", help="Find visible elements by ARIA role")
    p.add_argument("role", help="ARIA role to search for (e.g., 'button', 'link', 'textbox')")
    p.add_argument("--limit", type=int, default=10, help="Max results (default: 10)")

    p = subparsers.add_parser("find-label", help="Find form elements by associated label")
    p.add_argument("label", help="Label text to search for (e.g., 'Username', 'Email')")
    p.add_argument("--exact", action="store_true", help="Require exact label match")
    p.add_argument("--limit", type=int, default=10, help="Max results (default: 10)")
    p.add_argument(
        "-p",
        "--include-placeholder",
        dest="include_placeholder",
        action="store_true",
        help="Also match placeholder and title attributes (for Weibo-style inputs)",
    )

    p = subparsers.add_parser("fill", help="Fill an input element (clear and type)")
    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)")
    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="Scroll amount in pixels OR y-pixels for delta mode",
    )

    # Get information
    p = subparsers.add_parser("get", help="Get page info (text accepts @eN refs or CSS selectors)")
    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 (for authenticated content)"
    )
    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, "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-text":
            cmd_find_text(
                args.text,
                getattr(args, "exact", False),
                getattr(args, "limit", 10),
                getattr(args, "find_within", None),
            )
        elif cmd == "find-role":
            cmd_find_role(args.role, getattr(args, "limit", 10))
        elif cmd == "find-label":
            cmd_find_label(
                args.label,
                getattr(args, "exact", False),
                getattr(args, "limit", 10),
                getattr(args, "include_placeholder", 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())
