"""Chrome DevTools Protocol (CDP) utilities for cookie extraction and browser-driven chat.

Two capabilities:
1. Cookie extraction: Launch Chrome, navigate to Gemini, extract auth cookies.
2. Browser-driven chat: Use headless Chrome to send prompts with model switching.
   The browser handles BotGuard tokens automatically, enabling Pro/Thinking models.
"""

from __future__ import annotations

import json
import platform
import re
import shutil
import socket
import subprocess
import time
from pathlib import Path
from typing import Any

import httpx
from loguru import logger

from .constants import COOKIE_1PSID, EMAIL_PATTERN, TOKEN_PATTERNS
from .exceptions import AuthError, GeminiError

GEMINI_URL = "https://gemini.google.com/app"
CDP_DEFAULT_PORT = 9222
CDP_PORT_RANGE = range(9222, 9232)


# =============================================================================
# Port-to-Profile Mapping
# =============================================================================
# Tracks which CDP port belongs to which gemcli profile so we never reuse
# a Chrome instance from a different profile (or from other tools like
# Antigravity browser). Adapted from notebooklm-mcp-cli's approach.


def _get_port_map_file() -> Path:
    """Get path to chrome-port-map.json."""
    from .constants import CONFIG_DIR_NAME

    return Path.home() / CONFIG_DIR_NAME / "chrome-port-map.json"


def _read_port_map() -> dict[str, dict]:
    """Read the port map, pruning entries whose PIDs are no longer alive.

    Returns:
        Dict mapping port (as string key) to {"profile": str, "pid": int}.
    """
    import os

    map_file = _get_port_map_file()
    if not map_file.exists():
        return {}

    try:
        data = json.loads(map_file.read_text())
    except (json.JSONDecodeError, OSError):
        return {}

    # Prune stale entries (dead PIDs)
    alive: dict[str, dict] = {}
    changed = False
    for port_str, entry in data.items():
        pid = entry.get("pid")
        if pid is not None:
            try:
                os.kill(pid, 0)  # signal 0 = check if process exists
                alive[port_str] = entry
            except (OSError, ProcessLookupError):
                changed = True  # PID is dead, skip it
        else:
            alive[port_str] = entry

    if changed:
        _save_port_map(alive)

    return alive


def _save_port_map(data: dict[str, dict]) -> None:
    """Write port map to disk."""
    map_file = _get_port_map_file()
    try:
        map_file.parent.mkdir(parents=True, exist_ok=True)
        map_file.write_text(json.dumps(data, indent=2))
    except OSError:
        pass  # Best-effort


def _write_port_map(port: int, profile_name: str, pid: int) -> None:
    """Record which profile owns which port."""
    data = _read_port_map()
    data[str(port)] = {"profile": profile_name, "pid": pid}
    _save_port_map(data)


def _clear_port_map(port: int) -> None:
    """Remove a port entry after Chrome terminates."""
    data = _read_port_map()
    if str(port) in data:
        del data[str(port)]
        _save_port_map(data)


def find_existing_gemcli_chrome(
    port_range: range = CDP_PORT_RANGE, profile_name: str = "default"
) -> tuple[int | None, str | None]:
    """Find an existing gemcli Chrome instance for a specific profile.

    Uses the port-to-profile mapping to only reconnect to Chrome instances
    that belong to the requested profile, preventing cross-profile
    contamination (e.g., won't accidentally reuse Antigravity's browser).

    Args:
        port_range: Range of ports to scan.
        profile_name: Only reuse Chrome instances launched for this profile.

    Returns:
        (port, debugger_url) if found, (None, None) otherwise.
    """
    port_map = _read_port_map()

    # Check mapped ports for the target profile (fast path)
    for port_str, entry in port_map.items():
        if entry.get("profile") != profile_name:
            continue
        port = int(port_str)
        try:
            resp = httpx.get(f"http://127.0.0.1:{port}/json/version", timeout=2)
            if resp.status_code == 200:
                debugger_url = resp.json().get("webSocketDebuggerUrl")
                logger.debug(
                    f"Reusing mapped Chrome on port {port} "
                    f"for profile '{profile_name}'"
                )
                return port, debugger_url
        except Exception:
            pass
        # Mapped but not responding — stale entry, clean it up
        _clear_port_map(port)

    # No mapped instance found for this profile
    return None, None


def get_chrome_path() -> str | None:
    """Get the Chrome executable path for the current platform."""
    system = platform.system()
    if system == "Darwin":
        path = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
        return path if Path(path).exists() else None
    elif system == "Linux":
        for name in ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"]:
            if shutil.which(name):
                return name
        return None
    elif system == "Windows":
        path = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
        return path if Path(path).exists() else None
    return None


def get_chrome_profile_dir(profile_name: str = "default") -> Path:
    """Get the Chrome user-data-dir for a profile."""
    from .constants import CONFIG_DIR_NAME
    return Path.home() / CONFIG_DIR_NAME / "chrome-profiles" / profile_name


def find_available_port(start: int = 9222, attempts: int = 10) -> int:
    """Find an available port for Chrome debugging."""
    for offset in range(attempts):
        port = start + offset
        try:
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
                s.bind(("127.0.0.1", port))
                return port
        except OSError:
            continue
    raise RuntimeError(f"No available ports in range {start}-{start + attempts - 1}")


def find_existing_chrome(port_range: range = CDP_PORT_RANGE) -> int | None:
    """Find an existing Chrome instance with debugging enabled."""
    for port in port_range:
        try:
            resp = httpx.get(f"http://127.0.0.1:{port}/json/version", timeout=2)
            if resp.status_code == 200:
                return port
        except Exception:
            continue
    return None


def get_debugger_url(port: int) -> str | None:
    """Get the WebSocket debugger URL from Chrome."""
    try:
        resp = httpx.get(f"http://127.0.0.1:{port}/json/version", timeout=5)
        return resp.json().get("webSocketDebuggerUrl")
    except Exception:
        return None


def get_pages(port: int) -> list[dict]:
    """Get list of open pages in Chrome."""
    try:
        resp = httpx.get(f"http://127.0.0.1:{port}/json", timeout=5)
        return resp.json()
    except Exception:
        return []


def execute_cdp_command(ws_url: str, method: str, params: dict | None = None) -> dict:
    """Execute a CDP command via WebSocket."""
    try:
        import websocket
    except ImportError:
        raise AuthError("websocket-client not installed. Run: pip install websocket-client")

    try:
        ws = websocket.create_connection(ws_url, timeout=30, suppress_origin=True)
    except TypeError:
        ws = websocket.create_connection(ws_url, timeout=30)

    try:
        command = {"id": 1, "method": method, "params": params or {}}
        ws.send(json.dumps(command))
        while True:
            response = json.loads(ws.recv())
            if response.get("id") == 1:
                return response.get("result", {})
    finally:
        ws.close()


def get_page_cookies(ws_url: str) -> list[dict]:
    """Get all cookies via CDP Network.getAllCookies."""
    result = execute_cdp_command(ws_url, "Network.getAllCookies")
    return result.get("cookies", [])


def get_gemini_page_ws_url(port: int) -> str | None:
    """Find a Gemini page's WebSocket URL from Chrome on the given port.

    Searches open tabs for one on gemini.google.com and returns its
    page-level WebSocket URL. Page-level (not browser-level) is required
    because only page-level WS gives access to the full cookie jar
    including partitioned cookies.
    """
    pages = get_pages(port)
    for page in pages:
        if "gemini.google.com" in page.get("url", ""):
            ws_url = page.get("webSocketDebuggerUrl")
            if ws_url:
                return ws_url
    return None


def download_via_page_fetch(ws_url: str, url: str, timeout: int = 120) -> bytes:
    """Download a URL using fetch() from a Chrome page context.

    Bypasses partitioned-cookie issues by leveraging the Gemini page's
    full cookie jar. Required for downloading from
    contribution.usercontent.google.com, where CDP's
    Network.getAllCookies is missing critical cookies (AEC,
    GOOGLE_ABUSE_EXEMPTION) that exist only in Chrome's partitioned
    cookie store.

    Args:
        ws_url: Page-level WebSocket debugger URL.
        url: The URL to download.
        timeout: Max seconds to wait for the download to complete.

    Returns:
        Downloaded file content as bytes.

    Raises:
        GeminiError: If the download fails (HTTP error, timeout, etc.).
        AuthError: If websocket-client is not installed.
    """
    import base64 as b64_module

    try:
        import websocket
    except ImportError:
        raise AuthError("websocket-client not installed. Run: pip install websocket-client")

    escaped_url = json.dumps(url)
    js_expression = f"""
    (async () => {{
        const resp = await fetch({escaped_url}, {{
            credentials: 'include',
            mode: 'cors',
        }});
        if (!resp.ok) {{
            throw new Error('HTTP ' + resp.status + ': ' + resp.statusText);
        }}
        const blob = await resp.blob();
        const reader = new FileReader();
        const base64 = await new Promise((resolve, reject) => {{
            reader.onload = () => resolve(reader.result);
            reader.onerror = reject;
            reader.readAsDataURL(blob);
        }});
        return {{ success: true, size: blob.size, type: blob.type, data: base64 }};
    }})()
    """

    logger.debug(f"CDP page fetch: {url[:80]}...")

    try:
        ws = websocket.create_connection(ws_url, timeout=timeout, suppress_origin=True)
    except TypeError:
        ws = websocket.create_connection(ws_url, timeout=timeout)

    try:
        # Enable Runtime
        ws.send(json.dumps({"id": 1, "method": "Runtime.enable", "params": {}}))
        while True:
            resp = json.loads(ws.recv())
            if resp.get("id") == 1:
                break

        # Execute fetch + base64 conversion in page context
        ws.send(json.dumps({
            "id": 2,
            "method": "Runtime.evaluate",
            "params": {
                "expression": js_expression,
                "awaitPromise": True,
                "returnByValue": True,
            },
        }))

        while True:
            resp = json.loads(ws.recv())
            if resp.get("id") == 2:
                result = resp.get("result", {})

                # Check for JS exceptions
                if "exceptionDetails" in result:
                    exc = result["exceptionDetails"]
                    msg = exc.get("text", "Unknown error")
                    if "exception" in exc:
                        msg = exc["exception"].get("description", msg)
                    raise GeminiError(f"Browser download failed: {msg}")

                value = result.get("result", {}).get("value")
                if not value or not value.get("success"):
                    raise GeminiError("Browser download returned no data")

                file_size = value.get("size", 0)
                file_type = value.get("type", "unknown")
                logger.debug(f"CDP page fetch complete: {file_size} bytes, {file_type}")

                # Extract base64 data from data URL (strip "data:type;base64," prefix)
                data_url = value["data"]
                _, _, b64_data = data_url.partition(",")
                return b64_module.b64decode(b64_data)
    except websocket.WebSocketTimeoutException:
        raise GeminiError(f"Browser download timed out after {timeout}s")
    finally:
        ws.close()


def get_page_html(ws_url: str) -> str:
    """Get page HTML to extract tokens."""
    execute_cdp_command(ws_url, "Runtime.enable")
    result = execute_cdp_command(
        ws_url, "Runtime.evaluate",
        {"expression": "document.documentElement.outerHTML"},
    )
    return result.get("result", {}).get("value", "")


def get_current_url(ws_url: str) -> str:
    """Get the current page URL."""
    execute_cdp_command(ws_url, "Runtime.enable")
    result = execute_cdp_command(
        ws_url, "Runtime.evaluate",
        {"expression": "window.location.href"},
    )
    return result.get("result", {}).get("value", "")


def navigate_to_url(ws_url: str, url: str) -> None:
    """Navigate the page to a URL."""
    execute_cdp_command(ws_url, "Page.enable")
    execute_cdp_command(ws_url, "Page.navigate", {"url": url})
    time.sleep(3)


def is_logged_in(url: str) -> bool:
    """Check if the user is logged into Gemini (not redirected to accounts.google.com)."""
    if "accounts.google.com" in url:
        return False
    if "gemini.google.com" in url:
        return True
    return False


def find_or_create_gemini_page(port: int) -> dict | None:
    """Find an existing Gemini page or create a new one."""
    from urllib.parse import quote

    pages = get_pages(port)
    for page in pages:
        if "gemini.google.com" in page.get("url", ""):
            return page

    try:
        encoded = quote(GEMINI_URL, safe="")
        resp = httpx.put(f"http://127.0.0.1:{port}/json/new?{encoded}", timeout=15)
        if resp.status_code == 200 and resp.text.strip():
            return resp.json()

        resp = httpx.put(f"http://127.0.0.1:{port}/json/new", timeout=10)
        if resp.status_code == 200 and resp.text.strip():
            page = resp.json()
            ws_url = page.get("webSocketDebuggerUrl")
            if ws_url:
                navigate_to_url(ws_url, GEMINI_URL)
            return page
    except Exception:
        pass
    return None


def launch_chrome(
    port: int,
    profile_name: str = "default",
    headless: bool = False,
) -> subprocess.Popen | None:
    """Launch Chrome with remote debugging enabled."""
    chrome_path = get_chrome_path()
    if not chrome_path:
        return None

    profile_dir = get_chrome_profile_dir(profile_name)
    profile_dir.mkdir(parents=True, exist_ok=True)

    args = [
        chrome_path,
        f"--remote-debugging-port={port}",
        "--no-first-run",
        "--no-default-browser-check",
        "--disable-extensions",
        f"--user-data-dir={profile_dir}",
        "--remote-allow-origins=*",
    ]
    if headless:
        args.append("--headless")

    try:
        process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        time.sleep(3)
        return process
    except Exception:
        return None


def _filter_gemini_cookies(cookies_list: list[dict]) -> dict[str, str]:
    """Filter CDP cookies to only those relevant to gemini.google.com.

    Network.getAllCookies returns cookies from ALL domains (youtube.com,
    accounts.google.com, etc.). Cookies with the same name from different
    domains collide in a flat dict — e.g. NID, SID, HSID exist on multiple
    Google properties. Sending the wrong domain's cookie value to Gemini
    causes token extraction to fail (the page serves a consent/redirect
    page instead of the app page with SNlM0e).
    """
    cookies = {}
    for c in cookies_list:
        domain = c.get("domain", "")
        if "google.com" in domain:
            cookies[c["name"]] = c["value"]
    return cookies


def _has_auth_cookies(cookies: dict[str, str]) -> bool:
    """Check if the required auth cookies are present."""
    return bool(cookies.get(COOKIE_1PSID))


def _wait_for_auth_cookies(
    ws_url: str,
    timeout: int = 300,
    poll_interval: int = 3,
    on_waiting: callable | None = None,
) -> dict[str, str] | None:
    """Poll for auth cookies until they appear or timeout.

    This handles both cases:
    - User is already logged in (cookies appear immediately)
    - User needs to log in (wait for them to complete the login flow)

    Returns cookies dict if found, None if timeout.
    """
    start = time.time()
    notified = False

    while time.time() - start < timeout:
        try:
            cookies_list = get_page_cookies(ws_url)
            cookies = _filter_gemini_cookies(cookies_list)

            if _has_auth_cookies(cookies):
                return cookies
        except Exception:
            pass

        if not notified and on_waiting:
            on_waiting()
            notified = True

        time.sleep(poll_interval)

    return None


def extract_cookies_via_cdp(
    port: int = CDP_DEFAULT_PORT,
    auto_launch: bool = True,
    login_timeout: int = 300,
    profile_name: str = "default",
    chrome_profile_path: str | None = None,
    on_waiting_for_login: callable | None = None,
    force_new_session: bool = False,
) -> dict[str, Any]:
    """Extract cookies and tokens from Chrome via CDP.

    This is the main entry point for automated authentication.

    Flow:
    1. Find or launch Chrome with remote debugging
    2. Navigate to gemini.google.com
    3. Poll for auth cookies (handles both logged-in and needs-login cases)
    4. Once cookies found, extract tokens from page HTML
    5. Only terminate Chrome if WE launched it

    The browser stays open until auth completes -- the user gets time to
    log in if needed. Chrome is only killed if we launched it ourselves
    (not if we connected to an existing instance).

    Args:
        port: Chrome DevTools port
        auto_launch: Launch Chrome if not running
        login_timeout: Max seconds to wait for auth cookies (default 5 min)
        profile_name: Profile name for Chrome user-data-dir
        chrome_profile_path: Override Chrome profile path (e.g., from NLM)
        on_waiting_for_login: Callback when waiting for user to log in
        force_new_session: If True, skip existing Chrome and launch a visible
            Chrome with a fresh profile-specific directory. Used for new profiles
            so the user can sign into a different Google account.
    """
    chrome_process = None
    we_launched_chrome = False
    used_profile_dir = None

    try:
        # Check for existing Chrome with debugging — skip if forcing new session
        debugger_url = None
        if not force_new_session:
            existing_port = find_existing_chrome()
            if existing_port:
                port = existing_port
                debugger_url = get_debugger_url(port)
                logger.debug(f"Found existing Chrome on port {port}")

        if not debugger_url and auto_launch:
            chrome_path = get_chrome_path()
            if not chrome_path:
                raise AuthError("Chrome not found. Install Google Chrome.")

            port = find_available_port()

            # For new sessions, always use a fresh profile-specific Chrome dir
            # so we don't inherit cookies from another account.
            if force_new_session:
                profile_dir = get_chrome_profile_dir(profile_name)
            elif chrome_profile_path:
                profile_dir = Path(chrome_profile_path)
            else:
                profile_dir = get_chrome_profile_dir(profile_name)
            profile_dir.mkdir(parents=True, exist_ok=True)
            used_profile_dir = str(profile_dir)

            logger.debug(f"Launching Chrome on port {port} with profile: {profile_dir}")
            args = [
                chrome_path,
                f"--remote-debugging-port={port}",
                "--no-first-run",
                "--no-default-browser-check",
                "--disable-extensions",
                f"--user-data-dir={profile_dir}",
                "--remote-allow-origins=*",
                GEMINI_URL,
            ]
            chrome_process = subprocess.Popen(
                args, stdout=subprocess.PIPE, stderr=subprocess.PIPE
            )
            we_launched_chrome = True

            # Poll for the debugger URL (heavy profiles may take longer to start)
            start_wait = time.time()
            while time.time() - start_wait < 15:
                debugger_url = get_debugger_url(port)
                if debugger_url:
                    break
                time.sleep(0.5)

        if not debugger_url:
            raise AuthError(
                f"Cannot connect to Chrome on port {port}. "
                "Make sure Chrome isn't already running."
            )

        # Find or create Gemini page
        page = find_or_create_gemini_page(port)
        if not page:
            raise AuthError("Failed to open Gemini page in Chrome.")

        ws_url = page.get("webSocketDebuggerUrl")
        if not ws_url:
            raise AuthError("No WebSocket URL for Gemini page.")

        # Navigate to Gemini if needed
        current = page.get("url", "")
        if "gemini.google.com" not in current:
            navigate_to_url(ws_url, GEMINI_URL)
            time.sleep(2)

        # Poll for auth cookies -- this is the main wait loop.
        # Handles both "already logged in" and "user needs to log in" cases.
        cookies = _wait_for_auth_cookies(
            ws_url,
            timeout=login_timeout,
            poll_interval=3,
            on_waiting=on_waiting_for_login,
        )

        if not cookies:
            raise AuthError(
                f"Login timeout ({login_timeout}s). "
                "Please log in to your Google account in the Chrome window."
            )

        # Wait a moment for page to fully load after login
        time.sleep(2)

        # Navigate to Gemini app page to extract tokens (may have been on login page)
        try:
            current = get_current_url(ws_url)
            if "gemini.google.com/app" not in current:
                navigate_to_url(ws_url, GEMINI_URL)
                time.sleep(3)
        except Exception:
            pass

        # Extract tokens and email from page HTML
        html = ""
        tokens = {}
        email = ""
        try:
            html = get_page_html(ws_url)
            for name, pattern in TOKEN_PATTERNS.items():
                match = re.search(pattern, html)
                if match:
                    tokens[name] = match.group(1)
            email_match = re.search(EMAIL_PATTERN, html)
            if email_match:
                email = email_match.group(1)
        except Exception as e:
            logger.debug(f"Token extraction from HTML failed: {e}")

        # ── Stale cookie detection ──────────────────────────────────────
        # If we found cookies but no SNlM0e token, the session is expired.
        # Google keeps __Secure-1PSID in the Chrome profile even after it
        # expires, so _has_auth_cookies returns True for dead sessions.
        # When this happens: clear the stale cookies from Chrome, navigate
        # back to Gemini (which triggers a login redirect), and wait for
        # the user to re-authenticate.
        if not tokens.get("snlm0e"):
            logger.debug(
                "Login: cookies found but no SNlM0e token — "
                "session is stale, waiting for re-login"
            )

            # Delete the stale auth cookies from Chrome so the next
            # cookie poll won't be fooled by them again.
            try:
                execute_cdp_command(ws_url, "Network.enable")
                for cookie_name in [COOKIE_1PSID, "__Secure-1PSIDTS"]:
                    execute_cdp_command(
                        ws_url, "Network.deleteCookies",
                        {"name": cookie_name, "domain": ".google.com"},
                    )
                logger.debug("Login: cleared stale auth cookies from Chrome")
            except Exception as e:
                logger.debug(f"Login: failed to clear stale cookies: {e}")

            # Navigate to Gemini to trigger login redirect
            navigate_to_url(ws_url, GEMINI_URL)
            time.sleep(2)

            if on_waiting_for_login:
                on_waiting_for_login()

            # Wait for fresh login
            cookies = _wait_for_auth_cookies(
                ws_url,
                timeout=login_timeout,
                poll_interval=3,
                on_waiting=on_waiting_for_login,
            )

            if not cookies:
                raise AuthError(
                    f"Login timeout ({login_timeout}s). "
                    "Please log in to your Google account in the Chrome window."
                )

            time.sleep(2)

            # Navigate to app and re-extract tokens
            try:
                current = get_current_url(ws_url)
                if "gemini.google.com/app" not in current:
                    navigate_to_url(ws_url, GEMINI_URL)
                    time.sleep(3)
            except Exception:
                pass

            tokens = {}
            email = ""
            try:
                html = get_page_html(ws_url)
                for name, pattern in TOKEN_PATTERNS.items():
                    match = re.search(pattern, html)
                    if match:
                        tokens[name] = match.group(1)
                email_match = re.search(EMAIL_PATTERN, html)
                if email_match:
                    email = email_match.group(1)
            except Exception as e:
                logger.debug(f"Token extraction after re-login failed: {e}")

        return {
            "cookies": cookies,
            "tokens": tokens,
            "email": email,
            "chrome_profile_path": used_profile_dir or chrome_profile_path,
        }

    finally:
        # Only terminate Chrome if WE launched it.
        # If we connected to an existing instance, leave it running.
        if chrome_process and we_launched_chrome:
            logger.debug("Giving Chrome 3 seconds to flush cookies to disk...")
            time.sleep(3)
            try:
                chrome_process.terminate()
                chrome_process.wait(timeout=10)
            except Exception:
                try:
                    chrome_process.kill()
                except Exception:
                    pass


# =============================================================================
# Browser-driven chat via CDP (for model switching)
# =============================================================================

def run_js(ws_url: str, expression: str) -> Any:
    """Execute JavaScript in the page and return the result."""
    execute_cdp_command(ws_url, "Runtime.enable")
    result = execute_cdp_command(
        ws_url, "Runtime.evaluate",
        {
            "expression": expression,
            "awaitPromise": True,
            "returnByValue": True,
        },
    )
    value = result.get("result", {}).get("value")
    return value


# Model selectors (from social-browser-agents, confirmed working via CDP test)
MODEL_PICKER_SELECTOR = '[data-test-id="bard-mode-menu-button"]'
MODEL_OPTION_SELECTORS = {
    "fast": '[data-test-id="bard-mode-option-fast"]',
    "thinking": '[data-test-id="bard-mode-option-thinking"]',
    "pro": '[data-test-id="bard-mode-option-pro"]',
}


class BrowserTransport:
    """Browser-driven chat transport using CDP.

    Uses headless Chrome to interact with the Gemini web UI.
    The browser handles BotGuard tokens automatically, enabling
    Pro and Thinking model selection that direct HTTP cannot do.

    Usage:
        transport = BrowserTransport(
            chrome_profile_path="~/.notebooklm-mcp-cli/chrome-profiles/personal"
        )
        transport.start()
        response = transport.send("What is 2+2?", model="pro")
        transport.stop()
    """

    def __init__(self, chrome_profile_path: str | None = None, profile_name: str = "default"):
        self.chrome_profile_path = chrome_profile_path
        self.profile_name = profile_name
        self._process: subprocess.Popen | None = None
        self._port: int | None = None
        self._ws_url: str | None = None
        self._current_model: str | None = None
        self._started = False

    def start(self, headless: bool = True) -> None:
        """Start a headless Chrome instance and navigate to Gemini."""
        # Check for existing Chrome
        existing = find_existing_chrome()
        if existing:
            self._port = existing
            logger.debug(f"Reusing existing Chrome on port {existing}")
        else:
            self._port = find_available_port()
            profile_dir = (
                Path(self.chrome_profile_path)
                if self.chrome_profile_path
                else get_chrome_profile_dir(self.profile_name)
            )
            profile_dir.mkdir(parents=True, exist_ok=True)

            chrome_path = get_chrome_path()
            if not chrome_path:
                raise AuthError("Chrome not found.")

            args = [
                chrome_path,
                f"--remote-debugging-port={self._port}",
                "--no-first-run",
                "--no-default-browser-check",
                "--disable-extensions",
                f"--user-data-dir={profile_dir}",
                "--remote-allow-origins=*",
            ]
            if headless:
                args.append("--headless")

            self._process = subprocess.Popen(
                args, stdout=subprocess.PIPE, stderr=subprocess.PIPE
            )
            time.sleep(4)
            logger.debug(f"Launched Chrome on port {self._port} (headless={headless})")

        # Find or create Gemini page
        page = find_or_create_gemini_page(self._port)
        if not page:
            raise AuthError("Failed to open Gemini page.")

        self._ws_url = page.get("webSocketDebuggerUrl")
        if not self._ws_url:
            raise AuthError("No WebSocket URL for Gemini page.")

        # Wait for page to be ready
        for _ in range(10):
            try:
                url = get_current_url(self._ws_url)
                if "gemini.google.com" in url:
                    break
            except Exception:
                pass
            time.sleep(1)

        # Wait for the input field to appear (page fully loaded)
        for _ in range(15):
            ready = run_js(self._ws_url, """
                !!(document.querySelector('div[contenteditable="true"]')
                   || document.querySelector('textarea'))
            """)
            if ready:
                break
            time.sleep(1)

        self._started = True
        logger.debug("BrowserTransport ready.")

    def stop(self) -> None:
        """Stop the Chrome instance if we started it."""
        if self._process:
            try:
                self._process.terminate()
                self._process.wait(timeout=5)
            except Exception:
                try:
                    self._process.kill()
                except Exception:
                    pass
            self._process = None
        self._started = False

    def select_model(self, model: str) -> bool:
        """Select a model in the Gemini UI (fast, thinking, pro)."""
        if not self._ws_url:
            return False

        model_lower = model.lower()
        if model_lower == self._current_model:
            return True  # Already selected

        selector = MODEL_OPTION_SELECTORS.get(model_lower)
        if not selector:
            logger.warning(f"Unknown model for browser transport: {model}")
            return False

        # Click the model picker button
        picker_found = run_js(self._ws_url, f"""
            (() => {{
                const btn = document.querySelector('{MODEL_PICKER_SELECTOR}');
                if (btn) {{ btn.click(); return true; }}
                return false;
            }})()
        """)

        if not picker_found:
            logger.warning("Model picker button not found")
            return False

        # Wait for dropdown to render
        time.sleep(1.0)

        # Click the model option
        option_found = run_js(self._ws_url, f"""
            (() => {{
                const opt = document.querySelector('{selector}');
                if (opt) {{ opt.click(); return true; }}
                return false;
            }})()
        """)

        time.sleep(0.5)

        if option_found:
            self._current_model = model_lower
            logger.debug(f"Model switched to: {model_lower}")
            return True

        logger.warning(f"Model option not found: {selector}")
        return False

    def send(self, prompt: str, model: str | None = None, timeout: int = 120) -> str:
        """Send a prompt via the browser and return the response text.

        Args:
            prompt: The text to send
            model: Model to use (fast, thinking, pro). Switches if different from current.
            timeout: Max seconds to wait for response

        Returns:
            The response text from Gemini.
        """
        if not self._started or not self._ws_url:
            raise AuthError("BrowserTransport not started. Call start() first.")

        # Switch model if needed
        if model:
            self.select_model(model)

        # Clear any existing input and type the prompt
        escaped = prompt.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n")
        run_js(self._ws_url, f"""
            const input = document.querySelector('div[contenteditable="true"]')
                || document.querySelector('textarea');
            if (input) {{
                input.focus();
                input.click();
                // Clear existing text
                document.execCommand('selectAll');
                document.execCommand('delete');
                // Type new prompt
                document.execCommand('insertText', false, '{escaped}');
            }}
        """)
        time.sleep(0.3)

        # Submit by clicking the Send button (more reliable than Enter key)
        run_js(self._ws_url, """
            (() => {
                // Try Send button first
                const sendBtn = document.querySelector('button[aria-label="Send message"]')
                    || document.querySelector('button[data-test-id="send-button"]');
                if (sendBtn) { sendBtn.click(); return; }
                // Fallback: press Enter via the page's keyboard handling
                const input = document.querySelector('div[contenteditable="true"]')
                    || document.querySelector('textarea');
                if (input) {
                    const ev = new KeyboardEvent('keydown', {
                        key: 'Enter', code: 'Enter', keyCode: 13,
                        which: 13, bubbles: true, cancelable: true
                    });
                    input.dispatchEvent(ev);
                }
            })()
        """)

        # Poll for response text stability
        last_text = ""
        stable_count = 0
        start_time = time.time()

        while time.time() - start_time < timeout:
            time.sleep(1)

            current_text = run_js(self._ws_url, """
                (() => {
                    // Find all "Gemini said" headings and get the text after the last one
                    const headings = document.querySelectorAll('h6');
                    let lastResponse = '';
                    for (const h of headings) {
                        if (h.textContent.includes('Gemini said')) {
                            let sibling = h.nextElementSibling;
                            while (sibling) {
                                const text = sibling.innerText || sibling.textContent;
                                if (text && text.trim().length > 0) {
                                    lastResponse = text.trim();
                                    break;
                                }
                                sibling = sibling.nextElementSibling;
                            }
                        }
                    }
                    return lastResponse;
                })()
            """) or ""

            if current_text and current_text == last_text:
                stable_count += 1
                if stable_count >= 3:  # Stable for 3 seconds
                    return current_text
            else:
                last_text = current_text
                stable_count = 0

        # Timeout -- return whatever we have
        return last_text or ""

    def new_chat(self) -> None:
        """Start a new chat (navigate to /app)."""
        if self._ws_url:
            navigate_to_url(self._ws_url, GEMINI_URL)
            time.sleep(2)

    def get_model_button_text(self) -> str:
        """Get the current model from the model picker button."""
        if not self._ws_url:
            return "unknown"
        return run_js(self._ws_url, f"""
            const btn = document.querySelector('{MODEL_PICKER_SELECTOR}');
            btn ? btn.innerText : 'unknown';
        """) or "unknown"
