"""
E2E: LLM Perception Pipeline Integrity
========================================
Tests the LLM/VLM integration layer WITHOUT calling a real LLM.

These tests validate the deterministic processing logic AROUND the LLM:
- Response parsing (what happens with malformed JSON, empty strings, timeouts)
- Structural guards (do they block VLM hallucinations?)
- Box index extraction from diverse response formats
- ActionMemory confidence tracking (boost/penalty math)
- Semantic match validation (prevents memory poisoning)

WHY THIS MATTERS (from 2026-05-02 production log):
    VLM was asked to "tap back button" but selected box [0] (a Reel thumbnail)
    instead of box [22] (desc='Back'). The structural guards SHOULD have
    prevented this, but were never tested.
"""

import json

import pytest

from GramAddict.core.perception.action_memory import (
    ActionMemory,
    _intent_matches_node,
    _parse_yes_no,
)
from GramAddict.core.perception.intent_resolver import IntentResolver
from GramAddict.core.perception.spatial_parser import SpatialNode

# ═══════════════════════════════════════════════════════════════════════
# Spy UIMemoryDB — wraps the REAL UIMemoryDB to track calls
# ═══════════════════════════════════════════════════════════════════════


class SpyUIMemoryDB:
    """
    Wraps the real UIMemoryDB to track method calls for assertions
    while exercising the actual production code path.

    When Qdrant is down, UIMemoryDB gracefully degrades (client=None)
    and all operations become no-ops — which is the exact production
    behavior we want to test against.
    """

    def __init__(self):
        from GramAddict.core.qdrant_memory import UIMemoryDB

        self._real = UIMemoryDB()
        self.store_memory_calls = []
        self.boost_confidence_calls = []
        self.decay_confidence_calls = []
        self.retrieve_memory_calls = []

    def retrieve_memory(self, intent, xml_context, **kwargs):
        self.retrieve_memory_calls.append((intent, xml_context))
        return self._real.retrieve_memory(intent, xml_context, **kwargs)

    def store_memory(self, intent, xml_context, node_dict):
        self.store_memory_calls.append((intent, xml_context, node_dict))
        self._real.store_memory(intent, xml_context, node_dict)

    def boost_confidence(self, intent, xml_context=None, **kwargs):
        self.boost_confidence_calls.append((intent, xml_context))
        self._real.boost_confidence(intent, xml_context, **kwargs)

    def decay_confidence(self, intent, xml_context=None, **kwargs):
        self.decay_confidence_calls.append((intent, xml_context))
        self._real.decay_confidence(intent, xml_context, **kwargs)


# ═══════════════════════════════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════════════════════════════


def _make_node(
    resource_id="",
    content_desc="",
    text="",
    clickable=True,
    bounds=(0, 0, 100, 100),
    center_y=None,
):
    """Create a SpatialNode for testing.

    If center_y is specified, adjusts bounds to produce that center_y value.
    """
    if center_y is not None:
        # Recalculate bounds to produce the desired center_y
        # Keep x bounds as-is, adjust y bounds symmetrically around center_y
        half_height = (bounds[3] - bounds[1]) // 2
        bounds = (bounds[0], center_y - half_height, bounds[2], center_y + half_height)

    return SpatialNode(
        bounds=bounds,
        node_id=f"node_{resource_id or text or content_desc}",
        text=text,
        content_desc=content_desc,
        resource_id=resource_id,
        clickable=clickable,
    )


# ═══════════════════════════════════════════════════════════════════════
# CONTRACT 1: VLM Response Parsing — _parse_yes_no
# ═══════════════════════════════════════════════════════════════════════
#
# This function parses every VLM verification response. If it misparses,
# the bot either accepts failed clicks or rejects successful ones.
# ═══════════════════════════════════════════════════════════════════════


class TestVLMResponseParsing:
    """Validates _parse_yes_no handles every response format the VLM produces."""

    # Known VLM response formats from production logs
    @pytest.mark.parametrize(
        "response,expected",
        [
            # Clean responses
            ("YES", True),
            ("NO", False),
            ("yes", True),
            ("no", False),
            # JSON responses (seen in production)
            ('{"YES": 1}', True),
            ('{"NO": 1}', False),
            ('{"answer": "YES"}', True),
            ('{"answer": "NO"}', False),
            ('{ "YES": 1 }', True),
            # Responses with explanations (VLM ignores instructions)
            ("YES, the button was tapped successfully.", True),
            ("NO, the screen did not change.", False),
            ("Yes - the action succeeded.", True),
            ("No - navigation failed.", False),
            # Edge cases that must NOT match
            ("now loading", None),  # "now" starts with "no"
            ("not applicable", None),  # "not" starts with "no"
            ("nothing changed", None),  # contains "no" but not standalone
            # Empty/garbage
            ("", None),
            ("   ", None),
            # Free-form that happened in production
            (
                '{ "intent": "tap back button", "element_tapped": "some reel" }',
                None,
            ),
            # Ambiguous — starts with YES, so early-exit returns True
            # NOTE: This is current behavior. If you want None here, fix _parse_yes_no.
            ("YES and NO are both possible", True),
        ],
        ids=[
            "clean_YES",
            "clean_NO",
            "lowercase_yes",
            "lowercase_no",
            "json_YES_key",
            "json_NO_key",
            "json_answer_YES",
            "json_answer_NO",
            "json_spaced_YES",
            "yes_with_explanation",
            "no_with_explanation",
            "yes_dash",
            "no_dash",
            "now_loading",
            "not_applicable",
            "nothing_substring",
            "empty",
            "whitespace",
            "freeform_json",
            "ambiguous_both",
        ],
    )
    def test_parse_yes_no(self, response, expected):
        result = _parse_yes_no(response)
        assert result is expected, f"_parse_yes_no('{response}') returned {result}, expected {expected}"


# ═══════════════════════════════════════════════════════════════════════
# CONTRACT 2: VLM Box Index Extraction
# ═══════════════════════════════════════════════════════════════════════
#
# The VLM returns {"box": N} but often varies the format.
# The parsing logic in _visual_discovery must handle all variants.
# ═══════════════════════════════════════════════════════════════════════


class TestBoxIndexParsing:
    """Validates box index extraction from diverse VLM response formats."""

    @pytest.mark.parametrize(
        "vlm_response,expected_box",
        [
            ('{"box": 6}', 6),
            ('{"box": 0}', 0),
            ('{"box": null}', None),
            ('{"selected_index": 3}', 3),
            ('{"box_index": 12}', 12),
            ('{"index": 5}', 5),
            # Edge cases
            ('{"box": 999}', 999),  # Out of range — must not crash
        ],
        ids=["box_6", "box_0", "box_null", "selected_index", "box_index", "index", "out_of_range"],
    )
    def test_box_index_extraction(self, vlm_response, expected_box):
        """Verify the JSON parsing logic extracts box indices correctly."""
        data = json.loads(vlm_response)
        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")

        assert box_idx == expected_box


# ═══════════════════════════════════════════════════════════════════════
# CONTRACT 3: Structural Guards — Preventing VLM Hallucinations
# ═══════════════════════════════════════════════════════════════════════
#
# These guards are the LAST LINE OF DEFENSE before a hallucinated click
# hits the device. If they fail, the bot clicks wrong UI elements.
# ═══════════════════════════════════════════════════════════════════════


class TestStructuralGuards:
    """Tests the IntentResolver's structural guards that prevent VLM hallucinations."""

    def setup_method(self):
        self.resolver = IntentResolver()

    # ── Navigation Conflict Guard ──

    def test_tab_intent_excludes_back_button(self):
        """
        Production bug 2026-04-30: VLM picked 'Back' for 'tap profile tab'.
        The guard must exclude Back/Close buttons for tab intents.
        """
        candidates = [
            _make_node(resource_id="com.instagram.android:id/action_bar_button_back", content_desc="Back"),
            _make_node(resource_id="com.instagram.android:id/profile_tab", content_desc="Profile", center_y=2350),
        ]
        filtered = self.resolver.filter_navigation_conflicts(candidates, "tap profile tab")
        assert len(filtered) == 1
        assert filtered[0].content_desc == "Profile"

    def test_back_intent_keeps_back_button(self):
        """Back intent must NOT filter out the Back button."""
        candidates = [
            _make_node(resource_id="com.instagram.android:id/action_bar_button_back", content_desc="Back"),
            _make_node(resource_id="com.instagram.android:id/profile_tab", content_desc="Profile", center_y=2350),
        ]
        filtered = self.resolver.filter_navigation_conflicts(candidates, "tap back button")
        # Back button must be preserved
        back_nodes = [n for n in filtered if n.content_desc == "Back"]
        assert len(back_nodes) == 1

    def test_tab_intent_excludes_non_bottom_elements(self):
        """
        Tab elements are ALWAYS at the bottom (y > 85% of screen).
        Elements higher up must be excluded for tab intents.
        """
        candidates = [
            _make_node(content_desc="Search", center_y=200, bounds=(0, 150, 200, 250)),  # Top search bar
            _make_node(
                resource_id="com.instagram.android:id/search_tab",
                content_desc="Search and explore",
                center_y=2350,
                bounds=(216, 2300, 432, 2400),
            ),
        ]
        filtered = self.resolver.filter_navigation_conflicts(candidates, "tap explore tab", screen_height=2400)
        assert len(filtered) == 1
        assert filtered[0].center_y == 2350

    def test_author_intent_excludes_nav_tabs(self):
        """
        Production bug 2026-05-01: VLM picked 'Profile' tab for 'post author username'.
        Author intents must never resolve to navigation tabs.
        """
        candidates = [
            _make_node(
                resource_id="com.instagram.android:id/row_feed_photo_profile_name",
                text="photographer_jane",
                clickable=True,
            ),
            _make_node(
                resource_id="com.instagram.android:id/profile_tab",
                content_desc="Profile",
                center_y=2350,
            ),
        ]
        filtered = self.resolver.filter_navigation_conflicts(candidates, "tap post author username", screen_height=2400)
        author_nodes = [n for n in filtered if n.text == "photographer_jane"]
        assert len(author_nodes) == 1

    def test_author_intent_with_exclude_tabs_suffix_preserves_author(self):
        """
        Production bug 2026-05-02 22:19: Intent 'post author username text
        (exclude bottom tabs)' contains the word 'tab', which falsely
        triggers the Tab Height Guard. This nukes the actual author node
        (clips_author_username at y=2012) because 2012 < 85% of 2400.

        The fix: is_tab_intent must only match REAL tab navigation intents,
        not intents that mention 'tab' in a parenthetical exclusion note.
        """
        candidates = [
            _make_node(
                resource_id="com.instagram.android:id/clips_author_username",
                content_desc="trenny_m",
                center_y=2012,
            ),
            _make_node(
                resource_id="com.instagram.android:id/profile_tab",
                content_desc="Profile",
                center_y=2350,
            ),
            _make_node(
                resource_id="com.instagram.android:id/like_button",
                content_desc="Like",
                center_y=1353,
            ),
        ]
        filtered = self.resolver.filter_navigation_conflicts(
            candidates,
            "post author username text (exclude bottom tabs)",
            screen_height=2400,
        )
        # The author username at y=2012 MUST survive
        author_nodes = [n for n in filtered if n.content_desc == "trenny_m"]
        assert len(author_nodes) == 1, (
            f"Author node 'trenny_m' was incorrectly filtered out! "
            f"Remaining: {[(n.resource_id, n.content_desc) for n in filtered]}"
        )

    # ── Structural Fast-Paths ──

    def test_fast_path_message_input(self):
        """Message input must resolve structurally, never through VLM."""
        candidates = [
            _make_node(resource_id="com.instagram.android:id/composer_edittext", text="Message…"),
            _make_node(content_desc="Like", resource_id="like_button"),
        ]
        result = self.resolver.resolve("message text box", candidates, device=None)
        assert result is not None
        assert "composer_edittext" in result.resource_id

    def test_fast_path_send_button(self):
        """Send button must resolve structurally."""
        candidates = [
            _make_node(resource_id="com.instagram.android:id/composer_send_button", content_desc="Send"),
            _make_node(content_desc="Like"),
        ]
        result = self.resolver.resolve("send message button", candidates, device=None)
        assert result is not None
        assert "send" in result.resource_id.lower()

    def test_fast_path_post_author(self):
        """Post author username must resolve structurally."""
        candidates = [
            _make_node(
                resource_id="com.instagram.android:id/row_feed_photo_profile_name",
                text="photographer_jane",
            ),
            _make_node(content_desc="Profile", resource_id="profile_tab"),
        ]
        result = self.resolver.resolve("post author username", candidates, device=None)
        assert result is not None
        assert result.text == "photographer_jane"

    def test_fast_path_comment_button(self):
        """Comment button must resolve structurally."""
        candidates = [
            _make_node(resource_id="com.instagram.android:id/row_feed_button_comment", content_desc="Comment"),
            _make_node(text="Nice photo!", resource_id="comment_text"),
        ]
        result = self.resolver.resolve("comment button", candidates, device=None)
        assert result is not None
        assert "comment" in result.resource_id.lower()

    def test_abstract_goals_return_none(self):
        """Abstract GOAP goals must never resolve to a node click."""
        candidates = [
            _make_node(text="anything", resource_id="whatever"),
        ]
        for goal in ["open profile", "open explore", "open following", "learn own profile"]:
            result = self.resolver.resolve(goal, candidates, device=None)
            assert result is None, f"Abstract goal '{goal}' should return None!"

    def test_semantic_guard_quoted_target(self):
        """Quoted targets must match exactly — VLM must not hallucinate."""
        candidates = [
            _make_node(text="New Message", resource_id="new_msg_btn"),
            _make_node(text="Settings", resource_id="settings_btn"),
            _make_node(text="Archive", resource_id="archive_btn"),
        ]
        result = self.resolver.resolve("tap 'New Message'", candidates, device=None)
        assert result is not None
        assert result.text == "New Message"

    def test_semantic_guard_no_match_returns_none(self):
        """If quoted target doesn't exist, return None (not hallucinate)."""
        candidates = [
            _make_node(text="Settings", resource_id="settings_btn"),
            _make_node(text="Archive", resource_id="archive_btn"),
        ]
        result = self.resolver.resolve("tap 'Delete Account'", candidates, device=None)
        assert result is None


# ═══════════════════════════════════════════════════════════════════════
# CONTRACT 4: ActionMemory — Semantic Match Validation
# ═══════════════════════════════════════════════════════════════════════
#
# Prevents memory poisoning: if the VLM clicks a Reel thumbnail for
# "follow", the semantic guard must BLOCK confirmation into memory.
# ═══════════════════════════════════════════════════════════════════════


class TestSemanticMatchGuard:
    """Validates _intent_matches_node prevents memory poisoning."""

    @pytest.mark.parametrize(
        "intent,semantic_string,expected",
        [
            # Correct matches
            ("follow", "text: 'Follow', desc: '', id: 'follow_button'", True),
            ("like", "text: '', desc: 'Like', id: 'like_button'", True),
            ("save", "text: 'Save', desc: 'Add to Saved', id: 'save_btn'", True),
            # German locale — MUST be rejected (Zero-Maintenance: no localized strings)
            ("follow", "text: 'Abonnieren', desc: '', id: ''", False),
            ("like", "text: '', desc: 'Gefällt mir', id: ''", False),
            # POISONING ATTEMPTS — must be blocked
            (
                "follow",
                "text: '', desc: 'Reel by trenny_m. View Count 3.143', id: 'preview_clip_thumbnail'",
                False,
            ),
            (
                "like",
                "text: 'photographer_jane', desc: 'Photo by photographer_jane', id: 'feed_photo'",
                False,
            ),
            ("save", "text: 'Nice photo!', desc: '', id: 'comment_text'", False),
            # Non-toggle intents always pass
            ("tap back button", "text: '', desc: 'Back', id: 'back_btn'", True),
            ("open profile", "text: 'anything', desc: '', id: ''", True),
        ],
        ids=[
            "follow_correct",
            "like_correct",
            "save_correct",
            "follow_german_rejected",
            "like_german_rejected",
            "follow_reel_poison",
            "like_photo_poison",
            "save_comment_poison",
            "back_non_toggle",
            "abstract_non_toggle",
        ],
    )
    def test_intent_matches_node(self, intent, semantic_string, expected):
        result = _intent_matches_node(intent, semantic_string)
        assert result is expected, (
            f"_intent_matches_node('{intent}', '{semantic_string[:50]}...') " f"returned {result}, expected {expected}"
        )


# ═══════════════════════════════════════════════════════════════════════
# CONTRACT 5: ActionMemory Lifecycle — Track → Verify → Confirm/Reject
# ═══════════════════════════════════════════════════════════════════════
#
# This is the full lifecycle that runs for EVERY click in production.
# If any step in this chain is broken, the bot either:
# - Accepts hallucinated clicks (memory poisoning)
# - Rejects correct clicks (performance death)
# ═══════════════════════════════════════════════════════════════════════


class TestActionMemoryLifecycle:
    """Tests the full click tracking → confirmation/rejection lifecycle."""

    def _make_memory(self):
        """Create ActionMemory with a SpyUIMemoryDB wrapping the real UIMemoryDB."""
        spy_db = SpyUIMemoryDB()
        return ActionMemory(ui_memory=spy_db), spy_db

    def test_confirm_correct_follow_stores_in_memory(self):
        """A confirmed 'follow' click on a Follow button must be stored."""
        memory, fake_db = self._make_memory()
        node = _make_node(resource_id="follow_button", content_desc="Follow")
        memory.track_click("follow", node)
        memory.confirm_click("follow")
        assert len(fake_db.store_memory_calls) == 1

    def test_confirm_poisoned_follow_is_blocked(self):
        """
        Production bug: VLM clicked a Reel thumbnail for 'follow'.
        The semantic guard must BLOCK this from being stored in memory.
        """
        memory, fake_db = self._make_memory()
        node = _make_node(
            resource_id="com.instagram.android:id/preview_clip_thumbnail",
            content_desc="Reel by trenny_m. View Count 3.143",
        )
        memory.track_click("follow", node)
        memory.confirm_click("follow")
        # Must NOT have stored this poisoned memory
        assert len(fake_db.store_memory_calls) == 0

    def test_reject_click_triggers_decay(self):
        """A rejected click must trigger confidence decay."""
        memory, fake_db = self._make_memory()
        node = _make_node(content_desc="Back")
        memory.track_click("tap back button", node)
        memory.reject_click("tap back button")
        assert len(fake_db.decay_confidence_calls) == 1

    def test_confirm_without_track_is_noop(self):
        """Confirming without tracking must not crash or store."""
        memory, fake_db = self._make_memory()
        memory.confirm_click("follow")  # No prior track_click
        assert len(fake_db.store_memory_calls) == 0

    def test_mismatched_intent_is_ignored(self):
        """Confirming with a different intent than tracked must be ignored."""
        memory, fake_db = self._make_memory()
        node = _make_node(content_desc="Follow")
        memory.track_click("follow", node)
        memory.confirm_click("like")  # Wrong intent
        assert len(fake_db.store_memory_calls) == 0

    def test_verify_follow_success_markers(self):
        """
        After a follow click, the post-click XML must contain
        'following' or 'requested' to count as success.
        """
        memory, _ = self._make_memory()

        post_xml_success = '<node text="Following" />'
        result = memory.verify_success("follow", pre_click_xml="<node/>", post_click_xml=post_xml_success)
        assert result is True

    def test_verify_view_post_success(self):
        """After tapping a grid item, post detail markers must appear."""
        memory, _ = self._make_memory()

        post_xml_success = '<node resource-id="com.instagram.android:id/row_feed_button_like" />'
        result = memory.verify_success("view a post", pre_click_xml="<node/>", post_click_xml=post_xml_success)
        assert result is True


# ═══════════════════════════════════════════════════════════════════════
# CONTRACT 6: extract_json() — LLM Response Sanitizer
# ═══════════════════════════════════════════════════════════════════════
#
# EVERY LLM response passes through this function. If it fails,
# the entire Brain/GOAP/Perception pipeline gets garbage data.
# Previously untested.
# ═══════════════════════════════════════════════════════════════════════


class TestExtractJson:
    """Validates extract_json handles every LLM output format."""

    @pytest.mark.parametrize(
        "raw_response,expected_keys",
        [
            # Clean JSON
            ('{"action": "scroll_down"}', ["action"]),
            # Markdown code fences (VLM wraps JSON in ```)
            ('```json\n{"box": 3}\n```', ["box"]),
            ('```\n{"box": 3}\n```', ["box"]),
            # Thinking blocks (reasoning models like qwen3.5)
            (
                '<think>I need to find the button...</think>{"box": 5}',
                ["box"],
            ),
            # Text prefix before JSON
            (
                'Based on my analysis, the answer is: {"should_like": true}',
                ["should_like"],
            ),
            # Truncated JSON — fuzzy recovery (only completed key-value pairs are salvaged)
            ('{"action": "scroll_down", "target": "feed', ["action"]),
            # Pure garbage — must return None
            ("I don't know what to do", None),
            ("", None),
            (None, None),
        ],
        ids=[
            "clean_json",
            "markdown_json_fence",
            "markdown_fence",
            "thinking_block",
            "text_prefix",
            "truncated_recovery",
            "garbage",
            "empty",
            "none",
        ],
    )
    def test_extract_json(self, raw_response, expected_keys):
        from GramAddict.core.llm_provider import extract_json

        result = extract_json(raw_response)

        if expected_keys is None:
            assert result is None, f"Expected None but got: {result}"
        else:
            assert result is not None, f"Expected JSON with keys {expected_keys} but got None"
            import json as json_mod

            parsed = json_mod.loads(result)
            for key in expected_keys:
                assert key in parsed, f"Expected key '{key}' in {parsed}"


# ═══════════════════════════════════════════════════════════════════════
# CONTRACT 7: _visual_discovery Pre-Filters
# ═══════════════════════════════════════════════════════════════════════
#
# These are the guards INSIDE _visual_discovery() that run BEFORE the
# VLM ever sees the candidates. If they fail, the VLM gets garbage
# candidates and hallucinates. Previously completely untested because
# they require a device object.
#
# We test them in ISOLATION by extracting the filter logic.
# ═══════════════════════════════════════════════════════════════════════


class TestVisualDiscoveryPreFilters:
    """Tests the pre-filtering logic that runs before the VLM sees candidates."""

    # ── Area Filter ──

    def test_area_filter_excludes_tiny_nodes(self):
        """Nodes with area <= 200 must be filtered (invisible dots)."""
        tiny = _make_node(text="dot", bounds=(0, 0, 10, 10))  # area = 100
        normal = _make_node(text="button", bounds=(0, 0, 50, 50))  # area = 2500
        candidates = [tiny, normal]

        filtered = [n for n in candidates if 200 < n.area < 400000]
        assert len(filtered) == 1
        assert filtered[0].text == "button"

    def test_area_filter_excludes_huge_containers(self):
        """Nodes with area >= 400000 must be filtered (full-screen containers)."""
        huge = _make_node(text="container", bounds=(0, 0, 1080, 2400))  # area = 2,592,000
        normal = _make_node(text="button", bounds=(0, 0, 50, 50))  # area = 2500
        candidates = [huge, normal]

        filtered = [n for n in candidates if 200 < n.area < 400000]
        assert len(filtered) == 1
        assert filtered[0].text == "button"

    # ── SystemUI Filter ──

    def test_system_ui_excluded(self):
        """Android system UI elements must never reach the VLM."""
        sys_node = _make_node(
            resource_id="com.android.systemui:id/notification_icon",
            content_desc="Battery 85 per cent",
        )
        app_node = _make_node(
            resource_id="com.instagram.android:id/like_button",
            content_desc="Like",
        )
        candidates = [sys_node, app_node]

        filtered = [
            n
            for n in candidates
            if "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()
        ]
        assert len(filtered) == 1
        assert filtered[0].content_desc == "Like"

    # ── Strict Button Guard ──

    def test_button_guard_filters_long_text_captions(self):
        """
        When looking for a 'button', nodes with long text (captions,
        comments) must be excluded to prevent VLM confusion.
        """
        caption = _make_node(
            text="This is a really long caption about my amazing vacation in Bali #travel #photography #blessed",
            resource_id="caption_text",
        )
        button = _make_node(
            text="Like",
            resource_id="like_button",
        )
        candidates = [caption, button]
        intent_lower = "like button"

        # Replicate the exact guard from _visual_discovery
        if "button" in intent_lower:
            candidates = [n for n in candidates if len(n.text or "") < 40]

        assert len(candidates) == 1
        assert candidates[0].text == "Like"

    def test_button_guard_preserves_short_text_buttons(self):
        """Short-text elements (actual buttons) must pass through."""
        candidates = [
            _make_node(text="Follow", resource_id="follow_btn"),
            _make_node(text="Following", resource_id="following_btn"),
            _make_node(text="Message", resource_id="msg_btn"),
        ]
        intent_lower = "follow button"

        if "button" in intent_lower:
            candidates = [n for n in candidates if len(n.text or "") < 40]

        assert len(candidates) == 3

    # ── Grid Item Guard ──

    def test_grid_guard_filters_non_grid_elements(self):
        """
        When looking for 'first post', the guard must filter to actual
        grid items (with 'row 1', 'photos by', 'reel by' in desc).
        """
        search_tab = _make_node(content_desc="Search and explore", resource_id="search_tab")
        grid_item = _make_node(
            content_desc="6 photos by photographer_jane. Row 1, Column 1.",
            resource_id="grid_card",
        )
        reel_item = _make_node(
            content_desc="Reel by dancer_x. 1.2M views",
            resource_id="reel_card",
        )
        candidates = [search_tab, grid_item, reel_item]
        intent_lower = "first post"

        if "first post" in intent_lower:
            grid_candidates = []
            for node in candidates:
                desc = (node.content_desc or "").lower()
                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:
                candidates = grid_candidates

        assert len(candidates) == 2
        descs = [n.content_desc for n in candidates]
        assert "Search and explore" not in descs

    def test_grid_guard_fallthrough_when_no_grid_items(self):
        """If no grid items match, all candidates pass through (no crash)."""
        candidates = [
            _make_node(content_desc="Search"),
            _make_node(content_desc="Home"),
        ]
        intent_lower = "first post"

        if "first post" in intent_lower:
            grid_candidates = []
            for node in candidates:
                desc = (node.content_desc or "").lower()
                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:
                candidates = grid_candidates

        # No grid items matched, so all candidates are kept
        assert len(candidates) == 2

    # ── Author/Username Guard ──

    def test_author_guard_filters_nav_tabs(self):
        """
        When looking for 'post author username', navigation tabs
        (Profile, Home, Search, Reels) must be excluded.
        """
        profile_tab = _make_node(
            resource_id="com.instagram.android:id/profile_tab",
            content_desc="Profile",
        )
        username = _make_node(
            resource_id="com.instagram.android:id/row_feed_photo_profile_name",
            text="photographer_jane",
            content_desc="",
        )
        candidates = [profile_tab, username]
        intent_lower = "post author username"

        if "author" in intent_lower or "username" in intent_lower:
            filtered = []
            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"]
                ):
                    continue
                filtered.append(node)
            candidates = filtered

        assert len(candidates) == 1
        assert candidates[0].text == "photographer_jane"


# ═══════════════════════════════════════════════════════════════════════
# CONTRACT 8: verify_success Structural Delta Logic
# ═══════════════════════════════════════════════════════════════════════
#
# After every click, verify_success() decides if the click worked.
# If the delta logic is wrong, the bot either:
# - Thinks failed clicks succeeded (ghost interactions)
# - Thinks successful clicks failed (infinite retry loops)
# Previously only tested for follow/view-post markers.
# ═══════════════════════════════════════════════════════════════════════


class TestVerifySuccessStructuralDelta:
    """Tests the structural XML diff logic in verify_success."""

    def _make_memory(self):
        spy_db = SpyUIMemoryDB()
        return ActionMemory(ui_memory=spy_db), spy_db

    def test_toggle_massive_shift_is_navigation_error(self):
        """
        If a 'like' click causes >1000 char XML diff, the bot
        accidentally navigated away. Must return False.
        """
        memory, _ = self._make_memory()
        pre_xml = "<node>" + "x" * 500 + "</node>"
        post_xml = "<completely_different>" + "y" * 2000 + "</completely_different>"

        result = memory.verify_success("like", pre_click_xml=pre_xml, post_click_xml=post_xml)
        assert result is False

    def test_toggle_small_diff_is_success(self):
        """A small structural change for a toggle intent = success."""
        memory, _ = self._make_memory()
        # Track a click so the semantic gate has context
        node = _make_node(content_desc="Like", resource_id="like_button")
        memory.track_click("like", node)

        pre_xml = '<node text="Like" />'
        post_xml = '<node text="Liked" />'  # Small change

        result = memory.verify_success("like", pre_click_xml=pre_xml, post_click_xml=post_xml)
        assert result is True

    def test_toggle_zero_diff_is_false(self):
        """Zero XML change for a toggle = fail."""
        memory, _ = self._make_memory()
        same_xml = '<node text="Like" />'

        result = memory.verify_success("like", pre_click_xml=same_xml, post_click_xml=same_xml)
        assert result is False

    def test_follow_success_with_requested_marker(self):
        """
        Private accounts show 'Requested' instead of 'Following'.
        Must still count as success.
        """
        memory, _ = self._make_memory()
        post_xml = '<node text="Requested" />'
        result = memory.verify_success("follow", pre_click_xml="<node/>", post_click_xml=post_xml)
        assert result is True

    def test_follow_success_german_locale(self):
        """German locale uses 'Abonniert' or 'Angefragt'."""
        memory, _ = self._make_memory()
        post_xml = '<node text="Abonniert" />'
        result = memory.verify_success("follow", pre_click_xml="<node/>", post_click_xml=post_xml)
        assert result is True

    def test_view_post_clips_viewer_marker(self):
        """Opening a Reel shows clips_viewer_view_pager — must be detected."""
        memory, _ = self._make_memory()
        post_xml = '<node resource-id="com.instagram.android:id/clips_viewer_view_pager" />'
        result = memory.verify_success("view a post", pre_click_xml="<node/>", post_click_xml=post_xml)
        assert result is True

    def test_view_post_still_on_grid_is_false(self):
        """
        If after 'view a post' the XML still shows explore_action_bar
        WITHOUT post detail markers, navigation failed.
        """
        memory, _ = self._make_memory()
        post_xml = '<node resource-id="com.instagram.android:id/explore_action_bar" />'
        result = memory.verify_success("grid item", pre_click_xml="<node/>", post_click_xml=post_xml)
        assert result is False

    def test_semantic_gate_blocks_wrong_toggle_element(self):
        """
        If a 'like' click was tracked on a caption (not a like button),
        verify_success must reject it via the semantic gate.
        """
        memory, _ = self._make_memory()
        node = _make_node(
            text="Beautiful sunset photo!",
            resource_id="caption_text",
            content_desc="",
        )
        memory.track_click("like", node)

        pre_xml = '<node text="Like" />'
        post_xml = '<node text="Liked" />'

        result = memory.verify_success("like", pre_click_xml=pre_xml, post_click_xml=post_xml)
        assert result is False

    def test_semantic_evaluator_malformed_json_fallback(self):
        """TDD Test to ensure malformed JSON without closing braces is handled automatically without regex."""
        from GramAddict.core.perception.semantic_evaluator import SemanticEvaluator

        evaluator = SemanticEvaluator()

        # Override the VLM query to return a truncated JSON string (like an LLM out of tokens)
        evaluator._query_vlm = (
            lambda prompt, img: '{\n  "should_like": true,\n  "should_comment": false,\n  "is_ad": false'
        )

        class MockDevice:
            def get_screenshot_b64(self):
                return "dummy_b64"

        result = evaluator.evaluate_post_vibe(MockDevice(), ["test"])

        assert result is not None
        assert result["should_like"] is True
        assert result["should_comment"] is False
        assert result["is_ad"] is False
