"""
GOAP Loop Prevention & Following List Resolution Tests

These tests prove:
1. The HD Map planner breaks infinite routing loops when edges are masked.
2. The TelepathicEngine can structurally resolve "tap following list" on a real
   Instagram profile XML dump WITHOUT needing VLM inference — using the XML's
   own semantic signals (resource-id, content-desc containing "following").
3. The intent_map in q_nav_graph correctly maps "tap_following_list" to a
   semantically rich intent string.

Requires: Real XML fixture at tests/fixtures/user_profile_dump.xml
"""

import pytest

from GramAddict.core.navigation.planner import GoalPlanner
from GramAddict.core.perception.screen_identity import ScreenType
from GramAddict.core.screen_topology import ScreenTopology
from GramAddict.core.telepathic_engine import TelepathicEngine

# ═══════════════════════════════════════════════════════
# TEST 1: HD Map Routing Avoids Masked Edges
# ═══════════════════════════════════════════════════════


def test_goap_planner_avoids_infinite_loop_on_masked_edge():
    """
    When 'tap following list' has failed repeatedly (masked),
    the HD Map must NOT keep routing through OWN_PROFILE.
    It must recognize the dead end and fall back to discovery.
    """
    planner = GoalPlanner("test_user")

    import os

    from GramAddict.core.perception.screen_identity import ScreenIdentity

    xml_path = os.path.join(os.path.dirname(__file__), "fixtures", "home_feed_real.xml")
    with open(xml_path, "r", encoding="utf-8") as f:
        xml = f.read()

    identity = ScreenIdentity("test_user")
    screen = identity.identify(xml)

    # Removed LLM monkeypatch to enforce real LLM inference as requested.

    # MASKED: simulate that "tap following list" failed >= 2 times
    action_failures = {"tap following list": 2}

    action_avoided = planner.plan_next_step(
        "open following list",
        screen,
        action_failures=action_failures,
    )

    # The HD Map should fail, and because the planner is trapped, it falls back to back-tracking
    assert (
        action_avoided == "press back" or action_avoided == "force start instagram"
    ), "Planner routed BLIND into the dead end despite the edge being masked!"


# ═══════════════════════════════════════════════════════
# TEST 2: ScreenTopology.find_route respects avoid_actions
# ═══════════════════════════════════════════════════════


def test_screen_topology_find_route_avoids_blocked_edges():
    """
    find_route with avoid_actions={'tap following list'} must return None
    when the only path to FOLLOW_LIST goes through that edge.
    """
    # Normal route exists
    route_normal = ScreenTopology.find_route(ScreenType.OWN_PROFILE, ScreenType.FOLLOW_LIST)
    assert route_normal is not None
    assert len(route_normal) == 1
    assert route_normal[0][0] == "tap following list"

    # Blocked route returns None
    route_blocked = ScreenTopology.find_route(
        ScreenType.OWN_PROFILE,
        ScreenType.FOLLOW_LIST,
        avoid_actions={"tap following list"},
    )
    assert route_blocked is None, "Route should be unreachable when the only edge is blocked"


# ═══════════════════════════════════════════════════════
# TEST 3: TelepathicEngine finds "following" node structurally
# ═══════════════════════════════════════════════════════


def _load_profile_xml():
    with open("tests/fixtures/user_profile_dump.xml", "r", encoding="utf-8") as f:
        return f.read()


def test_telepathic_engine_finds_following_node_on_profile():
    """
    The TelepathicEngine MUST find the correct 'following' counter node
    (profile_header_following_stacked_familiar) on a real profile XML dump.

    This is the ROOT CAUSE of the infinite loop: if the engine can't find
    this node, GOAP burns the action and loops forever.

    We test with VLM mocked to return the correct index, proving the
    pipeline works when the VLM cooperates. The real fix is ensuring
    the VLM prompt clearly distinguishes 'followers' from 'following'.
    """
    xml = _load_profile_xml()

    engine = TelepathicEngine()

    # Parse the XML to see what candidates the engine extracts
    root = engine._parser.parse(xml)
    candidates = engine._parser.get_clickable_nodes(root)

    # Find the CORRECT node in the candidate list
    following_nodes = [
        (i, n)
        for i, n in enumerate(candidates)
        if "following_stacked" in (n.resource_id or "")
        or "following" in (n.content_desc or "").lower()
        or "following" in (n.text or "").lower()
    ]

    assert len(following_nodes) > 0, (
        "The 'following' counter node is not in the clickable candidates! "
        "SpatialParser is filtering it out. This is the root cause."
    )

    idx, correct_node = following_nodes[0]
    assert (
        "991" in (correct_node.content_desc or "")
        or "following" in (correct_node.content_desc or "").lower()
        or "following" in (correct_node.text or "").lower()
    ), f"Found node does not look like the following counter: {correct_node}"

    # Verify it's NOT the followers node (the common VLM confusion)
    assert (
        "followers" not in (correct_node.content_desc or "").lower()
        and "followers" not in (correct_node.text or "").lower()
    ), f"Got the FOLLOWERS node instead of FOLLOWING! desc={correct_node.content_desc}"


def test_following_vs_followers_are_both_candidates():
    """
    Both 'followers' and 'following' counters must be in the candidate list.
    If only one shows up, the VLM has no chance of picking the right one.
    """
    xml = _load_profile_xml()
    engine = TelepathicEngine()
    root = engine._parser.parse(xml)
    candidates = engine._parser.get_clickable_nodes(root)

    followers_found = any(
        "followers" in (n.content_desc or "").lower() or "followers" in (n.text or "").lower() for n in candidates
    )
    following_found = any(
        n
        for n in candidates
        if "following_stacked" in (n.resource_id or "")
        or (
            ("following" in (n.content_desc or "").lower() or "following" in (n.text or "").lower())
            and "followers" not in (n.content_desc or "").lower()
            and "followers" not in (n.text or "").lower()
        )
    )

    assert followers_found, "Followers counter not in candidates"
    assert following_found, "Following counter not in candidates — VLM can never find it!"


def test_vlm_prompt_humanizes_content_desc():
    """
    The IntentResolver must humanize concatenated content-desc values
    before sending to the VLM. '991following' → '991 following' so the
    VLM can distinguish 'followers' from 'following'.
    """
    import re

    def _humanize_desc(raw: str) -> str:
        if not raw:
            return ""
        # "991following" → "991 following", "140Kfollowers" → "140K followers"
        # Matches digit (with optional K/M/B suffix) directly followed by a lowercase word
        return re.sub(r"(\d[KMBkmb]?)([a-z])", r"\1 \2", raw)

    # Instagram's raw concatenated format
    assert _humanize_desc("991following") == "991 following"
    assert _humanize_desc("140Kfollowers") == "140K followers"
    assert _humanize_desc("1.099posts") == "1.099 posts"
    assert _humanize_desc("1099posts") == "1099 posts"
    # Already clean strings pass through unchanged
    assert _humanize_desc("Follow") == "Follow"
    assert _humanize_desc("") == ""

    # Now verify the actual node context would contain humanized versions
    xml = _load_profile_xml()
    engine = TelepathicEngine()
    root = engine._parser.parse(xml)
    candidates = engine._parser.get_clickable_nodes(root)

    # Filter like IntentResolver does (area < 500000, no tabs)
    filtered = [n for n in candidates if n.area < 500000]

    # Build humanized node context like the production IntentResolver now does
    node_context = []
    for i, node in enumerate(filtered):
        text = 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}]")

    context_str = "\n".join(node_context)

    # After humanization, "followers" and "following" must be clearly distinct words
    assert "followers" in context_str.lower(), "VLM context is missing followers node"
    assert "following" in context_str.lower(), "VLM context is missing following node"

    # The humanized desc should contain spaces between number and word
    assert (
        "991 following" in context_str or "991following" not in context_str
    ), "content-desc was NOT humanized — VLM will confuse followers/following"


@pytest.mark.live_llm
def test_live_vlm_selects_following_not_followers(make_real_device_with_image):
    """
    LIVE LLM TEST: Calls the real local Ollama to prove the VLM
    correctly picks the 'following' node (not 'followers') when asked
    to 'tap following list' on a real profile XML.

    This is the ultimate truth test — if this fails, the bot will
    loop forever in production.

    Requires: Ollama running locally with qwen3.5:latest or llava:latest
    """

    xml = _load_profile_xml()

    # We need a dummy image for _build_spatial_map.
    # The image is only used for drawing the boxes, so a dummy is fine.
    from PIL import Image

    dummy_img = Image.new("RGB", (1080, 2400))

    from GramAddict.core.perception.intent_resolver import IntentResolver

    resolver = IntentResolver()

    # This perfectly mimics production: XML -> Parser -> Deduplication
    engine = TelepathicEngine()
    root = engine._parser.parse(xml)
    candidates = engine._parser.get_clickable_nodes(root)

    device = make_real_device_with_image(dummy_img)

    intent = "tap 'following' list"

    # We test the ACTUAL intent resolution pipeline, no prompt engineering lies.
    # We do NOT catch exceptions to skip. If Ollama is down, the test FAILS.
    selected_node = resolver.resolve(intent, candidates, device)

    assert selected_node is not None, "VLM returned null — couldn't find ANY following node."
    selected_desc = (selected_node.content_desc or "").lower()
    selected_text = (selected_node.text or "").lower()
    selected_id = (selected_node.resource_id or "").lower()

    # THE CRITICAL ASSERTION: Must be "following", NOT "followers"
    assert "following" in selected_id or "following" in selected_desc or "following" in selected_text, (
        f"VLM hallucinated and selected wrong node! Got: desc='{selected_node.content_desc}', text='{selected_node.text}', id='{selected_node.resource_id}'. "
        f"This proves the local VLM failed the negative constraint."
    )
    assert (
        "followers" not in selected_id
    ), f"VLM CONFUSED followers with following! Selected: id='{selected_node.resource_id}'"
