"""
Unit tests for PromptBuilder — system prompt construction, context injection
ordering, static/dynamic split, token budgets, job class flavors, credential
warnings, and planning context.
"""

from unittest.mock import MagicMock, patch

import pytest

from familiar.core.prompt_builder import PromptBuilder

try:
    from familiar.core.constitution import InteractionTracker

    HAS_CONSTITUTION = True
except ImportError:
    HAS_CONSTITUTION = False


@pytest.fixture
def mock_agent():
    agent = MagicMock()
    agent.config.agent.name = "TestFamiliar"
    agent.config.agent.persona = "a helpful test assistant"
    agent.config.agent.skills_enabled = False
    agent.provider = MagicMock()
    agent.provider.name = "mock"
    agent.provider.model_name = "mock-model"
    agent.memory = None
    agent.retriever = MagicMock()
    agent.retriever.count = 0
    agent.mesh_gateway = None
    agent.planner = None
    agent._memory_manager = MagicMock()
    agent._memory_manager.get_episodic_memory.return_value = None
    agent.skills = MagicMock()
    agent.skills.skills = {}
    # Prevent MagicMock from auto-generating config.context (must be None for defaults)
    agent.config.context = None
    # Constitution needs a real InteractionTracker to avoid enum lookup failures
    if HAS_CONSTITUTION:
        agent._interaction_tracker = InteractionTracker()
    return agent


@pytest.fixture
def pb(mock_agent):
    return PromptBuilder(mock_agent)


# ════════════════════════════════════════════════════════════════
# BUILD SYSTEM PROMPT
# ════════════════════════════════════════════════════════════════


class TestBuildSystemPrompt:
    """build_system_prompt returns a single string."""

    def test_returns_string(self, pb):
        result = pb.build_system_prompt()
        assert isinstance(result, str)
        assert len(result) > 0

    def test_contains_agent_name(self, pb, mock_agent):
        result = pb.build_system_prompt()
        assert mock_agent.config.agent.name in result

    def test_joins_static_and_dynamic(self, pb):
        with patch.object(pb, "build_system_prompt_parts") as mock_parts:
            mock_parts.return_value = ("STATIC", "DYNAMIC")
            result = pb.build_system_prompt()
            assert "STATIC" in result
            assert "DYNAMIC" in result

    def test_static_only_when_no_dynamic(self, pb):
        with patch.object(pb, "build_system_prompt_parts") as mock_parts:
            mock_parts.return_value = ("STATIC_ONLY", "")
            result = pb.build_system_prompt()
            assert result == "STATIC_ONLY"

    def test_passes_all_params(self, pb):
        session = MagicMock()
        with patch.object(pb, "build_system_prompt_parts") as mock_parts:
            mock_parts.return_value = ("s", "d")
            pb.build_system_prompt(
                session=session,
                current_message="hello",
                channel="telegram",
                is_proactive=True,
                routed_skills={"email"},
                job_class_key="helper",
            )
            mock_parts.assert_called_once()
            args = mock_parts.call_args
            assert args[0][0] is session  # session
            assert args[0][2] == "hello"  # current_message
            assert args[0][3] == "telegram"  # channel
            assert args[0][4] is True  # is_proactive


class TestBuildSystemPromptParts:
    """build_system_prompt_parts returns (static, dynamic) tuple."""

    def test_returns_tuple(self, pb):
        result = pb.build_system_prompt_parts()
        assert isinstance(result, tuple)
        assert len(result) == 2

    def test_static_contains_tool_guide(self, pb):
        static, _ = pb.build_system_prompt_parts()
        assert "<tool_guide>" in static

    def test_static_contains_error_recovery(self, pb):
        static, _ = pb.build_system_prompt_parts()
        assert "<error_recovery>" in static

    def test_session_budget_in_dynamic(self, pb):
        session = MagicMock()
        session.trust_level.value = "trusted"
        session.capabilities = []
        session.remaining_budget = 5.50
        session.session_context = {}
        session.user_id = "test_user"
        _, dynamic = pb.build_system_prompt_parts(session=session)
        assert "user_context" in dynamic
        assert "$5.50" in dynamic

    def test_user_context_in_dynamic(self, pb):
        """Phase 1 user context appears in dynamic portion."""
        user_ctx = MagicMock()
        user_ctx.user.display_name = "Alice"
        user_ctx.user.email = "alice@test.com"
        user_ctx.user.id = "alice1"
        user_ctx.user.role.value = "admin"
        user_ctx.can_write = True
        with patch("familiar.core.prompt_builder.HAS_USER_MANAGEMENT", True):
            _, dynamic = pb.build_system_prompt_parts(user_context=user_ctx)
        assert "Alice" in dynamic
        assert "ADMIN" in dynamic

    def test_memory_context_in_dynamic(self, pb, mock_agent):
        """Memory context appears in dynamic portion when available."""
        mock_agent.memory = MagicMock()
        mock_agent.memory.get_relevant_context.return_value = "User likes cats"
        mock_agent.memory.get_context_string.return_value = "Known facts: has a cat named Luna"
        _, dynamic = pb.build_system_prompt_parts(current_message="tell me about my cat")
        assert "cats" in dynamic or "Luna" in dynamic

    def test_no_memory_context_when_none(self, pb, mock_agent):
        """No memory section when agent.memory is None."""
        mock_agent.memory = None
        _, dynamic = pb.build_system_prompt_parts()
        assert "memory" not in dynamic.lower() or dynamic == ""

    def test_session_context_triage_emails(self, pb):
        """Triage email list from session context appears in dynamic."""
        from datetime import datetime, timezone

        session = MagicMock()
        session.trust_level.value = "owner"
        session.capabilities = []
        session.remaining_budget = 10.0
        session.user_id = "test_user"
        # Use a recent timestamp so triage isn't marked stale (>4h threshold)
        recent = datetime.now(timezone.utc).isoformat()
        session.session_context = {
            "triage_emails": [
                {
                    "index": 1,
                    "from_name": "Bob",
                    "from_email": "bob@test.com",
                    "subject": "Meeting tomorrow",
                    "priority": "action",
                    "category": "work",
                    "id_source": "imap-123",
                },
            ],
            "triage_at": recent,
        }
        _, dynamic = pb.build_system_prompt_parts(session=session)
        assert "Bob" in dynamic
        assert "Meeting tomorrow" in dynamic

    def test_session_context_last_doc(self, pb):
        """Last doc ID from session context appears in dynamic."""
        session = MagicMock()
        session.trust_level.value = "owner"
        session.capabilities = []
        session.remaining_budget = 10.0
        session.user_id = "test_user"
        session.session_context = {
            "last_doc_id": "doc-abc",
            "last_doc_title": "Grant Proposal",
        }
        _, dynamic = pb.build_system_prompt_parts(session=session)
        assert "doc-abc" in dynamic
        assert "Grant Proposal" in dynamic


class TestSkillDocs:
    """Skill documentation injection."""

    def test_skills_disabled_no_skills_section(self, pb, mock_agent):
        mock_agent.config.agent.skills_enabled = False
        static, _ = pb.build_system_prompt_parts()
        assert "<available_skills>" not in static

    def test_skills_enabled_with_summaries(self, pb, mock_agent):
        mock_agent.config.agent.skills_enabled = True
        mock_agent.skills.get_skill_summaries.return_value = "email: Send and receive emails"
        static, _ = pb.build_system_prompt_parts()
        assert "<available_skills>" in static
        assert "email" in static

    def test_local_model_gets_compact_skills(self, pb, mock_agent):
        """Ollama provider gets compact skill list instead of full docs."""
        mock_agent.config.agent.skills_enabled = True
        mock_agent.provider.name = "ollama"
        mock_skill = MagicMock()
        mock_skill.name = "email"
        mock_skill.enabled = True
        mock_agent.skills.skills = {"email": mock_skill}
        static, _ = pb.build_system_prompt_parts()
        assert "email" in static


# ════════════════════════════════════════════════════════════════
# CONTEXT INJECTION METHODS
# ════════════════════════════════════════════════════════════════


class TestInjectEpisodicContext:
    """inject_episodic_context adds past conversations."""

    def test_noop_when_no_episodic(self, pb, mock_agent):
        mock_agent._memory_manager.get_episodic_memory.return_value = None
        result = pb.inject_episodic_context("base prompt", "hello", "user1")
        assert result == "base prompt"

    def test_skips_for_local_provider(self, pb, mock_agent):
        """Local Ollama provider skips episodic context."""
        try:
            from familiar.core.providers import OllamaProvider

            mock_agent.provider = MagicMock(spec=OllamaProvider)
            result = pb.inject_episodic_context("base", "hello", "user1")
            assert result == "base"
        except ImportError:
            pytest.skip("OllamaProvider not available")

    def test_appends_episodic_block(self, pb, mock_agent):
        mock_ms = MagicMock()
        mock_memory = MagicMock()
        mock_memory.id = "m1"
        mock_memory.content = "User mentioned they like hiking"
        mock_ms.retrieve.return_value = [mock_memory]
        # working_memory must return a non-empty context string
        mock_ms.working_memory.get_context_string.return_value = "Recent: hiking trip"
        # Make working_memory iterable with no overlap so extra_lines includes m1
        mock_ms.working_memory.__iter__ = MagicMock(return_value=iter([]))
        mock_agent._memory_manager.get_episodic_memory.return_value = mock_ms

        result = pb.inject_episodic_context("base prompt", "outdoor activities", "user1")
        assert "<episodic_context>" in result
        assert "hiking" in result

    def test_handles_retrieval_error(self, pb, mock_agent):
        mock_ms = MagicMock()
        mock_ms.retrieve.side_effect = RuntimeError("retrieval failed")
        mock_agent._memory_manager.get_episodic_memory.return_value = mock_ms
        result = pb.inject_episodic_context("base", "query", "user1")
        assert result == "base"  # Graceful fallback


class TestInjectMarkdownMemory:
    """inject_markdown_memory_context adds note snippets."""

    def test_noop_when_no_store(self, pb):
        with patch("familiar.core.prompt_builder.logger"):
            result = pb.inject_markdown_memory_context("base", "query", "user1")
            # Should return base — either no store or exception caught
            assert "base" in result

    def test_appends_markdown_block(self, pb):
        mock_store = MagicMock()
        mock_store.search.return_value = [
            ("cat-facts", 0.8, "Cats sleep 16 hours a day"),
        ]
        with patch("familiar.core.prompt_builder.logger"):
            with patch("familiar.core.markdown_memory._get_store", return_value=mock_store):
                result = pb.inject_markdown_memory_context("base", "cats", "user1")
                assert "<markdown_memories>" in result
                assert "cat-facts" in result


class TestInjectMemoryTree:
    """inject_memory_tree_context adds curated working-tier facts."""

    def test_noop_when_tree_empty(self, pb):
        mock_tree = MagicMock()
        mock_tree.list_by_tier.return_value = []
        with patch("familiar.core.prompt_builder.logger"):
            with patch("familiar.core.memory_tree.get_memory_tree", return_value=mock_tree):
                result = pb.inject_memory_tree_context("base", "user1")
                assert result == "base"

    def test_appends_tree_block(self, pb):
        mock_entry = MagicMock()
        mock_entry.title = "Pet"
        mock_entry.body = "User has a cat named Luna"
        mock_tree = MagicMock()
        mock_tree.list_by_tier.return_value = [mock_entry]
        with patch("familiar.core.prompt_builder.logger"):
            with patch("familiar.core.memory_tree.get_memory_tree", return_value=mock_tree):
                result = pb.inject_memory_tree_context("base", "user1")
                assert "<memory_tree_working>" in result
                assert "Luna" in result

    def test_respects_char_budget(self, pb, mock_agent):
        """Entries exceeding token budget are truncated."""
        mock_agent.config.context = MagicMock()
        mock_agent.config.context.max_memory_tree_tokens = 10  # Very small
        entries = []
        for i in range(50):
            e = MagicMock()
            e.title = f"Fact{i}"
            e.body = "x" * 200
            entries.append(e)
        mock_tree = MagicMock()
        mock_tree.list_by_tier.return_value = entries
        with patch("familiar.core.prompt_builder.logger"):
            with patch("familiar.core.memory_tree.get_memory_tree", return_value=mock_tree):
                result = pb.inject_memory_tree_context("base", "user1")
                # Should have some entries but not all 50
                if "<memory_tree_working>" in result:
                    assert "Fact49" not in result


class TestInjectRagContext:
    """inject_rag_context adds retrieved document chunks."""

    def test_noop_when_no_docs(self, pb, mock_agent):
        mock_agent.retriever.count = 0
        result = pb.inject_rag_context("base", "query")
        assert result == "base"

    def test_appends_rag_when_docs_exist(self, pb, mock_agent):
        mock_agent.retriever.count = 5
        mock_agent._retrieve_rag_context = MagicMock(
            return_value="<retrieved_knowledge>\nGrant deadline is March 30\n</retrieved_knowledge>"
        )
        result = pb.inject_rag_context("base", "grant deadline")
        assert "<retrieved_knowledge>" in result
        assert "March 30" in result

    def test_handles_rag_error(self, pb, mock_agent):
        mock_agent.retriever.count = 5
        mock_agent._retrieve_rag_context = MagicMock(side_effect=RuntimeError("embedding fail"))
        result = pb.inject_rag_context("base", "query")
        assert result == "base"


class TestInjectMeshContext:
    """inject_mesh_context adds cross-device memories."""

    def test_noop_when_no_gateway(self, pb, mock_agent):
        mock_agent.mesh_gateway = None
        result = pb.inject_mesh_context("base", "query")
        assert result == "base"

    def test_noop_when_gateway_not_running(self, pb, mock_agent):
        mock_agent.mesh_gateway = MagicMock()
        mock_agent.mesh_gateway._running = False
        result = pb.inject_mesh_context("base", "query")
        assert result == "base"

    def test_appends_mesh_context(self, pb, mock_agent):
        mock_agent.mesh_gateway = MagicMock()
        mock_agent.mesh_gateway._running = True
        mock_agent.get_mesh_context = MagicMock(return_value="Peer memory: shopping list")
        result = pb.inject_mesh_context("base", "what's on my list")
        assert "shopping list" in result

    def test_handles_mesh_error(self, pb, mock_agent):
        mock_agent.mesh_gateway = MagicMock()
        mock_agent.mesh_gateway._running = True
        mock_agent.get_mesh_context = MagicMock(side_effect=RuntimeError("timeout"))
        result = pb.inject_mesh_context("base", "query")
        assert result == "base"


class TestInjectPlanningContext:
    """inject_planning_context generates plans for complex requests."""

    def test_noop_when_no_planner(self, pb, mock_agent):
        mock_agent.planner = None
        result = pb.inject_planning_context("base", "do something complex", [])
        assert result == "base"

    def test_noop_when_plan_not_needed(self, pb, mock_agent):
        mock_agent.planner = MagicMock()
        mock_agent.planner.should_plan.return_value = False
        result = pb.inject_planning_context("base", "simple greeting", [])
        assert result == "base"

    def test_appends_plan(self, pb, mock_agent):
        mock_plan = MagicMock()
        mock_plan.goal = "Set up donor pipeline"
        mock_plan.steps = [MagicMock(), MagicMock()]
        mock_plan.to_prompt.return_value = "<plan>Step 1: ...\nStep 2: ...</plan>"
        mock_agent.planner = MagicMock()
        mock_agent.planner.should_plan.return_value = True
        mock_agent.planner.create_plan.return_value = mock_plan
        mock_agent.memory = None

        result = pb.inject_planning_context("base", "set up donor tracking", [{"name": "tool1"}])
        assert "plan" in result.lower()

    def test_sets_plan_tracker_on_agent(self, pb, mock_agent):
        """Creates PlanTracker on agent for tool loop progress."""
        mock_plan = MagicMock()
        mock_plan.goal = "test"
        mock_plan.steps = [MagicMock()]
        mock_plan.to_prompt.return_value = "<plan>Step 1</plan>"
        mock_agent.planner = MagicMock()
        mock_agent.planner.should_plan.return_value = True
        mock_agent.planner.create_plan.return_value = mock_plan
        mock_agent.memory = None

        pb.inject_planning_context("base", "complex task", [])
        assert mock_agent._current_plan_tracker is not None


# ════════════════════════════════════════════════════════════════
# CREDENTIAL WARNINGS
# ════════════════════════════════════════════════════════════════


class TestCredentialWarnings:
    """_get_credential_warnings returns services needing attention."""

    def test_empty_when_all_healthy(self, pb):
        with patch(
            "familiar.core.prompt_builder.PromptBuilder._get_credential_warnings",
            return_value="",
        ):
            _, dynamic = pb.build_system_prompt_parts()
            assert "credential_warnings" not in dynamic

    def test_returns_warning_block(self, pb):
        mock_status = MagicMock()
        mock_status.service_key = "todoist"
        mock_status.last_error = "HTTP 401 Unauthorized"
        with patch(
            "familiar.core.credential_health.get_attention_needed", return_value=[mock_status]
        ):
            result = pb._get_credential_warnings()
            assert "<credential_warnings>" in result
            assert "todoist" in result
            assert "401" in result

    def test_handles_import_error(self, pb):
        with patch(
            "familiar.core.prompt_builder.PromptBuilder._get_credential_warnings",
            wraps=pb._get_credential_warnings,
        ):
            # Should not crash even if credential_health is missing
            result = pb._get_credential_warnings()
            assert isinstance(result, str)


# ════════════════════════════════════════════════════════════════
# INJECTION ORDERING
# ════════════════════════════════════════════════════════════════


class TestInjectionOrdering:
    """Context injection methods are composable and order-independent."""

    def test_chain_multiple_injections(self, pb, mock_agent):
        """Multiple inject calls chain correctly."""
        base = "base prompt"
        result = pb.inject_episodic_context(base, "hello", "user1")
        result = pb.inject_rag_context(result, "hello")
        result = pb.inject_mesh_context(result, "hello")
        result = pb.inject_planning_context(result, "hello", [])
        # Should still contain base
        assert "base prompt" in result

    def test_all_injections_noop_on_empty(self, pb, mock_agent):
        """All injections return base when there's nothing to inject."""
        mock_agent._memory_manager.get_episodic_memory.return_value = None
        mock_agent.retriever.count = 0
        mock_agent.mesh_gateway = None
        mock_agent.planner = None

        base = "minimal prompt"
        r = pb.inject_episodic_context(base, "hi", "u1")
        r = pb.inject_rag_context(r, "hi")
        r = pb.inject_mesh_context(r, "hi")
        r = pb.inject_planning_context(r, "hi", [])
        r = pb.inject_memory_tree_context(r, "u1")
        r = pb.inject_markdown_memory_context(r, "hi", "u1")
        assert r == base
