"""Tests for the strategy-reminder injection (v0.9.24).

When the dispatcher runs a built-in mutating MCP tool, it appends a
short reminder TextContent block to the tool's response listing the
section titles from STRATEGY.md so the agent re-surfaces the strategy
context after every mutation. Soft enforcement only — never refuses,
just injects.

These tests pin:

1. The reminder fires only for *mutating* built-in tools (not for
   read-only / discover tools, not for plugin tools — those are out of
   scope for v1).
2. The reminder is *appended*, never replaces the tool's content.
3. Missing STRATEGY.md silently skips the reminder.
4. Env-var opt-out (``MUREO_DISABLE_STRATEGY_REMINDER=1``) suppresses
   the reminder entirely.
5. The reminder lists STRATEGY.md section titles only — never the
   content (small context cost, prevents bloat).
"""

from __future__ import annotations

from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from mcp.types import TextContent

from mureo.context.models import StrategyEntry
from mureo.core.strategy_reminder import (
    build_reminder_text,
    is_mutating_builtin_tool,
)

# ---------------------------------------------------------------------------
# Tool classification
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestIsMutatingBuiltinTool:
    @pytest.mark.parametrize(
        "name",
        [
            "_synthetic_test_tool_create",
            "google_ads_campaigns_update_status",
            "google_ads_keywords_add",
            "google_ads_keywords_remove",
            "google_ads_keywords_pause",
            "google_ads_budget_update",
            "google_ads_negative_keywords_add",
            "google_ads_recommendations_apply",
            "google_ads_assets_upload_image",
            "meta_ads_campaigns_create",
            "meta_ads_campaigns_pause",
            "meta_ads_campaigns_enable",
            "meta_ads_campaigns_update",
            "meta_ads_audiences_create_lookalike",
            "meta_ads_audiences_delete",
            "meta_ads_conversions_send",
            "meta_ads_videos_upload",
            "search_console_sitemaps_submit",
            "rollback_apply",
            "mureo_state_upsert_campaign",
            "mureo_strategy_set",
            "mureo_state_action_log_append",
        ],
    )
    def test_known_mutating_tools_classified_mutating(self, name: str) -> None:
        assert is_mutating_builtin_tool(name) is True

    @pytest.mark.parametrize(
        "name",
        [
            "_synthetic_test_tool_get",
            "google_ads_campaigns_get",
            "google_ads_keywords_list",
            "google_ads_keywords_audit",
            "google_ads_search_terms_report",
            "google_ads_performance_report",
            "meta_ads_campaigns_list",
            "meta_ads_campaigns_get",
            "meta_ads_insights_report",
            "search_console_sites_list",
            "search_console_url_inspection_inspect",
            "rollback_plan_get",
            "analysis_anomalies_check",
            "mureo_state_get",
            "mureo_strategy_get",
            "mureo_learning_insights_get",
            "mureo_consult_advisor",
        ],
    )
    def test_known_read_only_tools_not_mutating(self, name: str) -> None:
        assert is_mutating_builtin_tool(name) is False

    def test_unknown_tool_not_classified_mutating(self) -> None:
        """Conservative: unknown tools default to NOT mutating so the
        reminder does not fire spuriously on a plugin or a future tool
        the classifier doesn't yet know about."""
        assert is_mutating_builtin_tool("totally_unknown_tool_xyz") is False

    def test_plugin_tool_namespace_excluded(self) -> None:
        """Plugin tools are out of scope for v1 — they have their own
        ``derive_semantics`` machinery. Reminder must not fire on
        ``mcp__mureo__<plugin_id>_*`` patterns."""
        assert is_mutating_builtin_tool("mcp__mureo__acme_create") is False


# ---------------------------------------------------------------------------
# Full-catalogue truth-table — round-2 review insisted on this
# (a 22-example parametrize leaves ~150 tools un-pinned and a new
# mutating tool added without classifier update slips through).
# ---------------------------------------------------------------------------


# Hand-curated truth table for every built-in tool name shipped today.
# Maintained alongside the classifier — adding a new built-in tool MUST
# add its name here (CI fails until both this table and the classifier
# agree). Generated by cross-checking ``mureo/mcp/_tools_*.py`` against
# each tool's description and handler implementation.
_MUTATING_CATALOGUE: frozenset[str] = frozenset(
    {
        # Google Ads — mutating
        "google_ads_ad_groups_create",
        "google_ads_ad_groups_update",
        "google_ads_ads_create",
        "google_ads_ads_create_display",
        "google_ads_ads_update",
        "google_ads_ads_update_status",
        "google_ads_assets_upload_image",
        "google_ads_bid_adjustments_update",
        "google_ads_budget_create",
        "google_ads_budget_update",
        "google_ads_callouts_create",
        "google_ads_callouts_remove",
        "google_ads_campaigns_create",
        "google_ads_campaigns_update",
        "google_ads_campaigns_update_status",
        "google_ads_conversions_create",
        "google_ads_conversions_remove",
        "google_ads_conversions_tag",
        "google_ads_conversions_update",
        "google_ads_device_targeting_set",
        "google_ads_keywords_add",
        "google_ads_keywords_pause",
        "google_ads_keywords_remove",
        "google_ads_location_targeting_update",
        "google_ads_negative_keywords_add",
        "google_ads_negative_keywords_add_to_ad_group",
        "google_ads_negative_keywords_remove",
        "google_ads_recommendations_apply",
        "google_ads_schedule_targeting_update",
        "google_ads_sitelinks_create",
        "google_ads_sitelinks_remove",
        # Meta Ads — mutating
        "meta_ads_ad_rules_create",
        "meta_ads_ad_rules_delete",
        "meta_ads_ad_rules_update",
        "meta_ads_ad_sets_create",
        "meta_ads_ad_sets_enable",
        "meta_ads_ad_sets_pause",
        "meta_ads_ad_sets_update",
        "meta_ads_ads_create",
        "meta_ads_ads_enable",
        "meta_ads_ads_pause",
        "meta_ads_ads_update",
        "meta_ads_audiences_create",
        "meta_ads_audiences_create_lookalike",
        "meta_ads_audiences_delete",
        "meta_ads_campaigns_create",
        "meta_ads_campaigns_enable",
        "meta_ads_campaigns_pause",
        "meta_ads_campaigns_update",
        "meta_ads_catalogs_create",
        "meta_ads_catalogs_delete",
        "meta_ads_conversions_send",
        "meta_ads_conversions_send_lead",
        "meta_ads_conversions_send_purchase",
        "meta_ads_creatives_create",
        "meta_ads_creatives_create_carousel",
        "meta_ads_creatives_create_collection",
        "meta_ads_creatives_create_dynamic",
        "meta_ads_creatives_create_lead",
        "meta_ads_creatives_upload_image",
        "meta_ads_feeds_create",
        "meta_ads_images_upload_file",
        "meta_ads_instagram_boost",
        "meta_ads_lead_forms_create",
        "meta_ads_lead_forms_duplicate",  # CRITICAL round-2 fix
        "meta_ads_lead_forms_update",
        "meta_ads_page_posts_boost",
        "meta_ads_products_add",
        "meta_ads_products_delete",
        "meta_ads_products_update",
        "meta_ads_split_tests_create",
        "meta_ads_split_tests_end",  # CRITICAL round-2 fix
        "meta_ads_videos_upload",
        "meta_ads_videos_upload_file",
        # Search Console — mutating
        "search_console_sitemaps_submit",
        # Rollback — mutating
        "rollback_apply",
        # Mureo context — mutating
        "mureo_state_upsert_campaign",
        "mureo_strategy_set",
        "mureo_state_action_log_append",
        "mureo_state_report_set",
        "mureo_state_platform_metrics_set",
    }
)


def _all_builtin_tool_names() -> set[str]:
    """Discover every built-in tool name at runtime by walking the
    server's registered TOOLS lists. The truth-table must match this
    set exactly (modulo the plugin namespace, which is out of scope)."""
    from mureo.mcp.server import (
        _ANALYSIS_NAMES,
        _ANALYTICS_REGISTRY_NAMES,
        _GOOGLE_ADS_NAMES,
        _LEARNING_NAMES,
        _META_ADS_NAMES,
        _MUREO_CONTEXT_NAMES,
        _ROLLBACK_NAMES,
        _SEARCH_CONSOLE_NAMES,
    )

    return (
        set(_GOOGLE_ADS_NAMES)
        | set(_META_ADS_NAMES)
        | set(_SEARCH_CONSOLE_NAMES)
        | set(_ROLLBACK_NAMES)
        | set(_ANALYSIS_NAMES)
        | set(_MUREO_CONTEXT_NAMES)
        | set(_ANALYTICS_REGISTRY_NAMES)
        | set(_LEARNING_NAMES)
    )


@pytest.mark.unit
class TestFullCatalogueTruthTable:
    """Pin the classifier against the FULL built-in tool registry, not
    just a hand-picked sample. A new built-in tool added without a
    classifier-set update fails CI here."""

    def test_classifier_matches_truth_table_for_every_builtin_tool(self) -> None:
        all_names = _all_builtin_tool_names()
        mismatches: list[str] = []
        for name in sorted(all_names):
            expected_mutating = name in _MUTATING_CATALOGUE
            actual_mutating = is_mutating_builtin_tool(name)
            if expected_mutating != actual_mutating:
                mismatches.append(
                    f"  {name}: truth={expected_mutating} "
                    f"classifier={actual_mutating}"
                )
        assert not mismatches, (
            "Classifier disagrees with truth table on the following "
            "tools — either fix _MUTATING_BUILTIN_SUFFIXES / _EXACT_NAMES "
            "in strategy_reminder.py or update _MUTATING_CATALOGUE "
            "above:\n" + "\n".join(mismatches)
        )

    def test_truth_table_contains_only_real_tool_names(self) -> None:
        """The other direction: every name in the truth table must
        correspond to a real registered tool. Catches stale entries
        after a tool rename or removal."""
        all_names = _all_builtin_tool_names()
        stale = sorted(_MUTATING_CATALOGUE - all_names)
        assert not stale, (
            "Truth table references tool names that no longer exist in "
            "the registry — remove from _MUTATING_CATALOGUE: " + ", ".join(stale)
        )


# ---------------------------------------------------------------------------
# Reminder text builder
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestBuildReminderText:
    def test_returns_none_when_strategy_empty(self) -> None:
        assert build_reminder_text([]) is None

    def test_lists_section_titles_only(self) -> None:
        entries = [
            StrategyEntry(context_type="goal", title="Q2 CPA target", content="..."),
            StrategyEntry(context_type="persona", title="B2B SaaS", content="..."),
        ]
        text = build_reminder_text(entries)
        assert text is not None
        assert "Q2 CPA target" in text
        assert "B2B SaaS" in text
        # Content must NOT appear — only titles. The dummy content is
        # ``"..."`` which is also a natural token in prose, but every
        # entry uses the same content so its absence (zero occurrences)
        # pins that no entry's content was rendered.
        assert text.count("...") == 0

    def test_marks_as_reminder(self) -> None:
        entries = [StrategyEntry(context_type="goal", title="g1", content="x")]
        text = build_reminder_text(entries)
        assert text is not None
        assert "STRATEGY" in text.upper() or "reminder" in text.lower()

    def test_caps_at_reasonable_section_count(self) -> None:
        """Defensive: a STRATEGY.md with 100 sections should not balloon
        the reminder. Cap at a sensible number (around 20)."""
        entries = [
            StrategyEntry(context_type="t", title=f"title_{i}", content="x")
            for i in range(100)
        ]
        text = build_reminder_text(entries)
        assert text is not None
        # First 20 must be present.
        assert "title_0" in text
        assert "title_19" in text
        # Truncated entries must NOT appear, AND the explicit "+N more"
        # indicator must be present so the cap is observable to the
        # agent. Both halves pinned separately (round-2 review caught
        # that the v1 ``or`` masked regressions where one half silently
        # dropped).
        assert "title_20" not in text
        assert "title_99" not in text
        assert "+80 more" in text


# ---------------------------------------------------------------------------
# Dispatcher integration
# ---------------------------------------------------------------------------


@pytest.mark.unit
@pytest.mark.asyncio
class TestDispatcherInjection:
    async def _run(
        self,
        tool_name: str,
        handler_result: list[Any],
        strategy: list[StrategyEntry] | None = None,
        env: dict[str, str] | None = None,
    ) -> list[Any]:
        from mureo.mcp.server import handle_call_tool

        fake_ctx = MagicMock()
        fake_ctx.state_store.read_strategy.return_value = (
            strategy if strategy is not None else []
        )
        fake_handler = AsyncMock(return_value=handler_result)
        # Route every test through a single real handler branch
        # (rollback) so we exercise the dispatcher integration without
        # building Google Ads / Meta Ads mocks per case.
        with (
            patch("mureo.mcp.server._load_policy_gates", return_value=()),
            patch("mureo.mcp.server.handle_rollback_tool", new=fake_handler),
            patch(
                "mureo.mcp.server._ROLLBACK_NAMES",
                new={tool_name},
            ),
            patch(
                "mureo.core.strategy_reminder.get_runtime_context",
                return_value=fake_ctx,
            ),
            patch.dict(
                "os.environ",
                env if env is not None else {},
                clear=False,
            ),
        ):
            if env is not None and "MUREO_DISABLE_STRATEGY_REMINDER" not in env:
                # Make sure the opt-out env var is NOT inherited from
                # the developer's shell — otherwise the suite fails
                # depending on who runs it.
                import os

                os.environ.pop("MUREO_DISABLE_STRATEGY_REMINDER", None)
            return await handle_call_tool(tool_name, {})

    async def test_mutating_tool_with_strategy_gets_reminder_appended(self) -> None:
        result = await self._run(
            tool_name="_synthetic_test_tool_create",
            handler_result=[TextContent(type="text", text="original tool output")],
            strategy=[
                StrategyEntry(context_type="goal", title="Q2 CPA target", content="x"),
            ],
        )
        # Original content preserved + reminder appended.
        assert len(result) == 2
        assert result[0].text == "original tool output"
        assert "Q2 CPA target" in result[1].text

    async def test_read_only_tool_gets_no_reminder(self) -> None:
        result = await self._run(
            tool_name="_synthetic_test_tool_get",
            handler_result=[TextContent(type="text", text="original tool output")],
            strategy=[
                StrategyEntry(context_type="goal", title="Q2 CPA target", content="x"),
            ],
        )
        assert len(result) == 1
        assert result[0].text == "original tool output"

    async def test_missing_strategy_silently_skips(self) -> None:
        result = await self._run(
            tool_name="_synthetic_test_tool_create",
            handler_result=[TextContent(type="text", text="original tool output")],
            strategy=[],
        )
        assert len(result) == 1
        assert result[0].text == "original tool output"

    async def test_env_var_opt_out_suppresses_reminder(self) -> None:
        result = await self._run(
            tool_name="_synthetic_test_tool_create",
            handler_result=[TextContent(type="text", text="original tool output")],
            strategy=[
                StrategyEntry(context_type="goal", title="Q2 CPA target", content="x"),
            ],
            env={"MUREO_DISABLE_STRATEGY_REMINDER": "1"},
        )
        assert len(result) == 1
        assert result[0].text == "original tool output"

    @pytest.mark.parametrize(
        "val",
        ["true", "yes", "True", "TRUE", "0", " 1", "1 ", "", "01", "11"],
        ids=[
            "true",
            "yes",
            "True",
            "TRUE",
            "zero",
            "leading_space",
            "trailing_space",
            "empty",
            "01",
            "11",
        ],
    )
    async def test_non_exact_one_does_not_opt_out(self, val: str) -> None:
        """The opt-out env var follows the established mureo contract:
        exact-string ``"1"`` only. ``"true"`` / ``"yes"`` / ``" 1"``
        / ``""`` / etc. must NOT disable the reminder. Matches the
        `_is_disabled` contract pinned in test_truthy_coercion_does_
        not_disable for ``MUREO_DISABLE_GOOGLE_ADS`` et al."""
        result = await self._run(
            tool_name="_synthetic_test_tool_create",
            handler_result=[TextContent(type="text", text="original tool output")],
            strategy=[
                StrategyEntry(context_type="goal", title="Q2 CPA target", content="x"),
            ],
            env={"MUREO_DISABLE_STRATEGY_REMINDER": val},
        )
        # Reminder STILL fires because val != exact "1".
        assert len(result) == 2
        assert "Q2 CPA target" in result[1].text

    async def test_state_read_failure_does_not_break_dispatch(self) -> None:
        """A corrupted STATE.json / missing strategy file MUST NOT
        break the mutating tool dispatch — the reminder is best-
        effort and silent on failure."""
        from mureo.mcp.server import handle_call_tool

        fake_ctx = MagicMock()
        fake_ctx.state_store.read_strategy.side_effect = OSError("disk gone")
        fake_handler = AsyncMock(
            return_value=[TextContent(type="text", text="original")]
        )
        with (
            patch("mureo.mcp.server._load_policy_gates", return_value=()),
            patch("mureo.mcp.server.handle_rollback_tool", new=fake_handler),
            patch(
                "mureo.mcp.server._ROLLBACK_NAMES",
                new={"_synthetic_test_tool_create"},
            ),
            patch(
                "mureo.core.strategy_reminder.get_runtime_context",
                return_value=fake_ctx,
            ),
        ):
            result = await handle_call_tool("_synthetic_test_tool_create", {})
        # Original output preserved; no reminder; no exception.
        assert len(result) == 1
        assert result[0].text == "original"


@pytest.mark.unit
class TestPluginReminderBuilder:
    """``maybe_build_reminder_for_plugin`` — the plugin entry point used by
    the dispatcher for a *mutating* plugin call. Unlike the built-in builder
    it must NOT run the built-in suffix classifier (the caller has already
    classified the tool via ``derive_semantics``), but it shares the same
    env opt-out and best-effort contract."""

    def _ctx(self, strategy):
        ctx = MagicMock()
        ctx.state_store.read_strategy.return_value = strategy
        return ctx

    def test_fires_for_plugin_namespaced_tool(self) -> None:
        from mureo.core.strategy_reminder import maybe_build_reminder_for_plugin

        ctx = self._ctx(
            [StrategyEntry(context_type="goal", title="Q2 CPA target", content="x")]
        )
        with (
            patch(
                "mureo.core.strategy_reminder.get_runtime_context",
                return_value=ctx,
            ),
            patch.dict("os.environ", {}, clear=False),
        ):
            import os

            os.environ.pop("MUREO_DISABLE_STRATEGY_REMINDER", None)
            # A plugin-namespaced name the built-in classifier would reject.
            text = maybe_build_reminder_for_plugin("mcp__mureo__acme_create")
        assert text is not None
        assert "Q2 CPA target" in text

    def test_empty_strategy_returns_none(self) -> None:
        from mureo.core.strategy_reminder import maybe_build_reminder_for_plugin

        with patch(
            "mureo.core.strategy_reminder.get_runtime_context",
            return_value=self._ctx([]),
        ):
            assert maybe_build_reminder_for_plugin("acme_resume") is None

    def test_env_opt_out_suppresses(self) -> None:
        from mureo.core.strategy_reminder import maybe_build_reminder_for_plugin

        ctx = self._ctx(
            [StrategyEntry(context_type="goal", title="Q2 CPA target", content="x")]
        )
        with (
            patch(
                "mureo.core.strategy_reminder.get_runtime_context",
                return_value=ctx,
            ),
            patch.dict(
                "os.environ",
                {"MUREO_DISABLE_STRATEGY_REMINDER": "1"},
                clear=False,
            ),
        ):
            assert maybe_build_reminder_for_plugin("acme_resume") is None

    def test_state_read_failure_returns_none(self) -> None:
        from mureo.core.strategy_reminder import maybe_build_reminder_for_plugin

        ctx = MagicMock()
        ctx.state_store.read_strategy.side_effect = OSError("disk gone")
        with patch(
            "mureo.core.strategy_reminder.get_runtime_context", return_value=ctx
        ):
            assert maybe_build_reminder_for_plugin("acme_resume") is None

    def test_text_build_failure_returns_none(self) -> None:
        """The text builder is inside the try too — a raise there must
        degrade to ``None``, never propagate into the dispatch."""
        from mureo.core.strategy_reminder import maybe_build_reminder_for_plugin

        ctx = self._ctx(
            [StrategyEntry(context_type="goal", title="Q2 CPA target", content="x")]
        )
        with (
            patch(
                "mureo.core.strategy_reminder.get_runtime_context",
                return_value=ctx,
            ),
            patch(
                "mureo.core.strategy_reminder.build_reminder_text",
                side_effect=RuntimeError("boom"),
            ),
        ):
            assert maybe_build_reminder_for_plugin("acme_resume") is None
