"""
E2E: GOAP Navigation & Learning Engine
========================================
Tests the autonomous navigation brain WITHOUT a live device.

These tests validate the GOAP decision-making pipeline:
- Screen identification from XML structural markers
- Action failure counting & masking
- HD Map BFS routing with masked edges
- Trap detection & forced restart logic
- Goal achievement detection
- Recalled path execution logic

WHY THIS MATTERS (from 2026-05-02 production log):
    Bot tried 'tap reels tab' but VLM clicked wrong element.
    Screen identity reported EXPLORE_GRID instead of REELS_FEED.
    After 2 failures the action was masked → HD Map said 'unreachable'
    → GOAP declared 'completely trapped' → forced Instagram restart.
    All tests were green because NONE of this logic was tested.
"""

import pytest

from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
from GramAddict.core.screen_topology import ScreenTopology

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


def _xml_with_ids(*resource_ids, selected_tab=None, texts=None, descs=None):
    """Build a minimal XML dump with given resource IDs."""
    nodes = []
    for rid in resource_ids:
        selected = "true" if selected_tab and rid.endswith(selected_tab) else "false"
        nodes.append(
            f'<node package="com.instagram.android" '
            f'resource-id="com.instagram.android:id/{rid}" '
            f'text="" content-desc="" clickable="true" selected="{selected}" '
            f'bounds="[0,0][100,100]" />'
        )
    for text in texts or []:
        nodes.append(
            f'<node package="com.instagram.android" '
            f'resource-id="" text="{text}" content-desc="" '
            f'clickable="false" selected="false" bounds="[0,0][100,100]" />'
        )
    for desc in descs or []:
        nodes.append(
            f'<node package="com.instagram.android" '
            f'resource-id="" text="" content-desc="{desc}" '
            f'clickable="false" selected="false" bounds="[0,0][100,100]" />'
        )
    return f'<hierarchy rotation="0">{"".join(nodes)}</hierarchy>'


# ═══════════════════════════════════════════════════════════════════════
# CONTRACT 1: Screen Identification from XML
# ═══════════════════════════════════════════════════════════════════════
#
# If screen identification is wrong, EVERY downstream decision is wrong:
# routing, goal checking, action masking — everything.
# ═══════════════════════════════════════════════════════════════════════


class TestScreenIdentification:
    """Tests ScreenIdentity._classify_screen with real XML patterns."""

    def setup_method(self):
        self.si = ScreenIdentity(bot_username="testbot")

    def test_home_feed_from_selected_tab(self):
        xml = _xml_with_ids("feed_tab", "search_tab", "clips_tab", "profile_tab", selected_tab="feed_tab")
        result = self.si.identify(xml)
        assert result["screen_type"] == ScreenType.HOME_FEED

    def test_explore_grid_from_selected_tab(self):
        xml = _xml_with_ids("feed_tab", "search_tab", "clips_tab", "profile_tab", selected_tab="search_tab")
        result = self.si.identify(xml)
        assert result["screen_type"] == ScreenType.EXPLORE_GRID

    def test_reels_feed_from_selected_tab(self):
        xml = _xml_with_ids("feed_tab", "search_tab", "clips_tab", "profile_tab", selected_tab="clips_tab")
        result = self.si.identify(xml)
        assert result["screen_type"] == ScreenType.REELS_FEED

    def test_reels_feed_from_structural_markers(self):
        """Reels in full-screen mode hides the tab bar → no selected_tab."""
        xml = _xml_with_ids("clips_viewer_container", "root_clips_layout")
        result = self.si.identify(xml)
        assert result["screen_type"] == ScreenType.REELS_FEED

    def test_own_profile_from_selected_tab(self):
        xml = _xml_with_ids(
            "feed_tab", "search_tab", "clips_tab", "profile_tab", "profile_header_container", selected_tab="profile_tab"
        )
        result = self.si.identify(xml)
        assert result["screen_type"] == ScreenType.OWN_PROFILE

    def test_other_profile_from_header_without_tab(self):
        """Other profile has header but profile_tab is NOT selected."""
        xml = _xml_with_ids("profile_header_container", "feed_tab", selected_tab="feed_tab")
        result = self.si.identify(xml)
        assert result["screen_type"] == ScreenType.OTHER_PROFILE

    def test_follow_list(self):
        xml = _xml_with_ids("unified_follow_list_tab_layout")
        result = self.si.identify(xml)
        assert result["screen_type"] == ScreenType.FOLLOW_LIST

    def test_dm_inbox_from_tab(self):
        xml = _xml_with_ids("direct_tab", selected_tab="direct_tab")
        result = self.si.identify(xml)
        assert result["screen_type"] == ScreenType.DM_INBOX

    def test_dm_thread_from_message_input(self):
        xml = _xml_with_ids("direct_thread_header", texts=["Message..."])
        result = self.si.identify(xml)
        assert result["screen_type"] == ScreenType.DM_THREAD

    def test_story_view_from_markers(self):
        xml = _xml_with_ids("reel_viewer_media_layout", "reel_viewer_header")
        result = self.si.identify(xml)
        assert result["screen_type"] == ScreenType.STORY_VIEW

    def test_modal_from_creation_flow(self):
        xml = _xml_with_ids("creation_flow_container", "gallery_cancel_button")
        result = self.si.identify(xml)
        assert result["screen_type"] == ScreenType.MODAL

    def test_foreign_app_when_no_instagram_package(self):
        xml = '<hierarchy><node package="com.android.chrome" resource-id="" text="" content-desc="" clickable="false" selected="false" bounds="[0,0][100,100]" /></hierarchy>'
        result = self.si.identify(xml)
        assert result["screen_type"] == ScreenType.FOREIGN_APP

    def test_post_detail_has_feed_markers_but_no_action_bar(self):
        """POST_DETAIL has row_feed_button_like but NO main_feed_action_bar."""
        xml = _xml_with_ids("row_feed_button_like", "row_feed_photo_profile_name")
        result = self.si.identify(xml)
        assert result["screen_type"] == ScreenType.POST_DETAIL

    def test_home_feed_has_feed_markers_with_action_bar(self):
        """HOME_FEED has row_feed_* markers AND main_feed_action_bar."""
        xml = _xml_with_ids(
            "row_feed_button_like",
            "row_feed_photo_profile_name",
            "main_feed_action_bar",
            "feed_tab",
            selected_tab="feed_tab",
        )
        result = self.si.identify(xml)
        assert result["screen_type"] == ScreenType.HOME_FEED

    def test_empty_xml_returns_foreign_app(self):
        result = self.si.identify("")
        assert result["screen_type"] == ScreenType.FOREIGN_APP

    def test_garbage_xml_returns_foreign_app(self):
        result = self.si.identify("not xml at all!")
        assert result["screen_type"] == ScreenType.FOREIGN_APP


# ═══════════════════════════════════════════════════════════════════════
# CONTRACT 2: Available Actions Extraction
# ═══════════════════════════════════════════════════════════════════════
#
# If available_actions is wrong, the planner masks the wrong things
# or can't find valid actions to execute.
# ═══════════════════════════════════════════════════════════════════════


class TestAvailableActions:
    """Tests that screen identification extracts correct available actions."""

    def setup_method(self):
        self.si = ScreenIdentity(bot_username="testbot")

    def test_home_feed_has_all_tabs(self):
        xml = _xml_with_ids("feed_tab", "search_tab", "clips_tab", "profile_tab", "direct_tab", selected_tab="feed_tab")
        result = self.si.identify(xml)
        actions = result["available_actions"]
        assert "tap home tab" in actions
        assert "tap explore tab" in actions
        assert "tap reels tab" in actions
        assert "tap profile tab" in actions
        assert "tap messages tab" in actions

    def test_explore_grid_has_first_post(self):
        xml = _xml_with_ids("search_tab", selected_tab="search_tab")
        result = self.si.identify(xml)
        actions = result["available_actions"]
        assert "tap first post" in actions

    def test_profile_has_following_list(self):
        xml = _xml_with_ids("profile_header_container", "profile_tab", selected_tab="profile_tab", descs=["following"])
        result = self.si.identify(xml)
        actions = result["available_actions"]
        assert "tap following list" in actions

    def test_always_has_scroll_and_back(self):
        xml = _xml_with_ids("feed_tab", selected_tab="feed_tab")
        result = self.si.identify(xml)
        actions = result["available_actions"]
        assert "scroll down" in actions
        assert "press back" in actions


# ═══════════════════════════════════════════════════════════════════════
# CONTRACT 3: HD Map BFS Routing
# ═══════════════════════════════════════════════════════════════════════
#
# The HD Map is the bot's GPS. If BFS routing breaks, the bot
# can't navigate between screens and gets stuck.
# ═══════════════════════════════════════════════════════════════════════


class TestHDMapRouting:
    """Tests ScreenTopology.find_route BFS pathfinding."""

    def test_direct_route_home_to_explore(self):
        route = ScreenTopology.find_route(ScreenType.HOME_FEED, ScreenType.EXPLORE_GRID)
        assert route is not None
        assert len(route) == 1
        assert route[0] == ("tap explore tab", ScreenType.EXPLORE_GRID)

    def test_direct_route_home_to_reels(self):
        route = ScreenTopology.find_route(ScreenType.HOME_FEED, ScreenType.REELS_FEED)
        assert route is not None
        assert len(route) == 1
        assert route[0] == ("tap reels tab", ScreenType.REELS_FEED)

    def test_multi_step_route_home_to_follow_list(self):
        """HOME → OWN_PROFILE → FOLLOW_LIST (2 steps)."""
        route = ScreenTopology.find_route(ScreenType.HOME_FEED, ScreenType.FOLLOW_LIST)
        assert route is not None
        assert len(route) == 2
        assert route[0][1] == ScreenType.OWN_PROFILE
        assert route[1][1] == ScreenType.FOLLOW_LIST

    def test_already_there_returns_empty(self):
        route = ScreenTopology.find_route(ScreenType.HOME_FEED, ScreenType.HOME_FEED)
        assert route == []

    def test_unreachable_returns_none(self):
        """MODAL has no outgoing edges → can't reach other screens."""
        route = ScreenTopology.find_route(ScreenType.MODAL, ScreenType.DM_INBOX)
        assert route is None

    def test_masked_edge_makes_route_none(self):
        """
        Production bug 2026-05-02: 'tap reels tab' was masked after 2 failures.
        HD Map must return None when the only edge to REELS_FEED is masked.
        """
        route = ScreenTopology.find_route(
            ScreenType.HOME_FEED,
            ScreenType.REELS_FEED,
            avoid_actions={"tap reels tab"},
        )
        # Should find alternative route via EXPLORE_GRID or OWN_PROFILE
        if route is not None:
            # If there IS an alternative route, it must not use the masked action
            for action, _ in route:
                assert action != "tap reels tab"
        # If None, the target is unreachable (which matches production behavior)

    def test_masked_all_edges_returns_none(self):
        """When ALL edges to a target are masked, route must be None."""
        # Mask ALL routes that could reach REELS_FEED
        all_reels_actions = set()
        for screen, transitions in ScreenTopology.TRANSITIONS.items():
            for action, target in transitions.items():
                if target == ScreenType.REELS_FEED:
                    all_reels_actions.add(action)

        route = ScreenTopology.find_route(
            ScreenType.HOME_FEED,
            ScreenType.REELS_FEED,
            avoid_actions=all_reels_actions,
        )
        assert route is None, f"Expected None (unreachable) but got route: {route}"


# ═══════════════════════════════════════════════════════════════════════
# CONTRACT 4: Goal → Target Screen Mapping
# ═══════════════════════════════════════════════════════════════════════
#
# If the goal map is wrong, the bot navigates to the wrong screen
# and thinks it failed when it succeeded (or vice versa).
# ═══════════════════════════════════════════════════════════════════════


class TestGoalMapping:
    """Tests ScreenTopology.goal_to_target_screen."""

    @pytest.mark.parametrize(
        "goal,expected_screen",
        [
            ("open home feed", ScreenType.HOME_FEED),
            ("open home", ScreenType.HOME_FEED),
            ("open explore", ScreenType.EXPLORE_GRID),
            ("open explore feed", ScreenType.EXPLORE_GRID),
            ("open reels", ScreenType.REELS_FEED),
            ("open profile", ScreenType.OWN_PROFILE),
            ("learn own profile", ScreenType.OWN_PROFILE),
            ("open messages", ScreenType.DM_INBOX),
            ("open following list", ScreenType.FOLLOW_LIST),
            ("view a post", ScreenType.POST_DETAIL),
            ("open post author profile", ScreenType.OTHER_PROFILE),
            # Non-navigation goals
            ("like this post", None),
            ("follow the user", None),
            ("comment on the post", None),
        ],
        ids=[
            "home_feed",
            "home_short",
            "explore",
            "explore_full",
            "reels",
            "profile",
            "learn_profile",
            "messages",
            "following_list",
            "post",
            "other_profile",
            "like_non_nav",
            "follow_non_nav",
            "comment_non_nav",
        ],
    )
    def test_goal_mapping(self, goal, expected_screen):
        result = ScreenTopology.goal_to_target_screen(goal)
        assert result == expected_screen


# ═══════════════════════════════════════════════════════════════════════
# CONTRACT 5: Action Failure Masking Logic
# ═══════════════════════════════════════════════════════════════════════
#
# Production bug 2026-05-02: After 2 failures of 'tap reels tab',
# the action was masked. But GOAP continued trying to route through
# it, causing an infinite restart loop.
# ═══════════════════════════════════════════════════════════════════════


class TestActionMasking:
    """Tests the action failure tracking and masking logic in GoalExecutor."""

    def test_action_masked_after_max_retries(self):
        """After 2 failures, the action must be excluded from available_actions."""
        action_failures = {(ScreenType.HOME_FEED, "tap reels tab"): 2}
        original_actions = ["tap home tab", "tap reels tab", "tap profile tab"]
        MAX_RETRIES = 2

        masked = [a for a in original_actions if action_failures.get((ScreenType.HOME_FEED, a), 0) < MAX_RETRIES]
        assert "tap reels tab" not in masked
        assert "tap home tab" in masked
        assert "tap profile tab" in masked

    def test_action_not_masked_under_threshold(self):
        """1 failure is not enough to mask."""
        action_failures = {(ScreenType.HOME_FEED, "tap reels tab"): 1}
        original_actions = ["tap reels tab", "tap profile tab"]
        MAX_RETRIES = 2

        masked = [a for a in original_actions if action_failures.get((ScreenType.HOME_FEED, a), 0) < MAX_RETRIES]
        assert "tap reels tab" in masked

    def test_success_resets_failure_count(self):
        """A successful execution must reset the failure counter."""
        action_failures = {(ScreenType.HOME_FEED, "tap reels tab"): 1}
        # Simulate success
        action_failures[(ScreenType.HOME_FEED, "tap reels tab")] = 0
        assert action_failures[(ScreenType.HOME_FEED, "tap reels tab")] == 0

    def test_hd_map_unreachable_with_masked_actions(self):
        """
        Production chain 2026-05-02:
        1. 'tap reels tab' fails 2x → masked
        2. HD Map can't find route → returns None
        3. Planner says 'unreachable' → trapped → restart

        This test reproduces the exact chain.
        """
        avoid_actions = {"tap reels tab"}

        # Step 1: Check if route exists without masking
        route_clean = ScreenTopology.find_route(ScreenType.HOME_FEED, ScreenType.REELS_FEED)
        assert route_clean is not None, "Route should exist without masking"

        # Step 2: Check if route exists WITH masking
        route_masked = ScreenTopology.find_route(
            ScreenType.HOME_FEED,
            ScreenType.REELS_FEED,
            avoid_actions=avoid_actions,
        )

        # Step 3: The planner's HD Map pre-check logic
        # If route_masked is None BUT route_clean exists → "unreachable due to masked edges"
        if route_masked is None:
            # This is exactly the production scenario
            is_unreachable_due_to_masking = route_clean is not None
            assert is_unreachable_due_to_masking is True


# ═══════════════════════════════════════════════════════════════════════
# CONTRACT 6: Goal Achievement Detection
# ═══════════════════════════════════════════════════════════════════════
#
# If the goal detection is wrong, the bot either:
# - Stops too early (thinks it's done but isn't)
# - Loops forever (can't detect when it's actually done)
# ═══════════════════════════════════════════════════════════════════════


class TestGoalAchievement:
    """Tests GoalPlanner._is_goal_achieved logic."""

    def setup_method(self):
        from GramAddict.core.navigation.planner import GoalPlanner

        self.planner = GoalPlanner(username="testbot")

    def test_open_explore_achieved_on_explore(self):
        assert self.planner._is_goal_achieved("open explore", ScreenType.EXPLORE_GRID, {}) is True

    def test_open_explore_not_achieved_on_home(self):
        assert self.planner._is_goal_achieved("open explore", ScreenType.HOME_FEED, {}) is False

    def test_open_reels_achieved_on_reels(self):
        assert self.planner._is_goal_achieved("open reels", ScreenType.REELS_FEED, {}) is True

    def test_open_reels_not_achieved_on_explore(self):
        """
        Production bug 2026-05-02: Bot tapped 'reels tab' but
        landed on EXPLORE_GRID. Goal must NOT be achieved.
        """
        assert self.planner._is_goal_achieved("open reels", ScreenType.EXPLORE_GRID, {}) is False

    def test_like_achieved_when_context_is_liked(self):
        assert self.planner._is_goal_achieved("like a post", ScreenType.POST_DETAIL, {"is_liked": True}) is True

    def test_like_not_achieved_when_not_liked(self):
        assert self.planner._is_goal_achieved("like a post", ScreenType.POST_DETAIL, {"is_liked": False}) is False

    def test_view_profile_achieved_on_own_profile(self):
        assert self.planner._is_goal_achieved("view profile", ScreenType.OWN_PROFILE, {}) is True

    def test_view_profile_achieved_on_other_profile(self):
        assert self.planner._is_goal_achieved("view profile", ScreenType.OTHER_PROFILE, {}) is True

    def test_non_navigation_goal_not_achieved_by_screen(self):
        """Goals like 'follow the user' are NOT achieved by just being on a screen."""
        assert self.planner._is_goal_achieved("follow the user", ScreenType.OTHER_PROFILE, {}) is False


# ═══════════════════════════════════════════════════════════════════════
# CONTRACT 7: Structural Action Protection
# ═══════════════════════════════════════════════════════════════════════
#
# Structural actions (from the HD Map) must NEVER be permanently
# learned as traps. The VLM may fail to click them, but the route
# itself is architecturally valid.
# ═══════════════════════════════════════════════════════════════════════


class TestStructuralActionProtection:
    """Tests that HD Map actions are never permanently poisoned."""

    def test_tap_reels_tab_is_structural_on_home(self):
        assert ScreenTopology.is_structural_action(ScreenType.HOME_FEED, "tap reels tab") is True

    def test_tap_profile_tab_is_structural_on_explore(self):
        assert ScreenTopology.is_structural_action(ScreenType.EXPLORE_GRID, "tap profile tab") is True

    def test_random_action_is_not_structural(self):
        assert ScreenTopology.is_structural_action(ScreenType.HOME_FEED, "like this post") is False

    def test_structural_on_wrong_screen_is_false(self):
        """'tap following list' is structural on OWN_PROFILE, not on HOME_FEED."""
        assert ScreenTopology.is_structural_action(ScreenType.HOME_FEED, "tap following list") is False
        assert ScreenTopology.is_structural_action(ScreenType.OWN_PROFILE, "tap following list") is True


# ═══════════════════════════════════════════════════════════════════════
# CONTRACT 8: Step-Aware Navigation Validation
# ═══════════════════════════════════════════════════════════════════════
#
# After executing an action, the GOAP checks if the resulting screen
# matches what the HD Map expected. If this is wrong, correct actions
# get rejected and the bot enters a failure loop.
# ═══════════════════════════════════════════════════════════════════════


class TestStepValidation:
    """Tests expected_screen_for_action validation."""

    def test_tap_reels_tab_from_home_expects_reels(self):
        expected = ScreenTopology.expected_screen_for_action("tap reels tab", ScreenType.HOME_FEED)
        assert expected == ScreenType.REELS_FEED

    def test_tap_explore_tab_from_home_expects_explore(self):
        expected = ScreenTopology.expected_screen_for_action("tap explore tab", ScreenType.HOME_FEED)
        assert expected == ScreenType.EXPLORE_GRID

    def test_tap_reels_tab_landing_on_explore_is_failure(self):
        """
        Production bug 2026-05-02: Bot tapped 'reels tab' but
        landed on EXPLORE_GRID. This must be detected as a failure.
        """
        expected = ScreenTopology.expected_screen_for_action("tap reels tab", ScreenType.HOME_FEED)
        actual = ScreenType.EXPLORE_GRID
        assert expected != actual, "Reels tab landing on Explore must be a mismatch!"

    def test_unknown_action_returns_none(self):
        expected = ScreenTopology.expected_screen_for_action("like this post", ScreenType.HOME_FEED)
        assert expected is None

    def test_press_back_from_follow_list_expects_profile(self):
        expected = ScreenTopology.expected_screen_for_action("press back", ScreenType.FOLLOW_LIST)
        assert expected == ScreenType.OWN_PROFILE

    def test_other_profile_to_home_feed_routing_uses_back_press(self):
        # We removed 'tap home tab' from OTHER_PROFILE because it doesn't exist.
        route = ScreenTopology.find_route(ScreenType.OTHER_PROFILE, ScreenType.HOME_FEED)
        assert route is not None
        assert len(route) == 1
        assert route[0][0] == "press back"
        assert route[0][1] == ScreenType.HOME_FEED
