"""
Browser Automation Tool
Provides stealth browser control via Playwright with:
- Anti-detection (playwright-stealth)
- Interactive element map (numbered elements for LLM navigation)
- Persistent/ephemeral browser profiles (workspace-scoped)
- Network request sniffing
- Screenshot capture
- Proxy support

The agent interacts with pages by referencing numbered interactive elements,
eliminating the need to guess CSS selectors.
"""

import asyncio
import concurrent.futures
import json
import logging
import os
import re
import sys
import tempfile
import threading
import time
from pathlib import Path
from typing import Dict, Any, List, Optional
from dataclasses import dataclass, field

from ..base import (
    BaseTool, ToolMetadata, ToolParameter, ToolExecutionContext,
    ToolResult, ToolPermission
)

logger = logging.getLogger(__name__)

# Maximum interactive elements to return per page snapshot
MAX_ELEMENTS = 50
# Maximum text content length per page snapshot
MAX_PAGE_TEXT = 3000
# Maximum captured network requests to store
MAX_NETWORK_LOG = 100


@dataclass
class CapturedRequest:
    """A captured network request/response pair."""
    method: str
    url: str
    status: Optional[int] = None
    request_headers: Dict[str, str] = field(default_factory=dict)
    response_headers: Dict[str, str] = field(default_factory=dict)
    request_body: Optional[str] = None
    response_preview: Optional[str] = None
    timestamp: float = 0.0


class StealthBrowserSession:
    """
    Manages a Playwright browser session with anti-detection and state persistence.
    
    ARCHITECTURE: Playwright objects (browser, context, page) are bound to the event
    loop that created them. To persist across multiple synchronous tool calls, this
    session owns a dedicated background thread running a ProactorEventLoop (Windows)
    or standard event loop (Unix). All async Playwright operations are submitted to
    this single loop via run_async(), ensuring objects remain valid.
    
    Lifecycle:
    1. launch() → starts background thread + event loop, creates browser + context
    2. Agent makes tool calls (navigate, click, type, etc.) → submitted to same loop
    3. close() → saves profile state, closes browser, stops loop + thread
    
    Stored in runner.session_state['_browser'] for cross-tool-call persistence.
    """

    def __init__(self):
        self.playwright = None
        self.browser = None
        self.context = None
        self.page = None
        self.profile_name: Optional[str] = None
        self.profile_dir: Optional[str] = None
        self.workspace_path: Optional[str] = None
        self.is_ephemeral: bool = True
        self.headless: bool = True
        self.stealth: bool = True
        
        # Interactive element map: {element_number: {"selector": ..., "type": ..., "text": ...}}
        self.element_map: Dict[int, Dict[str, Any]] = {}
        
        # Network sniffing
        self.sniffing: bool = False
        self.sniff_pattern: Optional[str] = None
        self.network_log: List[CapturedRequest] = []
        
        # Persistent event loop thread — keeps Playwright objects alive across calls
        self._loop: Optional[asyncio.AbstractEventLoop] = None
        self._thread: Optional[threading.Thread] = None
        self._start_loop_thread()

    def _start_loop_thread(self):
        """Start a dedicated background thread with its own event loop."""
        ready = threading.Event()
        
        def _thread_main():
            if sys.platform == 'win32':
                loop = asyncio.ProactorEventLoop()
            else:
                loop = asyncio.new_event_loop()
            asyncio.set_event_loop(loop)
            self._loop = loop
            ready.set()
            loop.run_forever()  # Blocks until loop.call_soon_threadsafe(loop.stop) is called
        
        self._thread = threading.Thread(target=_thread_main, daemon=True, name="browser-session-loop")
        self._thread.start()
        ready.wait(timeout=5)  # Wait for loop to be ready
        logger.info(f"[BROWSER] Background event loop started on thread {self._thread.name}")

    def run_async(self, coro, timeout: float = 120) -> Any:
        """
        Submit an async coroutine to the session's persistent event loop and wait for result.
        This is the ONLY way to run async Playwright operations — ensures all objects
        stay on the same loop/thread.
        """
        if not self._loop or not self._thread or not self._thread.is_alive():
            raise RuntimeError("Browser session event loop is not running")
        
        future = asyncio.run_coroutine_threadsafe(coro, self._loop)
        return future.result(timeout=timeout)

    def _stop_loop_thread(self):
        """Stop the background event loop and thread."""
        if self._loop and self._loop.is_running():
            self._loop.call_soon_threadsafe(self._loop.stop)
        if self._thread and self._thread.is_alive():
            self._thread.join(timeout=5)
        self._loop = None
        self._thread = None
        logger.info("[BROWSER] Background event loop stopped")

    async def launch(
        self,
        workspace_path: str,
        profile: str = "ephemeral",
        headless: bool = True,
        stealth: bool = True,
        proxy: Optional[str] = None,
        timezone_id: str = "America/New_York",
    ) -> str:
        """
        Launch a browser session.
        
        Args:
            workspace_path: Agent's workspace root
            profile: Profile name or "ephemeral" for temp session
            headless: Run without visible window
            stealth: Apply anti-detection patches
            proxy: Optional proxy URL (e.g. "http://user:pass@host:port")
        """
        from playwright.async_api import async_playwright
        
        self.workspace_path = workspace_path
        self.headless = headless
        self.stealth = stealth
        self.is_ephemeral = (profile == "ephemeral")
        self.profile_name = profile

        # Set up profile directory
        if not self.is_ephemeral:
            self.profile_dir = os.path.join(workspace_path, ".browser_profiles", profile)
            os.makedirs(self.profile_dir, exist_ok=True)
            storage_path = os.path.join(self.profile_dir, "storage_state.json")
        else:
            self.profile_dir = tempfile.mkdtemp(prefix="browser_ephemeral_")
            storage_path = None

        # Launch Playwright
        self.playwright = await async_playwright().start()
        
        # ── Launch args — two strategies ──
        # PERSISTENT profiles (already logged in): use MINIMAL args.
        # The user built trust with sites (cookies, fingerprint) under a real Chrome
        # session with no extra flags. Adding 15+ --disable-xxx args changes the
        # browser fingerprint and causes Cloudflare/bot-detection to re-challenge.
        #
        # EPHEMERAL sessions: full stealth flags are fine since there's no prior
        # fingerprint to match — we want maximum anti-detection regardless.
        _persistent_launch_args = [
            # Only the bare minimum — everything else makes us look less like real Chrome
            '--disable-blink-features=AutomationControlled',
            '--start-maximized',
            '--disable-notifications',
        ]

        _ephemeral_launch_args = [
            '--disable-blink-features=AutomationControlled',
            '--disable-infobars',
            '--disable-component-extensions-with-background-pages',
            '--disable-default-apps',
            '--disable-dev-shm-usage',
            '--disable-extensions',
            '--no-first-run',
            '--no-default-browser-check',
            '--no-service-autorun',
            '--disable-background-timer-throttling',
            '--disable-backgrounding-occluded-windows',
            '--disable-renderer-backgrounding',
            '--disable-hang-monitor',
            '--disable-ipc-flooding-protection',
            '--disable-popup-blocking',
            '--disable-prompt-on-repost',
            '--disable-sync',
            '--disable-notifications',
            '--ignore-certificate-errors',
            '--metrics-recording-only',
            '--password-store=basic',
            '--use-mock-keychain',
            '--start-maximized',
        ]

        launch_args = _persistent_launch_args if not self.is_ephemeral else _ephemeral_launch_args

        # Switches to explicitly EXCLUDE — Playwright adds these by default
        # This is the Playwright equivalent of Selenium's:
        #   excludeSwitches: ["enable-automation"]
        #   useAutomationExtension: False
        ignore_args = [
            '--enable-automation',           # THE key flag — Selenium excludes this too
            '--enable-blink-features=IdleDetection',
        ]

        # Proxy configuration
        proxy_config = None
        if proxy:
            parts = proxy.split('@')
            if len(parts) == 2:
                # user:pass@host:port format
                creds, server = parts
                if '://' in creds:
                    scheme, creds = creds.split('://', 1)
                else:
                    scheme = 'http'
                user_pass = creds.split(':')
                proxy_config = {
                    "server": f"{scheme}://{server}",
                    "username": user_pass[0],
                    "password": user_pass[1] if len(user_pass) > 1 else "",
                }
            else:
                proxy_config = {"server": proxy}

        # ── USE REAL CHROME (not Playwright's bundled Chromium) ──
        # This is THE critical difference vs Selenium.
        # Selenium uses chromedriver.exe + real Chrome → undetected
        # Playwright defaults to bundled Chromium → easily detected
        # channel="chrome" tells Playwright to use the locally installed Chrome
        use_channel = "chrome"

        # Context settings differ by profile type:
        #
        # PERSISTENT profiles: use MINIMAL overrides — let real Chrome speak for itself.
        # The cf_clearance cookie Cloudflare sets is cryptographically bound to the
        # browser fingerprint (UA, TLS, canvas, etc.) from the session that created it.
        # If we override UA with a different/hardcoded version, the fingerprint no longer
        # matches and Cloudflare immediately invalidates cf_clearance, causing re-challenge.
        # channel="chrome" already uses correctly-versioned UA and Sec-CH-UA headers.
        #
        # EPHEMERAL sessions: use full stealth overrides — no prior fingerprint to match,
        # so we want maximum anti-detection hardening.
        _chrome_ver = "131.0.6778.140"
        _chrome_major = _chrome_ver.split('.')[0]

        if not self.is_ephemeral:
            # Persistent: bare minimum — don't touch UA, headers, or CSP
            context_args = {
                "viewport": {"width": 1920, "height": 1080},
                "locale": "en-US",
                "timezone_id": timezone_id,
                "java_script_enabled": True,
            }
        else:
            # Ephemeral: full stealth overrides
            context_args = {
                "viewport": {"width": 1920, "height": 1080},
                "user_agent": (
                    f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                    f"AppleWebKit/537.36 (KHTML, like Gecko) "
                    f"Chrome/{_chrome_ver} Safari/537.36"
                ),
                "extra_http_headers": {
                    "Sec-CH-UA": f'"Google Chrome";v="{_chrome_major}", "Chromium";v="{_chrome_major}", "Not=A?Brand";v="99"',
                    "Sec-CH-UA-Mobile": "?0",
                    "Sec-CH-UA-Platform": '"Windows"',
                },
                "locale": "en-US",
                "timezone_id": timezone_id,
                "java_script_enabled": True,
                "bypass_csp": True,
                "color_scheme": "light",
                "device_scale_factor": 1,
                "has_touch": False,
                "is_mobile": False,
            }


        if proxy_config:
            context_args["proxy"] = proxy_config

        # ── Launch strategy depends on profile type ──
        # Named profiles use launch_persistent_context (= Selenium's user-data-dir)
        # Ephemeral uses regular launch + new_context
        
        if not self.is_ephemeral:
            # PERSISTENT CONTEXT — equivalent to Selenium's user-data-dir
            # This gives us REAL Chrome profile persistence (cookies, localStorage,
            # extensions, autofill, etc.) — not just Playwright's storage_state JSON
            try:
                self.context = await self.playwright.chromium.launch_persistent_context(
                    user_data_dir=self.profile_dir,
                    channel=use_channel,
                    headless=headless,
                    args=launch_args,
                    ignore_default_args=ignore_args,
                    **context_args,
                )
                self.browser = self.context.browser
                logger.info(f"[BROWSER] Launched real Chrome with persistent profile: {profile}")
            except Exception as e:
                # Fallback: Chrome not installed, use bundled Chromium
                logger.warning(f"[BROWSER] Real Chrome not available ({e}), falling back to Chromium")
                use_channel = None
                self.context = await self.playwright.chromium.launch_persistent_context(
                    user_data_dir=self.profile_dir,
                    headless=headless,
                    args=launch_args,
                    ignore_default_args=ignore_args,
                    **context_args,
                )
                self.browser = self.context.browser

            # Persistent context creates a default page automatically
            pages = self.context.pages
            self.page = pages[0] if pages else await self.context.new_page()
            await self._fit_viewport_to_screen()

        else:
            # EPHEMERAL — regular launch + new_context
            try:
                self.browser = await self.playwright.chromium.launch(
                    channel=use_channel,
                    headless=headless,
                    args=launch_args,
                    ignore_default_args=ignore_args,
                )
                logger.info("[BROWSER] Launched real Chrome (ephemeral)")
            except Exception as e:
                logger.warning(f"[BROWSER] Real Chrome not available ({e}), falling back to Chromium")
                use_channel = None
                self.browser = await self.playwright.chromium.launch(
                    headless=headless,
                    args=launch_args,
                    ignore_default_args=ignore_args,
                )

            self.context = await self.browser.new_context(**context_args)

            # Apply stealth patches (only for non-persistent — persistent applies via init_script)
            if stealth:
                try:
                    from playwright_stealth import Stealth
                    stealth_obj = Stealth()
                    await stealth_obj.apply_stealth_async(self.context)
                    logger.info("[BROWSER] Stealth patches applied (v2 API)")
                except ImportError:
                    logger.warning("[BROWSER] playwright-stealth not installed")
                except Exception as e:
                    logger.warning(f"[BROWSER] Stealth patch failed: {e}")

            self.page = await self.context.new_page()
            await self._fit_viewport_to_screen()

        # Apply stealth patches for persistent context too
        if not self.is_ephemeral and stealth:
            try:
                from playwright_stealth import Stealth
                stealth_obj = Stealth()
                await stealth_obj.apply_stealth_async(self.context)
                logger.info("[BROWSER] Stealth patches applied to persistent context")
            except (ImportError, Exception) as e:
                logger.warning(f"[BROWSER] Stealth patch for persistent context: {e}")
        
        # Comprehensive anti-detection init script
        await self.page.add_init_script("""
            // ── 1. Remove webdriver flag (THE primary detection signal) ──
            Object.defineProperty(navigator, 'webdriver', {
                get: () => undefined,
                configurable: true,
            });
            // Also delete it from the prototype
            delete Navigator.prototype.webdriver;

            // ── 1b. Defensively clean ChromeDriver / Selenium document-level tells ──
            // The advanced bot detection (runBotDetection) scans window.document for keys
            // matching /\$[a-z]dc_/ with a cache_ property — ChromeDriver injects these.
            // Also clears selenium/webdriver attributes from documentElement.
            try {
                // Remove $cdc_* keys that ChromeDriver / older WebDriver versions inject
                for (const key of Object.keys(document)) {
                    if (/^\$[a-z]dc_/.test(key)) { try { delete document[key]; } catch(e) {} }
                }
                // Remove selenium/webdriver HTML attributes that automation tools set
                ['selenium', 'webdriver', 'driver'].forEach(attr => {
                    document.documentElement.removeAttribute(attr);
                });
                // Remove any stray window-level selenium keys
                ['_phantom','__nightmare','_selenium','callPhantom','callSelenium',
                 '_Selenium_IDE_Recorder','$chrome_asyncScriptInfo'].forEach(k => {
                    try { delete window[k]; } catch(e) {}
                });
            } catch(e) {}

            // ── 2. Fake realistic plugins (not just [1,2,3,4,5]) ──
            Object.defineProperty(navigator, 'plugins', {
                get: () => {
                    const makePlugin = (name, desc, filename) => {
                        const p = Object.create(Plugin.prototype);
                        Object.defineProperties(p, {
                            name: {get: () => name},
                            description: {get: () => desc},
                            filename: {get: () => filename},
                            length: {get: () => 1},
                        });
                        return p;
                    };
                    const plugins = [
                        makePlugin('Chrome PDF Plugin', 'Portable Document Format', 'internal-pdf-viewer'),
                        makePlugin('Chrome PDF Viewer', '', 'mhjfbmdgcfjbbpaeojofohoefgiehjai'),
                        makePlugin('Native Client', '', 'internal-nacl-plugin'),
                    ];
                    Object.setPrototypeOf(plugins, PluginArray.prototype);
                    return plugins;
                },
            });
            
            // ── 3. Fake mimeTypes ──
            Object.defineProperty(navigator, 'mimeTypes', {
                get: () => {
                    const mimes = [
                        {type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format'},
                    ];
                    Object.setPrototypeOf(mimes, MimeTypeArray.prototype);
                    return mimes;
                },
            });
            
            // ── 4. Languages ──
            Object.defineProperty(navigator, 'languages', {
                get: () => ['en-US', 'en'],
            });
            
            // ── 5. Chrome runtime — must look real, not empty ──
            window.chrome = {
                app: {
                    isInstalled: false,
                    InstallState: {DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed'},
                    RunningState: {CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running'},
                },
                runtime: {
                    OnInstalledReason: {CHROME_UPDATE: 'chrome_update', INSTALL: 'install', SHARED_MODULE_UPDATE: 'shared_module_update', UPDATE: 'update'},
                    OnRestartRequiredReason: {APP_UPDATE: 'app_update', OS_UPDATE: 'os_update', PERIODIC: 'periodic'},
                    PlatformArch: {ARM: 'arm', ARM64: 'arm64', MIPS: 'mips', MIPS64: 'mips64', X86_32: 'x86-32', X86_64: 'x86-64'},
                    PlatformNaclArch: {ARM: 'arm', MIPS: 'mips', MIPS64: 'mips64', X86_32: 'x86-32', X86_64: 'x86-64'},
                    PlatformOs: {ANDROID: 'android', CROS: 'cros', LINUX: 'linux', MAC: 'mac', OPENBSD: 'openbsd', WIN: 'win'},
                    RequestUpdateCheckStatus: {NO_UPDATE: 'no_update', THROTTLED: 'throttled', UPDATE_AVAILABLE: 'update_available'},
                    connect: function() { return {onDisconnect: {addListener: function() {}}, onMessage: {addListener: function() {}}, postMessage: function() {}}; },
                    sendMessage: function() {},
                },
                csi: function() { return {}; },
                loadTimes: function() { return {}; },
            };
            
            // ── 6. Permissions API — automation query should NOT return 'denied' ──
            const originalQuery = window.Permissions?.prototype?.query;
            if (originalQuery) {
                window.Permissions.prototype.query = function(parameters) {
                    if (parameters.name === 'notifications') {
                        return Promise.resolve({state: Notification.permission});
                    }
                    return originalQuery.call(this, parameters);
                };
            }
            
            // ── 7. WebGL vendor/renderer (headless detection) ──
            const getParameter = WebGLRenderingContext.prototype.getParameter;
            WebGLRenderingContext.prototype.getParameter = function(parameter) {
                if (parameter === 37445) return 'Google Inc. (Intel)';  // UNMASKED_VENDOR_WEBGL
                if (parameter === 37446) return 'ANGLE (Intel, Intel(R) UHD Graphics 630, OpenGL 4.5)';  // UNMASKED_RENDERER_WEBGL
                return getParameter.call(this, parameter);
            };
            const getParameter2 = WebGL2RenderingContext?.prototype?.getParameter;
            if (getParameter2) {
                WebGL2RenderingContext.prototype.getParameter = function(parameter) {
                    if (parameter === 37445) return 'Google Inc. (Intel)';
                    if (parameter === 37446) return 'ANGLE (Intel, Intel(R) UHD Graphics 630, OpenGL 4.5)';
                    return getParameter2.call(this, parameter);
                };
            }
            
            // ── 8. Connection API ──
            Object.defineProperty(navigator, 'connection', {
                get: () => ({
                    effectiveType: '4g',
                    rtt: 50,
                    downlink: 10,
                    saveData: false,
                }),
            });
            
            // ── 9. Hardware concurrency (don't expose 0 or unusual values) ──
            Object.defineProperty(navigator, 'hardwareConcurrency', {get: () => 8});
            
            // ── 10. Device memory ──
            Object.defineProperty(navigator, 'deviceMemory', {get: () => 8});
            
            // ── 11. Screen dimensions — headless reports 0×0 which is an instant giveaway ──
            const screenProps = {
                width:       { get: () => 1920 },
                height:      { get: () => 1080 },
                availWidth:  { get: () => 1920 },
                availHeight: { get: () => 1040 },  // taskbar takes ~40px
                colorDepth:  { get: () => 24 },
                pixelDepth:  { get: () => 24 },
            };
            for (const [prop, descriptor] of Object.entries(screenProps)) {
                try { Object.defineProperty(screen, prop, descriptor); } catch(e) {}
            }
            
            // ── 12. outerWidth/outerHeight — must be >= innerWidth/innerHeight ──
            // In headless they're often equal or 0; real browsers add window chrome (~82px)
            try {
                Object.defineProperty(window, 'outerWidth',  { get: () => window.innerWidth });
                Object.defineProperty(window, 'outerHeight', { get: () => window.innerHeight + 82 });
            } catch(e) {}
            
            // ── 13. speechSynthesis — returns [] voices in headless ──
            if (window.speechSynthesis) {
                const origGetVoices = window.speechSynthesis.getVoices.bind(window.speechSynthesis);
                window.speechSynthesis.getVoices = function() {
                    const real = origGetVoices();
                    if (real && real.length > 0) return real;
                    // Return a plausible stub so the array is non-empty
                    return [{ name: 'Microsoft David - English (United States)', lang: 'en-US', localService: true, default: true, voiceURI: 'Microsoft David - English (United States)' }];
                };
            }
        """)

        status_parts = [f"Browser launched"]
        if not self.is_ephemeral:
            if storage_path and os.path.exists(storage_path):
                status_parts.append(f"profile '{profile}' restored (cookies/session loaded)")
            else:
                status_parts.append(f"new profile '{profile}' created")
        else:
            status_parts.append("ephemeral session (no state saved)")
        
        if stealth:
            status_parts.append("stealth mode active")
        if proxy:
            status_parts.append(f"proxy: {proxy.split('@')[-1] if '@' in proxy else proxy}")

        return " | ".join(status_parts)

    async def _fit_viewport_to_screen(self):
        """
        Resize the Playwright viewport to match the actual screen resolution.
        Called after page creation in both persistent and ephemeral launch paths.

        Asks Chrome itself for screen.availWidth/Height so we always match the
        real monitor — no hardcoded resolution assumptions that cause content to
        overflow horizontally when the screen is smaller than the hardcoded size.
        """
        if not self.page:
            return
        try:
            dims = await self.page.evaluate(
                "() => ({"
                "  w: window.screen.availWidth  || window.screen.width  || 1920,"
                "  h: window.screen.availHeight || window.screen.height || 1080"
                "})"
            )
            w = max(int(dims.get("w", 1920)), 800)
            h = max(int(dims.get("h", 1080)), 600)
            await self.page.set_viewport_size({"width": w, "height": h})
            logger.info(f"[BROWSER] Viewport fitted to screen: {w}x{h}")
        except Exception as e:
            logger.warning(f"[BROWSER] Could not fit viewport to screen: {e}")

    async def navigate(self, url: str) -> str:

        """Navigate to a URL and return page snapshot with interactive elements."""
        if not self.page:
            return "Error: Browser not launched. Use action='launch' first."

        # Validate URL
        if not url.startswith(('http://', 'https://')):
            url = f'https://{url}'

        try:
            response = await self.page.goto(url, wait_until="domcontentloaded", timeout=30000)
            # Wait a bit for dynamic content
            await self.page.wait_for_timeout(1500)
        except Exception as e:
            return f"Navigation failed: {str(e)}"

        return await self._build_page_snapshot()

    async def click(self, element: Optional[int] = None, selector: Optional[str] = None, text: Optional[str] = None) -> str:
        """Click an element by number, selector, or visible text."""
        if not self.page:
            return "Error: Browser not launched."

        try:
            if element is not None:
                # Click by element number from the map
                elem_info = self.element_map.get(int(element))
                if not elem_info:
                    return f"Error: Element [{element}] not found. Run navigate or scroll to refresh the element map."
                
                sel = elem_info.get("selector")
                try:
                    await self.page.click(sel, timeout=5000)
                except Exception:
                    # Fallback: try by text content
                    elem_text = elem_info.get("text", "")
                    if elem_text:
                        await self.page.get_by_text(elem_text, exact=False).first.click(timeout=5000)
                    else:
                        raise

            elif text:
                await self.page.get_by_text(text, exact=False).first.click(timeout=5000)

            elif selector:
                await self.page.click(selector, timeout=5000)

            else:
                return "Error: Provide element number, selector, or text."

            # Wait for any navigation/rendering
            await self.page.wait_for_timeout(1500)
            return await self._build_page_snapshot()

        except Exception as e:
            return f"Click failed: {str(e)}"

    async def type_text(self, element: Optional[int] = None, selector: Optional[str] = None, text: str = "", clear: bool = True, press_enter: bool = False) -> str:
        """
        Type text into an input element.
        Uses multiple strategies to handle modern web apps (React, contenteditable, etc.).
        """
        if not self.page:
            return "Error: Browser not launched."

        try:
            target_selector = None
            if element is not None:
                elem_info = self.element_map.get(int(element))
                if not elem_info:
                    return f"Error: Element [{element}] not found."
                target_selector = elem_info.get("selector")
            elif selector:
                target_selector = selector
            else:
                return "Error: Provide element number or selector."

            typed = False
            
            # Strategy 1: page.fill() — works for standard <input>/<textarea>
            if clear:
                try:
                    await self.page.fill(target_selector, text, timeout=3000)
                    typed = True
                except Exception:
                    pass  # Fall through to strategy 2
            
            # Strategy 2: click element + keyboard.type() — works for React/contenteditable
            if not typed:
                try:
                    locator = self.page.locator(target_selector).first
                    await locator.click(timeout=3000)
                    await self.page.wait_for_timeout(200)
                    if clear:
                        await self.page.keyboard.press('Control+a')
                        await self.page.keyboard.press('Backspace')
                    await self.page.keyboard.type(text, delay=30)
                    typed = True
                except Exception:
                    pass  # Fall through to strategy 3
            
            # Strategy 3: press_sequentially on the locator
            if not typed:
                try:
                    locator = self.page.locator(target_selector).first
                    await locator.click(timeout=3000)
                    if clear:
                        await locator.press('Control+a')
                        await locator.press('Backspace')
                    await locator.press_sequentially(text, delay=30)
                    typed = True
                except Exception as e:
                    return f"Type failed (all strategies exhausted): {str(e)}"

            # Optionally press Enter after typing
            if press_enter:
                await self.page.keyboard.press('Enter')
                await self.page.wait_for_timeout(1500)
            
            # Return page snapshot so agent can see what happened
            await self.page.wait_for_timeout(500)
            return await self._build_page_snapshot()

        except Exception as e:
            return f"Type failed: {str(e)}"

    async def press_key(self, key: str, count: int = 1) -> str:
        """Press a keyboard key (Enter, Tab, Escape, ArrowDown, etc.)."""
        if not self.page:
            return "Error: Browser not launched."

        try:
            for _ in range(count):
                await self.page.keyboard.press(key)
                await self.page.wait_for_timeout(100)
            
            # Wait for any navigation/rendering after key press
            await self.page.wait_for_timeout(1000)
            return await self._build_page_snapshot()

        except Exception as e:
            return f"Key press failed: {str(e)}"

    async def extract(self, selector: Optional[str] = None, max_length: int = MAX_PAGE_TEXT) -> str:
        """Extract text content from the page or a specific element."""
        if not self.page:
            return "Error: Browser not launched."

        try:
            if selector:
                elements = await self.page.query_selector_all(selector)
                if not elements:
                    return f"No elements found matching '{selector}'"
                
                texts = []
                for el in elements[:10]:  # Limit to 10 matching elements
                    text = await el.inner_text()
                    if text.strip():
                        texts.append(text.strip())
                content = "\n\n".join(texts)
            else:
                content = await self.page.inner_text("body")

            # Clean up
            content = re.sub(r'\n{3,}', '\n\n', content)
            content = '\n'.join(line for line in content.split('\n') if line.strip())

            if len(content) > max_length:
                content = content[:max_length] + f"\n\n... [truncated at {max_length} chars]"

            return content

        except Exception as e:
            return f"Extract failed: {str(e)}"

    async def extract_links(self, selector: Optional[str] = None) -> str:
        """Extract all links from the page or a specific section."""
        if not self.page:
            return "Error: Browser not launched."

        try:
            container = selector or "body"
            links = await self.page.evaluate(f"""
                () => {{
                    const container = document.querySelector('{container}') || document.body;
                    const anchors = container.querySelectorAll('a[href]');
                    return Array.from(anchors).slice(0, 100).map((a, i) => ({{
                        text: a.innerText.trim().substring(0, 80),
                        url: a.href,
                    }})).filter(l => l.text && !l.url.startsWith('javascript:'));
                }}
            """)

            if not links:
                return "No links found on the page."

            output = [f"Found {len(links)} links:\n"]
            for i, link in enumerate(links, 1):
                output.append(f"[{i}] {link['text']} → {link['url']}")

            return "\n".join(output)

        except Exception as e:
            return f"Extract links failed: {str(e)}"

    async def screenshot(self) -> Dict[str, str]:
        """Take a screenshot, save to workspace, and return base64 for vision analysis."""
        if not self.page:
            return {"output": "Error: Browser not launched.", "path": "", "base64": ""}

        try:
            import base64
            
            # Save to workspace
            screenshots_dir = os.path.join(self.workspace_path, ".browser_screenshots")
            os.makedirs(screenshots_dir, exist_ok=True)
            
            filename = f"screenshot_{int(time.time())}.png"
            filepath = os.path.join(screenshots_dir, filename)
            
            # Capture screenshot as bytes AND save to file
            screenshot_bytes = await self.page.screenshot(full_page=False)
            with open(filepath, 'wb') as f:
                f.write(screenshot_bytes)
            
            # Base64 encode for vision model
            b64_data = base64.b64encode(screenshot_bytes).decode('utf-8')
            
            return {
                "output": f"Screenshot captured ({len(screenshot_bytes)} bytes). The image has been sent to the vision model for analysis.",
                "path": filepath,
                "base64": b64_data,
            }

        except Exception as e:
            return {"output": f"Screenshot failed: {str(e)}", "path": "", "base64": ""}

    async def scroll(self, direction: str = "down", amount: int = 500) -> str:
        """Scroll the page."""
        if not self.page:
            return "Error: Browser not launched."

        try:
            if direction == "down":
                await self.page.evaluate(f"window.scrollBy(0, {amount})")
            elif direction == "up":
                await self.page.evaluate(f"window.scrollBy(0, -{amount})")
            elif direction == "bottom":
                await self.page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
            elif direction == "top":
                await self.page.evaluate("window.scrollTo(0, 0)")

            await self.page.wait_for_timeout(800)
            return await self._build_page_snapshot()

        except Exception as e:
            return f"Scroll failed: {str(e)}"

    async def execute_js(self, script: str) -> str:
        """Execute JavaScript on the page and return the result."""
        if not self.page:
            return "Error: Browser not launched."

        try:
            result = await self.page.evaluate(script)
            return json.dumps(result, indent=2, default=str) if result is not None else "(no return value)"
        except Exception as e:
            return f"JS execution failed: {str(e)}"

    async def wait(self, selector: Optional[str] = None, seconds: Optional[float] = None, timeout: int = 30) -> str:
        """Wait for an element to appear or for a fixed duration."""
        if not self.page:
            return "Error: Browser not launched."

        try:
            if seconds:
                seconds = min(float(seconds), 30)
                await self.page.wait_for_timeout(int(seconds * 1000))
                return f"Waited {seconds}s"
            elif selector:
                await self.page.wait_for_selector(selector, timeout=timeout * 1000)
                return f"Element '{selector}' appeared"
            else:
                return "Error: Provide selector or seconds."
        except Exception as e:
            return f"Wait failed: {str(e)}"

    async def go_back(self) -> str:
        """Navigate back."""
        if not self.page:
            return "Error: Browser not launched."
        try:
            await self.page.go_back(wait_until="domcontentloaded", timeout=15000)
            await self.page.wait_for_timeout(1000)
            return await self._build_page_snapshot()
        except Exception as e:
            return f"Back navigation failed: {str(e)}"

    async def sniff_start(self, url_pattern: str = "*") -> str:
        """Start capturing network requests matching a URL pattern."""
        if not self.page:
            return "Error: Browser not launched."

        if self.sniffing:
            return f"Network sniffing already active (pattern: '{self.sniff_pattern}'). Existing logs preserved."

        self.sniffing = True
        self.sniff_pattern = url_pattern
        self.network_log.clear()
        self._pending_requests: Dict[str, CapturedRequest] = {}  # Track by request URL+method

        import fnmatch

        async def on_request(request):
            """Capture outgoing requests immediately (before response)."""
            try:
                if len(self.network_log) >= MAX_NETWORK_LOG:
                    return

                url = request.url
                resource_type = request.resource_type

                # Skip static assets
                if resource_type in ('image', 'stylesheet', 'font', 'media', 'websocket', 'manifest', 'other'):
                    return

                # Filter by pattern
                if self.sniff_pattern != "*" and not fnmatch.fnmatch(url, f"*{self.sniff_pattern}*"):
                    return

                captured = CapturedRequest(
                    method=request.method,
                    url=url,
                    timestamp=time.time(),
                    request_headers=dict(request.headers) if request.headers else {},
                )

                # Capture request body (POST/PUT/PATCH)
                try:
                    post_data = request.post_data
                    if post_data:
                        captured.request_body = post_data[:5000]
                except Exception:
                    pass

                # Store as pending, keyed by url+method to match with response later
                key = f"{request.method}:{url}"
                self._pending_requests[key] = captured

            except Exception as e:
                logger.debug(f"[BROWSER-SNIFF] Request capture error: {e}")

        async def on_response(response):
            """Capture response data and match with pending request."""
            try:
                if len(self.network_log) >= MAX_NETWORK_LOG:
                    return

                request = response.request
                url = request.url
                resource_type = request.resource_type

                # Skip static assets
                if resource_type in ('image', 'stylesheet', 'font', 'media', 'websocket', 'manifest', 'other'):
                    return

                # Filter by pattern
                if self.sniff_pattern != "*" and not fnmatch.fnmatch(url, f"*{self.sniff_pattern}*"):
                    return

                # Find matching pending request or create new
                key = f"{request.method}:{url}"
                captured = self._pending_requests.pop(key, None)
                if not captured:
                    captured = CapturedRequest(
                        method=request.method,
                        url=url,
                        timestamp=time.time(),
                        request_headers=dict(request.headers) if request.headers else {},
                    )
                    # Capture request body
                    try:
                        post_data = request.post_data
                        if post_data:
                            captured.request_body = post_data[:5000]
                    except Exception:
                        pass

                # Fill in response data
                captured.status = response.status
                captured.response_headers = dict(response.headers) if response.headers else {}

                # Capture response body — handle different content types
                try:
                    content_type = (response.headers.get('content-type') or '').lower()
                    
                    # Only capture text-based responses
                    is_text = any(t in content_type for t in [
                        'text/', 'json', 'xml', 'javascript', 'html',
                        'csv', 'form-urlencoded', 'graphql',
                    ])

                    if is_text or not content_type:
                        try:
                            body = await response.text()
                            if body:
                                captured.response_preview = body[:5000]
                        except Exception:
                            # Fallback: try body() for raw bytes
                            try:
                                raw = await response.body()
                                if raw:
                                    try:
                                        captured.response_preview = raw.decode('utf-8', errors='replace')[:5000]
                                    except Exception:
                                        captured.response_preview = f"<binary {len(raw)} bytes>"
                            except Exception:
                                captured.response_preview = "<response body unavailable>"
                    else:
                        # Binary content — just note the size from content-length
                        content_length = response.headers.get('content-length', '?')
                        captured.response_preview = f"<{content_type.split(';')[0]} — {content_length} bytes>"

                except Exception as e:
                    captured.response_preview = f"<body capture error: {str(e)[:100]}>"
                    logger.debug(f"[BROWSER-SNIFF] Response body capture failed for {url}: {e}")

                self.network_log.append(captured)
                logger.debug(f"[BROWSER-SNIFF] Captured: {request.method} {url[:100]} → {response.status}")

            except Exception as e:
                logger.warning(f"[BROWSER-SNIFF] Response handler error: {e}")

        self._sniff_request_handler = on_request
        self._sniff_response_handler = on_response
        self.page.on("request", on_request)
        self.page.on("response", on_response)
        return f"Network sniffing started (pattern: '{url_pattern}'). Navigate and interact to capture requests."

    async def sniff_stop(self) -> str:
        """Stop capturing network requests."""
        if not self.sniffing:
            return "Sniffing is not active."

        self.sniffing = False
        
        # Remove event handlers
        try:
            if hasattr(self, '_sniff_request_handler'):
                self.page.remove_listener("request", self._sniff_request_handler)
            if hasattr(self, '_sniff_response_handler'):
                self.page.remove_listener("response", self._sniff_response_handler)
        except Exception as e:
            logger.debug(f"[BROWSER-SNIFF] Error removing handlers: {e}")

        # Flush any pending requests that never got a response
        if hasattr(self, '_pending_requests'):
            for key, captured in self._pending_requests.items():
                captured.response_preview = "<no response received>"
                self.network_log.append(captured)
            self._pending_requests.clear()

        count = len(self.network_log)
        return f"Network sniffing stopped. {count} request(s) captured. Use action='get_network_log' to view."

    async def get_network_log(self, filter_str: Optional[str] = None) -> str:
        """Return captured network requests."""
        if not self.network_log:
            if self.sniffing:
                return f"Sniffing is active (pattern: '{self.sniff_pattern}') but no requests captured yet. Navigate or interact with the page."
            return "No network requests captured. Start sniffing first with action='sniff_start'."

        import fnmatch
        
        logs = self.network_log
        if filter_str:
            logs = [r for r in logs if fnmatch.fnmatch(r.url, f"*{filter_str}*")]

        if not logs:
            return f"No requests matching filter '{filter_str}'. Total captured: {len(self.network_log)}"

        output = [f"📡 Captured {len(logs)} request(s) (total: {len(self.network_log)}):\n"]
        for i, req in enumerate(logs, 1):
            output.append(f"━━━ [{i}] {req.method} {req.url}")
            output.append(f"    Status: {req.status or 'pending'}")
            
            # Show key request headers
            if req.request_headers:
                content_type = req.request_headers.get('content-type', '')
                if content_type:
                    output.append(f"    Content-Type: {content_type}")

            if req.request_body:
                output.append(f"    📤 Request Body ({len(req.request_body)} chars):")
                output.append(f"    {req.request_body[:2000]}")

            if req.response_preview:
                output.append(f"    📥 Response ({len(req.response_preview)} chars):")
                output.append(f"    {req.response_preview[:2000]}")

            # Show key response headers
            if req.response_headers:
                for header in ['content-type', 'set-cookie', 'location', 'x-request-id']:
                    val = req.response_headers.get(header, '')
                    if val:
                        output.append(f"    📋 {header}: {val[:200]}")

            output.append("")

        return "\n".join(output)

    # ─── Element Inspection (DevTools-like) ─────────────────────────────

    _INSPECT_JS = """
    (elementSelector) => {
        // Resolve the target element
        const el = document.querySelector(elementSelector);
        if (!el) return { error: 'Element not found for selector: ' + elementSelector };

        // ── Helper: generate CSS selector strategies ──
        function getSelectors(node) {
            const strategies = [];

            // Strategy 1: ID
            if (node.id) {
                strategies.push({ strategy: 'id', selector: '#' + CSS.escape(node.id), stability: 'high' });
            }

            // Strategy 2: data-testid / data-cy / data-qa
            for (const attr of ['data-testid', 'data-cy', 'data-qa', 'data-test', 'data-automation-id']) {
                const val = node.getAttribute(attr);
                if (val) {
                    strategies.push({ strategy: attr, selector: `[${attr}="${val}"]`, stability: 'high' });
                }
            }

            // Strategy 3: aria-label
            const ariaLabel = node.getAttribute('aria-label');
            if (ariaLabel) {
                strategies.push({ strategy: 'aria-label', selector: `[aria-label="${ariaLabel}"]`, stability: 'medium-high' });
            }

            // Strategy 4: name attribute (forms)
            const name = node.getAttribute('name');
            if (name) {
                strategies.push({ strategy: 'name', selector: `[name="${name}"]`, stability: 'medium-high' });
            }

            // Strategy 5: Unique class combination
            if (node.classList.length > 0) {
                const classes = Array.from(node.classList);
                // Try single unique class first
                for (const cls of classes) {
                    if (cls && document.querySelectorAll('.' + CSS.escape(cls)).length === 1) {
                        strategies.push({ strategy: 'unique-class', selector: '.' + CSS.escape(cls), stability: 'medium' });
                        break;
                    }
                }
                // Tag + classes combo
                const tag = node.tagName.toLowerCase();
                const classSelector = tag + classes.map(c => '.' + CSS.escape(c)).join('');
                if (document.querySelectorAll(classSelector).length === 1) {
                    strategies.push({ strategy: 'tag+classes', selector: classSelector, stability: 'medium' });
                }
            }

            // Strategy 6: Structural path (nth-of-type chain)
            const pathParts = [];
            let current = node;
            while (current && current !== document.body && pathParts.length < 5) {
                let part = current.tagName.toLowerCase();
                if (current.id) {
                    pathParts.unshift('#' + CSS.escape(current.id));
                    break;
                }
                const parent = current.parentElement;
                if (parent) {
                    const siblings = Array.from(parent.children).filter(c => c.tagName === current.tagName);
                    if (siblings.length > 1) {
                        const idx = siblings.indexOf(current) + 1;
                        part += ':nth-of-type(' + idx + ')';
                    }
                }
                pathParts.unshift(part);
                current = parent;
            }
            if (pathParts.length > 0) {
                strategies.push({ strategy: 'structural-path', selector: pathParts.join(' > '), stability: 'low' });
            }

            // Add uniqueness count for each selector
            for (const s of strategies) {
                try {
                    s.matches = document.querySelectorAll(s.selector).length;
                } catch(e) {
                    s.matches = -1;
                }
            }

            return strategies;
        }

        // ── Helper: generate XPath ──
        function getXPath(node) {
            if (node.id) return '//*[@id="' + node.id + '"]';

            const parts = [];
            let current = node;
            while (current && current.nodeType === Node.ELEMENT_NODE) {
                let index = 0;
                let sibling = current.previousSibling;
                while (sibling) {
                    if (sibling.nodeType === Node.ELEMENT_NODE && sibling.tagName === current.tagName) index++;
                    sibling = sibling.previousSibling;
                }
                const tag = current.tagName.toLowerCase();
                parts.unshift(tag + '[' + (index + 1) + ']');
                current = current.parentNode;
                if (current === document.body) { parts.unshift('body'); break; }
                if (current === document.documentElement) { parts.unshift('html'); break; }
            }
            return '/' + parts.join('/');
        }

        // ── Helper: get all attributes ──
        function getAttributes(node) {
            const attrs = {};
            for (const attr of node.attributes) {
                attrs[attr.name] = attr.value.substring(0, 200);
            }
            return attrs;
        }

        // ── Helper: parent chain ──
        function getParentChain(node, depth = 4) {
            const chain = [];
            let current = node.parentElement;
            let d = 0;
            while (current && current !== document.documentElement && d < depth) {
                let desc = current.tagName.toLowerCase();
                if (current.id) desc += '#' + current.id;
                else if (current.className && typeof current.className === 'string')
                    desc += '.' + current.className.split(/\\s+/).slice(0, 2).join('.');
                chain.push(desc);
                current = current.parentElement;
                d++;
            }
            return chain;
        }

        // ── Build result ──
        const rect = el.getBoundingClientRect();
        const computed = window.getComputedStyle(el);

        return {
            tag: el.tagName.toLowerCase(),
            text: (el.innerText || '').trim().substring(0, 200),
            selectors: getSelectors(el),
            xpath: getXPath(el),
            attributes: getAttributes(el),
            parent_chain: getParentChain(el),
            bounding_box: {
                x: Math.round(rect.x),
                y: Math.round(rect.y),
                width: Math.round(rect.width),
                height: Math.round(rect.height),
            },
            computed_styles: {
                display: computed.display,
                position: computed.position,
                visibility: computed.visibility,
                zIndex: computed.zIndex,
            },
            is_visible: rect.width > 0 && rect.height > 0 && computed.display !== 'none' && computed.visibility !== 'hidden',
            in_viewport: rect.top < window.innerHeight && rect.bottom > 0 && rect.left < window.innerWidth && rect.right > 0,
        };
    }
    """

    async def inspect(self, element: Optional[int] = None, selector: Optional[str] = None) -> str:
        """Inspect an element and return detailed selector/attribute information."""
        if not self.page:
            return "Error: Browser not launched."

        try:
            # Resolve selector from element number or direct selector
            target_selector = None
            if element is not None:
                elem_info = self.element_map.get(int(element))
                if not elem_info:
                    return f"Error: Element [{element}] not found. Use a number from the interactive elements list."
                target_selector = elem_info.get("selector")
            elif selector:
                target_selector = selector
            else:
                return "Error: Provide element number or CSS selector."

            # Run the inspection JS
            result = await self.page.evaluate(self._INSPECT_JS, target_selector)

            if isinstance(result, dict) and 'error' in result:
                return f"Inspect error: {result['error']}"

            # Format output
            output = []
            output.append(f"🔍 Element Inspection")
            output.append(f"{'─' * 50}")
            output.append(f"Tag: <{result['tag']}>")
            if result.get('text'):
                output.append(f"Text: \"{result['text']}\"")
            output.append(f"Visible: {'✅' if result.get('is_visible') else '❌'}  |  In viewport: {'✅' if result.get('in_viewport') else '❌'}")
            box = result.get('bounding_box', {})
            output.append(f"Position: ({box.get('x', 0)}, {box.get('y', 0)}) — {box.get('width', 0)}×{box.get('height', 0)}px")

            # Selectors ranked by stability
            output.append(f"\n── CSS Selectors (ranked by stability) ──")
            selectors = result.get('selectors', [])
            stability_order = {'high': 0, 'medium-high': 1, 'medium': 2, 'low': 3}
            selectors.sort(key=lambda s: stability_order.get(s.get('stability', 'low'), 99))

            for sel in selectors:
                unique_flag = "✅ unique" if sel.get('matches') == 1 else f"⚠️  {sel.get('matches')} matches"
                output.append(f"  [{sel['stability']:>11}] {sel['selector']}")
                output.append(f"               Strategy: {sel['strategy']}  |  {unique_flag}")

            # Recommend best selector
            best = next((s for s in selectors if s.get('matches') == 1), None)
            if best:
                output.append(f"\n  ★ Recommended: {best['selector']}")

            # XPath
            output.append(f"\n── XPath ──")
            output.append(f"  {result.get('xpath', 'N/A')}")

            # Attributes
            attrs = result.get('attributes', {})
            if attrs:
                output.append(f"\n── Attributes ({len(attrs)}) ──")
                for key, val in attrs.items():
                    output.append(f"  {key}=\"{val}\"")

            # Parent chain
            chain = result.get('parent_chain', [])
            if chain:
                output.append(f"\n── Parent Chain ──")
                output.append(f"  {' → '.join(reversed(chain))} → [{result['tag']}]")

            return "\n".join(output)

        except Exception as e:
            return f"Inspect failed: {str(e)}"

    _FIND_SELECTOR_JS = """
    (searchText) => {
        const results = [];
        const searchLower = searchText.toLowerCase();

        // Search all elements for matching text
        const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
        const seen = new Set();

        while (walker.nextNode()) {
            const el = walker.currentNode;

            // Check text content (direct, not children)
            const directText = Array.from(el.childNodes)
                .filter(n => n.nodeType === Node.TEXT_NODE)
                .map(n => n.textContent.trim())
                .join(' ');

            const ariaLabel = el.getAttribute('aria-label') || '';
            const placeholder = el.getAttribute('placeholder') || '';
            const title = el.getAttribute('title') || '';
            const alt = el.getAttribute('alt') || '';
            const value = el.getAttribute('value') || '';

            const allText = [directText, el.innerText || '', ariaLabel, placeholder, title, alt, value]
                .join(' ').toLowerCase();

            if (!allText.includes(searchLower)) continue;

            // Skip invisible
            const rect = el.getBoundingClientRect();
            if (rect.width === 0 && rect.height === 0) continue;
            const style = window.getComputedStyle(el);
            if (style.display === 'none' || style.visibility === 'hidden') continue;

            // Dedup
            const key = el.tagName + el.outerHTML.substring(0, 80);
            if (seen.has(key)) continue;
            seen.add(key);

            // Build best selector
            let bestSelector = '';
            let strategy = '';

            if (el.id) {
                bestSelector = '#' + CSS.escape(el.id);
                strategy = 'id';
            } else {
                for (const attr of ['data-testid', 'data-cy', 'data-qa']) {
                    const v = el.getAttribute(attr);
                    if (v) { bestSelector = '[' + attr + '="' + v + '"]'; strategy = attr; break; }
                }
            }
            if (!bestSelector && ariaLabel) {
                bestSelector = '[aria-label="' + ariaLabel + '"]';
                strategy = 'aria-label';
            }
            if (!bestSelector) {
                // Structural fallback
                const parts = [];
                let cur = el;
                while (cur && cur !== document.body && parts.length < 4) {
                    let p = cur.tagName.toLowerCase();
                    if (cur.id) { parts.unshift('#' + CSS.escape(cur.id)); break; }
                    const par = cur.parentElement;
                    if (par) {
                        const sibs = Array.from(par.children).filter(c => c.tagName === cur.tagName);
                        if (sibs.length > 1) p += ':nth-of-type(' + (sibs.indexOf(cur) + 1) + ')';
                    }
                    parts.unshift(p);
                    cur = cur.parentElement;
                }
                bestSelector = parts.join(' > ');
                strategy = 'structural-path';
            }

            let matches = -1;
            try { matches = document.querySelectorAll(bestSelector).length; } catch(e) {}

            results.push({
                tag: el.tagName.toLowerCase(),
                text: (directText || el.innerText || '').trim().substring(0, 100),
                selector: bestSelector,
                strategy: strategy,
                matches: matches,
                in_viewport: rect.top < window.innerHeight && rect.bottom > 0,
            });

            if (results.length >= 20) break;
        }
        return results;
    }
    """

    async def find_selector(self, text: str) -> str:
        """Find elements matching visible text and return their best selectors."""
        if not self.page:
            return "Error: Browser not launched."

        try:
            results = await self.page.evaluate(self._FIND_SELECTOR_JS, text)

            if not results:
                return f"No elements found matching text '{text}'."

            output = [f"🔎 Found {len(results)} element(s) matching \"{text}\":\n"]

            for i, r in enumerate(results, 1):
                unique_flag = "✅ unique" if r.get('matches') == 1 else f"⚠️  {r.get('matches')} matches"
                viewport = "in viewport" if r.get('in_viewport') else "offscreen"
                output.append(f"[{i}] <{r['tag']}> \"{r['text'][:60]}\"")
                output.append(f"    Selector: {r['selector']}")
                output.append(f"    Strategy: {r['strategy']}  |  {unique_flag}  |  {viewport}")
                output.append("")

            return "\n".join(output)

        except Exception as e:
            return f"Find selector failed: {str(e)}"

    async def list_profiles(self) -> str:
        """List saved browser profiles in workspace."""
        if not self.workspace_path:
            return "Error: No workspace path set."

        profiles_dir = os.path.join(self.workspace_path, ".browser_profiles")
        if not os.path.exists(profiles_dir):
            return "No browser profiles saved yet."

        profiles = []
        for name in os.listdir(profiles_dir):
            profile_path = os.path.join(profiles_dir, name)
            if os.path.isdir(profile_path):
                state_file = os.path.join(profile_path, "storage_state.json")
                has_state = os.path.exists(state_file)
                if has_state:
                    size = os.path.getsize(state_file)
                    mtime = time.ctime(os.path.getmtime(state_file))
                    profiles.append(f"  • {name} (state: {size}B, last saved: {mtime})")
                else:
                    profiles.append(f"  • {name} (no saved state)")

        if not profiles:
            return "No browser profiles saved yet."

        return "Saved browser profiles:\n" + "\n".join(profiles)

    async def run_script(self, script: str) -> str:
        """Execute a raw Playwright Python script with access to page, browser, context.
        
        The script runs as an async function body. It has access to:
        - page: the active Playwright Page object
        - browser: the Browser instance
        - context: the BrowserContext
        
        The script should use 'return' to produce output, or print() for side effects.
        All Playwright calls must use 'await' since they are async.
        """
        if not self.page:
            return "Error: Browser not launched. Use 'launch' or 'navigate' first."
        
        import io
        import contextlib
        
        # Wrap user script in an async function that has page/browser/context in scope
        wrapped = f"""async def __script__(page, browser, context):\n"""
        for line in script.strip().splitlines():
            wrapped += f"    {line}\n"
        
        # If script doesn't have an explicit return, capture stdout
        stdout_capture = io.StringIO()
        
        try:
            # Compile and execute the function definition
            exec_globals = {'__builtins__': __builtins__}
            exec(compile(wrapped, '<browser_script>', 'exec'), exec_globals)
            
            # Call the async function with our live objects
            with contextlib.redirect_stdout(stdout_capture):
                result = await exec_globals['__script__'](self.page, self.browser, self.context)
            
            # Prefer explicit return value, fall back to captured stdout
            if result is not None:
                return str(result)
            
            captured = stdout_capture.getvalue()
            if captured:
                return captured.strip()
            
            return "Script executed successfully (no output)."
            
        except SyntaxError as e:
            # Show the exact line with syntax error + surrounding context
            import traceback
            lines = script.strip().splitlines()
            error_details = [f"Script syntax error: {e}"]
            
            # Show the line with the error (adjust for wrapping offset)
            if e.lineno:
                # lineno from compile() refers to the wrapped code
                # Subtract 1 for the wrapper function line
                actual_lineno = e.lineno - 1
                if 1 <= actual_lineno <= len(lines):
                    error_details.append(f"\nLine {actual_lineno}:")
                    # Show context: 2 lines before, the error line, 2 lines after
                    start = max(0, actual_lineno - 3)
                    end = min(len(lines), actual_lineno + 2)
                    for i in range(start, end):
                        marker = ">>> " if i == actual_lineno - 1 else "    "
                        error_details.append(f"{marker}{i+1:3d}: {lines[i]}")
                    if e.offset:
                        error_details.append(" " * (e.offset + 8) + "^")
            
            error_details.append(f"\nDetails: {e.msg}")
            return "\n".join(error_details)
            
        except Exception as e:
            import traceback
            tb = traceback.format_exc()
            return f"Script error: {e}\n\n--- Full Traceback ---\n{tb}"

    async def close(self, save_profile: bool = True) -> str:
        """Close the browser and optionally save profile state."""
        result_parts = []

        if self.page and not self.is_ephemeral and save_profile and self.profile_dir:
            try:
                state_path = os.path.join(self.profile_dir, "storage_state.json")
                await self.context.storage_state(path=state_path)
                result_parts.append(f"Profile '{self.profile_name}' saved")
            except Exception as e:
                result_parts.append(f"Warning: Could not save profile: {e}")

        # Cleanup
        try:
            if self.page:
                await self.page.close()
            if self.context:
                await self.context.close()
            if self.browser:
                await self.browser.close()
            if self.playwright:
                await self.playwright.stop()
        except Exception as e:
            logger.warning(f"[BROWSER] Cleanup error: {e}")

        # Clean up ephemeral temp dir
        if self.is_ephemeral and self.profile_dir:
            try:
                import shutil
                shutil.rmtree(self.profile_dir, ignore_errors=True)
            except Exception:
                pass

        self.page = None
        self.context = None
        self.browser = None
        self.playwright = None
        self.element_map.clear()
        self.network_log.clear()

        result_parts.append("Browser closed")
        return " | ".join(result_parts)

    # ── Internal Helpers ──────────────────────────────────

    async def _build_page_snapshot(self) -> str:
        """
        Build a page snapshot with interactive elements map.
        This is the core feature — gives the LLM a structured view of the page
        with numbered elements it can reference in subsequent actions.
        """
        if not self.page:
            return "Error: No page loaded."

        try:
            # Get page info
            url = self.page.url
            title = await self.page.title()

            # Extract interactive elements
            self.element_map.clear()
            elements_data = await self.page.evaluate("""
                () => {
                    const interactiveSelectors = [
                        'a[href]',
                        'button',
                        'input:not([type="hidden"])',
                        'select',
                        'textarea',
                        '[contenteditable="true"]',
                        '[role="button"]',
                        '[role="link"]',
                        '[role="tab"]',
                        '[role="menuitem"]',
                        '[role="combobox"]',
                        '[role="searchbox"]',
                        '[role="textbox"]',
                        '[onclick]',
                        'summary',
                    ];
                    
                    const elements = [];
                    const seen = new Set();
                    
                    for (const sel of interactiveSelectors) {
                        for (const el of document.querySelectorAll(sel)) {
                            // Skip hidden/invisible elements
                            const rect = el.getBoundingClientRect();
                            if (rect.width === 0 && rect.height === 0) continue;
                            const style = window.getComputedStyle(el);
                            if (style.display === 'none' || style.visibility === 'hidden') continue;
                            
                            // Dedup by element identity
                            const key = el.outerHTML.substring(0, 100);
                            if (seen.has(key)) continue;
                            seen.add(key);
                            
                            const tag = el.tagName.toLowerCase();
                            const type = el.getAttribute('type') || '';
                            const text = (
                                el.innerText || 
                                el.getAttribute('aria-label') || 
                                el.getAttribute('placeholder') || 
                                el.getAttribute('title') || 
                                el.getAttribute('alt') ||
                                el.getAttribute('value') ||
                                el.getAttribute('name') ||
                                ''
                            ).trim().substring(0, 80);
                            
                            let elementType = tag;
                            if (tag === 'input') elementType = `input[${type || 'text'}]`;
                            else if (tag === 'a') elementType = 'link';
                            else if (tag === 'select') elementType = 'dropdown';
                            else if (el.getAttribute('role')) elementType = el.getAttribute('role');
                            
                            // Build a unique CSS selector
                            let cssSelector = '';
                            if (el.id) {
                                cssSelector = `#${el.id}`;
                            } else {
                                // Build path-based selector
                                const parts = [];
                                let current = el;
                                while (current && current !== document.body) {
                                    let part = current.tagName.toLowerCase();
                                    if (current.id) {
                                        parts.unshift(`#${current.id}`);
                                        break;
                                    }
                                    const parent = current.parentElement;
                                    if (parent) {
                                        const siblings = Array.from(parent.children).filter(
                                            c => c.tagName === current.tagName
                                        );
                                        if (siblings.length > 1) {
                                            const idx = siblings.indexOf(current) + 1;
                                            part += `:nth-of-type(${idx})`;
                                        }
                                    }
                                    parts.unshift(part);
                                    current = current.parentElement;
                                    if (parts.length > 4) break;
                                }
                                cssSelector = parts.join(' > ');
                            }
                            
                            // Check if element is in viewport
                            const inViewport = (
                                rect.top < window.innerHeight &&
                                rect.bottom > 0 &&
                                rect.left < window.innerWidth &&
                                rect.right > 0
                            );
                            
                            elements.push({
                                type: elementType,
                                text: text,
                                selector: cssSelector,
                                href: tag === 'a' ? el.getAttribute('href') : null,
                                placeholder: el.getAttribute('placeholder') || null,
                                checked: el.checked || null,
                                selected: el.classList.contains('active') || el.getAttribute('aria-selected') === 'true' || null,
                                inViewport: inViewport,
                            });
                            
                            if (elements.length >= """ + str(MAX_ELEMENTS) + """) break;
                        }
                        if (elements.length >= """ + str(MAX_ELEMENTS) + """) break;
                    }
                    
                    return elements;
                }
            """)

            # Build element map
            for i, elem_data in enumerate(elements_data, 1):
                self.element_map[i] = elem_data

            # Get visible text content
            visible_text = await self.page.evaluate("""
                () => {
                    const body = document.body;
                    if (!body) return '';
                    
                    // Clone body and strip interactive/noise elements for text
                    const clone = body.cloneNode(true);
                    const remove = clone.querySelectorAll('script, style, noscript, svg, iframe');
                    remove.forEach(el => el.remove());
                    
                    return clone.innerText.trim();
                }
            """)

            # Clean and truncate text
            visible_text = re.sub(r'\n{3,}', '\n\n', visible_text)
            visible_text = '\n'.join(line for line in visible_text.split('\n') if line.strip())
            if len(visible_text) > MAX_PAGE_TEXT:
                visible_text = visible_text[:MAX_PAGE_TEXT] + f"\n... [truncated]"

            # Build output
            output = []
            output.append(f"Page: {url}")
            output.append(f"Title: {title}")
            output.append("")

            # Interactive elements section
            if elements_data:
                output.append(f"── Interactive Elements ({len(elements_data)}) ──")
                for i, elem in enumerate(elements_data, 1):
                    line = f"[{i:>2}] {elem['type']:<16}"
                    
                    text = elem.get('text', '')
                    placeholder = elem.get('placeholder', '')
                    
                    if text:
                        line += f' "{text}"'
                    elif placeholder:
                        line += f' [{placeholder}]'
                    
                    # Flags
                    flags = []
                    if elem.get('selected'):
                        flags.append('★ active')
                    if elem.get('checked'):
                        flags.append('✓ checked')
                    if not elem.get('inViewport'):
                        flags.append('↕ offscreen')
                    if flags:
                        line += f"  ({', '.join(flags)})"
                    
                    output.append(line)
            else:
                output.append("── No interactive elements found ──")

            output.append("")
            output.append("── Page Content ──")
            output.append(visible_text)

            return "\n".join(output)

        except Exception as e:
            logger.error(f"[BROWSER] Page snapshot failed: {e}", exc_info=True)
            return f"Error building page snapshot: {str(e)}"


class SeleniumBrowserSession:
    """
    Selenium-based browser session.

    WHY: Playwright connects via Chrome DevTools Protocol (CDP), which Cloudflare's
    Private Access Token + VM challenge detects.  Selenium uses a separate channel
    (WebDriver BiDi / W3C), and with the right ChromeOptions the browser is
    indistinguishable from a manually launched Chrome — confirmed to bypass Cloudflare.

    REUSE: Unlike Playwright (which needs an async event loop thread), Selenium is
    synchronous.  The webdriver.Chrome instance is kept alive in memory
    (referenced from runner.session_state['_browser']) and reused across tool calls.
    The browser window stays open between calls; close() is the only thing that
    shuts it down.
    """

    def __init__(self):
        self.driver = None
        self.profile_name: Optional[str] = None
        self.profile_dir: Optional[str] = None
        self.is_ephemeral: bool = True
        self.headless: bool = False
        self.element_map: Dict[int, Dict[str, Any]] = {}
        self.network_log: List[CapturedRequest] = []
        self.sniffing: bool = False
        self.sniff_pattern: Optional[str] = None
        # Sentinel thread/loop so BrowserTool's alive-check works the same way
        self._thread = threading.current_thread()

    # ── Compatibility shim ──────────────────────────────────
    def run_async(self, fn_or_result, timeout: float = 120):
        """
        Compatibility shim — Selenium is synchronous, so 'run_async' just
        calls the callable directly (or returns a value if already resolved).
        """
        if callable(fn_or_result):
            return fn_or_result()
        return fn_or_result

    def is_alive(self) -> bool:
        """Return True if the Chrome window is still open and controllable."""
        try:
            if self.driver is None:
                return False
            _ = self.driver.session_id          # raises if quit()
            _ = self.driver.window_handles      # raises if window closed
            return True
        except Exception:
            return False

    # ── Launch ───────────────────────────────────────────────
    def launch(
        self,
        workspace_path: str,
        profile: str = "ephemeral",
        headless: bool = False,
        stealth: bool = True,
        proxy: Optional[str] = None,
    ) -> str:
        """
        Launch a Selenium Chrome session.

        Args:
            workspace_path: Agent workspace root (profile dirs live here)
            profile: Profile name or 'ephemeral' for a temp session
            headless: Run without a visible window (NOTE: headless is detectable
                      by Cloudflare — strongly prefer headless=False for CF sites)
            stealth: Apply exclusion switches that remove automation indicators
            proxy: 'host:port' or 'user:pass:host:port'
        """
        try:
            from selenium import webdriver as sel_webdriver
            from selenium.webdriver.chrome.options import Options
        except ImportError:
            return "Error: selenium is not installed. Run: pip install selenium"

        self.is_ephemeral = (profile == "ephemeral")
        self.profile_name = profile
        self.headless = headless

        # ── Profile directory ────────────────────────────────
        if not self.is_ephemeral:
            self.profile_dir = os.path.join(workspace_path, ".browser_profiles", profile)
            os.makedirs(self.profile_dir, exist_ok=True)
        else:
            import tempfile
            self.profile_dir = tempfile.mkdtemp(prefix="selenium_ephemeral_")

        # ── Chrome options (your confirmed-working config) ───
        options = Options()

        if headless:
            options.add_argument("--headless=new")

        options.add_argument("--start-maximized")
        options.add_argument("--disable-notifications")
        options.add_argument("--disable-infobars")
        options.add_argument("--disable-gpu")
        options.add_argument("--no-first-run")
        options.add_argument("--no-default-browser-check")
        options.add_argument("ignore-certificate-errors")
        options.add_argument("log-level=3")

        if stealth:
            # THE key flags — remove all automation signals
            options.add_argument("--disable-blink-features=AutomationControlled")
            options.add_experimental_option("useAutomationExtension", False)
            options.add_experimental_option("excludeSwitches", ["enable-automation"])

        # Profile persistence
        options.add_argument(f"--user-data-dir={self.profile_dir}")
        options.add_argument("--profile-directory=Default")

        # Proxy
        if proxy:
            parts = proxy.split(":")
            if len(parts) == 2:                 # host:port
                options.add_argument(f"--proxy-server=http://{proxy}")
            elif len(parts) == 4:               # user:pass:host:port (extension handled externally)
                user, pw, host, port = parts
                options.add_argument(f"--proxy-server=http://{host}:{port}")
                # Proxy auth extension could be added here if needed

        # Page load strategy — 'none' lets us proceed without waiting for full load
        options.page_load_strategy = "none"

        # Selenium 4.6+ has built-in Selenium Manager — no chromedriver download needed
        self.driver = sel_webdriver.Chrome(options=options)

        # Inject anti-webdriver patch via CDP (Selenium 4 BiDi / CDP execute)
        if stealth:
            try:
                self.driver.execute_cdp_cmd(
                    "Page.addScriptToEvaluateOnNewDocument",
                    {"source": (
                        "Object.defineProperty(navigator,'webdriver',{get:()=>undefined,configurable:true});"
                        "delete Navigator.prototype.webdriver;"
                    )},
                )
            except Exception:
                pass  # CDP not available in all setups — silently skip

        profile_status = "restored" if (not self.is_ephemeral and
                                        os.path.exists(os.path.join(self.profile_dir, "Default", "Cookies"))) else "new"
        return (
            f"Selenium Chrome launched | engine=selenium | profile='{profile}' ({profile_status}) | "
            f"headless={headless} | stealth={stealth}"
        )

    # ── Navigation ───────────────────────────────────────────
    def navigate(self, url: str) -> str:
        if not self.driver:
            return "Error: Browser not launched."
        if not url.startswith(("http://", "https://")):
            url = f"https://{url}"
        try:
            self.driver.get(url)
            # Wait briefly for JS-heavy pages to render
            time.sleep(2)
            return self._build_page_snapshot()
        except Exception as e:
            return f"Navigation failed: {e}"

    # ── Click ────────────────────────────────────────────────
    def click(
        self,
        element: Optional[int] = None,
        selector: Optional[str] = None,
        text: Optional[str] = None,
    ) -> str:
        if not self.driver:
            return "Error: Browser not launched."
        try:
            from selenium.webdriver import ActionChains
            from selenium.webdriver.common.by import By
            from selenium.webdriver.support.ui import WebDriverWait
            from selenium.webdriver.support import expected_conditions as EC

            el = None
            if element is not None:
                info = self.element_map.get(int(element))
                if not info:
                    return f"Error: Element [{element}] not in map. Navigate first."
                sel = info.get("selector")
                el = WebDriverWait(self.driver, 5).until(
                    EC.element_to_be_clickable((By.CSS_SELECTOR, sel))
                )
            elif text:
                el = WebDriverWait(self.driver, 5).until(
                    EC.element_to_be_clickable(
                        (By.XPATH, f"//*[normalize-space(text())='{text}']")
                    )
                )
            elif selector:
                el = WebDriverWait(self.driver, 5).until(
                    EC.element_to_be_clickable((By.CSS_SELECTOR, selector))
                )
            else:
                return "Error: Provide element number, selector, or text."

            ActionChains(self.driver).move_to_element(el).click().perform()
            time.sleep(1.5)
            return self._build_page_snapshot()
        except Exception as e:
            return f"Click failed: {e}"

    # ── Type text ────────────────────────────────────────────
    def type_text(
        self,
        element: Optional[int] = None,
        selector: Optional[str] = None,
        text: str = "",
        clear: bool = True,
        press_enter: bool = False,
    ) -> str:
        if not self.driver:
            return "Error: Browser not launched."
        try:
            from selenium.webdriver.common.by import By
            from selenium.webdriver.common.keys import Keys
            from selenium.webdriver.support.ui import WebDriverWait
            from selenium.webdriver.support import expected_conditions as EC

            css = None
            if element is not None:
                info = self.element_map.get(int(element))
                if not info:
                    return f"Error: Element [{element}] not in map."
                css = info.get("selector")
            elif selector:
                css = selector
            else:
                return "Error: Provide element number or selector."

            el = WebDriverWait(self.driver, 5).until(
                EC.presence_of_element_located((By.CSS_SELECTOR, css))
            )
            if clear:
                el.clear()
            el.send_keys(text)
            if press_enter:
                el.send_keys(Keys.RETURN)
                time.sleep(1.5)
            time.sleep(0.5)
            return self._build_page_snapshot()
        except Exception as e:
            return f"Type failed: {e}"

    # ── Press key ────────────────────────────────────────────
    def press_key(self, key: str, count: int = 1) -> str:
        if not self.driver:
            return "Error: Browser not launched."
        try:
            from selenium.webdriver import ActionChains
            from selenium.webdriver.common.keys import Keys as K
            _map = {
                "Enter": K.RETURN, "Tab": K.TAB, "Escape": K.ESCAPE,
                "ArrowDown": K.ARROW_DOWN, "ArrowUp": K.ARROW_UP,
                "ArrowLeft": K.ARROW_LEFT, "ArrowRight": K.ARROW_RIGHT,
                "Backspace": K.BACK_SPACE, "Delete": K.DELETE,
                "PageDown": K.PAGE_DOWN, "PageUp": K.PAGE_UP,
                "Home": K.HOME, "End": K.END,
            }
            k = _map.get(key, key)
            chains = ActionChains(self.driver)
            for _ in range(count):
                chains.send_keys(k)
            chains.perform()
            time.sleep(1)
            return self._build_page_snapshot()
        except Exception as e:
            return f"Key press failed: {e}"

    # ── Scroll ───────────────────────────────────────────────
    def scroll(self, direction: str = "down", amount: int = 500) -> str:
        if not self.driver:
            return "Error: Browser not launched."
        try:
            delta = amount if direction == "down" else -amount
            self.driver.execute_script(f"window.scrollBy(0, {delta})")
            time.sleep(0.8)
            return self._build_page_snapshot()
        except Exception as e:
            return f"Scroll failed: {e}"

    # ── Extract text ─────────────────────────────────────────
    def extract(self, selector: Optional[str] = None, max_length: int = MAX_PAGE_TEXT) -> str:
        if not self.driver:
            return "Error: Browser not launched."
        try:
            from selenium.webdriver.common.by import By
            if selector:
                els = self.driver.find_elements(By.CSS_SELECTOR, selector)
                texts = [e.text.strip() for e in els[:10] if e.text.strip()]
                content = "\n\n".join(texts)
            else:
                content = self.driver.find_element(By.TAG_NAME, "body").text
            content = re.sub(r'\n{3,}', '\n\n', content)
            if len(content) > max_length:
                content = content[:max_length] + f"\n... [truncated]"
            return content
        except Exception as e:
            return f"Extract failed: {e}"

    # ── Screenshot ───────────────────────────────────────────
    def screenshot(self) -> Optional[bytes]:
        if not self.driver:
            return None
        try:
            return self.driver.get_screenshot_as_png()
        except Exception:
            return None

    # ── Execute JS ───────────────────────────────────────────
    def execute_script(self, script: str) -> str:
        if not self.driver:
            return "Error: Browser not launched."
        try:
            result = self.driver.execute_script(script)
            return str(result) if result is not None else "Script executed (no return value)."
        except Exception as e:
            return f"Script error: {e}"

    # ── Close ────────────────────────────────────────────────
    def close(self, save_profile: bool = True) -> str:
        parts = []
        try:
            if self.driver:
                self.driver.quit()
                self.driver = None
            parts.append("Browser closed")
        except Exception as e:
            parts.append(f"Close warning: {e}")

        if self.is_ephemeral and self.profile_dir:
            try:
                import shutil
                shutil.rmtree(self.profile_dir, ignore_errors=True)
            except Exception:
                pass

        self.element_map.clear()
        self.network_log.clear()
        return " | ".join(parts)

    # ── Page snapshot ─────────────────────────────────────────
    def _build_page_snapshot(self) -> str:
        """
        Build a numbered interactive-element map from the current page.
        Same output format as StealthBrowserSession so the LLM sees identical structure.
        """
        if not self.driver:
            return "Error: No page loaded."
        try:
            url = self.driver.current_url
            title = self.driver.title

            elements_data = self.driver.execute_script("""
                (function() {
                    var selectors = [
                        'a[href]','button','input:not([type="hidden"])',
                        'select','textarea','[contenteditable="true"]',
                        '[role="button"]','[role="link"]','[role="tab"]',
                        '[role="menuitem"]','[role="combobox"]','[role="searchbox"]',
                        '[role="textbox"]','[onclick]','summary'
                    ];
                    var results = [], seen = new Set();
                    for (var si = 0; si < selectors.length; si++) {
                        var nodes = document.querySelectorAll(selectors[si]);
                        for (var ni = 0; ni < nodes.length; ni++) {
                            var el = nodes[ni];
                            var rect = el.getBoundingClientRect();
                            if (rect.width === 0 && rect.height === 0) continue;
                            var key = el.tagName + '|' + (el.id||'') + '|' + Math.round(rect.top) + '|' + Math.round(rect.left);
                            if (seen.has(key)) continue;
                            seen.add(key);
                            // Build stable CSS selector
                            var sel = el.tagName.toLowerCase();
                            if (el.id) sel = '#' + el.id;
                            else if (el.getAttribute('data-testid')) sel = '[data-testid="'+el.getAttribute('data-testid')+'"]';
                            else if (el.name) sel = el.tagName.toLowerCase()+'[name="'+el.name+'"]';
                            var tag = el.tagName.toLowerCase();
                            var type = tag;
                            if (tag === 'input') type = 'input[' + (el.type||'text') + ']';
                            else if (tag === 'a') type = 'link';
                            results.push({
                                type: type,
                                text: (el.innerText||el.value||el.placeholder||'').trim().substring(0,80),
                                placeholder: el.placeholder||'',
                                href: el.href||'',
                                selector: sel,
                                disabled: el.disabled||false
                            });
                            if (results.length >= 50) break;
                        }
                        if (results.length >= 50) break;
                    }
                    return results;
                })();
            """) or []

            self.element_map.clear()
            for i, elem in enumerate(elements_data, 1):
                self.element_map[i] = elem

            # Visible text
            visible_text = self.driver.execute_script("""
                var clone = document.body.cloneNode(true);
                clone.querySelectorAll('script,style,noscript,svg,canvas').forEach(function(e){e.remove();});
                return clone.innerText.trim();
            """) or ""

            visible_text = re.sub(r'\n{3,}', '\n\n', visible_text)
            visible_text = '\n'.join(l for l in visible_text.split('\n') if l.strip())
            if len(visible_text) > MAX_PAGE_TEXT:
                visible_text = visible_text[:MAX_PAGE_TEXT] + "\n... [truncated]"

            output = [f"Page: {url}", f"Title: {title}", ""]
            if elements_data:
                output.append(f"── Interactive Elements ({len(elements_data)}) ──")
                for i, elem in enumerate(elements_data, 1):
                    line = f"[{i:>2}] {elem['type']:<16}"
                    text = elem.get('text', '')
                    placeholder = elem.get('placeholder', '')
                    if text:
                        line += f' "{text}"'
                    elif placeholder:
                        line += f' [{placeholder}]'
                    href = elem.get('href', '')
                    if href and href not in ('', 'javascript:void(0)', '#'):
                        line += f'  → {href[:60]}'
                    if elem.get('disabled'):
                        line += '  (disabled)'
                    output.append(line)
            else:
                output.append("── No interactive elements found ──")

            output.append("")
            output.append("── Page Content ──")
            output.append(visible_text)
            return "\n".join(output)

        except Exception as e:
            logger.error(f"[SELENIUM] Page snapshot failed: {e}", exc_info=True)
            return f"Error building page snapshot: {e}"


class BrowserTool(BaseTool):

    """
    Stealth browser automation tool for agents.
    
    Provides a high-level interface for web browsing with numbered interactive
    elements. The agent references elements by number to click, type, etc.
    
    Sessions persist across tool calls within the same agent session.
    Browser profiles can be saved/restored across different sessions.
    """

    def get_metadata(self) -> ToolMetadata:
        return ToolMetadata(
            name="BROWSER",
            category="web",
            description=(
                "Control a stealth web browser for tasks requiring JavaScript rendering, "
                "login sessions, or interactive web pages. The browser returns a numbered list "
                "of interactive elements — use the element number to click, type, etc.\n\n"
                "IMPORTANT: There is exactly ONE browser per session. If a browser is already "
                "active, 'launch' reuses it. If you call navigate/click/etc without launching, "
                "a browser is auto-launched for you. You do NOT need to call launch first.\n\n"
                "Actions:\n"
                "- launch: Start or check browser (reuses existing; profile='name' or 'ephemeral'; engine='playwright'|'selenium' — use engine='selenium' for Cloudflare-protected sites like Upwork)\n"
                "- navigate: Go to a URL (auto-launches browser if needed)\n"
                "- click: Click element by number, selector, or text\n"
                "- type: Type text into an input element (handles React/contenteditable; set press_enter=true to submit)\n"
                "- press_key: Press a keyboard key (Enter, Tab, Escape, ArrowDown, etc.)\n"
                "- run_script: ★ RECOMMENDED — Execute a Playwright Python script FILE with full access to page/browser/context. "
                "Write script with WRITE_FILE first, then pass the filename. Use 'await' for all Playwright calls.\n"
                "- inspect: Inspect element — get CSS selectors, XPath, attributes (for automation)\n"
                "- find_selector: Find elements by visible text and get their best selectors\n"
                "- extract: Get text content from page/element\n"
                "- extract_links: Get all links on the page\n"
                "- screenshot: Capture the current page\n"
                "- scroll: Scroll the page (up/down/top/bottom)\n"
                "- execute_js: Run JavaScript code\n"
                "- wait: Wait for element or fixed duration\n"
                "- sniff_start: Start capturing network requests\n"
                "- sniff_stop: Stop capturing and flush pending\n"
                "- get_network_log: View captured network requests\n"
                "- list_profiles: Show saved browser profiles\n"
                "- back: Navigate back\n"
                "- close: Save profile and close browser\n\n"
                "PREFER run_script for complex operations. Example workflow:\n"
                "1. WRITE_FILE: path='extract_links.py', content='links = await page.query_selector_all(\"a\")\\nresult=[]\\nfor link in links: result.append(await link.get_attribute(\"href\"))\\nreturn str(result)'\n"
                "2. BROWSER: action='run_script', script_file='extract_links.py'\n\n"
                "For simple page reading without JS, use FETCH_PAGE instead (faster)."
            ),
            version="1.0",
            parameters=[
                ToolParameter(
                    name="action",
                    type="choice",
                    required=True,
                    description="Browser action to perform",
                    choices=[
                        "launch", "navigate", "click", "type", "press_key", "run_script",
                        "inspect", "find_selector", "extract", "extract_links", "screenshot",
                        "scroll", "execute_js", "wait", "sniff_start", "sniff_stop",
                        "get_network_log", "list_profiles", "back", "close",
                    ]
                ),
                ToolParameter(
                    name="url",
                    type="string",
                    required=False,
                    description="URL for navigate action"
                ),
                ToolParameter(
                    name="element",
                    type="int",
                    required=False,
                    description="Element number from the interactive elements map (for click/type)"
                ),
                ToolParameter(
                    name="selector",
                    type="string",
                    required=False,
                    description="CSS selector (alternative to element number)"
                ),
                ToolParameter(
                    name="text",
                    type="string",
                    required=False,
                    description="Text to type, or visible text to click"
                ),
                ToolParameter(
                    name="key",
                    type="string",
                    required=False,
                    description="Key to press for press_key action (e.g. 'Enter', 'Tab', 'Escape', 'ArrowDown', 'Backspace')"
                ),
                ToolParameter(
                    name="press_enter",
                    type="bool",
                    required=False,
                    default=False,
                    description="Press Enter after typing (for type action, to submit search/forms)"
                ),
                ToolParameter(
                    name="profile",
                    type="string",
                    required=False,
                    default="ephemeral",
                    description="Browser profile name for launch (use 'ephemeral' for temp session)"
                ),
                ToolParameter(
                    name="headless",
                    type="bool",
                    required=False,
                    default=True,
                    description="Run browser without visible window (default: true)"
                ),
                ToolParameter(
                    name="stealth",
                    type="bool",
                    required=False,
                    default=True,
                    description="Apply anti-detection patches (default: true)"
                ),
                ToolParameter(
                    name="proxy",
                    type="string",
                    required=False,
                    description="Proxy URL for launch (e.g. 'http://user:pass@host:port')"
                ),
                ToolParameter(
                    name="engine",
                    type="choice",
                    required=False,
                    default="playwright",
                    description=(
                        "Browser engine to use for launch. "
                        "'playwright' (default) — full async stealth browser. "
                        "'selenium' — use this for Cloudflare-protected sites (e.g. Upwork): "
                        "Selenium's W3C WebDriver avoids the CDP detection that Cloudflare's "
                        "Private Access Token challenge uses to block Playwright."
                    ),
                    choices=["playwright", "selenium"]
                ),
                ToolParameter(
                    name="direction",
                    type="choice",
                    required=False,
                    description="Scroll direction",
                    choices=["up", "down", "top", "bottom"]
                ),
                ToolParameter(
                    name="script",
                    type="string",
                    required=False,
                    description="JavaScript code for execute_js action"
                ),
                ToolParameter(
                    name="script_file",
                    type="string",
                    required=False,
                    description="Path to Playwright Python script file for run_script action (relative to workspace). Use WRITE_FILE to create the script first."
                ),
                ToolParameter(
                    name="url_pattern",
                    type="string",
                    required=False,
                    default="*",
                    description="URL pattern for network sniffing (e.g. '*/api/*')"
                ),
                ToolParameter(
                    name="filter",
                    type="string",
                    required=False,
                    description="URL pattern to filter network log results"
                ),
                ToolParameter(
                    name="seconds",
                    type="int",
                    required=False,
                    description="Seconds to wait (for wait action, max 30)"
                ),
                ToolParameter(
                    name="clear",
                    type="bool",
                    required=False,
                    default=True,
                    description="Clear existing text before typing (default: true)"
                ),
                ToolParameter(
                    name="max_length",
                    type="int",
                    required=False,
                    default=3000,
                    description="Max characters for extract action"
                ),
            ],
            permissions=[ToolPermission.NETWORK, ToolPermission.EXECUTE],
            examples=[
                '```json\n{"name": "BROWSER", "parameters": {"action": "launch", "profile": "research", "stealth": true}}\n```',
                '```json\n{"name": "BROWSER", "parameters": {"action": "navigate", "url": "https://example.com"}}\n```',
                '```json\n{"name": "BROWSER", "parameters": {"action": "run_script", "script": "links = await page.query_selector_all(\'a\')\\nresult = []\\nfor link in links:\\n    href = await link.get_attribute(\'href\')\\n    text = await link.inner_text()\\n    result.append(f\'{text}: {href}\')\\nreturn \'\\n\'.join(result)"}}\n```',
                '```json\n{"name": "BROWSER", "parameters": {"action": "click", "element": 5}}\n```',
                '```json\n{"name": "BROWSER", "parameters": {"action": "type", "element": 3, "text": "search query"}}\n```',
                '```json\n{"name": "BROWSER", "parameters": {"action": "screenshot"}}\n```',
                '```json\n{"name": "BROWSER", "parameters": {"action": "scroll", "direction": "down"}}\n```',
                '```json\n{"name": "BROWSER", "parameters": {"action": "close"}}\n```',
            ],
            tags=["web", "browser", "automation", "scraper"],
        )

    def _get_session(self, context: ToolExecutionContext) -> Optional[StealthBrowserSession]:
        """Get or return the existing browser session from runner state."""
        runner = context.metadata.get('runner_instance')
        if runner and hasattr(runner, 'session_state'):
            return runner.session_state.get('_browser')
        # Fallback: check context metadata directly
        return context.metadata.get('_browser')

    def _set_session(self, context: ToolExecutionContext, session: Optional[StealthBrowserSession]):
        """Store browser session in runner state."""
        runner = context.metadata.get('runner_instance')
        if runner and hasattr(runner, 'session_state'):
            if session:
                runner.session_state['_browser'] = session
            elif '_browser' in runner.session_state:
                del runner.session_state['_browser']
        else:
            if session:
                context.metadata['_browser'] = session
            elif '_browser' in context.metadata:
                del context.metadata['_browser']

    def execute(self, params: Dict[str, Any], context: ToolExecutionContext) -> ToolResult:
        """
        Execute browser action.
        
        All async Playwright operations are submitted to the session's persistent
        background thread+loop via session.run_async(). This ensures browser/page
        objects stay alive across multiple tool calls (launch → navigate → click → ...).
        """
        action = params.get("action", "")
        if not action:
            return ToolResult(success=False, output="", error="Action is required")

        try:
            # The session's own event loop handles all async operations.
            # _execute_async creates the session if needed (launch) or reuses it.
            result = self._execute_sync(action, params, context)
            return result

        except Exception as e:
            logger.error(f"[BROWSER] Action '{action}' failed: {e}", exc_info=True)
            return ToolResult(success=False, output="", error=f"Browser error: {str(e)}")

    def _is_session_alive(self, session) -> bool:
        """Check if a browser session is still usable (works for both Playwright and Selenium)."""
        if session is None:
            return False
        # SeleniumBrowserSession exposes is_alive()
        if isinstance(session, SeleniumBrowserSession):
            return session.is_alive()
        # StealthBrowserSession (Playwright): check page + thread
        return bool(session.page and session._thread and session._thread.is_alive())

    def _ensure_session(self, context: ToolExecutionContext, params: Dict[str, Any] = None):
        """
        Ensure a browser session exists. If one is active, return it.
        If not, auto-launch one. Deterministic — exactly 0 or 1 browser.
        Respects engine=selenium to create a SeleniumBrowserSession.
        """
        session = self._get_session(context)
        if self._is_session_alive(session):
            return session  # Reuse existing

        # Auto-launch browser
        p = params or {}
        profile = p.get("profile", "ephemeral")
        engine = p.get("engine", "playwright").lower()
        headless_default = False if engine == "selenium" else True
        headless = p.get("headless", headless_default) not in (False, "false", "False")
        stealth = p.get("stealth", True) not in (False, "false", "False")
        proxy = p.get("proxy")
        workspace = context.workspace_path or os.getcwd()

        logger.info(f"[BROWSER] Auto-launching browser session (engine={engine}, profile='{profile}')")

        if engine == "selenium":
            session = SeleniumBrowserSession()
            result = session.launch(
                workspace_path=workspace,
                profile=profile,
                headless=headless,
                stealth=stealth,
                proxy=proxy,
            )
            logger.info(f"[BROWSER] {result}")
        else:
            session = StealthBrowserSession()
            session.run_async(session.launch(
                workspace_path=workspace,
                profile=profile,
                headless=headless,
                stealth=stealth,
                proxy=proxy,
            ))

        self._set_session(context, session)
        return session

    def _execute_sync(self, action: str, params: Dict[str, Any], context: ToolExecutionContext) -> ToolResult:
        """
        Synchronous wrapper that dispatches async work to the session's persistent loop.
        Deterministic: exactly 0 or 1 browser per agent. Launch is idempotent.
        """
        session = self._get_session(context)

        # --- launch: idempotent — reuse if active, create if not ---
        if action == "launch":
            if self._is_session_alive(session):
                # Browser already active — return current state instead of erroring
                try:
                    if isinstance(session, SeleniumBrowserSession):
                        page_info = session._build_page_snapshot()
                    else:
                        page_info = session.run_async(session._build_page_snapshot(), timeout=10)
                except Exception:
                    page_info = "Browser active"
                return ToolResult(
                    success=True,
                    output=f"Browser already active — reusing existing session.\n\n{page_info}"
                )

            # No active browser — launch new one
            engine = params.get("engine", "playwright").lower()
            profile = params.get("profile", "ephemeral")
            headless_default = False if engine == "selenium" else True
            headless = params.get("headless", headless_default) not in (False, "false", "False")
            stealth = params.get("stealth", True) not in (False, "false", "False")
            proxy = params.get("proxy")
            workspace = context.workspace_path or os.getcwd()

            if engine == "selenium":
                session = SeleniumBrowserSession()
                result = session.launch(
                    workspace_path=workspace,
                    profile=profile,
                    headless=headless,
                    stealth=stealth,
                    proxy=proxy,
                )
            else:
                session = StealthBrowserSession()
                result = session.run_async(session.launch(
                    workspace_path=workspace,
                    profile=profile,
                    headless=headless,
                    stealth=stealth,
                    proxy=proxy,
                ))

            self._set_session(context, session)
            return ToolResult(success=True, output=result)

        if action == "list_profiles":
            workspace = context.workspace_path or os.getcwd()
            temp_session = StealthBrowserSession()
            temp_session.workspace_path = workspace
            result = temp_session.run_async(temp_session.list_profiles())
            temp_session._stop_loop_thread()
            return ToolResult(success=True, output=result)

        # --- All other actions: auto-launch if no browser ---
        session = self._ensure_session(context, params)

        # Dispatch to session's persistent loop
        if action == "navigate":
            url = params.get("url", "")
            if not url:
                return ToolResult(success=False, output="", error="URL required for navigate")
            result = session.run_async(session.navigate(url))
            return ToolResult(success=True, output=result)

        elif action == "click":
            result = session.run_async(session.click(
                element=params.get("element"),
                selector=params.get("selector"),
                text=params.get("text"),
            ))
            return ToolResult(success=True, output=result)

        elif action == "type":
            text = params.get("text", "")
            if not text:
                return ToolResult(success=False, output="", error="Text required for type action")
            clear = params.get("clear", True)
            if isinstance(clear, str):
                clear = clear.lower() in ('true', '1', 'yes')
            press_enter = params.get("press_enter", False)
            if isinstance(press_enter, str):
                press_enter = press_enter.lower() in ('true', '1', 'yes')
            result = session.run_async(session.type_text(
                element=params.get("element"),
                selector=params.get("selector"),
                text=text,
                clear=clear,
                press_enter=press_enter,
            ))
            return ToolResult(success=True, output=result)

        elif action == "press_key":
            key = params.get("key", "")
            if not key:
                return ToolResult(success=False, output="", error="Key required for press_key (e.g. 'Enter', 'Tab', 'Escape')")
            count = int(params.get("count", 1))
            result = session.run_async(session.press_key(key=key, count=count))
            return ToolResult(success=True, output=result)

        elif action == "extract":
            max_length = int(params.get("max_length", MAX_PAGE_TEXT))
            result = session.run_async(session.extract(
                selector=params.get("selector"),
                max_length=max_length,
            ))
            return ToolResult(success=True, output=result)

        elif action == "extract_links":
            result = session.run_async(session.extract_links(selector=params.get("selector")))
            return ToolResult(success=True, output=result)

        elif action == "screenshot":
            if isinstance(session, SeleniumBrowserSession):
                raw = session.screenshot()  # returns bytes (PNG)
                if raw:
                    import base64
                    b64 = base64.b64encode(raw).decode()
                    return ToolResult(
                        success=True,
                        output=f"Screenshot taken ({len(raw)} bytes)",
                        metadata={"screenshot_base64": b64}
                    )
                return ToolResult(success=False, output="", error="Screenshot failed")
            else:
                result = session.run_async(session.screenshot())
                metadata = {"screenshot_path": result.get("path", "")}
                if result.get("base64"):
                    metadata["screenshot_base64"] = result["base64"]
                return ToolResult(
                    success=True,
                    output=result["output"],
                    metadata=metadata
                )

        elif action == "scroll":
            direction = params.get("direction", "down")
            result = session.run_async(session.scroll(direction=direction))
            return ToolResult(success=True, output=result)

        elif action == "execute_js":
            script = params.get("script", "")
            if not script:
                return ToolResult(success=False, output="", error="Script required for execute_js")
            result = session.run_async(session.execute_js(script))
            return ToolResult(success=True, output=result)

        elif action == "wait":
            result = session.run_async(session.wait(
                selector=params.get("selector"),
                seconds=params.get("seconds"),
            ))
            return ToolResult(success=True, output=result)

        elif action == "sniff_start":
            pattern = params.get("url_pattern", "*")
            result = session.run_async(session.sniff_start(url_pattern=pattern))
            return ToolResult(success=True, output=result)

        elif action == "sniff_stop":
            result = session.run_async(session.sniff_stop())
            return ToolResult(success=True, output=result)

        elif action == "get_network_log":
            result = session.run_async(session.get_network_log(filter_str=params.get("filter")))
            return ToolResult(success=True, output=result)

        elif action == "inspect":
            result = session.run_async(session.inspect(
                element=params.get("element"),
                selector=params.get("selector"),
            ))
            return ToolResult(success=True, output=result)

        elif action == "find_selector":
            text = params.get("text", "")
            if not text:
                return ToolResult(success=False, output="", error="Text is required for find_selector")
            result = session.run_async(session.find_selector(text=text))
            return ToolResult(success=True, output=result)

        elif action == "run_script":
            script_file = params.get("script_file", "")
            # Backwards compat: accept 'script' if it looks like a filename
            if not script_file:
                script_raw = params.get("script", "").strip()
                if script_raw and '\n' not in script_raw and script_raw.endswith('.py'):
                    script_file = script_raw
                elif script_raw:
                    return ToolResult(
                        success=False, output="",
                        error=(
                            "run_script no longer accepts inline code. "
                            "Use WRITE_FILE to create the script first, then pass the filename: "
                            '{"name": "BROWSER", "parameters": {"action": "run_script", "script_file": "my_script.py"}}'
                        )
                    )
            if not script_file:
                return ToolResult(success=False, output="", error="script_file required for run_script action. Write a .py file with Playwright code using 'page', 'browser', 'context'.")
            
            # Read script from file
            from pathlib import Path
            workspace = Path(context.workspace_path or os.getcwd())
            script_path = Path(script_file) if os.path.isabs(script_file) else workspace / script_file
            if not script_path.exists():
                return ToolResult(
                    success=False, output="",
                    error=f"Script file not found: {script_path}. Use WRITE_FILE to create the script first."
                )
            try:
                script_code = script_path.read_text(encoding="utf-8")
            except Exception as e:
                return ToolResult(success=False, output="", error=f"Failed to read script file: {e}")
            
            if not script_code.strip():
                return ToolResult(success=False, output="", error=f"Script file is empty: {script_path}")
            
            result = session.run_async(session.run_script(script_code))
            # Check if the result indicates an error
            if result.startswith("Script error:") or result.startswith("Script syntax error:"):
                return ToolResult(success=False, output=result, error=result.split('\n')[0])
            return ToolResult(success=True, output=result)

        elif action == "back":
            result = session.run_async(session.go_back())
            return ToolResult(success=True, output=result)

        elif action == "close":
            save = params.get("save_profile", True)
            if isinstance(save, str):
                save = save.lower() in ('true', '1', 'yes')
            result = session.run_async(session.close(save_profile=save))
            # Only Playwright sessions have a background thread to stop
            if not isinstance(session, SeleniumBrowserSession):
                session._stop_loop_thread()
            self._set_session(context, None)
            return ToolResult(success=True, output=result)

        else:
            return ToolResult(
                success=False, output="",
                error=f"Unknown action: {action}. Valid: launch, navigate, click, type, press_key, inspect, "
                      f"find_selector, extract, extract_links, screenshot, scroll, execute_js, "
                      f"wait, sniff_start, sniff_stop, get_network_log, list_profiles, back, close"
            )





def get_all_tools():
    """Return all browser tool classes for registry."""
    return [BrowserTool]
