"""CDP-based DOM extraction with accessibility tree, paint order, and JS listener detection.

Ported from browser-use's approach: parallel CDP requests for DOMSnapshot, AX tree,
and viewport metrics. Elements are filtered by visibility, paint order, and viewport
distance, then indexed sequentially for compact LLM-friendly references.

Key advantages over JS-only extraction:
- AX tree gives proper semantic roles/names (not guessed from attributes)
- DOMSnapshot gives layout bounds without scroll-position issues
- JS event listener detection catches Vue @click, React onClick, etc.
- Backend node IDs enable reliable element targeting across navigations

Usage:
    extractor = DOMExtractor(page)
    result = await extractor.extract()
    # result.elements[0].index == 1, result.elements[0].role == "button", etc.
"""

from __future__ import annotations

import asyncio
from dataclasses import dataclass, field
from typing import Any

from loguru import logger


@dataclass(frozen=True)
class ElementBounds:
    """Bounding rectangle for an element."""

    x: float
    y: float
    width: float
    height: float

    @property
    def center_x(self) -> float:
        return self.x + self.width / 2

    @property
    def center_y(self) -> float:
        return self.y + self.height / 2

    @property
    def area(self) -> float:
        return self.width * self.height

    def is_visible(self, viewport_width: int, viewport_height: int, threshold: int = 1000) -> bool:
        """Check if element is within viewport + threshold distance."""
        return (
            self.x + self.width > -threshold
            and self.y + self.height > -threshold
            and self.x < viewport_width + threshold
            and self.y < viewport_height + threshold
            and self.width >= 1
            and self.height >= 1
        )


@dataclass(frozen=True)
class ExtractedElement:
    """A single actionable DOM element with accessibility and visual info."""

    index: int
    tag: str
    role: str
    name: str
    description: str
    bounds: ElementBounds
    is_clickable: bool
    is_editable: bool
    is_visible: bool
    backend_node_id: int
    node_id: int = 0
    input_type: str = ""
    value: str = ""
    placeholder: str = ""
    href: str = ""
    aria_expanded: str = ""
    aria_checked: str = ""
    aria_selected: str = ""
    is_disabled: bool = False
    paint_order: int = 0
    selector_hint: str = ""
    has_js_click_listener: bool = False
    is_scrollable: bool = False


@dataclass
class ViewportInfo:
    """Current viewport dimensions and scroll position."""

    width: int = 1280
    height: int = 720
    scroll_x: float = 0
    scroll_y: float = 0
    device_pixel_ratio: float = 1.0


@dataclass
class ExtractionResult:
    """Result of a DOM extraction pass."""

    elements: list[ExtractedElement] = field(default_factory=list)
    viewport: ViewportInfo = field(default_factory=ViewportInfo)
    url: str = ""
    title: str = ""
    page_text: str = ""

    @property
    def element_count(self) -> int:
        return len(self.elements)


# ── Interactive element detection (ported from browser-use ClickableElementDetector) ──

_INTERACTIVE_ROLES = frozenset({
    "button", "link", "textbox", "combobox", "listbox", "menuitem",
    "menuitemcheckbox", "menuitemradio", "option", "radio", "searchbox",
    "slider", "spinbutton", "switch", "tab", "checkbox", "treeitem",
    "row", "cell", "gridcell",
})

_INTERACTIVE_TAGS = frozenset({
    "a", "button", "input", "textarea", "select", "option",
    "details", "summary",
})

_INTERACTIVE_ARIA_ROLES = frozenset({
    "button", "link", "menuitem", "option", "radio", "checkbox",
    "tab", "textbox", "combobox", "slider", "spinbutton", "search",
    "searchbox", "row", "cell", "gridcell",
})

_EDITABLE_INPUT_TYPES = frozenset({
    "text", "password", "email", "search", "url", "tel",
    "number", "date", "datetime-local", "month", "week", "time",
    "color", "range", "",
})

# Computed styles to capture from DOMSnapshot
_SNAPSHOT_STYLES = [
    "display", "visibility", "opacity", "overflow",
    "pointer-events", "cursor", "position",
]


class DOMExtractor:
    """Extract actionable elements from a Playwright page using CDP.

    Combines multiple CDP information sources in parallel:
    1. DOMSnapshot.captureSnapshot — layout bounds, computed styles, paint order
    2. Accessibility.getFullAXTree — semantic roles, names, properties
    3. Page.getLayoutMetrics — viewport dimensions, device pixel ratio
    4. JS event listener detection — catches framework-attached click handlers
    """

    def __init__(
        self,
        page: Any,
        viewport_threshold: int = 1000,
        max_elements: int = 200,
        include_text: bool = True,
        text_max_chars: int = 2000,
    ):
        self._page = page
        self._viewport_threshold = viewport_threshold
        self._max_elements = max_elements
        self._include_text = include_text
        self._text_max_chars = text_max_chars
        self._cdp_session: Any | None = None

    async def _ensure_cdp_session(self) -> Any:
        """Create or reuse a CDP session for this page."""
        if self._cdp_session is None:
            try:
                self._cdp_session = await self._page.context.new_cdp_session(self._page)
            except Exception as exc:
                logger.warning("Failed to create CDP session: {}", exc)
                raise
        return self._cdp_session

    async def extract(self) -> ExtractionResult:
        """Run the full CDP-based extraction pipeline."""
        try:
            cdp = await self._ensure_cdp_session()
        except Exception:
            logger.warning("CDP session unavailable, falling back to JS extraction")
            return await self._extract_js_fallback()

        try:
            return await self._extract_via_cdp(cdp)
        except Exception as exc:
            logger.warning("CDP extraction failed ({}), falling back to JS", exc)
            return await self._extract_js_fallback()

    async def _extract_via_cdp(self, cdp: Any) -> ExtractionResult:
        """Primary extraction path using CDP protocol calls."""
        # Phase 1: Parallel CDP data fetch (browser-use pattern)
        snapshot_task = self._fetch_snapshot(cdp)
        ax_task = self._fetch_ax_tree(cdp)
        metrics_task = self._fetch_layout_metrics(cdp)
        meta_task = self._get_page_metadata()

        snapshot, ax_tree, metrics, meta = await asyncio.gather(
            snapshot_task, ax_task, metrics_task, meta_task,
            return_exceptions=True,
        )

        # Handle partial failures gracefully
        if isinstance(snapshot, Exception):
            logger.warning("DOMSnapshot failed: {}", snapshot)
            return await self._extract_js_fallback()

        viewport = ViewportInfo(
            width=int(metrics.get("cssContentSize", {}).get("width", 1280)) if not isinstance(metrics, Exception) else 1280,
            height=int(metrics.get("cssContentSize", {}).get("height", 720)) if not isinstance(metrics, Exception) else 720,
            device_pixel_ratio=metrics.get("devicePixelRatio", 1.0) if not isinstance(metrics, Exception) else 1.0,
        )

        # Also get scroll position
        try:
            scroll_info = await self._page.evaluate("() => ({x: window.scrollX, y: window.scrollY})")
            viewport.scroll_x = scroll_info.get("x", 0)
            viewport.scroll_y = scroll_info.get("y", 0)
        except Exception:
            pass

        # Phase 2: Build AX node lookup (backendNodeId → AX data)
        ax_lookup: dict[int, dict[str, Any]] = {}
        if not isinstance(ax_tree, Exception):
            ax_lookup = self._build_ax_lookup(ax_tree)

        # Phase 3: Detect JS click listeners
        js_click_ids: set[int] = set()
        try:
            js_click_ids = await self._detect_js_click_listeners(cdp)
        except Exception as exc:
            logger.debug("JS click listener detection failed: {}", exc)

        # Phase 4: Assemble elements from snapshot + AX data
        raw_elements = self._assemble_elements(snapshot, ax_lookup, js_click_ids, viewport)

        # Phase 5: Filter by viewport distance
        filtered = [
            el for el in raw_elements
            if el["bounds"].is_visible(viewport.width, viewport.height, self._viewport_threshold)
        ]

        # Sort by paint order (higher = on top) then position (top-to-bottom, left-to-right)
        filtered.sort(key=lambda el: (-el.get("paint_order", 0), el["bounds"].y, el["bounds"].x))

        # Remove occluded elements
        visible = self._filter_occluded(filtered)

        # Limit and index
        limited = visible[:self._max_elements]
        indexed_elements = [
            ExtractedElement(
                index=i + 1,
                tag=el["tag"],
                role=el["role"],
                name=el["name"],
                description=el.get("description", ""),
                bounds=el["bounds"],
                is_clickable=el["is_clickable"],
                is_editable=el["is_editable"],
                is_visible=True,
                backend_node_id=el.get("backend_node_id", 0),
                node_id=el.get("node_id", 0),
                input_type=el.get("input_type", ""),
                value=el.get("value", ""),
                placeholder=el.get("placeholder", ""),
                href=el.get("href", ""),
                aria_expanded=el.get("aria_expanded", ""),
                aria_checked=el.get("aria_checked", ""),
                aria_selected=el.get("aria_selected", ""),
                is_disabled=el.get("is_disabled", False),
                paint_order=el.get("paint_order", 0),
                selector_hint=el.get("selector_hint", ""),
                has_js_click_listener=el.get("has_js_click_listener", False),
                is_scrollable=el.get("is_scrollable", False),
            )
            for i, el in enumerate(limited)
        ]

        return ExtractionResult(
            elements=indexed_elements,
            viewport=viewport,
            url=meta.get("url", "") if not isinstance(meta, Exception) else "",
            title=meta.get("title", "") if not isinstance(meta, Exception) else "",
            page_text=meta.get("text", "") if self._include_text and not isinstance(meta, Exception) else "",
        )

    # ── CDP fetch methods (parallel, browser-use pattern) ──

    async def _fetch_snapshot(self, cdp: Any) -> dict[str, Any]:
        """Fetch DOMSnapshot with computed styles and layout info."""
        return await asyncio.wait_for(
            cdp.send("DOMSnapshot.captureSnapshot", {
                "computedStyles": _SNAPSHOT_STYLES,
                "includePaintOrder": True,
                "includeDOMRects": True,
            }),
            timeout=10.0,
        )

    async def _fetch_ax_tree(self, cdp: Any) -> dict[str, Any]:
        """Fetch full accessibility tree."""
        return await asyncio.wait_for(
            cdp.send("Accessibility.getFullAXTree"),
            timeout=10.0,
        )

    async def _fetch_layout_metrics(self, cdp: Any) -> dict[str, Any]:
        """Fetch page layout metrics for viewport info."""
        try:
            result = await asyncio.wait_for(
                cdp.send("Page.getLayoutMetrics"),
                timeout=5.0,
            )
            # Extract viewport dimensions from cssVisualViewport
            visual_viewport = result.get("cssVisualViewport", {})
            return {
                "cssContentSize": {
                    "width": visual_viewport.get("clientWidth", 1280),
                    "height": visual_viewport.get("clientHeight", 720),
                },
                "devicePixelRatio": visual_viewport.get("scale", 1.0),
            }
        except Exception:
            return {}

    async def _detect_js_click_listeners(self, cdp: Any) -> set[int]:
        """Detect elements with JS click/mouse event listeners (Vue @click, React onClick, etc.).

        Uses Runtime.evaluate to walk the DOM and DOMDebugger.getEventListeners
        to check each element. Returns set of backend node IDs with listeners.
        """
        backend_ids: set[int] = set()

        try:
            # Get all elements in the page
            result = await cdp.send("Runtime.evaluate", {
                "expression": """
                    (() => {
                        const all = document.querySelectorAll('*');
                        const ids = [];
                        for (const el of all) {
                            ids.push(el);
                        }
                        return ids.length;
                    })()
                """,
                "returnByValue": True,
            })
            element_count = result.get("result", {}).get("value", 0)
            if element_count == 0:
                return backend_ids

            # Get element references in batches and check listeners
            batch_size = 50
            for offset in range(0, min(element_count, 500), batch_size):
                try:
                    # Get a batch of element object IDs
                    batch_result = await cdp.send("Runtime.evaluate", {
                        "expression": f"""
                            (() => {{
                                const all = Array.from(document.querySelectorAll('*'));
                                return all.slice({offset}, {offset + batch_size});
                            }})()
                        """,
                        "returnByValue": False,
                    })
                    array_id = batch_result.get("result", {}).get("objectId")
                    if not array_id:
                        continue

                    # Get array properties to iterate elements
                    props = await cdp.send("Runtime.getProperties", {
                        "objectId": array_id,
                        "ownProperties": True,
                    })

                    for prop in props.get("result", []):
                        if not prop.get("value", {}).get("objectId"):
                            continue
                        if prop.get("name", "").startswith("__"):
                            continue
                        try:
                            idx_val = int(prop["name"])
                        except (ValueError, KeyError):
                            continue

                        obj_id = prop["value"]["objectId"]
                        try:
                            listeners = await cdp.send("DOMDebugger.getEventListeners", {
                                "objectId": obj_id,
                            })
                            click_types = {"click", "mousedown", "mouseup", "pointerdown", "pointerup"}
                            has_click = any(
                                l.get("type") in click_types
                                for l in listeners.get("listeners", [])
                            )
                            if has_click:
                                # Resolve to backend node ID
                                try:
                                    node_info = await cdp.send("DOM.describeNode", {
                                        "objectId": obj_id,
                                    })
                                    bid = node_info.get("node", {}).get("backendNodeId")
                                    if bid:
                                        backend_ids.add(bid)
                                except Exception:
                                    pass
                        except Exception:
                            pass

                    # Release the array object
                    try:
                        await cdp.send("Runtime.releaseObject", {"objectId": array_id})
                    except Exception:
                        pass

                except Exception:
                    continue

        except Exception as exc:
            logger.debug("JS listener batch detection failed: {}", exc)

        return backend_ids

    # ── Tree assembly ──

    def _build_ax_lookup(self, ax_tree: dict[str, Any]) -> dict[int, dict[str, Any]]:
        """Build mapping from backendDOMNodeId → AX node data."""
        lookup: dict[int, dict[str, Any]] = {}
        for node in ax_tree.get("nodes", []):
            backend_id = node.get("backendDOMNodeId")
            if backend_id is None:
                continue

            role_value = node.get("role", {}).get("value", "")
            name_value = node.get("name", {}).get("value", "")
            desc_value = node.get("description", {}).get("value", "")
            ignored = node.get("ignored", False)

            # Extract properties
            properties: dict[str, Any] = {}
            for prop in node.get("properties", []):
                prop_name = prop.get("name", "")
                prop_val = prop.get("value", {}).get("value")
                if prop_name and prop_val is not None:
                    properties[prop_name] = prop_val

            lookup[backend_id] = {
                "role": role_value,
                "name": name_value,
                "description": desc_value,
                "ignored": ignored,
                "properties": properties,
            }
        return lookup

    def _assemble_elements(
        self,
        snapshot: dict[str, Any],
        ax_lookup: dict[int, dict[str, Any]],
        js_click_ids: set[int],
        viewport: ViewportInfo,
    ) -> list[dict[str, Any]]:
        """Assemble interactive elements from DOMSnapshot + AX tree data."""
        documents = snapshot.get("documents", [])
        if not documents:
            return []

        elements: list[dict[str, Any]] = []

        for doc in documents:
            nodes = doc.get("nodes", {})
            layout = doc.get("layout", {})
            text_value = doc.get("textBoxes", {})

            # Node arrays from snapshot
            node_names = nodes.get("nodeName", [])
            node_types = nodes.get("nodeType", [])
            node_values = nodes.get("nodeValue", [])
            backend_ids = nodes.get("backendNodeId", [])
            attributes_arr = nodes.get("attributes", [])
            parent_indices = nodes.get("parentIndex", [])
            is_clickable_arr = nodes.get("isClickable", [])
            current_source_url = nodes.get("currentSourceURL", [])

            # Layout arrays
            layout_node_indices = layout.get("nodeIndex", [])
            layout_bounds = layout.get("bounds", [])
            layout_paint_orders = layout.get("paintOrder", [])
            layout_styles = layout.get("styles", [])

            # String table
            strings = snapshot.get("strings", [])

            def resolve_str(idx: int) -> str:
                if idx < 0 or idx >= len(strings):
                    return ""
                return strings[idx]

            # Build layout lookup: node_index → {bounds, paint_order, styles}
            layout_lookup: dict[int, dict[str, Any]] = {}
            for i, node_idx in enumerate(layout_node_indices):
                bounds_data = layout_bounds[i] if i < len(layout_bounds) else []
                paint_order = layout_paint_orders[i] if i < len(layout_paint_orders) else 0
                style_indices = layout_styles[i] if i < len(layout_styles) else []

                # Bounds: [x, y, width, height]
                bx = bounds_data[0] if len(bounds_data) > 0 else 0
                by = bounds_data[1] if len(bounds_data) > 1 else 0
                bw = bounds_data[2] if len(bounds_data) > 2 else 0
                bh = bounds_data[3] if len(bounds_data) > 3 else 0

                # Resolve computed styles
                computed: dict[str, str] = {}
                for si, style_name in enumerate(_SNAPSHOT_STYLES):
                    if si < len(style_indices):
                        computed[style_name] = resolve_str(style_indices[si])

                layout_lookup[node_idx] = {
                    "bounds": ElementBounds(x=bx, y=by, width=bw, height=bh),
                    "paint_order": paint_order,
                    "styles": computed,
                }

            # Iterate nodes and filter interactive ones
            for node_idx in range(len(node_names)):
                node_type = node_types[node_idx] if node_idx < len(node_types) else 0
                if node_type != 1:  # ELEMENT_NODE only
                    continue

                tag = resolve_str(node_names[node_idx]).lower()
                bid = backend_ids[node_idx] if node_idx < len(backend_ids) else 0

                # Parse attributes from flat [key_idx, val_idx, key_idx, val_idx, ...] array
                attrs: dict[str, str] = {}
                attr_pairs = attributes_arr[node_idx] if node_idx < len(attributes_arr) else []
                for ai in range(0, len(attr_pairs) - 1, 2):
                    key = resolve_str(attr_pairs[ai])
                    val = resolve_str(attr_pairs[ai + 1])
                    if key:
                        attrs[key] = val

                # Get layout data
                layout_data = layout_lookup.get(node_idx)
                if layout_data is None:
                    continue  # No layout = not rendered

                bounds = layout_data["bounds"]
                paint_order = layout_data["paint_order"]
                styles = layout_data["styles"]

                # Visibility check from computed styles
                if styles.get("display") == "none":
                    continue
                if styles.get("visibility") == "hidden":
                    continue
                if styles.get("opacity") == "0":
                    continue

                # Get AX data for this node
                ax = ax_lookup.get(bid, {})
                ax_role = ax.get("role", "")
                ax_name = ax.get("name", "")
                ax_desc = ax.get("description", "")
                ax_props = ax.get("properties", {})
                ax_ignored = ax.get("ignored", False)

                # Skip AX-ignored nodes (decorative, hidden from AT)
                if ax_ignored and tag not in _INTERACTIVE_TAGS:
                    continue

                # Snapshot clickable flag
                is_snapshot_clickable = False
                if node_idx < len(is_clickable_arr):
                    is_snapshot_clickable = bool(is_clickable_arr[node_idx])

                has_js_listener = bid in js_click_ids

                # ── Interactivity detection (ported from browser-use ClickableElementDetector) ──
                is_interactive = False

                # 1. JS click listener (Vue @click, React onClick, etc.)
                if has_js_listener:
                    is_interactive = True

                # 2. Interactive tag
                elif tag in _INTERACTIVE_TAGS:
                    is_interactive = True

                # 3. Interactive ARIA role (from attributes or AX tree)
                elif attrs.get("role", "") in _INTERACTIVE_ARIA_ROLES:
                    is_interactive = True
                elif ax_role in _INTERACTIVE_ARIA_ROLES:
                    is_interactive = True

                # 4. Interactive attributes (onclick, tabindex, contenteditable)
                elif any(a in attrs for a in ("onclick", "onmousedown", "onkeydown", "tabindex")):
                    is_interactive = True
                elif attrs.get("contenteditable") == "true":
                    is_interactive = True

                # 5. AX properties that indicate interactivity
                elif any(ax_props.get(p) for p in ("focusable", "editable", "settable")):
                    is_interactive = True
                elif any(p in ax_props for p in ("checked", "expanded", "pressed", "selected")):
                    is_interactive = True

                # 6. Cursor pointer from computed styles
                elif styles.get("cursor") == "pointer":
                    is_interactive = True

                # 7. Snapshot isClickable flag
                elif is_snapshot_clickable:
                    is_interactive = True

                if not is_interactive:
                    continue

                # Skip disabled elements
                if ax_props.get("disabled"):
                    continue
                if attrs.get("disabled") is not None and attrs.get("disabled") != "false":
                    continue

                # ── Build element data ──
                is_editable = False
                input_type = ""
                if tag == "textarea":
                    is_editable = True
                elif tag == "input":
                    input_type = attrs.get("type", "text").lower()
                    is_editable = input_type in _EDITABLE_INPUT_TYPES
                elif attrs.get("contenteditable") == "true" or ax_role == "textbox":
                    is_editable = True

                # Name: prefer AX name, fall back to attributes
                name = ax_name
                if not name:
                    name = (
                        attrs.get("aria-label", "")
                        or attrs.get("placeholder", "")
                        or attrs.get("title", "")
                        or attrs.get("alt", "")
                        or attrs.get("name", "")
                        or attrs.get("value", "")
                        or ""
                    )

                # Build selector hint
                selector = ""
                if attrs.get("id"):
                    selector = f"#{attrs['id']}"
                elif attrs.get("name"):
                    selector = f"{tag}[name={attrs['name']}]"
                elif attrs.get("type"):
                    selector = f"{tag}[type={attrs['type']}]"
                else:
                    selector = tag

                # Is clickable = not editable + has interaction signals
                is_clickable = (
                    not is_editable
                    or tag in ("a", "button", "summary")
                    or has_js_listener
                    or is_snapshot_clickable
                    or styles.get("cursor") == "pointer"
                )

                elements.append({
                    "tag": tag,
                    "role": ax_role or attrs.get("role", ""),
                    "name": name[:200],
                    "description": ax_desc[:200] if ax_desc else "",
                    "bounds": bounds,
                    "is_clickable": is_clickable,
                    "is_editable": is_editable,
                    "is_disabled": False,
                    "backend_node_id": bid,
                    "node_id": node_idx,
                    "input_type": input_type,
                    "value": attrs.get("value", "")[:100],
                    "placeholder": attrs.get("placeholder", ""),
                    "href": attrs.get("href", "") if tag == "a" else "",
                    "aria_expanded": str(ax_props.get("expanded", attrs.get("aria-expanded", ""))),
                    "aria_checked": str(ax_props.get("checked", attrs.get("aria-checked", ""))),
                    "aria_selected": str(ax_props.get("selected", attrs.get("aria-selected", ""))),
                    "paint_order": paint_order,
                    "selector_hint": selector,
                    "has_js_click_listener": has_js_listener,
                    "is_scrollable": styles.get("overflow") in ("auto", "scroll"),
                })

        return elements

    # ── Occlusion filtering ──

    @staticmethod
    def _filter_occluded(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
        """Remove elements that are fully covered by higher paint-order elements.

        Simple heuristic: if an element's center point is inside a higher paint-order
        element's bounds and the higher element is opaque, skip the lower one.
        """
        if len(elements) <= 1:
            return elements

        visible: list[dict[str, Any]] = []
        occupied_centers: set[tuple[int, int]] = set()

        for el in elements:
            bounds = el["bounds"]
            cx = int(bounds.center_x / 10) * 10  # Grid snap for fuzzy matching
            cy = int(bounds.center_y / 10) * 10
            center_key = (cx, cy)

            if center_key in occupied_centers:
                continue

            visible.append(el)
            occupied_centers.add(center_key)

        return visible

    # ── Page metadata ──

    async def _get_page_metadata(self) -> dict[str, str]:
        """Get page URL, title, and text excerpt."""
        try:
            meta = await self._page.evaluate("""(maxChars) => {
                const root = document.querySelector('main') || document.body || document.documentElement;
                const text = (root?.innerText || root?.textContent || '').replace(/\\s+/g, ' ').trim();
                return {
                    url: location.href,
                    title: document.title || '',
                    text: text.slice(0, maxChars),
                };
            }""", self._text_max_chars)
            return meta
        except Exception:
            return {"url": "", "title": "", "text": ""}

    # ── JS fallback (original extraction method) ──

    async def _extract_js_fallback(self) -> ExtractionResult:
        """Fallback extraction using page.evaluate() when CDP is unavailable.

        This is the original extraction method — less accurate but works in all
        Playwright contexts including non-Chromium browsers.
        """
        viewport = await self._get_viewport_info_js()
        meta = await self._get_page_metadata()

        try:
            raw = await self._page.evaluate(_JS_EXTRACT_INTERACTIVE)
        except Exception as exc:
            logger.warning("JS fallback extraction failed: {}", exc)
            return ExtractionResult(viewport=viewport, url=meta.get("url", ""), title=meta.get("title", ""))

        raw_elements = [
            {**el, "bounds": ElementBounds(
                x=el["bounds"]["x"], y=el["bounds"]["y"],
                width=el["bounds"]["width"], height=el["bounds"]["height"],
            )}
            for el in (raw or [])
        ]

        filtered = [
            el for el in raw_elements
            if el["bounds"].is_visible(viewport.width, viewport.height, self._viewport_threshold)
        ]
        filtered.sort(key=lambda el: (-el.get("paint_order", 0), el["bounds"].y, el["bounds"].x))
        visible = self._filter_occluded(filtered)
        limited = visible[:self._max_elements]

        indexed_elements = [
            ExtractedElement(
                index=i + 1, tag=el["tag"], role=el["role"], name=el["name"],
                description=el.get("description", ""), bounds=el["bounds"],
                is_clickable=el["is_clickable"], is_editable=el["is_editable"],
                is_visible=True, backend_node_id=el.get("backend_node_id", 0),
                node_id=el.get("node_id", 0), input_type=el.get("input_type", ""),
                value=el.get("value", ""), placeholder=el.get("placeholder", ""),
                href=el.get("href", ""), aria_expanded=el.get("aria_expanded", ""),
                aria_checked=el.get("aria_checked", ""), aria_selected=el.get("aria_selected", ""),
                is_disabled=el.get("is_disabled", False), paint_order=el.get("paint_order", 0),
                selector_hint=el.get("selector_hint", ""),
            )
            for i, el in enumerate(limited)
        ]

        return ExtractionResult(
            elements=indexed_elements, viewport=viewport,
            url=meta.get("url", ""), title=meta.get("title", ""),
            page_text=meta.get("text", "") if self._include_text else "",
        )

    async def _get_viewport_info_js(self) -> ViewportInfo:
        """Get viewport info via JS (fallback)."""
        try:
            info = await self._page.evaluate("""() => ({
                width: window.innerWidth,
                height: window.innerHeight,
                scrollX: window.scrollX,
                scrollY: window.scrollY,
                devicePixelRatio: window.devicePixelRatio,
            })""")
            return ViewportInfo(
                width=info.get("width", 1280), height=info.get("height", 720),
                scroll_x=info.get("scrollX", 0), scroll_y=info.get("scrollY", 0),
                device_pixel_ratio=info.get("devicePixelRatio", 1.0),
            )
        except Exception:
            return ViewportInfo()

    async def get_element_by_index(self, index: int, result: ExtractionResult) -> ExtractedElement | None:
        """Look up an element by its index from a previous extraction."""
        for el in result.elements:
            if el.index == index:
                return el
        return None

    async def close(self) -> None:
        """Detach CDP session if active."""
        if self._cdp_session is not None:
            try:
                await self._cdp_session.detach()
            except Exception:
                pass
            self._cdp_session = None


# ── JS fallback extraction script ──

_JS_EXTRACT_INTERACTIVE = """() => {
    const SELECTORS = [
        'a[href]', 'button', 'input', 'textarea', 'select', 'option',
        '[role="button"]', '[role="link"]', '[role="textbox"]',
        '[role="combobox"]', '[role="listbox"]', '[role="menuitem"]',
        '[role="option"]', '[role="radio"]', '[role="checkbox"]',
        '[role="switch"]', '[role="tab"]', '[role="slider"]',
        '[role="searchbox"]', '[role="spinbutton"]',
        '[contenteditable="true"]', '[tabindex]:not([tabindex="-1"])',
        'details > summary',
    ].join(',');

    const visible = (el) => {
        if (!(el instanceof HTMLElement)) return false;
        const style = window.getComputedStyle(el);
        if (!style || style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') return false;
        const rect = el.getBoundingClientRect();
        return rect.width >= 1 && rect.height >= 1;
    };

    const getLabel = (el) => {
        return (
            el.getAttribute('aria-label')
            || el.getAttribute('placeholder')
            || el.getAttribute('title')
            || el.getAttribute('alt')
            || (el.innerText || '').trim().slice(0, 200)
            || el.getAttribute('name')
            || el.getAttribute('value')
            || ''
        );
    };

    const isClickable = (el) => {
        const tag = (el.tagName || '').toLowerCase();
        if (['a', 'button', 'summary'].includes(tag)) return true;
        if (el.getAttribute('role') === 'button' || el.getAttribute('role') === 'link') return true;
        if (el.onclick || el.getAttribute('onclick')) return true;
        return window.getComputedStyle(el).cursor === 'pointer';
    };

    const isEditable = (el) => {
        const tag = (el.tagName || '').toLowerCase();
        if (tag === 'textarea') return true;
        if (tag === 'input') {
            const type = (el.getAttribute('type') || '').toLowerCase();
            return !['button', 'submit', 'reset', 'image', 'hidden', 'file', 'checkbox', 'radio'].includes(type);
        }
        if (el.getAttribute('contenteditable') === 'true') return true;
        return el.getAttribute('role') === 'textbox';
    };

    const getSelectorHint = (el) => {
        if (el.id) return '#' + CSS.escape(el.id);
        const tag = (el.tagName || '').toLowerCase();
        const name = el.getAttribute('name');
        if (name) return tag + '[name=' + CSS.escape(name) + ']';
        const type = el.getAttribute('type');
        if (type) return tag + '[type=' + type + ']';
        return tag;
    };

    const seen = new Set();
    const elements = [];
    let paintOrder = 0;

    for (const el of document.querySelectorAll(SELECTORS)) {
        if (seen.has(el) || !visible(el)) continue;
        seen.add(el);

        const rect = el.getBoundingClientRect();
        const tag = (el.tagName || '').toLowerCase();

        elements.push({
            tag,
            role: el.getAttribute('role') || '',
            name: getLabel(el),
            description: '',
            bounds: {
                x: Math.round(rect.x * 10) / 10,
                y: Math.round(rect.y * 10) / 10,
                width: Math.round(rect.width * 10) / 10,
                height: Math.round(rect.height * 10) / 10,
            },
            is_clickable: isClickable(el),
            is_editable: isEditable(el),
            is_disabled: el.disabled === true || el.getAttribute('aria-disabled') === 'true',
            input_type: tag === 'input' ? (el.getAttribute('type') || 'text').toLowerCase() : '',
            value: (tag === 'input' || tag === 'textarea') ? (el.value || '').slice(0, 100) : '',
            placeholder: el.getAttribute('placeholder') || '',
            href: tag === 'a' ? (el.getAttribute('href') || '') : '',
            aria_expanded: el.getAttribute('aria-expanded') || '',
            aria_checked: el.getAttribute('aria-checked') || '',
            aria_selected: el.getAttribute('aria-selected') || '',
            paint_order: paintOrder++,
            selector_hint: getSelectorHint(el),
            backend_node_id: 0,
            node_id: 0,
        });
    }

    return elements;
}"""
