"""Convierte una lista de AXNode CDP en un outline indentado legible por LLMs."""


# Roles que se consideran accionables (el LLM puede referirlos con #ref).
_ACTIONABLE_ROLES = frozenset({
    "button",
    "link",
    "textbox",
    "searchbox",
    "checkbox",
    "radio",
    "combobox",
    "listbox",
    "menuitem",
    "menuitemcheckbox",
    "menuitemradio",
    "tab",
    "option",
    "switch",
    "slider",
    "spinbutton",
    "treeitem",
    "gridcell",
})

# Roles sin valor semántico para el outline: se omiten (sus hijos se elevan).
_SKIP_ROLES = frozenset({"none", "presentation", "ignored"})

# Límite de profundidad: evita RecursionError en árboles AX patológicos.
_MAX_DEPTH = 60


def _role_val(node: dict) -> str:
    """Extrae el valor de role del nodo CDP."""
    r = node.get("role", {})
    if isinstance(r, dict):
        return r.get("value", "")
    return str(r) if r else ""


def _name_val(node: dict) -> str:
    """Extrae el valor de name del nodo CDP."""
    n = node.get("name", {})
    if isinstance(n, dict):
        return n.get("value", "")
    return str(n) if n else ""


def _value_val(node: dict) -> str:
    """Estado actual del nodo: texto escrito en un input, valor de un slider o
    combobox, etc. El LLM necesita saber qué hay ya en el campo."""
    v = node.get("value", {})
    if isinstance(v, dict):
        val = v.get("value", "")
        return str(val) if val not in (None, "") else ""
    return str(v) if v else ""


def _ref_id(node: dict) -> str:
    """Ref ESTABLE para acciones por referencia.

    Usa backendDOMNodeId (apunta al nodo DOM real, estable mientras el nodo viva)
    en lugar del nodeId del AX tree, que es efímero y cambia en cada
    Accessibility.getFullAXTree. Esto hace que el #ref que lee el LLM siga siendo
    válido cuando actúa sobre él un instante después. Fallback al nodeId si el
    backend no viene poblado.
    """
    bid = node.get("backendDOMNodeId")
    if bid is not None:
        return str(bid)
    return str(node.get("nodeId", ""))


def render_ax_outline(nodes: list[dict], max_chars: int = 0) -> str:
    """Convierte nodos AX tree CDP en un outline indentado legible y accionable.

    Reconstruye la jerarquía padre→hijo usando childIds/parentId y genera una
    representación de texto con indentación de 2 espacios por nivel. Los nodos
    accionables llevan un marcador #ref=<backendDOMNodeId> para que el LLM pueda
    referenciarlos en acciones posteriores (click_ref/type_ref/hover_ref). El
    estado actual de inputs (texto escrito, valor) se muestra como `= 'valor'`.

    Args:
        nodes: Lista de AXNode en formato CDP (campos: nodeId, backendDOMNodeId,
               role, name, value, childIds, parentId, ignored). Formato devuelto
               por cdp_get_ax_tree. Se puede pasar trim_ax_tree(nodes) antes para
               reducir ruido (conserva backendDOMNodeId y value.value).
        max_chars: Si > 0, trunca la salida a ese número de caracteres y añade una
                   línea final '…[outline truncado]'. 0 = sin límite.

    Returns:
        String multi-línea con el outline indentado. Vacío si nodes es vacío o
        todos los nodos son ignorados/sin role útil.
    """
    if not nodes:
        return ""

    # Lookup por nodeId (la jerarquía childIds usa nodeId, no backendDOMNodeId).
    by_id: dict[str, dict] = {}
    for node in nodes:
        nid = node.get("nodeId")
        if nid:
            by_id[nid] = node

    # Detectar nodos raíz: nodeId que no aparece como childId de nadie visible.
    all_child_ids: set[str] = set()
    for node in nodes:
        for cid in node.get("childIds", []):
            all_child_ids.add(cid)

    roots = [n for n in nodes if n.get("nodeId") not in all_child_ids]
    if not roots:
        roots = [nodes[0]]

    lines: list[str] = []
    visited: set[str] = set()  # guard de ciclo: un nodeId no se renderiza dos veces

    def _render_node(node: dict, depth: int) -> None:
        """Renderiza un nodo y sus hijos recursivamente."""
        nid = node.get("nodeId")
        if depth > _MAX_DEPTH or (nid and nid in visited):
            return
        if nid:
            visited.add(nid)

        if node.get("ignored", False):
            return

        role = _role_val(node)
        if not role or role in _SKIP_ROLES:
            # Nodos sin role útil: elevar los hijos al nivel actual.
            for cid in node.get("childIds", []):
                child = by_id.get(cid)
                if child:
                    _render_node(child, depth)
            return

        name = _name_val(node)
        indent = "  " * depth

        if name:
            base = f'{indent}{role} "{name}"'
        else:
            base = f"{indent}{role}"

        # Estado actual del campo (texto escrito, valor del slider/combobox).
        value = _value_val(node)
        if value:
            base += f" = {value!r}"

        # Ref accionable, sin padding (el relleno con espacios gastaba tokens).
        if role in _ACTIONABLE_ROLES:
            ref = _ref_id(node)
            if ref:
                base += f"  #ref={ref}"

        lines.append(base)

        for cid in node.get("childIds", []):
            child = by_id.get(cid)
            if child:
                _render_node(child, depth + 1)

    for root in roots:
        _render_node(root, 0)

    result = "\n".join(lines)

    if max_chars > 0 and len(result) > max_chars:
        result = result[:max_chars].rstrip()
        result += "\n…[outline truncado]"

    return result
