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
        is_create_intent = "create" in intent_lower or "camera" in intent_lower or "story" 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"])

        # Known bottom navigation tab resource_id suffixes
        NAV_TAB_SUFFIXES = ("_tab", "tab_icon", "navigation_bar")

        for node in candidates:
            rid = (node.resource_id or "").lower()
            desc = (node.content_desc or "").lower()

            is_back = "back" in rid or desc == "back"
            is_close = "close" in rid or desc == "close"
            is_create = "camera" in rid or "create" in rid or desc == "camera" or desc == "create" or "creation" in rid
            is_nav_tab = any(rid.endswith(s) for s in NAV_TAB_SUFFIXES)

            if is_tab_intent and (is_back or is_close):
                logger.debug(
                    f"🛡️ [Nav Conflict Guard] Excluded '{node.resource_id}' "
                    f"(desc='{node.content_desc}') for tab intent '{intent_description}'"
                )
                continue

            # NEW REGRESSION FIX 2026-05-01: Tab intents must never pick top-screen elements or headers
            # UPDATE: Actually, tabs are always at the very bottom. Filter out anything above 85% of screen height.
            if is_tab_intent:
                is_not_at_bottom = node.center_y < (screen_height * 0.85)
                if is_not_at_bottom:
                    logger.debug(
                        f"🛡️ [Tab Height Guard] Excluded non-bottom element '{node.resource_id}' "
                        f"(desc='{node.content_desc}', y={node.center_y}) for tab intent '{intent_description}'"
                    )
                    continue
                # Tab intents should NEVER be content items
                if any(kw in desc for kw in ["reel by", "photo by", "photos by", "row ", "column "]):
                    logger.debug(
                        f"🛡️ [Content Tab Guard] Excluded content item '{node.resource_id}' "
                        f"(desc='{node.content_desc}') for tab intent '{intent_description}'"
                    )
                    continue

            if is_author_intent and is_nav_tab:
                logger.debug(
                    f"🛡️ [Author Tab Guard] Excluded nav tab '{node.resource_id}' "
                    f"(desc='{node.content_desc}') for author intent '{intent_description}'"
                )
                continue

            if not is_create_intent and is_create:
                logger.debug(
                    f"🛡️ [Creation Conflict Guard] Excluded '{node.resource_id}' "
                    f"(desc='{node.content_desc}') for intent '{intent_description}'"
                )
                continue

            # NEW REGRESSION FIX: Exclude interaction buttons (comment, like, share) when looking for author or media
            # This prevents the weak VLM from hallucinating bounding box numbers that point to "Comment".
            interaction_suffixes = ["comment", "like", "share", "send", "save", "button_icon", "scrubber"]
            is_interaction = any(s in rid for s in interaction_suffixes) or any(s in desc for s in interaction_suffixes)
            node_text_lower = (node.text or "").lower()
            is_follow = "follow" in rid or "follow" in node_text_lower or "follow" in desc
            is_media_intent = "media content" in intent_lower or "image" in intent_lower or "video" in intent_lower

            if (is_author_intent or is_media_intent) and (is_interaction or is_follow):
                logger.debug(
                    f"🛡️ [Interaction Guard] Excluded non-target element '{node.resource_id}' "
                    f"(desc='{node.content_desc}', text='{node.text}') for intent '{intent_description}'"
                )
                continue

            # NEW REGRESSION FIX: Exclude stories and reels tray when looking for a POST author
            # This prevents the VLM from selecting the user's own story at the top of the feed
            is_post_author_intent = is_author_intent and "post" in intent_lower
            node_text_lower = (node.text or "").lower()
            is_story_or_reel = (
                "story" in rid
                or "story" in desc
                or "story" in node_text_lower
                or "reel" in rid
                or "reel" in desc
                or "reel" in node_text_lower
            )

            if is_post_author_intent and is_story_or_reel:
                logger.debug(
                    f"🛡️ [Post Author Story Guard] Excluded story/reel '{node.resource_id}' "
                    f"(desc='{node.content_desc}') for post author intent '{intent_description}'"
                )
                continue

            # NEW REGRESSION FIX: Exclude action bar titles (like 'For you') when looking for an author
            if is_author_intent and "action_bar_title" in rid:
                logger.debug(
                    f"🛡️ [Author Action Bar Guard] Excluded action bar title '{node.resource_id}' "
                    f"(desc='{node.content_desc}') for author intent '{intent_description}'"
                )
                continue

            filtered.append(node)

        return filtered

    def pre_filter_candidates(self, candidates: List[SpatialNode]) -> List[SpatialNode]:
        """
        Filters candidates by area, system UI, keyboard packages, and notifications.

        Production Bug 2026-05-04: The Android soft keyboard (com.google.android.inputmethod.*)
        flooded the candidate pool with 30+ single-letter nodes (A, B, C, N, ...),
        causing the VLM to hallucinate keyboard keys as valid UI targets.
        """
        return [
            n
            for n in candidates
            if 200 < n.area < 400000
            and "com.android.systemui" not in (n.resource_id or "")
            and "inputmethod" 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()
        ]

    def has_keyboard_open(self, candidates: List[SpatialNode]) -> bool:
        """
        Detects if the Android soft keyboard is currently visible
        by checking for input method package nodes in the candidate list.
        """
        return any("inputmethod" in (n.resource_id or "") for n in candidates)

    # ──────────────────────────────────────────────
    # 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 ---
        # Bypass VLM for deterministically identifiable UI components
        if "message text box" in intent_lower or "message input" in intent_lower or "type message" in intent_lower:
            for node in candidates:
                rid = (node.resource_id or "").lower()
                text = (node.text or "").lower()
                if "composer_edittext" in rid or "message…" in text or "message..." in text:
                    logger.info(f"🎯 [Structural Fast-Path] Found message input field: {rid}")
                    return node

        if "last received message text" in intent_lower or "received message" in intent_lower:
            # Gather all message text views
            msg_nodes = [n for n in candidates if "direct_text_message_text_view" in (n.resource_id or "").lower()]
            if msg_nodes:
                # The last one in the XML is typically the most recent message at the bottom of the screen
                latest_msg = msg_nodes[-1]
                logger.info(f"🎯 [Structural Fast-Path] Found last received message text: '{latest_msg.text}'")
                return latest_msg

        if "send message button" in intent_lower:
            for node in candidates:
                rid = (node.resource_id or "").lower()
                desc = (node.content_desc or "").lower()
                text = (node.text or "").lower()
                if "send" in rid or "composer_button" in rid:
                    logger.info(f"🎯 [Structural Fast-Path] Found send button: {rid or desc or text}")
                    return node

        if "post author username" in intent_lower or "tap post username" in intent_lower:
            # 1. Feed Posts
            for node in candidates:
                rid = (node.resource_id or "").lower()
                if "row_feed_photo_profile_imageview" in rid:
                    logger.info(f"🎯 [Structural Fast-Path] Found post author avatar image: {node.content_desc}")
                    return node
            for node in candidates:
                rid = (node.resource_id or "").lower()
                if "row_feed_photo_profile_name" in rid:
                    logger.info(f"🎯 [Structural Fast-Path] Found post author username text: {node.text}")
                    return node

            # 2. Reels (Clips)
            for node in candidates:
                rid = (node.resource_id or "").lower()
                if "clips_author_username" in rid or "clips_author_container" in rid:
                    logger.info(
                        f"🎯 [Structural Fast-Path] Found Reel author username: {node.text or node.content_desc}"
                    )
                    return node

        if "feed post content" in intent_lower or "post media content" in intent_lower:
            for node in candidates:
                rid = (node.resource_id or "").lower()
                if "row_feed_photo_imageview" in rid or "zoomable_view_container" in rid:
                    logger.info(f"🎯 [Structural Fast-Path] Found feed post content: {rid}")
                    return node

        if "comment" in intent_lower and "button" in intent_lower:
            # First try the View all comments button
            for node in candidates:
                if (
                    "view all comments" in (node.text or "").lower()
                    or "view all comments" in (node.content_desc or "").lower()
                ):
                    logger.info(
                        f"🎯 [Structural Fast-Path] Found comment button text: {node.text or node.content_desc}"
                    )
                    return node
            # Then try the icon itself if somehow clickable
            for node in candidates:
                if (
                    "row_feed_button_comment" in (node.resource_id or "").lower()
                    or "row_feed_textview_comments" in (node.resource_id or "").lower()
                ):
                    logger.info(f"🎯 [Structural Fast-Path] Found comment button: {node.resource_id}")
                    return node

        if "like" in intent_lower and ("button" in intent_lower or "post" in intent_lower):
            for node in candidates:
                rid = (node.resource_id or "").lower()
                if "row_feed_button_like" in rid:
                    logger.info(f"🎯 [Structural Fast-Path] Found like button: {rid}")
                    return node

        if ("send" in intent_lower or "share" in intent_lower) and "post" in intent_lower and "button" in intent_lower:
            for node in candidates:
                rid = (node.resource_id or "").lower()
                desc = (node.content_desc or "").lower()
                # Feed uses row_feed_button_share, POST_DETAIL may use direct_share_button or just desc "Send"
                if "row_feed_button_share" in rid or "direct_share_button" in rid or "send" in desc or "share" in desc:
                    logger.info(f"🎯 [Structural Fast-Path] Found send/share post button: {rid or desc}")
                    return node

        if "add to story" in intent_lower:
            # We skip structural fast-path for 'add to story' since it relies heavily on language/text strings
            # and let the VLM figure it out or rely on purely visual indicators.
            pass

        if "share" in intent_lower and ("button" in intent_lower or "post" in intent_lower):
            for node in candidates:
                rid = (node.resource_id or "").lower()
                if "row_feed_button_share" in rid:
                    logger.info(f"🎯 [Structural Fast-Path] Found share button: {rid}")
                    return node

        if "save" in intent_lower and ("button" in intent_lower or "post" in intent_lower):
            for node in candidates:
                rid = (node.resource_id or "").lower()
                if "row_feed_button_save" in rid:
                    logger.info(f"🎯 [Structural Fast-Path] Found save button: {rid}")
                    return node

        if "follow" in intent_lower and "button" in intent_lower:
            for node in candidates:
                rid = (node.resource_id or "").lower()
                if "profile_header_follow_button" in rid or "inline_follow_button" in rid:
                    logger.info(f"🎯 [Structural Fast-Path] Found follow/following button: {rid}")
                    return node

        if "first post" in intent_lower or "first item" in intent_lower or "first search result" in intent_lower:
            for node in candidates:
                rid = (node.resource_id or "").lower()
                if "grid_card_layout_container" in rid or "image_button" in rid or "row_search_user" in rid:
                    logger.info(f"🎯 [Structural Fast-Path] Found first post/item: {rid}")
                    return node

        if "story ring" in intent_lower or "story tray" in intent_lower:
            story_nodes = []
            for node in candidates:
                rid = (node.resource_id or "").lower()
                desc = (node.content_desc or "").lower()
                text = (node.text or "").lower()
                # Instagram story tray avatars usually have this resource id and 'story' in the content description
                if ("avatar_image_view" in rid or "row_profile_header_imageview" in rid) and "story" in desc:
                    # Ignore the user's explicit "Add to story" ring
                    if "add to story" not in desc and "your story" not in text:
                        story_nodes.append(node)

            if story_nodes:
                # Sort horizontally (left-to-right)
                story_nodes.sort(key=lambda n: n.x1)

                # Check if this is the home feed story tray (avatar_image_view without 'highlight' in desc)
                is_highlight = any("highlight" in (n.content_desc or "").lower() for n in story_nodes)
                if "avatar_image_view" in (story_nodes[0].resource_id or "").lower() and not is_highlight:
                    if len(story_nodes) > 1:
                        logger.info(
                            f"🎯 [Structural Fast-Path] Found {len(story_nodes)} story rings. Skipping own profile. Picking second: '{story_nodes[1].content_desc}'"
                        )
                        return story_nodes[1]
                    else:
                        logger.warning(
                            "🎯 [Structural Fast-Path] Only 1 story ring found on feed (likely own profile). Skipping to avoid modal trap."
                        )
                        return None
                else:
                    # Profile header or other single-story views
                    logger.info(
                        f"🎯 [Structural Fast-Path] Found story ring avatar: {story_nodes[0].resource_id} (desc: '{story_nodes[0].content_desc}')"
                    )
                    return story_nodes[0]

        # --- Navigation Tab Fast-Paths ---
        # Deterministically identify bottom navigation tabs to prevent VLM confusion
        tab_map = {
            "home tab": "feed_tab",
            "feed tab": "feed_tab",
            "reels tab": "clips_tab",
            "clips tab": "clips_tab",
            "explore tab": "search_tab",
            "search tab": "search_tab",
            "profile tab": "profile_tab",
            "message tab": "direct_tab",
            "direct tab": "direct_tab",
        }
        for intent_key, resource_suffix in tab_map.items():
            if intent_key in intent_lower:
                for node in candidates:
                    rid = (node.resource_id or "").lower()
                    if rid.endswith(f":id/{resource_suffix}"):
                        logger.info(f"🎯 [Structural Fast-Path] Found {intent_key}: {rid}")
                        return node

        # --- 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 = (node.text or "").lower()
                n_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"
                    if re.search(pattern, n_text) or re.search(pattern, n_desc):
                        semantic_candidates.append(node)
                        break  # Found a match, no need to check other localized targets

            if semantic_candidates:
                if len(semantic_candidates) == 1:
                    logger.info(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")
        ):
            print(f"DEBUG_INTENT: Entering Visual Discovery for '{intent_description}'")
            logger.info("📸 Device screenshot capability detected. Enforcing visual discovery.")
            return self._visual_discovery(intent_description, candidates, device, screen_height=screen_height)

        print(f"DEBUG_INTENT: Falling back to Text-based VLM for '{intent_description}'")
        # --- 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.
        if "following list" in intent_lower or "followers list" in intent_lower or "tap message button" in intent_lower:
            logger.warning(
                f"🛡️ [Hallucination Guard] Intent '{intent_description}' is a strict structural target. "
                "Since it wasn't resolved by fast-paths, it is missing. 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, system UI, and keyboard packages
        candidates = self.pre_filter_candidates(candidates)

        # --- 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 ---
        # VLMs frequently hallucinate 'Search' when asked to tap a post. We must pre-filter.
        if "first post" in intent_lower or "grid item" in intent_lower:
            grid_candidates = []
            for node in candidates:
                desc = (node.content_desc or "").lower()
                # Posts/grid items usually have 'row X, column Y', 'photos by', or 'reel by'
                if "row 1" in desc or "column" in desc or "photos by" in desc or "reel by" in desc:
                    grid_candidates.append(node)

            if grid_candidates:
                logger.info(f"🎯 [Grid Guard] Filtered to {len(grid_candidates)} actual grid candidates.")
                candidates = grid_candidates

        # --- Author/Username Guard ---
        # Prevents VLM from picking the "Profile" nav tab when asked for "post author username".
        if "author" in intent_lower or "username" in intent_lower or "profile name" in intent_lower:
            filtered_candidates = []
            for node in candidates:
                res_id = (node.resource_id or "").lower()
                desc = (node.content_desc or "").lower()
                if (
                    "tab" in res_id
                    or "navigation" in res_id
                    or "tabbar" in res_id
                    or desc in ["home", "search", "reels", "profile"]
                ):
                    logger.debug(
                        f"🛡️ [Author Guard] Filtered out navigation tab: '{node.content_desc}' ({node.resource_id})"
                    )
                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")
        url = getattr(cfg.args, "ai_telepathic_url")

        # 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 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"1. If the intent contains a word in quotes (e.g., 'Search', 'New Message'), you MUST look at the Box legend and pick the box that contains that word (case-insensitive). Do not pick anything else.\n"
            f"2. For icons without text:\n"
            f"   - 'like button' = HEART-SHAPED ICON (♡/❤), usually has desc='Like'.\n"
            f"   - 'comment button' = SPEECH BUBBLE ICON, usually has desc='Comment'.\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 contains 'following', you MUST pick the box containing 'following'. Do NOT pick 'followers' or 'Follow'.\n"
            f"6. If the intent is to tap a 'post', 'first post', or 'grid item':\n"
            f"   - Look for boxes with descriptions containing 'photos by', 'Reel by', or 'row 1, column 1'.\n"
            f"   - Pick the FIRST matching box index (e.g. if [0] says '6 photos...', return 0, NOT 6).\n"
            f"   - Do NOT pick navigation buttons like 'Search'.\n"
            f"7. 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"8. If the intent involves 'author username' or 'author profile':\n"
            f"   - Pick the profile picture (e.g. 'Profile picture of <username>') or the username text.\n"
            f"   - NEVER pick a 'Follow' button. Do NOT pick 'Follow <username>'.\n"
            f"9. 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"   - Usually has desc='Add to Saved' or 'Save'. Do NOT pick the post text or other action buttons.\n"
            f"10. 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"    - If looking for 'message input' or 'type message', do NOT select 'reactions' or emoji icons. Look for an empty text box or 'Message...'.\n"
            f"11. If the intent is 'feed post content' or 'post media content':\n"
            f"    - Pick the largest box that contains the actual image or video, usually described as 'Photo', 'Video', or 'Carousel'.\n"
            f"12. If the exact control is NOT visible, return null. Do NOT guess.\n\n"
            f'Reply ONLY with a valid JSON object: {{"box": <number>}} 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],
            )
            print(f"DEBUG_INTENT: VLM RAW RESPONSE for '{intent_description}': {res}")
            data = json.loads(res)
            box_idx = data.get("box")
            if box_idx is None:
                box_idx = data.get("selected_index")
            if box_idx is None:
                box_idx = data.get("box_index")
            if box_idx is None:
                box_idx = data.get("index")

            if box_idx is not None and box_idx in box_map:
                selected = box_map[box_idx]
                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 box={box_idx} which is not in box_map ({list(box_map.keys())[:5]}...)"
                )
        except Exception as e:
            logger.warning(f"⚠️ [Visual Discovery] VLM call failed: {e}")

        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")
        url = getattr(cfg.args, "ai_telepathic_url")

        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"
            "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. If none of the candidates clearly and safely match the intent, return null.\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,
            )
            print(f"DEBUG_INTENT: TEXT LLM RAW RESPONSE for '{intent_description}': {res}")
            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
