import base64
import json
import logging
from io import BytesIO
from typing import Dict, List, Optional, Tuple

from GramAddict.core.perception.spatial_parser import SpatialNode

logger = logging.getLogger(__name__)


def _humanize_desc(desc: str) -> str:
    """
    Inserts a space between numbers and letters to fix Instagram's concatenated content-desc.
    Example: "991following" -> "991 following", "140Kfollowers" -> "140K followers"
    """
    if not desc:
        return ""
    import re

    return re.sub(r"(\d[KMBkmb]?)([a-z])", r"\1 \2", desc)


class IntentResolver:
    """
    Vision-First Intent Resolver.

    Resolves UI intents by SEEING the screen, not by parsing text descriptions.
    Uses Set-of-Mark (SoM) visual prompting: annotates a screenshot with numbered
    bounding boxes around clickable candidates, sends the annotated image to the VLM,
    and lets the VLM visually decide which box to tap.

    Architecture:
        1. Navigation tabs → structural zone guard (bottom 15%, resource-id)
        2. Everything else → Visual Discovery (screenshot + numbered boxes + VLM)
        3. Fallback → text-based VLM (when no device/screenshot available)
    """

    # ──────────────────────────────────────────────
    # Structural Guards
    # ──────────────────────────────────────────────

    def filter_navigation_conflicts(
        self, candidates: List[SpatialNode], intent_description: str, screen_height: int = 2400
    ) -> List[SpatialNode]:
        """
        Prevents VLM from confusing navigation-bar buttons (Back, Close)
        with bottom tab-bar buttons (Home, Profile, Search).

        Production bug 2026-04-30: VLM picked action_bar_button_back
        for "tap profile tab" → account switch failed.

        Production bug 2026-05-01: VLM picked profile_tab (desc='Profile')
        for "post author username text" → navigated to own profile instead.

        Rules:
        - For tab intents: exclude nodes with "back" in resource_id or
          content_desc == "Back"
        - For back/close intents: no filtering (Back is the correct target)
        - For author/username intents: exclude bottom navigation tabs
        """
        intent_lower = intent_description.lower()

        # Only apply for REAL tab navigation intents.
        # REGRESSION FIX 2026-05-02: "tab" as a substring was too broad.
        # Intent "post author username text (exclude bottom tabs)" matched
        # because it contained "tab" → Tab Height Guard nuked the author node.
        # Now we require specific tab navigation patterns:
        #   - "tap profile tab", "tap home tab", "explore tab"
        #   - NOT "exclude bottom tabs", "tabbar", random mentions
        import re

        _TAB_PATTERN = re.compile(
            r"\btap\s+\w+\s+tab\b"  # "tap profile tab", "tap home tab"
            r"|\b\w+\s+tab\b"  # "profile tab", "explore tab"
            r"|^tab\b",  # "tab" at start of intent
            re.IGNORECASE,
        )
        filtered = []
        is_tab_intent = bool(_TAB_PATTERN.search(intent_lower)) and "back" not in intent_lower
        # REGRESSION FIX 2026-05-01: Author/username intents must never pick nav tabs
        is_author_intent = any(kw in intent_lower for kw in ["author", "username", "post media"])

        for node in candidates:
            cls_name = (node.class_name or "").lower()

            # Geometric and Class-based heuristics only. No language strings or Resource IDs!
            is_bottom_nav_area = node.center_y > (screen_height * 0.85)
            is_top_header_area = node.center_y < (screen_height * 0.15)
            is_input_field = "edittext" in cls_name
            is_image_or_video = "imageview" in cls_name or "textureview" in cls_name
            is_large_container = node.area > (screen_height * 0.3 * screen_height * 0.3)

            # Tab intents should NEVER be at the top of the screen
            if is_tab_intent and is_top_header_area:
                logger.debug("🛡️ [Tab Height Guard] Excluded top element for tab intent.")
                continue

            # Author/Username intents should not pick bottom navigation tabs
            if is_author_intent and is_bottom_nav_area:
                logger.debug("🛡️ [Author Tab Guard] Excluded bottom nav area for author intent.")
                continue

            # Input fields should never be picked unless explicitly asked for
            is_reply_intent = "reply" in intent_lower or "message" in intent_lower or "type" in intent_lower
            if is_input_field and not is_reply_intent:
                logger.debug("🛡️ [Reply Guard] Excluded input field for non-reply intent.")
                continue

            # Posts/Authors shouldn't be massive full-screen containers unless it's a specific media intent
            is_post_author_intent = is_author_intent and "post" in intent_lower
            if is_post_author_intent and is_large_container and not is_image_or_video:
                logger.debug("🛡️ [Author Container Guard] Excluded massive container for author intent.")
                continue

            filtered.append(node)

        return filtered

    # ──────────────────────────────────────────────
    # Public API
    # ──────────────────────────────────────────────

    def resolve(
        self, intent_description: str, candidates: List[SpatialNode], device=None, screen_height: int = 2400
    ) -> Optional[SpatialNode]:
        if not candidates:
            return None

        intent_lower = intent_description.lower()

        # Block abstract goals from leaking into node clicks
        abstract_goals = ["open profile", "open explore", "open following", "learn own profile"]
        if intent_lower in abstract_goals:
            return None

        # --- Strict Structural Fast-Paths ---
        # ALL HARDCODED UI STRUCTURAL GUARDS HAVE BEEN PURGED!
        # Rule: ZERO MAINTENANCE. We do not hardcode Resource IDs, content_desc, or text matching
        # for any UI elements (Likes, Follows, Messages, Tabs, Posts, Story Rings, etc.).
        # The bot MUST autonomously find elements via the Telepathic VLM Engine and store
        # them in Qdrant memory after successful structural delta verification.

        # --- Fast-Path: Send Message Button ---
        if "send message button" in intent_lower:
            for node in candidates:
                rid = (node.resource_id or "").lower()
                desc = (node.content_desc or "").lower()
                if "send_button" in rid or "absenden" in desc or "send" in desc or "send" in (node.text or "").lower():
                    logger.info("📬 [Fast-Path] Matched 'Send Message Button' structurally. Bypassing VLM.")
                    return node

        # --- Fast-Path: Structural Tab Resolver ---
        # PRIMARY: Match by resource-id (architectural constants of the Instagram APK).
        # FALLBACK: Pure geometry if resource-IDs are missing (e.g. custom ROMs).
        # Resource IDs like feed_tab, profile_tab, clips_tab are NOT localized strings —
        # they are compile-time Android identifiers that NEVER change across locales.
        _TAB_MAP = {
            "home": {"id": "feed_tab", "desc": "home"},
            "profile": {"id": "profile_tab", "desc": "profile"},
            "explore": {"id": "search_tab", "desc": "search and explore"},
            "search": {"id": "search_tab", "desc": "search and explore"},
            "reels": {"id": "clips_tab", "desc": "reels"},
            "messages": {"id": "direct_tab", "desc": "message"},
        }
        if "tab" in intent_lower and "tap" in intent_lower and "back" not in intent_lower:
            # Strategy 1: Structural resource-id and content-desc match (O(1), zero ambiguity)
            for keyword, targets in _TAB_MAP.items():
                if keyword in intent_lower:
                    for node in candidates:
                        # Bottom 20% guard to ensure it's actually a tab and not someone's name
                        if node.center_y < screen_height * 0.8:
                            continue

                        rid = (node.resource_id or "").lower()
                        desc = (node.content_desc or "").lower()
                        text = (node.text or "").lower()

                        # Use strictly equal for desc/text to avoid matching "profile picture"
                        if targets["id"] in rid or targets["desc"] == desc or targets["desc"] == text:
                            logger.info(
                                f"📐 [Structural Tab Resolver] Matched '{keyword}' tab structurally. Bypassing VLM."
                            )
                            return node
                    break  # keyword matched but no node found — fall through to geometry

            # Strategy 2: Geometric fallback (for edge cases where resource-IDs are stripped)
            # Requirements: bottom 5% of screen, FrameLayout, long-clickable (tabs are always long-clickable)
            tab_candidates = []
            for node in candidates:
                if node.center_y > screen_height * 0.93:
                    cls_name = (node.class_name or "").lower()
                    is_long_clickable = getattr(node, "long_clickable", False)
                    # Tabs are FrameLayouts that are long-clickable — this excludes:
                    # - Post grid thumbnails (ImageView, not long-clickable)
                    # - ViewGroup containers (wrong class)
                    if "framelayout" in cls_name and is_long_clickable:
                        tab_candidates.append(node)

            if tab_candidates:
                tab_candidates.sort(key=lambda n: n.center_x)

                # Deduplicate by X cluster (icon inside container shares same X)
                unique_tabs = []
                last_x = -1000
                for t in tab_candidates:
                    if t.center_x - last_x > 100:  # Tabs are ~216px apart, use 100px threshold
                        unique_tabs.append(t)
                        last_x = t.center_x

                if len(unique_tabs) >= 4:
                    logger.info(f"📐 [Geometric Tab Fallback] Found {len(unique_tabs)} bottom tabs. Bypassing VLM.")
                    if "home" in intent_lower:
                        return unique_tabs[0]
                    elif "profile" in intent_lower:
                        return unique_tabs[-1]
                    elif "explore" in intent_lower or "search" in intent_lower:
                        # search_tab is 4th from left in 5-tab layout
                        return unique_tabs[3] if len(unique_tabs) >= 5 else unique_tabs[1]
                    elif "reels" in intent_lower:
                        return unique_tabs[1] if len(unique_tabs) >= 5 else unique_tabs[-2]

        # --- Semantic Match Guard ---
        # If the intent explicitly quotes a target (e.g., "tap 'New Message'"),
        # we strictly filter candidates to those whose text or content_desc contains the quote.
        import re

        quotes = re.findall(r"['\"](.*?)['\"]", intent_description)
        if quotes:
            target_text = quotes[0].lower()

            # Only use the exact target string (no manual localized translation dictionaries!)
            localized_targets = [target_text]

            semantic_candidates = []
            for node in candidates:
                n_text = _humanize_desc((node.text or "").lower())
                n_desc = _humanize_desc((node.content_desc or "").lower())

                # Check if any of the localized targets match
                for loc_target in localized_targets:
                    pattern = r"\b" + re.escape(loc_target) + r"\b"

                    # Map interaction text to structural ID patterns for multilingual support
                    res_target = loc_target
                    if loc_target == "following" or loc_target == "follow":
                        res_target = "follow"
                    elif loc_target == "message":
                        res_target = "message"
                    elif loc_target == "like":
                        res_target = "like"
                    elif loc_target == "comment":
                        res_target = "comment"

                    if (
                        re.search(pattern, n_text)
                        or re.search(pattern, n_desc)
                        or res_target in (node.resource_id or "").lower()
                    ):
                        semantic_candidates.append(node)
                        break  # Found a match, no need to check other localized targets

            if semantic_candidates:
                if len(semantic_candidates) == 1:
                    logger.debug(f"🎯 [Semantic Guard] Exact match found for '{target_text}', skipping VLM.")
                    return semantic_candidates[0]
                else:
                    logger.info(
                        f"🎯 [Semantic Guard] {len(semantic_candidates)} matches found for '{target_text}'. Reducing candidates for VLM."
                    )
                    candidates = semantic_candidates
            else:
                logger.warning(
                    f"⚠️ [Semantic Guard] No candidates found containing '{target_text}'. Returning None to prevent hallucination."
                )
                return None

        # ── PRIMARY PATH: Visual Discovery ──
        # If we have a device, the VLM SEES the screen and decides.
        if device is not None and (
            hasattr(device, "screenshot") or hasattr(getattr(device, "deviceV2", None), "screenshot")
        ):
            logger.info("📸 Device screenshot capability detected. Enforcing visual discovery.")
            vlm_node = self._visual_discovery(intent_description, candidates, device, screen_height=screen_height)
            if vlm_node is not None:
                return vlm_node
            logger.warning("⚠️ Visual discovery returned None. Falling through to text-based fallback.")

        # --- Strict VLM Hallucination Guard (Text-only Fallback) ---
        # For known structural targets that the text-based VLM frequently hallucinates when they are missing,
        # we enforce a strict failure.
        structural_intents = [
            "following list",
            "followers list",
            "tap message button",
            "tab",
            "scroll",
            "back",
            "home",
            "profile",
            "reels",
            "search",
            "explore",
            "send message button",
        ]

        if any(si in intent_lower for si in structural_intents):
            logger.warning(
                f"🛡️ [Hallucination Guard] Intent '{intent_description}' is a strict structural target. "
                "Since it wasn't resolved by fast-paths, it is either missing or blocked. Rejecting VLM fallback."
            )
            return None

        # ── FALLBACK: Text-based VLM resolution ──
        # Only used when device is unavailable (e.g., unit tests without screenshots).
        return self._text_based_resolve(intent_description, candidates, device, screen_height=screen_height)

    # ──────────────────────────────────────────────
    # Visual Discovery (Set-of-Mark Prompting)
    # ──────────────────────────────────────────────

    def _annotate_screenshot_with_candidates(
        self, device, candidates: List[SpatialNode]
    ) -> Tuple[str, Dict[int, SpatialNode]]:
        """
        Takes a screenshot and draws numbered bounding boxes around clickable candidates.

        Returns:
            annotated_b64: Base64-encoded JPEG of the annotated screenshot.
            box_map: Dict mapping box number → SpatialNode for coordinate lookup.
        """
        from PIL import ImageDraw

        img = device.deviceV2.screenshot()

        # Stage 1: Basic area filter + exclude system UI and notifications (ALREADY HANDLED in _visual_discovery)
        pre_filtered = candidates

        # Stage 2: Spatial deduplication
        # A node could completely contain another.
        # If parent is clickable and child is not: suppress child (e.g. text inside button)
        # If parent is not clickable and child is: suppress parent (e.g. layout container around button)
        # If both are not clickable: suppress parent (keep the smaller, more specific text)
        # If both are clickable: keep both! (e.g. nested buttons like row and camera icon)
        def _contains(parent: SpatialNode, child: SpatialNode) -> bool:
            return (
                parent.x1 <= child.x1
                and parent.y1 <= child.y1
                and parent.x2 >= child.x2
                and parent.y2 >= child.y2
                and parent.node_id != child.node_id
            )

        to_suppress = set()
        # Sort by area DESCENDING so we process largest (parents) first
        pre_filtered.sort(key=lambda n: n.area, reverse=True)

        for i, parent in enumerate(pre_filtered):
            for j in range(i + 1, len(pre_filtered)):
                child = pre_filtered[j]
                if _contains(parent, child):
                    if parent.clickable and not child.clickable:
                        to_suppress.add(child.node_id)
                        # Merge semantic info from child to parent if missing
                        if (
                            child.text
                            and child.text not in (parent.text or "")
                            and child.text not in (parent.content_desc or "")
                        ):
                            parent.content_desc = f"{(parent.content_desc or '')} {child.text}".strip()
                        if (
                            child.content_desc
                            and child.content_desc not in (parent.text or "")
                            and child.content_desc not in (parent.content_desc or "")
                        ):
                            parent.content_desc = f"{(parent.content_desc or '')} {child.content_desc}".strip()
                    elif not parent.clickable and child.clickable:
                        to_suppress.add(parent.node_id)
                        # Pass any semantic info down just in case
                        if parent.content_desc and not child.content_desc:
                            child.content_desc = parent.content_desc
                        if parent.text and not child.text:
                            child.text = parent.text
                    elif not parent.clickable and not child.clickable:
                        to_suppress.add(parent.node_id)
                        if parent.content_desc and not child.content_desc:
                            child.content_desc = parent.content_desc
                    elif parent.clickable and child.clickable:
                        # Keep both, distinct nested interactables
                        pass

        visible_candidates = [n for n in pre_filtered if n.node_id not in to_suppress]

        draw = ImageDraw.Draw(img)
        box_map: Dict[int, SpatialNode] = {}

        # Color palette for distinct boxes
        colors = [
            (255, 0, 0),
            (0, 200, 0),
            (0, 0, 255),
            (255, 165, 0),
            (128, 0, 128),
            (0, 200, 200),
            (255, 20, 147),
            (0, 128, 0),
            (255, 215, 0),
            (70, 130, 180),
        ]

        for i, node in enumerate(visible_candidates):
            color = colors[i % len(colors)]

            # Draw bounding box
            draw.rectangle(
                [node.x1, node.y1, node.x2, node.y2],
                outline=color,
                width=3,
            )

            # Draw number label with background for readability
            label = str(i)
            label_x = node.x1 + 2
            label_y = max(node.y1 - 18, 0)

            # Draw label background
            bbox = draw.textbbox((label_x, label_y), label)
            draw.rectangle(
                [bbox[0] - 2, bbox[1] - 2, bbox[2] + 2, bbox[3] + 2],
                fill=color,
            )
            draw.text((label_x, label_y), label, fill=(255, 255, 255))

            box_map[i] = node

        # Encode to base64 JPEG
        buffered = BytesIO()
        img.save(buffered, format="JPEG", quality=85)
        annotated_b64 = base64.b64encode(buffered.getvalue()).decode("utf-8")

        return annotated_b64, box_map

    def _visual_discovery(
        self, intent_description: str, candidates: List[SpatialNode], device, screen_height: int = 2400
    ) -> Optional[SpatialNode]:
        """
        Vision-first intent resolution via Set-of-Mark (SoM) prompting.

        1. Takes a screenshot
        2. Draws numbered bounding boxes on clickable candidates
        3. Sends the annotated screenshot to the VLM
        4. VLM SEES the UI and picks which numbered box matches the intent
        5. Maps box number back to SpatialNode for precise coordinates
        """
        from GramAddict.core.config import Config
        from GramAddict.core.llm_provider import query_telepathic_llm

        # Pre-filter candidates by area and system UI before any semantic matching
        candidates = [
            n
            for n in candidates
            if 200 < n.area < 400000
            and "com.android.systemui" not in (n.resource_id or "")
            and "notification:" not in (n.content_desc or "").lower()
            and "per cent" not in (n.content_desc or "").lower()
            and "keyboard" not in (n.resource_id or "").lower()
            and "input_method" not in (n.resource_id or "").lower()
            and "tastatur" not in (n.content_desc or "").lower()
            and "eingabetaste" not in (n.content_desc or "").lower()
        ]

        # --- Navigation Conflict Guard ---
        # Prevents VLM from confusing Back buttons with tab buttons
        # Production bug 2026-04-30: VLM picked Back for "tap profile tab"
        candidates = self.filter_navigation_conflicts(candidates, intent_description, screen_height=screen_height)

        # --- Strict Button Guard ---
        # If the intent specifically asks for a "button", "icon", or "tab",
        # filter out candidates that contain long text (e.g. captions, comments)
        # to prevent the VLM from hallucinating text nodes as interactive buttons.
        intent_lower = intent_description.lower()
        if "button" in intent_lower or "icon" in intent_lower or "tab" in intent_lower:
            filtered_candidates = []
            for node in candidates:
                text_len = len(node.text or "")
                if text_len < 40:
                    filtered_candidates.append(node)
                else:
                    logger.debug(f"🛡️ [Strict Button Guard] Filtered out node with long text: '{node.text[:20]}...'")
            candidates = filtered_candidates

        # --- Post/Grid Item Guard ---
        # Removed hardcoded English string matching for 'row 1', 'photos by'. We trust the VLM.
        pass

        # --- Author/Username Guard ---
        # Geometric constraint: Author and username on profiles/posts are usually in the top half.
        if "author" in intent_lower or "username" in intent_lower or "profile name" in intent_lower:
            filtered_candidates = []
            for node in candidates:
                if node.center_y > (screen_height * 0.85):
                    logger.debug("🛡️ [Author Guard] Filtered out bottom area element.")
                else:
                    filtered_candidates.append(node)
            candidates = filtered_candidates

        # --- Reply Guard ---
        # Prevents VLM from clicking input fields for non-reply intents
        if (
            "reply" not in intent_lower
            and "message" not in intent_lower
            and "comment" not in intent_lower
            and "type" not in intent_lower
            and "write" not in intent_lower
        ):
            filtered_candidates = []
            for node in candidates:
                cls_name = (node.class_name or "").lower()
                if "edittext" in cls_name:
                    logger.debug("🛡️ [Reply Guard] Filtered out input field.")
                else:
                    filtered_candidates.append(node)
            candidates = filtered_candidates

        try:
            annotated_b64, box_map = self._annotate_screenshot_with_candidates(device, candidates)
        except Exception as e:
            import traceback

            traceback.print_exc()
            logger.warning(f"⚠️ [Visual Discovery] Screenshot annotation failed: {e}")
            return None

        if not box_map:
            return None

        self.last_box_map = box_map

        cfg = Config()
        model = getattr(cfg.args, "ai_telepathic_model", "llava:latest")
        url = getattr(cfg.args, "ai_telepathic_url", "http://localhost:11434/api/generate")

        # Build a compact legend of what each box contains
        box_legend_lines = []
        for idx in sorted(box_map.keys()):
            node = box_map[idx]
            label_parts = []
            if node.content_desc:
                desc = _humanize_desc(node.content_desc)
                label_parts.append(f"desc='{desc[:50]}'")
            if node.text and node.text != node.content_desc:
                text = _humanize_desc(node.text)
                label_parts.append(f"text='{text[:50]}'")
            if node.class_name:
                cls_short = node.class_name.split(".")[-1]
                label_parts.append(f"class='{cls_short}'")
            if node.long_clickable:
                label_parts.append("long_clickable=True")
            if not label_parts:
                label_parts.append("(no visible text)")
            box_legend_lines.append(f"  [{idx}] {', '.join(label_parts)}")
        box_legend = "\n".join(box_legend_lines)
        logger.debug(f"BOX LEGEND:\n{box_legend}")

        prompt = (
            f"You are looking at a mobile app screenshot with numbered bounding boxes drawn around interactive UI elements.\n"
            f"Each box has a number label in a colored rectangle.\n\n"
            f"Box legend (what each box contains):\n{box_legend}\n\n"
            f"Your task: Find the exact box number that corresponds to this intent: '{intent_description}'\n\n"
            f"CRITICAL RULES:\n"
            f"0. MULTILINGUAL UI AWARENESS: The UI might be in any language (English, German, etc.). You MUST translate the intent conceptually. If looking for 'Search', also accept 'Suche'. If looking for 'Following', also accept 'Abonniert'. If looking for 'Message', also accept 'Nachricht'.\n"
            f"1. If the intent contains a word in quotes (e.g., 'Search', 'New Message'), look at the Box legend and pick the box that contains that word or its localized equivalent.\n"
            f"2. For icons without text:\n"
            f"   - 'like button' = HEART-SHAPED ICON (♡/❤).\n"
            f"   - 'comment button' = SPEECH BUBBLE ICON.\n"
            f"3. Do NOT select text, captions, or view counts if looking for an icon.\n"
            f"4. Ignore numbers inside the text itself. Do not confuse the text '19' with Box [19].\n"
            f"5. If the intent is to tap a 'post', 'first post', or 'grid item':\n"
            f"   - Look for boxes indicating a photo or video by a user (e.g., 'photos by', 'Foto von', or grid coordinates like 'row 1' / 'Reihe 1').\n"
            f"   - Pick the FIRST matching box index.\n"
            f"   - Do NOT pick navigation buttons like 'Search'.\n"
            f"6. If the intent is a bottom navigation tab (e.g. 'profile tab', 'home tab'):\n"
            f"   - These are always at the BOTTOM edge of the screen.\n"
            f"   - 'profile tab' is usually the furthest right icon (your avatar).\n"
            f"   - 'home tab' is the furthest left icon (house).\n"
            f"   - 'explore tab' is the magnifying glass.\n"
            f"   - 'reels tab' is the video clapperboard.\n"
            f"7. If the intent involves 'author username' or 'author profile':\n"
            f"   - Pick the profile picture or the username text.\n"
            f"   - NEVER pick a 'Follow' button.\n"
            f"8. If the intent is 'save post':\n"
            f"   - The save icon is the bookmark icon on the bottom right of the post image/video.\n"
            f"9. DISTINGUISHING BOTTOM TABS vs CONTENT BUTTONS:\n"
            f"    - Bottom Navigation Tabs (Home, Search, Reels, Profile) are ALWAYS at the very bottom (y > 2100).\n"
            f"    - Content Interaction Buttons (Like, Comment, Share, Reactions, Message Input) are attached to posts or threads, NOT the bottom nav bar.\n"
            f"10. If the intent is 'feed post content' or 'post media content':\n"
            f"    - Pick the largest box that contains the actual image or video.\n"
            f"11. DO NOT HALLUCINATE. If you are on the wrong screen, or if the exact target is simply NOT visible, you MUST return null.\n"
            f"12. EXTREME GUARD: NEVER pick an input field (class='EditText') unless the intent EXPLICITLY asks you to type or reply.\n"
            f"13. EXTREME GUARD: Do NOT pick items that are 'long_clickable=True' if your intent is just a simple navigation click.\n"
            f"14. If the intent is 'tap post username', DO NOT pick random gallery folders like 'Recents' or 'Select album'. Return null.\n"
            f"15. EXTREME GUARD: If the intent is to tap 'following' or 'followers' list, NEVER pick a 'Follow' or 'Follow back' button.\n\n"
            f"VALID BOX NUMBERS: {list(box_map.keys())}\n"
            f'Reply ONLY with a valid JSON object: {{"box": <number from VALID BOX NUMBERS>}} or {{"box": null}}'
        )

        try:
            res = query_telepathic_llm(
                model=model,
                url=url,
                system_prompt="Strict visual JSON box selector. Respond only with JSON.",
                user_prompt=prompt,
                use_local_edge=True,
                images_b64=[annotated_b64],
            )
            data = json.loads(res)
            box_idx = self._parse_box_index(data)

            # Additional safety check to prevent hallucinated numbers
            if box_idx not in box_map:
                logger.warning(f"👁️ [Visual Discovery] VLM hallucinated invalid box number: {box_idx}")
                box_idx = None

            selected = self._validate_and_get_node(box_idx, box_map)

            if selected:
                logger.info(
                    f"👁️ [Visual Discovery] VLM selected box [{box_idx}] → "
                    f"id='{selected.resource_id}', desc='{selected.content_desc}'"
                )
                return selected
            else:
                logger.warning(f"👁️ [Visual Discovery] VLM returned invalid box={box_idx} (OOB or unparsable).")
        except Exception as e:
            logger.warning(f"⚠️ [Visual Discovery] VLM call failed: {e}")

        return None

    def _parse_box_index(self, data: Dict) -> Optional[int]:
        """Parses the box index from VLM JSON response, handling common hallucination formats."""
        if not isinstance(data, dict):
            return None

        # Check common keys
        val = None
        for key in ["box", "selected_index", "box_index", "index", "target"]:
            if key in data:
                val = data[key]
                break

        if val is None:
            return None

        # Handle null/None
        if val in [None, "null", "None", "None ", "null "]:
            return None

        # Convert to string and clean up common VLM prefixes
        val_str = str(val).strip().lower()

        # Remove "box " prefix if present (e.g. "Box 5")
        if val_str.startswith("box"):
            val_str = val_str.replace("box", "").strip()

        # Attempt integer conversion
        try:
            # Extract first number if it's a messy string
            import re

            match = re.search(r"(\d+)", val_str)
            if match:
                return int(match.group(1))
            return None
        except (ValueError, TypeError):
            return None

    def _validate_and_get_node(self, box_idx: Optional[int], box_map: Dict[int, SpatialNode]) -> Optional[SpatialNode]:
        """Validates that the box index exists within the current SoM box_map."""
        if box_idx is None:
            return None

        if box_idx in box_map:
            return box_map[box_idx]

        return None

    # ──────────────────────────────────────────────
    # Text-based Fallback (no device/screenshot)
    # ──────────────────────────────────────────────

    def _text_based_resolve(
        self, intent_description: str, candidates: List[SpatialNode], device=None, screen_height: int = 2400
    ) -> Optional[SpatialNode]:
        """
        Fallback resolution via text descriptions of XML nodes.
        Used only when no device is available for screenshots.
        """
        from GramAddict.core.config import Config
        from GramAddict.core.llm_provider import query_telepathic_llm

        intent_lower = intent_description.lower()

        filtered_candidates = [n for n in candidates if n.area < 500000]
        filtered_candidates = self.filter_navigation_conflicts(
            filtered_candidates, intent_description, screen_height=screen_height
        )
        if "profile" in intent_lower:
            filtered_candidates = [
                n
                for n in filtered_candidates
                if not any(kw in (n.resource_id or "").lower() for kw in ("tab", "navigation", "action_bar"))
            ]
        if not filtered_candidates:
            filtered_candidates = [n for n in candidates if n.area < 500000]

        cfg = Config()
        model = getattr(cfg.args, "ai_telepathic_model", "qwen3.5:latest")
        url = getattr(cfg.args, "ai_telepathic_url", "http://localhost:11434/api/generate")

        node_context = []
        for i, node in enumerate(filtered_candidates):
            text = _humanize_desc(node.text or "")
            desc = _humanize_desc(node.content_desc or "")
            res_id = node.resource_id or ""
            node_context.append(f"[{i}] text='{text}', desc='{desc}', id='{res_id}', bounds=[{node.y1},{node.y2}]")

        prompt = (
            f"You are a Spatial UI Intent Resolver.\n"
            f"Goal: Find the single best UI element to interact with to satisfy the intent: '{intent_description}'.\n"
            f"Candidates:\n" + "\n".join(node_context) + "\n\n"
            "CRITICAL RULES:\n"
            "0. MULTILINGUAL UI AWARENESS: The UI might be in any language (English, German, etc.). You MUST translate the intent conceptually and find the corresponding localized element.\n"
            "1. If the intent is a bottom navigation tab (e.g. 'profile tab', 'home tab'):\n"
            "   - These are always at the BOTTOM of the screen (typically y > 2100).\n"
            "   - 'profile tab' is usually the furthest right.\n"
            "   - 'home tab' is the furthest left.\n"
            "   - Do NOT select 'Go to <user>'s profile' or other header text.\n"
            "2. EXTREME GUARD: NEVER pick an input field (EditText) unless explicitly asked to type.\n"
            "3. EXTREME GUARD: If you are on the wrong screen entirely (e.g. 'Select album' gallery) instead of a profile, return null.\n"
            "4. If none of the candidates clearly and safely match the intent, return null. DO NOT guess.\n\n"
            "Reply ONLY with a valid JSON object strictly matching this schema:\n"
            '{"selected_index": <integer or null>}\n'
        )

        try:
            res = query_telepathic_llm(
                model=model,
                url=url,
                system_prompt="Strict JSON intent resolver.",
                user_prompt=prompt,
                use_local_edge=True,
            )
            data = json.loads(res)
            idx = data.get("selected_index")
            if idx is not None and 0 <= idx < len(filtered_candidates):
                return filtered_candidates[idx]
        except Exception as e:
            logger.warning(f"⚠️ [IntentResolver] Text-based VLM resolution failed ({e}).")

        return None
