import asyncio
import json
import os
import platform
import re
import subprocess
from pathlib import Path

import httpx

from gsearch_mcp_cli.constants import (
    CHROME_BASE_FLAGS,
    CHROME_PLATFORM_FLAGS,
    CHROME_PROFILE_DIR,
    CONFIG_DIR,
    PORT_MAP_FILE,
    TIMEOUT_CHROME_LAUNCH,
    get_browser_candidates,
)
from gsearch_mcp_cli.exceptions import BrowserNotFoundError, CDPConnectionError


def find_browser(preference: str = "auto") -> str:
    """Find a Chromium-based browser binary.

    Args:
        preference: "auto" for first found, or browser name like "chrome", "brave", "edge"

    Returns:
        Full path to browser binary.

    Raises:
        BrowserNotFoundError if no browser found.
    """
    candidates = get_browser_candidates()

    if preference != "auto":
        pref_lower = preference.lower()
        for name, path in candidates:
            if pref_lower in name.lower() and Path(path).exists():
                return path
        raise BrowserNotFoundError(
            f"Browser '{preference}' not found. Available browsers: "
            + ", ".join(name for name, path in candidates if Path(path).exists())
            + ". Install a Chromium-based browser or set browser=auto."
        )

    for name, path in candidates:
        if Path(path).exists():
            return path

    raise BrowserNotFoundError(
        "No Chromium-based browser found. Install one of: "
        "Google Chrome, Brave, Microsoft Edge, Chromium, Vivaldi, Opera."
    )


def build_chrome_args(browser_path: str, profile_dir: str, extra_flags: list[str] | None = None) -> list[str]:
    """Build Chrome launch arguments."""
    args = [browser_path]
    args.append("--remote-debugging-port=0")
    args.append(f"--user-data-dir={profile_dir}")
    args.extend(CHROME_BASE_FLAGS)

    system = platform.system()
    if system in CHROME_PLATFORM_FLAGS:
        args.extend(CHROME_PLATFORM_FLAGS[system])

    if extra_flags:
        args.extend(extra_flags)

    args.append("about:blank")
    return args


def parse_port_from_stderr(line: str) -> int | None:
    """Parse CDP port from Chrome's stderr output.

    Chrome prints: DevTools listening on ws://127.0.0.1:<PORT>/devtools/browser/<id>
    """
    match = re.search(r"DevTools listening on ws://127\.0\.0\.1:(\d+)/", line)
    if match:
        return int(match.group(1))
    return None


async def launch_chrome(
    profile_dir: str | Path | None = None,
    browser_path: str | None = None,
) -> tuple[subprocess.Popen, int]:
    """Launch headless Chrome and return (process, port).

    Uses --remote-debugging-port=0 so the OS assigns a free port.
    Reads the actual port from Chrome's stderr.
    """
    if profile_dir is None:
        profile_dir = str(CHROME_PROFILE_DIR)
    if browser_path is None:
        browser_path = find_browser()

    Path(str(profile_dir)).mkdir(parents=True, exist_ok=True)

    args = build_chrome_args(browser_path, str(profile_dir))

    proc = subprocess.Popen(
        args,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.PIPE,
        text=True,
    )

    port = None
    deadline = asyncio.get_event_loop().time() + TIMEOUT_CHROME_LAUNCH

    while asyncio.get_event_loop().time() < deadline:
        line = proc.stderr.readline()
        if not line:
            if proc.poll() is not None:
                raise CDPConnectionError(f"Chrome exited with code {proc.returncode}")
            await asyncio.sleep(0.1)
            continue
        port = parse_port_from_stderr(line)
        if port:
            break

    if port is None:
        proc.terminate()
        raise CDPConnectionError("Failed to read CDP port from Chrome stderr")

    async with httpx.AsyncClient() as client:
        for _ in range(int(TIMEOUT_CHROME_LAUNCH * 2)):
            try:
                resp = await client.get(f"http://localhost:{port}/json/version", timeout=1)
                if resp.status_code == 200:
                    _write_port_map(port, proc.pid)
                    return proc, port
            except httpx.ConnectError:
                await asyncio.sleep(0.5)

    proc.terminate()
    raise CDPConnectionError("CDP endpoint never became ready")


async def ensure_browser(port: int) -> bool:
    """Check if Chrome is responsive on the given port."""
    try:
        async with httpx.AsyncClient() as client:
            resp = await client.get(f"http://localhost:{port}/json/version", timeout=2)
            return resp.status_code == 200
    except Exception:
        return False


def shutdown_chrome(proc: subprocess.Popen, port: int | None = None) -> None:
    """Gracefully shutdown Chrome. Try terminate, then kill."""
    if proc.poll() is not None:
        if port:
            _remove_port_map(port)
        return

    try:
        proc.terminate()
        proc.wait(timeout=5)
    except subprocess.TimeoutExpired:
        proc.kill()
        proc.wait(timeout=3)

    if port:
        _remove_port_map(port)


def _read_port_map() -> dict:
    """Read the port map file, pruning dead entries."""
    if not PORT_MAP_FILE.exists():
        return {}
    try:
        data = json.loads(PORT_MAP_FILE.read_text())
    except (json.JSONDecodeError, OSError):
        return {}

    alive = {}
    for port_str, info in data.items():
        pid = info.get("pid")
        if pid and _is_process_alive(pid):
            alive[port_str] = info

    if alive != data:
        PORT_MAP_FILE.write_text(json.dumps(alive, indent=2))

    return alive


def _write_port_map(port: int, pid: int) -> None:
    """Record a port mapping."""
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    data = _read_port_map()
    data[str(port)] = {"pid": pid}
    PORT_MAP_FILE.write_text(json.dumps(data, indent=2))


def _remove_port_map(port: int) -> None:
    """Remove a port mapping."""
    data = _read_port_map()
    data.pop(str(port), None)
    if data:
        PORT_MAP_FILE.write_text(json.dumps(data, indent=2))
    elif PORT_MAP_FILE.exists():
        PORT_MAP_FILE.unlink()


def _is_process_alive(pid: int) -> bool:
    """Check if a process is still running."""
    try:
        os.kill(pid, 0)
        return True
    except OSError:
        return False
