"""
Test Suite: VLM Bombproofing & Interaction Integrity
=====================================================
TDD validation that the Telepathic Engine and bot_flow interaction loop
are structurally safe against VLM hallucinations, cache poisoning,
and Darwin-induced context loss.
"""

import json
import re
from unittest.mock import MagicMock, patch

from GramAddict.core.telepathic_engine import TelepathicEngine

# ── Shared Fixtures ──


def mock_device(width=1080, height=2400):
    device = MagicMock()
    device.get_info.return_value = {"displayWidth": width, "displayHeight": height}
    device.screenshot = MagicMock()
    device.cm_to_pixels = MagicMock(side_effect=lambda cm: int(cm * 160))  # ~160px per cm
    device._get_current_app.return_value = "com.instagram.android"
    device.app_id = "com.instagram.android"
    return device


def make_node(x, y, bounds, semantic, text="", desc=""):
    """Helper to construct realistic UI nodes."""
    node = {
        "x": x,
        "y": y,
        "raw_bounds": bounds,
        "semantic_string": semantic,
        "original_attribs": {"text": text, "desc": desc, "resource-id": "com.instagram.android:id/dummy"},
        "resource_id": "com.instagram.android:id/dummy",
    }
    # Parse bounds to calculate area for VLM structural guards
    m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds)
    if m:
        l, t, r, b = map(int, m.groups())
        width = r - l
        height = b - t
        node["width"] = width
        node["height"] = height
        node["area"] = width * height
    else:
        node["width"] = 0
        node["height"] = 0
        node["area"] = 0
    return node


# Realistic node sets extracted from live Instagram XML dumps
EXPLORE_FEED_NODES = [
    make_node(540, 1250, "[0,200][1080,2300]", "id context: 'swipeable nav view pager inner recycler view'"),
    make_node(100, 2200, "[50,2150][150,2250]", "description: 'Search and explore', id context: 'search tab'"),
    make_node(540, 2200, "[490,2150][590,2250]", "description: 'Reels', id context: 'reels tab'"),
    make_node(940, 2200, "[890,2150][990,2250]", "description: 'Profile', id context: 'profile tab'"),
]

REEL_POST_NODES = [
    make_node(540, 1200, "[0,0][1080,2200]", "id context: 'clips video container'"),
    make_node(1020, 800, "[980,760][1060,840]", "description: 'Like', id context: 'row feed button like'"),
    make_node(1020, 920, "[980,880][1060,960]", "description: 'Comment', id context: 'row feed button comment'"),
    make_node(1020, 1040, "[980,1000][1060,1080]", "description: 'Share', id context: 'row feed button share'"),
    make_node(
        180, 2100, "[50,2060][310,2140]", "text: 'super_azores', description: 'super_azores'", text="super_azores"
    ),
]

PHOTO_POST_NODES = [
    make_node(540, 850, "[0,400][1080,1300]", "id context: 'row feed photo imageview'"),
    make_node(100, 1350, "[50,1310][150,1390]", "description: 'Like', id context: 'row feed button like'"),
    make_node(200, 1350, "[150,1310][250,1390]", "description: 'Comment', id context: 'row feed button comment'"),
]

PROFILE_EDIT_NODES = [
    make_node(540, 300, "[0,50][1080,550]", "id context: 'profile header container'"),
    make_node(
        540, 650, "[100,600][980,700]", "text: 'Edit profile', id context: 'edit profile button'", text="Edit profile"
    ),
    make_node(
        540,
        800,
        "[100,750][980,850]",
        "text: 'Share profile', id context: 'share profile button'",
        text="Share profile",
    ),
]


class TestVLMHallucinationRejection:
    """
    Tests that the engine rejects VLM responses pointing to massive
    structural containers instead of small interactive elements.
    """

    @patch("builtins.open", new_callable=MagicMock)
    @patch("GramAddict.core.telepathic_engine.query_telepathic_llm")
    @patch("os.path.exists")
    def test_recycler_view_hallucination_is_rejected(self, mock_exists, mock_query, mock_open):
        """
        Exact reproduction of the bug from log 2026-04-13 17:22:31:
        VLM picked node 0 ('swipeable nav view pager inner recycler view')
        for intent 'tap like button'. Must be rejected.
        """
        mock_exists.return_value = False
        engine = TelepathicEngine()
        device = mock_device()
        mock_open.return_value.__enter__.return_value.read.return_value = b"fakeimage"

        mock_query.return_value = '{"index": 0, "reason": "I think this is it"}'

        result = engine._vision_cortex_fallback("tap like button", EXPLORE_FEED_NODES, device)

        assert result is None, (
            f"CRITICAL: Engine accepted a fullscreen recycler view as 'like button'! " f"Got: {result}"
        )

    @patch("builtins.open", new_callable=MagicMock)
    @patch("GramAddict.core.telepathic_engine.query_telepathic_llm")
    @patch("os.path.exists")
    def test_frame_layout_hallucination_is_rejected(self, mock_exists, mock_query, mock_open):
        """
        A different structural container variant: FrameLayout that spans
        the full display. Must also be rejected.
        """
        mock_exists.return_value = False
        engine = TelepathicEngine()
        device = mock_device()
        mock_open.return_value.__enter__.return_value.read.return_value = b"fakeimage"

        nodes = [
            make_node(540, 1200, "[0,0][1080,2400]", "id context: 'action bar root'"),
            make_node(100, 1350, "[50,1310][150,1390]", "description: 'Like', id context: 'row feed button like'"),
        ]

        mock_query.return_value = '{"index": 0, "reason": "action bar seems right"}'
        result = engine._vision_cortex_fallback("tap like button", nodes, device)

        assert result is None, f"CRITICAL: Engine accepted a fullscreen FrameLayout as 'like button'! " f"Got: {result}"

    @patch("builtins.open", new_callable=MagicMock)
    @patch("GramAddict.core.telepathic_engine.query_telepathic_llm")
    @patch("os.path.exists")
    def test_video_container_is_allowed_for_media_intent(self, mock_exists, mock_query, mock_open):
        """
        Fullscreen video containers (clips_video_container) are legitimate
        tap targets for intents like 'pause reel' or 'tap the reel video'.
        """
        mock_exists.return_value = False
        engine = TelepathicEngine()
        device = mock_device()
        mock_open.return_value.__enter__.return_value.read.return_value = b"fakeimage"

        mock_query.return_value = '{"index": 0, "reason": "Tapping the video to pause"}'
        result = engine._vision_cortex_fallback("tap the reel video", REEL_POST_NODES, device)

        assert result is not None, "Video container tap should be allowed for media intents"
        assert result["x"] == 540
        assert result["y"] == 1200

    @patch("builtins.open", new_callable=MagicMock)
    @patch("GramAddict.core.telepathic_engine.query_telepathic_llm")
    @patch("os.path.exists")
    def test_correct_like_button_is_accepted(self, mock_exists, mock_query, mock_open):
        """
        When VLM correctly identifies the small like button (80x80),
        it must be accepted without interference from the safety guard.
        """
        mock_exists.return_value = False
        engine = TelepathicEngine()
        device = mock_device()
        mock_open.return_value.__enter__.return_value.read.return_value = b"fakeimage"

        # VLM returns index 1 — the correct, tiny Like button
        mock_query.return_value = '{"index": 1, "reason": "It says Like and has the heart icon"}'
        result = engine._vision_cortex_fallback("tap like button", REEL_POST_NODES, device)

        assert result is not None, "Correct like button should be accepted"
        assert result["x"] == 1020
        assert result["y"] == 800

    @patch("builtins.open", new_callable=MagicMock)
    @patch("GramAddict.core.telepathic_engine.query_telepathic_llm")
    @patch("os.path.exists")
    def test_comment_button_is_accepted(self, mock_exists, mock_query, mock_open):
        """Correct comment button selection on a Reel post."""
        mock_exists.return_value = False
        engine = TelepathicEngine()
        device = mock_device()
        mock_open.return_value.__enter__.return_value.read.return_value = b"fakeimage"

        mock_query.return_value = '{"index": 2, "reason": "Comment button"}'
        result = engine._vision_cortex_fallback("tap comment button", REEL_POST_NODES, device)

        assert result is not None
        assert result["x"] == 1020
        assert result["y"] == 920

    @patch("builtins.open", new_callable=MagicMock)
    @patch("GramAddict.core.telepathic_engine.query_telepathic_llm")
    @patch("os.path.exists")
    def test_photo_imageview_container_not_blocked(self, mock_exists, mock_query, mock_open):
        """
        A photo imageview is large (~900x900) but NOT fullscreen.
        It should NOT trigger the safety guard.
        """
        mock_exists.return_value = False
        engine = TelepathicEngine()
        device = mock_device()
        mock_open.return_value.__enter__.return_value.read.return_value = b"fakeimage"

        mock_query.return_value = '{"index": 0, "reason": "Double tap to like the photo"}'
        result = engine._vision_cortex_fallback("double tap photo to like", PHOTO_POST_NODES, device)

        assert result is not None, "Photo imageview (1080x900) should NOT be blocked — it's a valid media target"


class TestTelepathicMemoryPoisoning:
    """
    Tests that the cache (telepathic_memory.json) never stores
    hallucinated or rejected VLM decisions.
    """

    @patch("builtins.open", new_callable=MagicMock)
    @patch("GramAddict.core.telepathic_engine.query_telepathic_llm")
    @patch("os.path.exists")
    def test_rejected_hallucination_is_never_cached(self, mock_exists, mock_query, mock_open):
        """
        When a VLM hallucination is rejected by the safety guard,
        it must NOT be written to telepathic_memory.json.
        """
        mock_exists.return_value = False
        engine = TelepathicEngine()
        device = mock_device()
        mock_open.return_value.__enter__.return_value.read.return_value = b"fakeimage"

        mock_query.return_value = '{"index": 0, "reason": "I think this is it"}'
        result = engine._vision_cortex_fallback("tap like button", EXPLORE_FEED_NODES, device)

        assert result is None, "Hallucination should be rejected"

        # Verify that open() was never called in WRITE mode ("w") for the cache file
        write_calls = [
            c
            for c in mock_open.call_args_list
            if len(c[0]) >= 2 and c[0][1] == "w" and "telepathic_memory.json" in c[0][0]
        ]
        assert (
            len(write_calls) == 0
        ), f"CRITICAL: A rejected hallucination was written to cache! Write calls: {write_calls}"

    @patch("builtins.open", new_callable=MagicMock)
    @patch("GramAddict.core.telepathic_engine.query_telepathic_llm")
    @patch("os.path.exists")
    def test_valid_decision_is_tracked_but_not_cached(self, mock_exists, mock_query, mock_open):
        """
        When a VLM correctly identifies a valid, small button,
        it SHOULD be tracked, but NOT written to telepathic_memory.json
        until explicit confirmation (bot_flow -> confirm_click).
        """
        mock_exists.return_value = False
        engine = TelepathicEngine()
        device = mock_device()
        mock_open.return_value.__enter__.return_value.read.return_value = b"fakeimage"

        # VLM correctly selects the tiny like button (node 1)
        mock_query.return_value = '{"index": 1, "reason": "Like button"}'
        result = engine._vision_cortex_fallback("tap like button", REEL_POST_NODES, device)

        assert result is not None, "Valid like button should be accepted"

        # Verify cache was NOT written immediately to prevent poisoning
        write_calls = [c for c in mock_open.call_args_list if len(c[0]) >= 2 and c[0][1] == "w"]
        assert len(write_calls) == 0, "VLM decision should NOT be saved to cache immediately to prevent poisoning!"

        # Verify it is tracked
        assert TelepathicEngine._last_click_context is not None
        assert TelepathicEngine._last_click_context["intent"] == "tap like button"


class TestTelepathicMemoryRecall:
    """
    Tests that the cache short-circuits correctly and never
    recalls a stale/wrong semantic match.
    """

    @patch("GramAddict.core.telepathic_engine.TelepathicEngine._extract_semantic_nodes")
    @patch("builtins.open", new_callable=MagicMock)
    @patch("os.path.exists")
    def test_cache_hit_returns_instantly(self, mock_exists, mock_open, mock_extract):
        """
        If the telepathic memory already contains a hit for this intent,
        it must return immediately WITHOUT taking a screenshot or calling VLM.
        """
        mock_exists.return_value = True
        engine = TelepathicEngine()
        device = mock_device()
        mock_extract.return_value = REEL_POST_NODES

        # Simulate a cached memory file
        cache_data = {"tap like button": ["description: 'Like', id context: 'row feed button like'"]}
        mock_open.return_value.__enter__.return_value.read.return_value = json.dumps(cache_data).encode()

        # Provide nodes that match the cached semantic
        result = engine.find_best_node("<fake_xml>", "tap like button", min_confidence=0.82, device=device)

        assert result is not None, "Cache hit should return a result"
        assert result["x"] == 1020
        assert result["y"] == 800
        assert result["score"] == 1.0

        # Screenshot should NEVER have been called (the early-return optimization)
        device.screenshot.assert_not_called()

    @patch("GramAddict.core.telepathic_engine.TelepathicEngine._extract_semantic_nodes")
    @patch("builtins.open", new_callable=MagicMock)
    @patch("os.path.exists")
    def test_cache_miss_does_not_match_wrong_intent(self, mock_exists, mock_open, mock_extract):
        """
        Cached memory for 'tap like button' must NOT be used when the
        intent is 'tap comment button'.
        """
        mock_exists.return_value = True
        engine = TelepathicEngine()
        device = mock_device()
        mock_extract.return_value = REEL_POST_NODES

        cache_data = {"tap like button": ["description: 'Like', id context: 'row feed button like'"]}
        mock_open.return_value.__enter__.return_value.read.return_value = json.dumps(cache_data).encode()

        # We ask for "comment" but only "like" is cached — no early return
        # Mock VLM fallback so it doesn't crash
        with patch.object(engine, "_vision_cortex_fallback", return_value={"x": 999, "y": 999, "score": 1.0}):
            result = engine.find_best_node("<fake>", "tap comment button", min_confidence=0.82, device=device)

        # It should NOT return the like button coordinates
        if result is not None:
            assert result["y"] != 800, "CRITICAL: Cache returned like button coordinates for a comment intent!"


class TestDarwinScrollSafety:
    """
    Tests that Darwin's 'cognitive wobble' and 'non-linear scroll'
    don't produce scroll distances large enough to leave the current post.
    """

    def test_back_swipe_distance_is_bounded(self):
        """
        The back-swipe (cognitive wobble) must never exceed 1.5cm (~240px).
        Anything larger risks scrolling past the current post entirely.
        """

        # Run 200 Monte Carlo iterations to catch edge cases
        max_observed_distance = 0
        for _ in range(200):
            # The back-swipe distance is: cm_to_pixels(uniform(0.8, 1.2))
            # At 160px/cm ≈ 128 to 192 pixels. Must stay under 240px.
            distance_cm = __import__("random").uniform(0.8, 1.2)
            distance_px = int(distance_cm * 160)  # approximate px/cm
            max_observed_distance = max(max_observed_distance, distance_px)

        assert max_observed_distance <= 240, (
            f"Back-swipe distance reached {max_observed_distance}px! "
            f"Max allowed is 240px to prevent scrolling off the current post."
        )

    def test_scroll_velocity_never_causes_multi_post_skip(self):
        """
        The non-linear scroll in execute_proof_of_resonance must not
        produce a swipe distance exceeding the screen height, which would
        skip multiple posts at once.
        """
        # scroll_velocity bounds: (0.1, 2.0)
        # distance = cm_to_pixels(uniform(4.0, 7.0)) * velocity
        # Worst case: 7cm * 2.0 velocity * 160px/cm = 2240px
        # Screen height is 2400px — this is dangerously close!

        max_distance_px = 0
        for _ in range(500):
            velocity = __import__("random").uniform(0.1, 2.0)
            base_cm = __import__("random").uniform(4.0, 7.0)
            distance_px = int(base_cm * 160 * velocity)
            max_distance_px = max(max_distance_px, distance_px)

        screen_height = 2400
        assert max_distance_px < screen_height, (
            f"Darwin scroll distance reached {max_distance_px}px which exceeds "
            f"screen height {screen_height}px! This would skip multiple posts."
        )


class TestFeedMarkerValidation:
    """
    Tests that the feed marker self-check in bot_flow correctly
    identifies when the bot is on a post vs. lost.
    """

    def test_reel_feed_markers_detected(self):
        """
        A Reel post contains 'clips_media_component' which is in FEED_MARKERS.
        """
        from GramAddict.core.bot_flow import FEED_MARKERS

        fake_xml = """
        <node class="android.widget.FrameLayout">
            <node resource-id="com.instagram.android:id/clips_media_component" />
            <node resource-id="com.instagram.android:id/row_feed_button_like" />
        </node>
        """
        has_markers = any(m in fake_xml for m in FEED_MARKERS)
        assert has_markers, "Reel XML should match feed markers via 'clips_media_component'"

    def test_photo_feed_markers_detected(self):
        """
        A photo post contains 'row_feed_photo_imageview' in FEED_MARKERS.
        """
        from GramAddict.core.bot_flow import FEED_MARKERS

        fake_xml = """
        <node class="android.widget.FrameLayout">
            <node resource-id="com.instagram.android:id/row_feed_photo_imageview" />
            <node resource-id="com.instagram.android:id/row_feed_button_like" />
        </node>
        """
        has_markers = any(m in fake_xml for m in FEED_MARKERS)
        assert has_markers, "Photo post XML should match feed markers via 'row_feed_photo_imageview'"

    def test_profile_page_has_no_feed_markers(self):
        """
        A profile page must NOT match any feed markers.
        This is the bug scenario: the bot tapped a user tag, ended up on
        a profile page, and then tried to interact with non-existent posts.
        """
        from GramAddict.core.bot_flow import FEED_MARKERS

        fake_profile_xml = """
        <node class="android.widget.FrameLayout">
            <node resource-id="com.instagram.android:id/profile_header_container" />
            <node resource-id="com.instagram.android:id/action_bar_textview_title" text="marisaundmarc" />
            <node resource-id="com.instagram.android:id/profile_tab_layout" />
            <node resource-id="com.instagram.android:id/profile_tab_icon_view" />
        </node>
        """
        has_markers = any(m in fake_profile_xml for m in FEED_MARKERS)
        assert not has_markers, (
            "Profile page XML must NOT match feed markers! "
            "If it does, the bot would try to like/interact on a profile page."
        )

    def test_edit_profile_page_has_no_feed_markers(self):
        """
        The edit profile page (where the bot got stuck) must NOT match markers.
        """
        from GramAddict.core.bot_flow import FEED_MARKERS

        fake_edit_xml = """
        <node class="android.widget.FrameLayout">
            <node resource-id="com.instagram.android:id/action_bar_textview_title" text="Edit profile" />
            <node resource-id="com.instagram.android:id/full_name_edit_text" />
            <node resource-id="com.instagram.android:id/bio_edit_text" />
        </node>
        """
        has_markers = any(m in fake_edit_xml for m in FEED_MARKERS)
        assert not has_markers, (
            "Edit profile page must NOT match feed markers! "
            "The bot was getting stuck here because it blindly tapped center-screen."
        )

    def test_explore_grid_has_no_feed_markers(self):
        """
        The Explore grid (before opening a post) must NOT match feed markers.
        The bot should only enter the interaction loop AFTER tapping into a post.
        """
        from GramAddict.core.bot_flow import FEED_MARKERS

        fake_explore_grid_xml = """
        <node class="android.widget.FrameLayout">
            <node resource-id="com.instagram.android:id/search_tab" />
            <node resource-id="com.instagram.android:id/explore_grid" />
            <node content-desc="Reel by user1 at row 1, column 1" clickable="true" />
            <node content-desc="Photo by user2 at row 1, column 2" clickable="true" />
        </node>
        """
        has_markers = any(m in fake_explore_grid_xml for m in FEED_MARKERS)
        assert not has_markers, "Explore grid must NOT match feed markers — the bot isn't on a post yet."


class TestAdDetection:
    """
    Tests that ad detection is accurate and doesn't flag organic posts.
    """

    def test_sponsored_post_detected(self):
        """A post with ad_cta_button is detected as an ad."""
        from GramAddict.core.utils import is_ad

        ad_xml = """
        <node class="android.widget.FrameLayout">
            <node resource-id="com.instagram.android:id/row_feed_photo_imageview" />
            <node resource-id="com.instagram.android:id/ad_cta_button" text="Shop Now" />
        </node>
        """
        assert is_ad(ad_xml) is True

    def test_organic_post_not_flagged(self):
        """A normal post without ad markers is NOT flagged as adv."""
        from GramAddict.core.utils import is_ad

        organic_xml = """
        <node class="android.widget.FrameLayout">
            <node resource-id="com.instagram.android:id/row_feed_photo_imageview" />
            <node resource-id="com.instagram.android:id/row_feed_button_like" />
            <node resource-id="com.instagram.android:id/row_feed_photo_profile_name" text="super_azores" />
            <node resource-id="com.instagram.android:id/secondary_label" text="Berlin, Germany" />
        </node>
        """
        assert is_ad(organic_xml) is False, "Organic post with location secondary_label must NOT be marked as ad!"

    def test_sponsored_secondary_label_detected(self):
        """An ad with a secondary_label containing 'Ad' should be flagged."""
        from GramAddict.core.utils import is_ad

        # Extracted directly from: manual_interrupt__2026-04-13_23-55-33.xml
        ad_xml = """
        <node index="0" text="" resource-id="com.instagram.android:id/row_feed_profile_header" class="android.view.ViewGroup">
          <node index="1" text="Ehuse Ferienhausvermietung" resource-id="com.instagram.android:id/row_feed_photo_profile_name" class="android.widget.Button" />
          <node index="2" text="Ad" resource-id="com.instagram.android:id/secondary_label" class="android.widget.Button" package="com.instagram.android" content-desc="Ad" />
        </node>
        """
        assert is_ad(ad_xml) is True, "Failed to detect an ad via the secondary_label 'Ad' badge!"

    def test_organic_post_with_music_not_flagged(self):
        """A post with a music track attribution must NOT be flagged."""
        from GramAddict.core.utils import is_ad

        music_xml = """
        <node class="android.widget.FrameLayout">
            <node resource-id="com.instagram.android:id/clips_media_component" />
            <node resource-id="com.instagram.android:id/secondary_label" text="Original Audio - Artist Name" />
            <node resource-id="com.instagram.android:id/row_feed_button_like" />
        </node>
        """
        assert is_ad(music_xml) is False, "Organic reel with music attribution must NOT be marked as ad!"

    @patch("builtins.open", new_callable=MagicMock)
    @patch("GramAddict.core.telepathic_engine.query_telepathic_llm")
    @patch("os.path.exists")
    def test_vlm_receives_semantically_relevant_nodes_first(self, mock_exists, mock_query, mock_open):
        """
        If the target node is late in the XML hierarchy (e.g. node 40),
        it MUST be passed to the VLM. The VLM should not be forced to
        guess from the first 10 random XML nodes.
        """
        mock_exists.return_value = False
        engine = TelepathicEngine()
        device = mock_device()
        mock_open.return_value.__enter__.return_value.read.return_value = b"fakeimage"

        # Create 15 garbage nodes
        nodes = []
        for i in range(15):
            nodes.append(make_node(10, 10, "[0,0][20,20]", f"garbage node {i}"))

        # Target node is at the end (index 15)
        target_node = make_node(
            500, 500, "[400,400][600,600]", "description: 'Like', id context: 'row feed button like'"
        )
        nodes.append(target_node)

        # We simulate that the embedding engine scores the target_node highest
        with patch.object(engine, "_cosine_similarity", side_effect=lambda v1, v2: 1.0 if v1 == v2 else 0.0):
            with patch.object(engine, "_get_cached_embedding", return_value=[0.1] * 768) as mock_get_embed:
                # Make target node have same embedding as intent
                def fake_embed(text, is_intent=False):
                    if is_intent or "Like" in text:
                        return [1.0] * 768
                    return [0.0] * 768

                mock_get_embed.side_effect = fake_embed

                # VLM should select index 0, because the target_node should be SORTED to the top!
                mock_query.return_value = '{"index": 0, "reason": "Like button"}'

                with patch.object(engine, "_extract_semantic_nodes", return_value=nodes):
                    # min_confidence=1.1 to force fallback
                    result = engine.find_best_node("<fake>", "tap like button", min_confidence=1.1, device=device)

        assert result is not None, "VLM should have returned a result"
        assert result["x"] == 500 and result["y"] == 500, "VLM did not receive the best semantic candidates!"
