"""Playwright 多步 UI 自动化：导航、点击、输入、截图等（单次浏览器会话）。"""

from __future__ import annotations

import sys
from pathlib import Path
from typing import Any, Callable
from urllib.parse import unquote, urlparse

MAX_AUTOMATION_STEPS = 40
MAX_TEXT_FIELD_CHARS = 32_000
_WAIT_UNTIL = frozenset({"commit", "domcontentloaded", "load", "networkidle"})
_LOC_STATES = frozenset({"attached", "detached", "hidden", "visible"})


def _blocked_scheme(url: str) -> str | None:
    s = url.strip().lower()
    for bad in ("javascript:", "data:", "vbscript:"):
        if s.startswith(bad):
            return f"blocked URL scheme (not allowed: {bad})"
    return None


def _file_url_to_path(url: str) -> Path:
    pu = urlparse(url.strip())
    if pu.scheme.lower() != "file":
        raise ValueError("not a file: URL")
    path = unquote(pu.path or "")
    if sys.platform == "win32" and path.startswith("/") and len(path) >= 3 and path[2] == ":":
        path = path[1:]
    return Path(path).resolve()


def _check_nav_url(url: str, policy_check: Callable[[Path], None]) -> str | None:
    bad = _blocked_scheme(url)
    if bad:
        return bad
    u = url.strip()
    low = u.lower()
    if low.startswith("http://") or low.startswith("https://"):
        return None
    if low.startswith("file:"):
        try:
            p = _file_url_to_path(u)
            policy_check(p)
        except Exception as e:  # noqa: BLE001
            return f"file: URL not allowed or invalid: {e}"
        return None
    return None


async def run_browser_automation(
    *,
    steps: list[dict[str, Any]],
    policy_check: Callable[[Path], None],
    cwd: Path,
    default_timeout_ms: int = 60_000,
    headless: bool = True,
    viewport: dict[str, Any] | None = None,
    slow_mo_ms: int | None = None,
) -> dict[str, Any]:
    """
    在同一 Chromium 会话中顺序执行 ``steps``。

    每步为对象，必填 ``op``。支持：
    ``goto``, ``wait_for_selector``, ``click``, ``dblclick``, ``fill``, ``type``,
    ``press``, ``hover``, ``wait_ms``, ``screenshot``, ``get_text``, ``get_attribute``,
    ``scroll_into_view``, ``content_snippet``.
    """
    if not isinstance(steps, list) or not steps:
        return {"ok": False, "error": "steps must be a non-empty list"}
    if len(steps) > MAX_AUTOMATION_STEPS:
        return {"ok": False, "error": f"max {MAX_AUTOMATION_STEPS} steps"}

    try:
        from playwright.async_api import async_playwright
    except ImportError as e:
        return {
            "ok": False,
            "error": f"playwright not available: {e}",
            "hint": "pip install playwright && python -m playwright install chromium",
        }

    def _resolve_out(path_str: str) -> Path:
        p = Path(path_str).expanduser()
        if not p.is_absolute():
            p = (cwd / p).resolve()
        else:
            p = p.resolve()
        policy_check(p)
        return p

    step_results: list[dict[str, Any]] = []
    timeout = max(1000, min(600_000, int(default_timeout_ms)))
    sm = slow_mo_ms
    if sm is not None:
        sm = max(0, min(2000, int(sm)))

    vw = viewport or {}
    vp_w = int(vw.get("width", 1280)) if isinstance(vw, dict) else 1280
    vp_h = int(vw.get("height", 720)) if isinstance(vw, dict) else 720
    vp_w = max(320, min(3840, vp_w))
    vp_h = max(240, min(2160, vp_h))

    try:
        async with async_playwright() as p:
            browser = await p.chromium.launch(
                headless=bool(headless),
                slow_mo=sm if sm else None,
            )
            try:
                context = await browser.new_context(viewport={"width": vp_w, "height": vp_h})
                page = await context.new_page()
                page.set_default_timeout(timeout)
                last_title = ""
                last_url = ""

                for i, raw in enumerate(steps):
                    if not isinstance(raw, dict):
                        return {
                            "ok": False,
                            "error": f"steps[{i}] must be object",
                            "step_results": step_results,
                            "failed_step_index": i,
                        }
                    op = str(raw.get("op", "")).strip().lower()
                    rec: dict[str, Any] = {"index": i, "op": op}

                    try:
                        if op == "goto":
                            url = str(raw.get("url", "")).strip()
                            if not url:
                                raise ValueError("goto requires url")
                            err = _check_nav_url(url, policy_check)
                            if err:
                                raise ValueError(err)
                            wu = str(raw.get("wait_until", "load")).strip().lower()
                            if wu not in _WAIT_UNTIL:
                                wu = "load"
                            to = int(raw.get("timeout_ms", timeout))
                            await page.goto(url, wait_until=wu, timeout=min(to, 600_000))
                            last_title = await page.title()
                            last_url = page.url
                            rec["ok"] = True
                            rec["title"] = last_title
                            rec["url"] = last_url

                        elif op == "wait_for_selector":
                            sel = str(raw.get("selector", "")).strip()
                            if not sel:
                                raise ValueError("wait_for_selector requires selector")
                            st = str(raw.get("state", "visible")).strip().lower()
                            if st not in _LOC_STATES:
                                st = "visible"
                            to = int(raw.get("timeout_ms", timeout))
                            await page.wait_for_selector(sel, state=st, timeout=min(to, 600_000))
                            rec["ok"] = True

                        elif op in ("click", "dblclick"):
                            sel = str(raw.get("selector", "")).strip()
                            if not sel:
                                raise ValueError(f"{op} requires selector")
                            loc = page.locator(sel).first
                            await loc.wait_for(state="visible", timeout=timeout)
                            if op == "dblclick":
                                await loc.dblclick(timeout=timeout)
                            else:
                                await loc.click(timeout=timeout)
                            rec["ok"] = True

                        elif op == "hover":
                            sel = str(raw.get("selector", "")).strip()
                            if not sel:
                                raise ValueError("hover requires selector")
                            await page.locator(sel).first.hover(timeout=timeout)
                            rec["ok"] = True

                        elif op == "fill":
                            sel = str(raw.get("selector", "")).strip()
                            if not sel:
                                raise ValueError("fill requires selector")
                            text = str(raw.get("text", ""))
                            if len(text) > MAX_TEXT_FIELD_CHARS:
                                raise ValueError(
                                    f"text too long (max {MAX_TEXT_FIELD_CHARS} chars)"
                                )
                            await page.locator(sel).first.fill(text, timeout=timeout)
                            rec["ok"] = True

                        elif op == "type":
                            sel = str(raw.get("selector", "")).strip()
                            if not sel:
                                raise ValueError("type requires selector")
                            text = str(raw.get("text", ""))
                            if len(text) > MAX_TEXT_FIELD_CHARS:
                                raise ValueError(
                                    f"text too long (max {MAX_TEXT_FIELD_CHARS} chars)"
                                )
                            delay = raw.get("delay_ms")
                            d = int(delay) if delay is not None else 0
                            d = max(0, min(200, d))
                            await page.locator(sel).first.type(text, delay=d, timeout=timeout)
                            rec["ok"] = True

                        elif op == "press":
                            key = str(raw.get("key", "")).strip()
                            if not key:
                                raise ValueError("press requires key (e.g. Enter, Tab)")
                            sel = str(raw.get("selector", "")).strip()
                            if sel:
                                await page.locator(sel).first.press(key, timeout=timeout)
                            else:
                                await page.keyboard.press(key)
                            rec["ok"] = True

                        elif op == "wait_ms":
                            ms = int(raw.get("ms", 0))
                            ms = max(0, min(120_000, ms))
                            await page.wait_for_timeout(ms)
                            rec["ok"] = True

                        elif op == "scroll_into_view":
                            sel = str(raw.get("selector", "")).strip()
                            if not sel:
                                raise ValueError("scroll_into_view requires selector")
                            await page.locator(sel).first.scroll_into_view_if_needed(
                                timeout=timeout
                            )
                            rec["ok"] = True

                        elif op == "screenshot":
                            out_s = str(raw.get("path", "")).strip()
                            if not out_s:
                                raise ValueError("screenshot requires path (.png)")
                            outp = _resolve_out(out_s)
                            if outp.suffix.lower() != ".png":
                                raise ValueError("screenshot path must end with .png")
                            outp.parent.mkdir(parents=True, exist_ok=True)
                            full = bool(raw.get("full_page", False))
                            sel = str(raw.get("selector", "")).strip()
                            if sel:
                                loc = page.locator(sel).first
                                await loc.wait_for(state="visible", timeout=timeout)
                                await loc.screenshot(path=str(outp), type="png")
                            else:
                                await page.screenshot(
                                    path=str(outp), full_page=full, type="png"
                                )
                            rec["ok"] = True
                            rec["png_path"] = str(outp)

                        elif op == "get_text":
                            sel = str(raw.get("selector", "")).strip()
                            if not sel:
                                raise ValueError("get_text requires selector")
                            txt = await page.locator(sel).first.inner_text(timeout=timeout)
                            rec["ok"] = True
                            rec["text"] = (txt or "")[:8000]
                            if len(txt or "") > 8000:
                                rec["truncated"] = True

                        elif op == "get_attribute":
                            sel = str(raw.get("selector", "")).strip()
                            name = str(raw.get("name", "")).strip()
                            if not sel or not name:
                                raise ValueError("get_attribute requires selector and name")
                            v = await page.locator(sel).first.get_attribute(
                                name, timeout=timeout
                            )
                            rec["ok"] = True
                            rec["value"] = v

                        elif op == "content_snippet":
                            lim = int(raw.get("max_chars", 8000))
                            lim = max(500, min(24_000, lim))
                            html = await page.content()
                            rec["ok"] = True
                            rec["html_snippet"] = html[:lim] + (
                                "..." if len(html) > lim else ""
                            )

                        else:
                            raise ValueError(
                                f"unknown op {op!r}; use goto, wait_for_selector, click, "
                                "dblclick, hover, fill, type, press, wait_ms, scroll_into_view, "
                                "screenshot, get_text, get_attribute, content_snippet"
                            )

                    except Exception as e:  # noqa: BLE001
                        rec["ok"] = False
                        rec["error"] = str(e)
                        step_results.append(rec)
                        return {
                            "ok": False,
                            "error": f"step {i} ({op}): {e}",
                            "failed_step_index": i,
                            "step_results": step_results,
                            "last_title": last_title,
                            "last_url": last_url,
                        }

                    step_results.append(rec)

                last_title = await page.title()
                last_url = page.url
                return {
                    "ok": True,
                    "step_results": step_results,
                    "last_title": last_title,
                    "last_url": last_url,
                    "note": "Combine screenshot paths with vision_analyze_image for visual assertions.",
                }
            finally:
                await browser.close()
    except Exception as e:
        msg = str(e).lower()
        hint = "python -m playwright install chromium"
        if "executable doesn't exist" in msg or "browsertype.launch" in msg:
            hint = "未找到 Chromium：请执行 python -m playwright install chromium"
        return {"ok": False, "error": str(e), "hint": hint, "step_results": step_results}
