"""
Unit tests for Agent — the main coordinator class.

Tests __init__ ordering, backward-compat delegates, chat/chat_stream/chat_with_results
entry points, _setup_request pre-flight, _run_agent_loop phase boundaries,
_finalize_response post-processing, _sync_user_to_session role mapping,
AgentProxy sandboxing, and helper methods.

All 5 managers (ToolExecutor, ProviderManager, PromptBuilder, MemoryManager,
StatusReporter) are mocked — their internals have dedicated test files.
"""

import os
import time
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from familiar.core.agent import Agent, AgentProxy, _GuardrailBlock, create_agent
from familiar.core.config import load_config
from familiar.core.exceptions import BudgetExceededError
from familiar.core.guardrails import GuardrailViolation, ViolationType
from familiar.core.providers import LLMResponse, StreamEvent, StreamEventType
from familiar.core.security import (
    Capability,
    SecureSession,
    SecurityMode,
    TrustLevel,
)

# ════════════════════════════════════════════════════════════════
# FIXTURES
# ════════════════════════════════════════════════════════════════


@pytest.fixture(autouse=True)
def isolate_filesystem(tmp_path, monkeypatch):
    """Redirect all filesystem side effects to temp dirs."""
    familiar_dir = tmp_path / ".familiar"
    familiar_dir.mkdir()
    (familiar_dir / "episodic").mkdir()
    (familiar_dir / "experiments").mkdir()
    (familiar_dir / "data").mkdir()
    (familiar_dir / "sessions").mkdir()
    monkeypatch.setattr("familiar.core.security.SESSIONS_DIR", tmp_path / ".familiar" / "sessions")
    monkeypatch.setenv("HOME", str(tmp_path))
    return familiar_dir


@pytest.fixture
def mock_provider():
    """A mock LLM provider with sensible defaults."""
    p = MagicMock()
    p.name = "MockProvider"
    p.provider_key = "mock"
    p.model_name = "mock-model"
    p.supports_tools = True
    p.supports_streaming = True
    p.chat.return_value = LLMResponse(
        text="Test response",
        tool_calls=[],
        stop_reason="end_turn",
        usage={"input_tokens": 10, "output_tokens": 8},
    )
    return p


@pytest.fixture
def minimal_config():
    """Config that disables heavy subsystems for fast Agent init."""
    cfg = load_config()
    cfg.agent.memory_enabled = False
    cfg.agent.scheduler_enabled = False
    cfg.agent.planning_enabled = False
    cfg.agent.memory_extraction_enabled = False
    cfg.agent.skills_enabled = False
    cfg.agent.mesh_enabled = False
    cfg.agent.eval_capture_enabled = False
    cfg.agent.distributed_bus_enabled = False
    cfg.llm.default_provider = "ollama"
    cfg.llm.ollama_model = "mock"
    return cfg


def _agent_patches(mock_provider):
    """Context manager stack for creating an Agent with mocked subsystems."""
    return (
        patch("familiar.core.provider_manager.get_provider", return_value=mock_provider),
        patch(
            "familiar.core.agent.get_tool_registry",
            return_value=MagicMock(
                tools={"test_tool": MagicMock()},
            ),
        ),
        patch("familiar.core.agent.SkillLoader", return_value=MagicMock(skills={})),
        patch("familiar.core.agent.get_metrics_collector", return_value=MagicMock()),
        patch("familiar.core.agent.get_tracer", return_value=MagicMock(exporter=None)),
        patch("familiar.core.security.bootstrap_security"),
        patch("familiar.core.agent.is_encryption_enabled", return_value=False),
        patch.dict(os.environ, {"FAMILIAR_ENCRYPTION_KEY": ""}, clear=False),
    )


@pytest.fixture
def agent(minimal_config, mock_provider, tmp_path):
    """Create a real Agent with mocked provider and minimal config."""
    patches = _agent_patches(mock_provider)
    # Enter all patches
    [p.__enter__() if hasattr(p, "__enter__") else p.start() for p in patches]
    try:
        a = Agent(config=minimal_config)
        a.provider = mock_provider
        yield a
    finally:
        for p in reversed(patches):
            try:
                if hasattr(p, "__exit__"):
                    p.__exit__(None, None, None)
                else:
                    p.stop()
            except Exception:
                pass


# ════════════════════════════════════════════════════════════════
# __init__ ORDERING & COMPONENT WIRING
# ════════════════════════════════════════════════════════════════


class TestAgentInit:
    """Agent.__init__ creates all managers in correct order."""

    def test_creates_provider_manager(self, agent):
        assert agent._provider_manager is not None

    def test_creates_tool_executor(self, agent):
        assert agent._tool_executor is not None

    def test_creates_memory_manager(self, agent):
        assert agent._memory_manager is not None

    def test_creates_prompt_builder(self, agent):
        assert agent._prompt_builder is not None

    def test_creates_status_reporter(self, agent):
        assert agent._status_reporter is not None

    def test_creates_guardrails(self, agent):
        assert agent.guardrails is not None

    def test_creates_bus(self, agent):
        assert agent.bus is not None

    def test_creates_health_checker(self, agent):
        assert agent.health is not None

    def test_creates_backup_manager(self, agent):
        assert agent.backup is not None

    def test_creates_sagas(self, agent):
        assert agent.sagas is not None

    def test_creates_experiments(self, agent):
        assert agent.experiments is not None

    def test_creates_orchestrator(self, agent):
        assert agent.orchestrator is not None

    def test_creates_tool_router(self, agent):
        assert agent.tool_router is not None

    def test_creates_retriever(self, agent):
        assert agent.retriever is not None

    def test_provider_set(self, agent, mock_provider):
        assert agent.provider is mock_provider

    def test_config_stored(self, agent, minimal_config):
        assert agent.config is minimal_config

    def test_security_mode_default(self, agent):
        assert isinstance(agent.security_mode, SecurityMode)

    def test_sessions_manager_created(self, agent):
        assert agent.sessions is not None

    def test_memory_disabled_when_config_says_so(self, agent):
        assert agent.memory is None  # minimal_config disables memory

    def test_scheduler_disabled_when_config_says_so(self, agent):
        assert agent.scheduler is None  # minimal_config disables scheduler

    def test_planner_disabled_when_config_says_so(self, agent):
        assert agent.planner is None

    def test_memory_extractor_disabled_when_config_says_so(self, agent):
        assert agent.memory_extractor is None

    def test_mesh_gateway_none_when_disabled(self, agent):
        assert agent.mesh_gateway is None

    def test_distributed_bus_none_when_disabled(self, agent):
        assert agent.distributed_bus is None

    def test_health_startup_marked_complete(self, agent):
        """Health checker marks startup complete at end of __init__."""
        assert agent.health._startup_complete is True

    def test_custom_security_mode(self, minimal_config, mock_provider):
        """Custom security mode is applied."""
        patches = _agent_patches(mock_provider)
        [p.__enter__() if hasattr(p, "__enter__") else p.start() for p in patches]
        try:
            a = Agent(config=minimal_config, security_mode=SecurityMode.PARANOID)
            assert a.security_mode == SecurityMode.PARANOID
        finally:
            for p in reversed(patches):
                try:
                    if hasattr(p, "__exit__"):
                        p.__exit__(None, None, None)
                except Exception:
                    pass


class TestCreateAgent:
    """create_agent() convenience function."""

    def test_returns_agent(self, minimal_config, mock_provider):
        patches = _agent_patches(mock_provider)
        [p.__enter__() if hasattr(p, "__enter__") else p.start() for p in patches]
        try:
            a = create_agent(minimal_config)
            assert isinstance(a, Agent)
        finally:
            for p in reversed(patches):
                try:
                    if hasattr(p, "__exit__"):
                        p.__exit__(None, None, None)
                except Exception:
                    pass


# ════════════════════════════════════════════════════════════════
# BACKWARD-COMPAT DELEGATES
# ════════════════════════════════════════════════════════════════


class TestProviderManagerDelegates:
    """Properties and methods that delegate to ProviderManager."""

    def test_model_router_property(self, agent):
        agent._provider_manager.model_router = "test_router"
        assert agent.model_router == "test_router"

    def test_model_router_setter(self, agent):
        agent.model_router = "new_router"
        assert agent._provider_manager.model_router == "new_router"

    def test_routing_mode_property(self, agent):
        agent._provider_manager._routing_mode = "manual"
        assert agent._routing_mode == "manual"

    def test_routing_mode_setter(self, agent):
        agent._routing_mode = "auto"
        assert agent._provider_manager._routing_mode == "auto"

    def test_manual_routes_property(self, agent):
        agent._provider_manager._manual_routes = {"chat": "ollama/llama3.2"}
        assert agent._manual_routes == {"chat": "ollama/llama3.2"}

    def test_manual_routes_setter(self, agent):
        agent._manual_routes = {"chat": "gpt-4o"}
        assert agent._provider_manager._manual_routes == {"chat": "gpt-4o"}

    def test_get_routing_config(self, agent):
        agent._provider_manager.get_routing_config = MagicMock(return_value={"mode": "auto"})
        result = agent.get_routing_config()
        assert result == {"mode": "auto"}
        agent._provider_manager.get_routing_config.assert_called_once()

    def test_set_routing_config(self, agent):
        agent._provider_manager.set_routing_config = MagicMock(return_value="ok")
        result = agent.set_routing_config("manual", {"chat": "gpt-4o"})
        assert result == "ok"
        agent._provider_manager.set_routing_config.assert_called_once_with(
            "manual", {"chat": "gpt-4o"}
        )

    def test_switch_provider(self, agent):
        agent._provider_manager.switch_provider = MagicMock(return_value="switched")
        result = agent.switch_provider("openai")
        assert result == "switched"

    def test_get_presets(self, agent):
        agent._provider_manager.get_presets = MagicMock(return_value=[])
        result = agent.get_presets()
        assert result == []

    def test_save_preset(self, agent):
        agent._provider_manager.save_preset = MagicMock(return_value="saved")
        agent.save_preset(1, "My Preset")
        agent._provider_manager.save_preset.assert_called_once_with(1, "My Preset")

    def test_load_preset(self, agent):
        agent._provider_manager.load_preset = MagicMock(return_value="loaded")
        assert agent.load_preset(1) == "loaded"

    def test_set_active_preset(self, agent):
        agent._provider_manager.set_active_preset = MagicMock()
        agent.set_active_preset(2)
        agent._provider_manager.set_active_preset.assert_called_once_with(2)

    def test_rename_preset(self, agent):
        agent._provider_manager.rename_preset = MagicMock()
        agent.rename_preset(1, "New Name")
        agent._provider_manager.rename_preset.assert_called_once_with(1, "New Name")

    def test_delete_preset(self, agent):
        agent._provider_manager.delete_preset = MagicMock()
        agent.delete_preset(3)
        agent._provider_manager.delete_preset.assert_called_once_with(3)


class TestToolExecutorDelegates:
    """Methods that delegate to ToolExecutor."""

    def test_set_tool_event_callback(self, agent):
        cb = MagicMock()
        agent._tool_executor.set_tool_event_callback = MagicMock()
        agent.set_tool_event_callback("user1", cb)
        agent._tool_executor.set_tool_event_callback.assert_called_once_with("user1", cb)

    def test_clear_tool_event_callback(self, agent):
        agent._tool_executor.clear_tool_event_callback = MagicMock()
        agent.clear_tool_event_callback("user1")
        agent._tool_executor.clear_tool_event_callback.assert_called_once_with("user1")

    def test_execute_tool_secure_delegates(self, agent):
        agent._tool_executor.execute_tool_secure = MagicMock(return_value="result")
        result = agent._execute_tool_secure("tool", {})
        assert result == "result"

    def test_execute_tools_delegates(self, agent):
        agent._tool_executor.execute_tools = MagicMock(return_value=([], []))
        result = agent._execute_tools(tool_calls=[])
        assert result == ([], [])

    def test_get_allowed_tools_delegates(self, agent):
        agent._tool_executor.get_allowed_tools = MagicMock(return_value=([], set()))
        result = agent._get_allowed_tools(MagicMock())
        assert result == ([], set())

    def test_get_skill_for_tool_delegates(self, agent):
        agent._tool_executor.get_skill_for_tool = MagicMock(return_value="my_skill")
        assert agent._get_skill_for_tool("some_tool") == "my_skill"

    def test_is_tool_error_delegates(self, agent):
        agent._tool_executor._is_tool_error = MagicMock(return_value=True)
        assert agent._is_tool_error("Error: something") is True


class TestMemoryManagerDelegates:
    """Properties and methods that delegate to MemoryManager."""

    def test_episodic_enabled_property(self, agent):
        agent._memory_manager._episodic_enabled = True
        assert agent._episodic_enabled is True

    def test_episodic_enabled_setter(self, agent):
        agent._episodic_enabled = False
        assert agent._memory_manager._episodic_enabled is False

    def test_episodic_dir_property(self, agent):
        test_path = Path("/tmp/test_episodic")
        agent._memory_manager._episodic_dir = test_path
        assert agent._episodic_dir == test_path

    def test_episodic_stores_property(self, agent):
        stores = {"user1": MagicMock()}
        agent._memory_manager._episodic_stores = stores
        assert agent._episodic_stores is stores

    def test_get_episodic_memory_delegates(self, agent):
        agent._memory_manager.get_episodic_memory = MagicMock(return_value="ep_mem")
        assert agent._get_episodic_memory("user1") == "ep_mem"

    def test_save_episodic_memory_async_delegates(self, agent):
        agent._memory_manager.save_episodic_memory_async = MagicMock()
        agent._save_episodic_memory_async("user1")
        agent._memory_manager.save_episodic_memory_async.assert_called_once_with("user1")

    def test_remember_delegates(self, agent):
        agent._memory_manager.remember = MagicMock()
        agent.remember("key", "value", "fact")
        agent._memory_manager.remember.assert_called_once_with("key", "value", "fact")

    def test_recall_delegates(self, agent):
        agent._memory_manager.recall = MagicMock(return_value=["memory1"])
        assert agent.recall("query") == ["memory1"]

    def test_clear_history_delegates(self, agent):
        agent._memory_manager.clear_history = MagicMock()
        agent.clear_history("user1", "telegram")
        agent._memory_manager.clear_history.assert_called_once_with("user1", "telegram")


class TestStatusReporterDelegates:
    """Methods that delegate to StatusReporter."""

    def test_get_status(self, agent):
        agent._status_reporter.get_status = MagicMock(return_value={"name": "test"})
        assert agent.get_status() == {"name": "test"}

    def test_get_health_status(self, agent):
        agent._status_reporter.get_health_status = MagicMock(return_value={"healthy": True})
        assert agent.get_health_status() == {"healthy": True}

    def test_get_health_status_with_resources(self, agent):
        agent._status_reporter.get_health_status = MagicMock()
        agent.get_health_status(include_resources=False)
        agent._status_reporter.get_health_status.assert_called_once_with(False)

    def test_get_metrics_summary(self, agent):
        agent._status_reporter.get_metrics_summary = MagicMock(return_value={"tools": {}})
        result = agent.get_metrics_summary()
        assert result == {"tools": {}}

    def test_get_metrics_summary_params(self, agent):
        agent._status_reporter.get_metrics_summary = MagicMock()
        agent.get_metrics_summary(tool_name="search", include_llm=False)
        agent._status_reporter.get_metrics_summary.assert_called_once_with(
            "search", False, True, True
        )

    def test_get_bus_status(self, agent):
        agent._status_reporter.get_bus_status = MagicMock(return_value={})
        assert agent.get_bus_status() == {}

    def test_get_mesh_status(self, agent):
        agent._status_reporter.get_mesh_status = MagicMock(return_value={})
        assert agent.get_mesh_status() == {}

    def test_get_retriever_status(self, agent):
        agent._status_reporter.get_retriever_status = MagicMock(return_value={})
        assert agent.get_retriever_status() == {}

    def test_mark_ready(self, agent):
        agent._status_reporter.mark_ready = MagicMock()
        agent.mark_ready()
        agent._status_reporter.mark_ready.assert_called_once()

    def test_liveness(self, agent):
        agent._status_reporter.liveness = MagicMock(return_value={"status": "ok"})
        assert agent.liveness() == {"status": "ok"}

    def test_readiness(self, agent):
        agent._status_reporter.readiness = MagicMock(return_value={"ready": True})
        assert agent.readiness() == {"ready": True}

    def test_startup_probe(self, agent):
        agent._status_reporter.startup_probe = MagicMock(return_value={"started": True})
        assert agent.startup_probe() == {"started": True}


class TestPromptBuilderDelegates:
    """Methods that delegate to PromptBuilder."""

    def test_get_system_prompt(self, agent):
        agent._prompt_builder.build_system_prompt = MagicMock(return_value="prompt text")
        result = agent.get_system_prompt(
            session=MagicMock(),
            current_message="hello",
            channel="cli",
        )
        assert result == "prompt text"
        agent._prompt_builder.build_system_prompt.assert_called_once()


# ════════════════════════════════════════════════════════════════
# CHAT ENTRY POINTS
# ════════════════════════════════════════════════════════════════


class TestChat:
    """Agent.chat() synchronous entry point."""

    def test_returns_string(self, agent):
        """chat() returns a plain string response."""
        with (
            patch.object(agent, "_setup_request") as mock_setup,
            patch.object(agent, "_run_agent_loop") as mock_loop,
        ):
            session = MagicMock()
            session.session_id = "test-session"
            mock_setup.return_value = (session, "Hello", "user1", None, None)

            # _run_agent_loop is a generator; StopIteration.value is the return
            def fake_loop(**kwargs):
                return iter([])  # empty iterator; value set via StopIteration

            gen = MagicMock()
            gen.__next__ = MagicMock(side_effect=StopIteration("Test response"))
            mock_loop.return_value = gen

            result = agent.chat("Hello")
            assert result == "Test response"

    def test_guardrail_block_returns_message(self, agent):
        """_GuardrailBlock during setup returns user-facing message."""
        with patch.object(agent, "_setup_request") as mock_setup:
            mock_setup.side_effect = _GuardrailBlock("Rate limited")
            result = agent.chat("Hello")
            assert result == "Rate limited"

    def test_passes_user_id(self, agent):
        with (
            patch.object(agent, "_setup_request") as mock_setup,
            patch.object(agent, "_run_agent_loop") as mock_loop,
        ):
            session = MagicMock()
            mock_setup.return_value = (session, "Hi", "user42", None, None)
            gen = MagicMock()
            gen.__next__ = MagicMock(side_effect=StopIteration("ok"))
            mock_loop.return_value = gen

            agent.chat("Hi", user_id="user42")
            mock_setup.assert_called_once()
            args = mock_setup.call_args
            assert args[0][1] == "user42"  # user_id arg

    def test_passes_channel(self, agent):
        with (
            patch.object(agent, "_setup_request") as mock_setup,
            patch.object(agent, "_run_agent_loop") as mock_loop,
        ):
            session = MagicMock()
            mock_setup.return_value = (session, "Hi", "default", None, None)
            gen = MagicMock()
            gen.__next__ = MagicMock(side_effect=StopIteration("ok"))
            mock_loop.return_value = gen

            agent.chat("Hi", channel="telegram")
            args = mock_setup.call_args
            assert args[0][2] == "telegram"  # channel arg

    def test_loop_called_with_stream_false(self, agent):
        with (
            patch.object(agent, "_setup_request") as mock_setup,
            patch.object(agent, "_run_agent_loop") as mock_loop,
        ):
            session = MagicMock()
            mock_setup.return_value = (session, "Hi", "default", None, None)
            gen = MagicMock()
            gen.__next__ = MagicMock(side_effect=StopIteration("ok"))
            mock_loop.return_value = gen

            agent.chat("Hi")
            _, kwargs = mock_loop.call_args
            assert kwargs["stream"] is False
            assert kwargs["emit_tool_events"] is False


class TestChatWithResults:
    """Agent.chat_with_results() returns structured AgentResponse."""

    def test_returns_agent_response(self, agent):
        from familiar.core.agent_response import AgentResponse

        with (
            patch.object(agent, "_setup_request") as mock_setup,
            patch.object(agent, "_run_agent_loop") as mock_loop,
        ):
            session = MagicMock()
            mock_setup.return_value = (session, "Hi", "default", None, None)
            gen = MagicMock()
            gen.__next__ = MagicMock(side_effect=StopIteration("response text"))
            mock_loop.return_value = gen

            result = agent.chat_with_results("Hi")
            assert isinstance(result, AgentResponse)
            assert result.text == "response text"

    def test_guardrail_block_returns_agent_response(self, agent):
        from familiar.core.agent_response import AgentResponse

        with patch.object(agent, "_setup_request") as mock_setup:
            mock_setup.side_effect = _GuardrailBlock("Blocked")
            result = agent.chat_with_results("Hi")
            assert isinstance(result, AgentResponse)
            assert result.text == "Blocked"

    def test_exception_returns_error_response(self, agent):
        from familiar.core.agent_response import AgentResponse

        with (
            patch.object(agent, "_setup_request") as mock_setup,
            patch.object(agent, "_run_agent_loop") as mock_loop,
        ):
            session = MagicMock()
            mock_setup.return_value = (session, "Hi", "default", None, None)
            gen = MagicMock()
            gen.__next__ = MagicMock(side_effect=RuntimeError("boom"))
            mock_loop.return_value = gen

            result = agent.chat_with_results("Hi")
            assert isinstance(result, AgentResponse)
            assert "error" in result.text.lower()

    def test_passes_job_class_key(self, agent):
        with (
            patch.object(agent, "_setup_request") as mock_setup,
            patch.object(agent, "_run_agent_loop") as mock_loop,
        ):
            session = MagicMock()
            mock_setup.return_value = (session, "Hi", "default", None, None)
            gen = MagicMock()
            gen.__next__ = MagicMock(side_effect=StopIteration("ok"))
            mock_loop.return_value = gen

            agent.chat_with_results("Hi", job_class_key="nonprofit_director")
            _, kwargs = mock_loop.call_args
            assert kwargs["job_class_key"] == "nonprofit_director"


class TestChatStream:
    """Agent.chat_stream() streaming entry point."""

    def test_fallback_when_no_streaming(self, agent, mock_provider):
        """Falls back to chat() when provider doesn't support streaming."""
        mock_provider.supports_streaming = False
        with (
            patch.object(agent, "chat", return_value="non-stream response"),
        ):
            events = list(agent.chat_stream("Hello"))
            assert any(e.type == StreamEventType.TEXT for e in events)
            assert any(e.type == StreamEventType.DONE for e in events)

    def test_guardrail_block_yields_text_and_done(self, agent):
        with patch.object(agent, "_setup_request") as mock_setup:
            mock_setup.side_effect = _GuardrailBlock("Rate limited")
            events = list(agent.chat_stream("Hello"))
            texts = [e for e in events if e.type == StreamEventType.TEXT]
            dones = [e for e in events if e.type == StreamEventType.DONE]
            assert len(texts) == 1
            assert texts[0].content == "Rate limited"
            assert len(dones) == 1

    def test_delegates_to_run_agent_loop_with_stream_true(self, agent):
        with (
            patch.object(agent, "_setup_request") as mock_setup,
            patch.object(agent, "_run_agent_loop") as mock_loop,
        ):
            session = MagicMock()
            mock_setup.return_value = (session, "Hi", "default", None, None)
            mock_loop.return_value = iter(
                [
                    StreamEvent(type=StreamEventType.TEXT, content="hello"),
                    StreamEvent(type=StreamEventType.DONE),
                ]
            )

            events = list(agent.chat_stream("Hi"))
            _, kwargs = mock_loop.call_args
            assert kwargs["stream"] is True
            assert kwargs["emit_tool_events"] is True
            assert len(events) == 2


# ════════════════════════════════════════════════════════════════
# _setup_request PRE-FLIGHT
# ════════════════════════════════════════════════════════════════


class TestSetupRequest:
    """_setup_request handles sanitization, sessions, guardrails."""

    def test_returns_five_tuple(self, agent):
        agent.guardrails.start_request = MagicMock(return_value=MagicMock())
        agent.guardrails.process_with_pii = MagicMock(return_value=("Hello", [], False))
        result = agent._setup_request("Hello", "user1", "cli", None)
        assert len(result) == 5
        session, msg, uid, trace, tracker = result
        assert isinstance(session, SecureSession)

    def test_sanitizes_input(self, agent):
        agent.guardrails.start_request = MagicMock(return_value=MagicMock())
        agent.guardrails.process_with_pii = MagicMock(return_value=("cleaned", [], False))
        _, msg, _, _, _ = agent._setup_request("Hello\x00", "user1", "cli", None)
        # Sanitization strips null bytes
        assert "\x00" not in msg

    def test_skips_sanitization_when_flagged(self, agent):
        """already_sanitized=True skips the sanitize_user_input call."""
        agent.guardrails.start_request = MagicMock(return_value=MagicMock())
        agent.guardrails.process_with_pii = MagicMock(return_value=("Hello", [], False))
        with patch("familiar.core.agent.sanitize_user_input") as mock_san:
            agent._setup_request("Hello", "user1", "cli", None, already_sanitized=True)
            mock_san.assert_not_called()

    def test_rate_limit_raises_guardrail_block(self, agent):
        agent.guardrails.start_request = MagicMock(
            side_effect=GuardrailViolation(ViolationType.RATE_LIMIT, "rate limit")
        )
        with pytest.raises(_GuardrailBlock):
            agent._setup_request("Hello", "user1", "cli", None)

    def test_pii_block_raises_guardrail_block(self, agent):
        agent.guardrails.start_request = MagicMock(return_value=MagicMock())
        agent.guardrails.process_with_pii = MagicMock(
            side_effect=GuardrailViolation(ViolationType.CONTENT_SAFETY, "PII detected")
        )
        with pytest.raises(_GuardrailBlock):
            agent._setup_request("my SSN is 123-45-6789", "user1", "cli", None)

    def test_budget_exceeded_raises(self, agent):
        agent.guardrails.start_request = MagicMock(return_value=MagicMock())
        agent.guardrails.process_with_pii = MagicMock(return_value=("Hello", [], False))
        # Make session budget exhausted
        session = agent.sessions.get_or_create_session("user1", "cli")
        session.spent_today = session.daily_budget + 1.0
        with patch.object(agent.sessions, "get_or_create_session", return_value=session):
            with pytest.raises(BudgetExceededError):
                agent._setup_request("Hello", "user1", "cli", None)

    def test_creates_trace_when_tracer_exists(self, agent):
        agent.guardrails.start_request = MagicMock(return_value=MagicMock())
        agent.guardrails.process_with_pii = MagicMock(return_value=("Hello", [], False))
        mock_span = MagicMock()
        agent.tracer = MagicMock()
        agent.tracer.create_span = MagicMock(return_value=mock_span)
        _, _, _, trace, _ = agent._setup_request("Hello", "user1", "cli", None)
        assert trace is mock_span


# ════════════════════════════════════════════════════════════════
# _finalize_response POST-PROCESSING
# ════════════════════════════════════════════════════════════════


class TestFinalizeResponse:
    """_finalize_response handles history, analytics, memory, guardrails."""

    @pytest.fixture(autouse=True)
    def _mock_finalize_deps(self, agent):
        """Mock real objects that _finalize_response interacts with."""
        agent.history = MagicMock()
        agent.sessions = MagicMock()

    def _make_response(self):
        return LLMResponse(
            text="Response",
            tool_calls=[],
            stop_reason="end_turn",
            usage={"input_tokens": 10, "output_tokens": 8},
        )

    def test_saves_history(self, agent):
        response = self._make_response()
        agent.guardrails.end_request = MagicMock()
        agent._get_episodic_memory = MagicMock(return_value=None)
        result = agent._finalize_response(
            message="Hi",
            final_text="Hello!",
            user_id="user1",
            channel="cli",
            user_context=None,
            session=MagicMock(session_id="s1", audit_log=[]),
            trace=None,
            response=response,
            start_time=time.time(),
        )
        assert isinstance(result, str)
        # History should have 2 new entries (user + assistant)
        assert agent.history.add_message.call_count == 2

    def test_records_analytics(self, agent):
        response = self._make_response()
        agent.guardrails.end_request = MagicMock()
        agent._get_episodic_memory = MagicMock(return_value=None)
        with patch.object(agent, "_record_analytics") as mock_analytics:
            agent._finalize_response(
                message="Hi",
                final_text="Hello!",
                user_id="user1",
                channel="cli",
                user_context=None,
                session=MagicMock(session_id="s1", audit_log=[]),
                trace=None,
                response=response,
                start_time=time.time(),
            )
            mock_analytics.assert_called_once()

    def test_ends_trace(self, agent):
        response = self._make_response()
        agent.guardrails.end_request = MagicMock()
        agent._get_episodic_memory = MagicMock(return_value=None)
        mock_trace = MagicMock()
        agent._finalize_response(
            message="Hi",
            final_text="Hello!",
            user_id="user1",
            channel="cli",
            user_context=None,
            session=MagicMock(session_id="s1", audit_log=[]),
            trace=mock_trace,
            response=response,
            start_time=time.time(),
        )
        mock_trace.end.assert_called_once()

    def test_saves_session(self, agent):
        response = self._make_response()
        agent.guardrails.end_request = MagicMock()
        agent._get_episodic_memory = MagicMock(return_value=None)
        mock_session = MagicMock(session_id="s1", audit_log=[])
        agent._finalize_response(
            message="Hi",
            final_text="Hello!",
            user_id="user1",
            channel="cli",
            user_context=None,
            session=mock_session,
            trace=None,
            response=response,
            start_time=time.time(),
        )
        agent.sessions.save_session.assert_called_once_with(mock_session)

    def test_triggers_memory_extraction(self, agent):
        response = self._make_response()
        agent.guardrails.end_request = MagicMock()
        agent._get_episodic_memory = MagicMock(return_value=None)
        agent.memory_extractor = MagicMock()
        agent._finalize_response(
            message="Hi",
            final_text="Hello!",
            user_id="user1",
            channel="cli",
            user_context=None,
            session=MagicMock(session_id="s1", audit_log=[]),
            trace=None,
            response=response,
            start_time=time.time(),
        )
        agent.memory_extractor.extract_async.assert_called_once_with("Hi", "Hello!", "cli")

    def test_ends_guardrail_tracking(self, agent):
        response = self._make_response()
        agent.guardrails.end_request = MagicMock()
        agent._get_episodic_memory = MagicMock(return_value=None)
        tracker = MagicMock()
        agent._finalize_response(
            message="Hi",
            final_text="Hello!",
            user_id="user1",
            channel="cli",
            user_context=None,
            session=MagicMock(session_id="s1", audit_log=[]),
            trace=None,
            response=response,
            start_time=time.time(),
            guardrail_tracker=tracker,
        )
        agent.guardrails.end_request.assert_called_once_with("user1", tracker)

    def test_stores_episodic_memory(self, agent):
        response = self._make_response()
        agent.guardrails.end_request = MagicMock()
        mock_episodic = MagicMock()
        agent._get_episodic_memory = MagicMock(return_value=mock_episodic)
        agent._save_episodic_memory_async = MagicMock()
        agent._finalize_response(
            message="Hi",
            final_text="Hello!",
            user_id="user1",
            channel="cli",
            user_context=None,
            session=MagicMock(session_id="s1", audit_log=[]),
            trace=None,
            response=response,
            start_time=time.time(),
        )
        mock_episodic.store_conversation.assert_called_once()
        agent._save_episodic_memory_async.assert_called_once_with("user1")


# ════════════════════════════════════════════════════════════════
# _run_agent_loop PHASE BOUNDARIES
# ════════════════════════════════════════════════════════════════


class TestRunAgentLoop:
    """_run_agent_loop orchestrates history, context, provider, tools."""

    def _make_session(self):
        s = MagicMock()
        s.session_id = "test-session"
        s.remaining_budget = 100.0
        s.charge.return_value = True
        s.audit_log = []
        return s

    def test_no_tool_calls_returns_text(self, agent, mock_provider):
        """When LLM returns text with no tool calls, loop returns it."""
        session = self._make_session()
        agent._tool_executor.get_allowed_tools = MagicMock(return_value=([], set()))
        agent._prompt_builder.build_system_prompt_parts = MagicMock(
            return_value=("static", "dynamic")
        )
        agent._prompt_builder.inject_memory_tree_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_episodic_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_rag_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_markdown_memory_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_mesh_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_planning_context = MagicMock(return_value="dynamic")
        agent._provider_manager.select_provider = MagicMock(
            return_value=(mock_provider, "mock-model")
        )
        agent.capacity_tracker.is_rate_limited = MagicMock(return_value=False)
        agent.guardrails.check_iteration = MagicMock()
        agent.guardrails.track_llm_usage = MagicMock()
        agent.guardrails.end_request = MagicMock()
        agent._get_episodic_memory = MagicMock(return_value=None)

        gen = agent._run_agent_loop(
            message="Hello",
            user_id="user1",
            channel="cli",
            include_history=False,
            user_context=None,
            session=session,
            trace=None,
            stream=False,
            emit_tool_events=False,
        )
        try:
            while True:
                next(gen)
        except StopIteration as done:
            assert "Test response" in done.value

    def test_tool_calls_loop_continues(self, agent, mock_provider):
        """When LLM returns tool calls, loop executes them and continues."""
        session = self._make_session()
        agent._tool_executor.get_allowed_tools = MagicMock(return_value=([], set()))
        agent._prompt_builder.build_system_prompt_parts = MagicMock(
            return_value=("static", "dynamic")
        )
        agent._prompt_builder.inject_memory_tree_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_episodic_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_rag_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_markdown_memory_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_mesh_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_planning_context = MagicMock(return_value="dynamic")
        agent._provider_manager.select_provider = MagicMock(
            return_value=(mock_provider, "mock-model")
        )
        agent.capacity_tracker.is_rate_limited = MagicMock(return_value=False)
        agent.guardrails.check_iteration = MagicMock()
        agent.guardrails.track_llm_usage = MagicMock()
        agent.guardrails.end_request = MagicMock()
        agent._get_episodic_memory = MagicMock(return_value=None)

        # First call returns tool call, second returns text
        tool_call_response = LLMResponse(
            text=None,
            tool_calls=[{"id": "tc1", "name": "search", "input": {"query": "test"}}],
            stop_reason="tool_use",
            usage={"input_tokens": 10, "output_tokens": 5},
        )
        text_response = LLMResponse(
            text="Final answer",
            tool_calls=[],
            stop_reason="end_turn",
            usage={"input_tokens": 15, "output_tokens": 10},
        )
        mock_provider.chat.side_effect = [tool_call_response, text_response]

        agent._tool_executor.execute_tools = MagicMock(
            return_value=(
                [{"tool_use_id": "tc1", "tool_name": "search", "content": "result"}],
                [],
            )
        )

        gen = agent._run_agent_loop(
            message="Search for X",
            user_id="user1",
            channel="cli",
            include_history=False,
            user_context=None,
            session=session,
            trace=None,
            stream=False,
            emit_tool_events=False,
        )
        try:
            while True:
                next(gen)
        except StopIteration as done:
            assert "Final answer" in done.value
        assert agent._tool_executor.execute_tools.call_count == 1

    def test_max_iterations_returns_timeout(self, agent, mock_provider):
        """Hitting max iterations returns timeout message."""
        session = self._make_session()
        agent._tool_executor.get_allowed_tools = MagicMock(return_value=([], set()))
        agent._prompt_builder.build_system_prompt_parts = MagicMock(
            return_value=("static", "dynamic")
        )
        agent._prompt_builder.inject_memory_tree_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_episodic_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_rag_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_markdown_memory_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_mesh_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_planning_context = MagicMock(return_value="dynamic")
        agent._provider_manager.select_provider = MagicMock(
            return_value=(mock_provider, "mock-model")
        )
        agent.capacity_tracker.is_rate_limited = MagicMock(return_value=False)
        agent.guardrails.check_iteration = MagicMock()
        agent.guardrails.track_llm_usage = MagicMock()
        agent.guardrails.end_request = MagicMock()

        # Always return tool calls to force iteration
        mock_provider.chat.return_value = LLMResponse(
            text=None,
            tool_calls=[{"id": "tc1", "name": "search", "input": {}}],
            stop_reason="tool_use",
            usage={"input_tokens": 10, "output_tokens": 5},
        )
        agent._tool_executor.execute_tools = MagicMock(
            return_value=(
                [{"tool_use_id": "tc1", "tool_name": "search", "content": "result"}],
                [],
            )
        )

        gen = agent._run_agent_loop(
            message="Loop forever",
            user_id="user1",
            channel="cli",
            include_history=False,
            user_context=None,
            session=session,
            trace=None,
            stream=False,
            emit_tool_events=False,
        )
        try:
            while True:
                next(gen)
        except StopIteration as done:
            assert "max iterations" in done.value.lower()

    def test_provider_error_with_fallback(self, agent, mock_provider):
        """LLM error triggers fallback provider."""
        session = self._make_session()
        agent._tool_executor.get_allowed_tools = MagicMock(return_value=([], set()))
        agent._prompt_builder.build_system_prompt_parts = MagicMock(
            return_value=("static", "dynamic")
        )
        agent._prompt_builder.inject_memory_tree_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_episodic_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_rag_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_markdown_memory_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_mesh_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_planning_context = MagicMock(return_value="dynamic")
        agent._provider_manager.select_provider = MagicMock(
            return_value=(mock_provider, "mock-model")
        )
        agent.capacity_tracker.is_rate_limited = MagicMock(return_value=False)
        agent.guardrails.check_iteration = MagicMock()
        agent.guardrails.track_llm_usage = MagicMock()
        agent.guardrails.end_request = MagicMock()
        agent._get_episodic_memory = MagicMock(return_value=None)

        # First provider fails, fallback succeeds
        mock_provider.chat.side_effect = RuntimeError("API down")
        fallback = MagicMock()
        fallback.name = "FallbackProvider"
        fallback.chat.return_value = LLMResponse(
            text="Fallback response",
            tool_calls=[],
            stop_reason="end_turn",
            usage={"input_tokens": 10, "output_tokens": 8},
        )
        agent._provider_manager.provider_fallback = MagicMock(return_value=fallback)

        gen = agent._run_agent_loop(
            message="Hello",
            user_id="user1",
            channel="cli",
            include_history=False,
            user_context=None,
            session=session,
            trace=None,
            stream=False,
            emit_tool_events=False,
        )
        try:
            while True:
                next(gen)
        except StopIteration as done:
            assert "Fallback response" in done.value
            assert "unavailable" in done.value.lower() or "using" in done.value.lower()

    def test_provider_error_no_fallback(self, agent, mock_provider):
        """LLM error with no fallback returns error message."""
        session = self._make_session()
        agent._tool_executor.get_allowed_tools = MagicMock(return_value=([], set()))
        agent._prompt_builder.build_system_prompt_parts = MagicMock(
            return_value=("static", "dynamic")
        )
        agent._prompt_builder.inject_memory_tree_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_episodic_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_rag_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_markdown_memory_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_mesh_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_planning_context = MagicMock(return_value="dynamic")
        agent._provider_manager.select_provider = MagicMock(
            return_value=(mock_provider, "mock-model")
        )
        agent.capacity_tracker.is_rate_limited = MagicMock(return_value=False)
        agent.guardrails.check_iteration = MagicMock()
        agent.guardrails.track_llm_usage = MagicMock()

        mock_provider.chat.side_effect = RuntimeError("API down")
        agent._provider_manager.provider_fallback = MagicMock(return_value=None)

        gen = agent._run_agent_loop(
            message="Hello",
            user_id="user1",
            channel="cli",
            include_history=False,
            user_context=None,
            session=session,
            trace=None,
            stream=False,
            emit_tool_events=False,
        )
        try:
            while True:
                next(gen)
        except StopIteration as done:
            assert "unavailable" in done.value.lower()

    def test_phi_guard_blocks_cloud(self, agent, mock_provider):
        """PHI detected blocks cloud provider with appropriate message."""
        session = self._make_session()
        agent._tool_executor.get_allowed_tools = MagicMock(return_value=([], set()))
        agent._prompt_builder.build_system_prompt_parts = MagicMock(
            return_value=("static", "dynamic")
        )
        agent._prompt_builder.inject_memory_tree_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_episodic_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_rag_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_markdown_memory_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_mesh_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_planning_context = MagicMock(return_value="dynamic")
        agent._provider_manager.select_provider = MagicMock(
            side_effect=RuntimeError("PHI_NO_LOCAL_FALLBACK")
        )
        agent.guardrails.check_iteration = MagicMock()

        gen = agent._run_agent_loop(
            message="Patient SSN is 123-45-6789",
            user_id="user1",
            channel="cli",
            include_history=False,
            user_context=None,
            session=session,
            trace=None,
            stream=False,
            emit_tool_events=False,
        )
        try:
            while True:
                next(gen)
        except StopIteration as done:
            assert "health information" in done.value.lower()

    def test_guardrail_iteration_violation(self, agent, mock_provider):
        """Guardrail iteration limit returns clean message."""
        session = self._make_session()
        agent._tool_executor.get_allowed_tools = MagicMock(return_value=([], set()))
        agent._prompt_builder.build_system_prompt_parts = MagicMock(
            return_value=("static", "dynamic")
        )
        agent._prompt_builder.inject_memory_tree_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_episodic_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_rag_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_markdown_memory_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_mesh_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_planning_context = MagicMock(return_value="dynamic")
        agent.guardrails.check_iteration = MagicMock(
            side_effect=GuardrailViolation(ViolationType.ITERATION_LIMIT, "too many")
        )

        gen = agent._run_agent_loop(
            message="Hello",
            user_id="user1",
            channel="cli",
            include_history=False,
            user_context=None,
            session=session,
            trace=None,
            stream=False,
            emit_tool_events=False,
        )
        try:
            while True:
                next(gen)
        except StopIteration as done:
            assert "loop" in done.value.lower() or "stuck" in done.value.lower()

    def test_budget_exhausted_mid_loop(self, agent, mock_provider):
        """Budget running out mid-loop returns budget message."""
        session = self._make_session()
        session.charge.return_value = False  # Budget exhausted
        agent._tool_executor.get_allowed_tools = MagicMock(return_value=([], set()))
        agent._prompt_builder.build_system_prompt_parts = MagicMock(
            return_value=("static", "dynamic")
        )
        agent._prompt_builder.inject_memory_tree_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_episodic_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_rag_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_markdown_memory_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_mesh_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_planning_context = MagicMock(return_value="dynamic")
        agent._provider_manager.select_provider = MagicMock(
            return_value=(mock_provider, "mock-model")
        )
        agent.capacity_tracker.is_rate_limited = MagicMock(return_value=False)
        agent.guardrails.check_iteration = MagicMock()
        agent.guardrails.track_llm_usage = MagicMock()

        gen = agent._run_agent_loop(
            message="Hello",
            user_id="user1",
            channel="cli",
            include_history=False,
            user_context=None,
            session=session,
            trace=None,
            stream=False,
            emit_tool_events=False,
        )
        try:
            while True:
                next(gen)
        except StopIteration as done:
            assert "budget" in done.value.lower()

    def test_stream_mode_yields_events(self, agent, mock_provider):
        """Stream mode yields StreamEvents."""
        session = self._make_session()
        agent._tool_executor.get_allowed_tools = MagicMock(return_value=([], set()))
        agent._prompt_builder.build_system_prompt_parts = MagicMock(
            return_value=("static", "dynamic")
        )
        agent._prompt_builder.inject_memory_tree_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_episodic_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_rag_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_markdown_memory_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_mesh_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_planning_context = MagicMock(return_value="dynamic")
        agent._provider_manager.select_provider = MagicMock(
            return_value=(mock_provider, "mock-model")
        )
        agent.capacity_tracker.is_rate_limited = MagicMock(return_value=False)
        agent.guardrails.check_iteration = MagicMock()
        agent.guardrails.track_llm_usage = MagicMock()
        agent.guardrails.end_request = MagicMock()
        agent._get_episodic_memory = MagicMock(return_value=None)

        # Setup streaming — provider returns events via generator
        def fake_stream(**kwargs):
            yield StreamEvent(type=StreamEventType.TEXT, content="Hello ")
            yield StreamEvent(type=StreamEventType.TEXT, content="world")
            return LLMResponse(
                text="Hello world",
                tool_calls=[],
                stop_reason="end_turn",
                usage={"input_tokens": 10, "output_tokens": 5},
            )

        mock_provider.chat_stream = MagicMock(side_effect=fake_stream)

        events = list(
            agent._run_agent_loop(
                message="Hello",
                user_id="user1",
                channel="cli",
                include_history=False,
                user_context=None,
                session=session,
                trace=None,
                stream=True,
                emit_tool_events=True,
            )
        )
        text_events = [e for e in events if e.type == StreamEventType.TEXT]
        done_events = [e for e in events if e.type == StreamEventType.DONE]
        assert len(text_events) >= 1
        assert len(done_events) == 1

    def test_history_included_when_flag_true(self, agent, mock_provider):
        """include_history=True adds conversation history to messages."""
        session = self._make_session()
        agent._tool_executor.get_allowed_tools = MagicMock(return_value=([], set()))
        agent._prompt_builder.build_system_prompt_parts = MagicMock(
            return_value=("static", "dynamic")
        )
        agent._prompt_builder.inject_memory_tree_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_episodic_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_rag_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_markdown_memory_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_mesh_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_planning_context = MagicMock(return_value="dynamic")
        agent._provider_manager.select_provider = MagicMock(
            return_value=(mock_provider, "mock-model")
        )
        agent.capacity_tracker.is_rate_limited = MagicMock(return_value=False)
        agent.guardrails.check_iteration = MagicMock()
        agent.guardrails.track_llm_usage = MagicMock()
        agent.guardrails.end_request = MagicMock()
        agent._get_episodic_memory = MagicMock(return_value=None)

        # Pre-populate history
        agent.history.get_history = MagicMock(
            return_value=[
                {"role": "user", "content": "Previous message"},
                {"role": "assistant", "content": "Previous response"},
            ]
        )

        gen = agent._run_agent_loop(
            message="New message",
            user_id="user1",
            channel="cli",
            include_history=True,
            user_context=None,
            session=session,
            trace=None,
            stream=False,
            emit_tool_events=False,
        )
        try:
            while True:
                next(gen)
        except StopIteration:
            pass

        # Check that history was passed to provider
        call_kwargs = mock_provider.chat.call_args[1]
        messages = call_kwargs["messages"]
        assert len(messages) == 3  # 2 history + 1 new

    def test_collected_tool_results(self, agent, mock_provider):
        """collected_tool_results accumulates ToolResult objects."""
        session = self._make_session()
        agent._tool_executor.get_allowed_tools = MagicMock(return_value=([], set()))
        agent._prompt_builder.build_system_prompt_parts = MagicMock(
            return_value=("static", "dynamic")
        )
        agent._prompt_builder.inject_memory_tree_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_episodic_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_rag_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_markdown_memory_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_mesh_context = MagicMock(return_value="dynamic")
        agent._prompt_builder.inject_planning_context = MagicMock(return_value="dynamic")
        agent._provider_manager.select_provider = MagicMock(
            return_value=(mock_provider, "mock-model")
        )
        agent.capacity_tracker.is_rate_limited = MagicMock(return_value=False)
        agent.guardrails.check_iteration = MagicMock()
        agent.guardrails.track_llm_usage = MagicMock()
        agent.guardrails.end_request = MagicMock()
        agent._get_episodic_memory = MagicMock(return_value=None)

        tool_call_response = LLMResponse(
            text=None,
            tool_calls=[{"id": "tc1", "name": "search", "input": {"q": "test"}}],
            stop_reason="tool_use",
            usage={"input_tokens": 10, "output_tokens": 5},
        )
        text_response = LLMResponse(
            text="Done",
            tool_calls=[],
            stop_reason="end_turn",
            usage={"input_tokens": 15, "output_tokens": 10},
        )
        mock_provider.chat.side_effect = [tool_call_response, text_response]
        agent._tool_executor.execute_tools = MagicMock(
            return_value=(
                [
                    {
                        "tool_use_id": "tc1",
                        "tool_name": "search",
                        "content": "found it",
                        "tool_input": {"q": "test"},
                    }
                ],
                [],
            )
        )

        collected = []
        gen = agent._run_agent_loop(
            message="Search",
            user_id="user1",
            channel="cli",
            include_history=False,
            user_context=None,
            session=session,
            trace=None,
            stream=False,
            emit_tool_events=False,
            collected_tool_results=collected,
        )
        try:
            while True:
                next(gen)
        except StopIteration:
            pass
        assert len(collected) == 1
        assert collected[0].tool_name == "search"


# ════════════════════════════════════════════════════════════════
# _sync_user_to_session ROLE MAPPING
# ════════════════════════════════════════════════════════════════


class TestSyncUserToSession:
    """_sync_user_to_session maps roles to trust levels."""

    def test_no_user_context_no_op(self, agent):
        session = SecureSession("user1", "cli")
        original_trust = session.trust_level
        agent._sync_user_to_session(None, session)
        assert session.trust_level == original_trust

    def test_owner_telegram_id_promotes(self, agent):
        agent.config.channels = MagicMock()
        agent.config.channels.owner_telegram_id = "12345"
        session = SecureSession("12345", "telegram")
        agent._sync_user_to_session(None, session)
        assert session.trust_level == TrustLevel.OWNER

    def test_persisted_role_restores_trust(self, agent):
        session = SecureSession("user1", "cli")
        session.user_role = "staff"
        session.trust_level = TrustLevel.STRANGER
        agent._sync_user_to_session(None, session)
        assert session.trust_level == TrustLevel.TRUSTED

    def test_admin_role_maps_to_owner(self, agent):
        try:
            from familiar.core.users import UserRole
        except ImportError:
            pytest.skip("users module not available")
        user = MagicMock()
        user.id = "admin1"
        user.email = "admin@test.com"
        user.role = UserRole.ADMIN
        ctx = MagicMock()
        ctx.user = user
        session = SecureSession("admin1", "cli")
        with patch("familiar.core.agent.HAS_USER_MANAGEMENT", True):
            agent._sync_user_to_session(ctx, session)
        assert session.trust_level == TrustLevel.OWNER

    def test_staff_role_maps_to_trusted(self, agent):
        try:
            from familiar.core.users import UserRole
        except ImportError:
            pytest.skip("users module not available")
        user = MagicMock()
        user.id = "staff1"
        user.email = "staff@test.com"
        user.role = UserRole.STAFF
        ctx = MagicMock()
        ctx.user = user
        session = SecureSession("staff1", "cli")
        with patch("familiar.core.agent.HAS_USER_MANAGEMENT", True):
            agent._sync_user_to_session(ctx, session)
        assert session.trust_level == TrustLevel.TRUSTED

    def test_readonly_role_maps_to_known(self, agent):
        try:
            from familiar.core.users import UserRole
        except ImportError:
            pytest.skip("users module not available")
        user = MagicMock()
        user.id = "ro1"
        user.email = "ro@test.com"
        user.role = UserRole.READONLY
        ctx = MagicMock()
        ctx.user = user
        session = SecureSession("ro1", "cli")
        with patch("familiar.core.agent.HAS_USER_MANAGEMENT", True):
            agent._sync_user_to_session(ctx, session)
        assert session.trust_level == TrustLevel.KNOWN


# ════════════════════════════════════════════════════════════════
# GUARDRAIL HANDLING
# ════════════════════════════════════════════════════════════════


class TestGuardrailViolationHandling:
    """_handle_guardrail_violation returns clean user messages."""

    def test_rate_limit_message(self, agent):
        gv = GuardrailViolation(ViolationType.RATE_LIMIT, "too fast")
        msg = agent._handle_guardrail_violation(gv, "user1")
        assert "too quickly" in msg.lower()

    def test_cost_limit_message(self, agent):
        gv = GuardrailViolation(ViolationType.COST_LIMIT, "over budget")
        msg = agent._handle_guardrail_violation(gv, "user1")
        assert "usage limit" in msg.lower()

    def test_token_limit_message(self, agent):
        gv = GuardrailViolation(ViolationType.TOKEN_LIMIT, "too many tokens")
        msg = agent._handle_guardrail_violation(gv, "user1")
        assert "long" in msg.lower()

    def test_iteration_limit_message(self, agent):
        gv = GuardrailViolation(ViolationType.ITERATION_LIMIT, "too many loops")
        msg = agent._handle_guardrail_violation(gv, "user1")
        assert "loop" in msg.lower() or "stuck" in msg.lower()

    def test_timeout_message(self, agent):
        gv = GuardrailViolation(ViolationType.TIMEOUT, "timed out")
        msg = agent._handle_guardrail_violation(gv, "user1")
        assert "long" in msg.lower()

    def test_content_safety_message(self, agent):
        gv = GuardrailViolation(ViolationType.CONTENT_SAFETY, "unsafe")
        msg = agent._handle_guardrail_violation(gv, "user1")
        assert "safety" in msg.lower()

    def test_tool_blocked_message(self, agent):
        gv = GuardrailViolation(ViolationType.TOOL_BLOCKED, "blocked")
        msg = agent._handle_guardrail_violation(gv, "user1")
        assert "admin" in msg.lower()


# ════════════════════════════════════════════════════════════════
# AGENT PROXY
# ════════════════════════════════════════════════════════════════


class TestAgentProxy:
    """AgentProxy sandboxes tool handler access to the agent."""

    def test_exposes_sessions(self, agent):
        proxy = AgentProxy(agent)
        assert proxy.sessions is agent.sessions

    def test_exposes_memory(self, agent):
        proxy = AgentProxy(agent)
        assert proxy.memory is agent.memory

    def test_exposes_tools(self, agent):
        proxy = AgentProxy(agent)
        assert proxy.tools is agent.tools

    def test_exposes_metrics(self, agent):
        proxy = AgentProxy(agent)
        assert proxy.metrics is agent.metrics

    def test_exposes_skills(self, agent):
        proxy = AgentProxy(agent)
        assert proxy.skills is agent.skills

    def test_exposes_guardrails(self, agent):
        proxy = AgentProxy(agent)
        assert proxy.guardrails is agent.guardrails

    def test_exposes_config(self, agent):
        proxy = AgentProxy(agent)
        assert proxy.config is agent.config

    def test_exposes_provider(self, agent):
        proxy = AgentProxy(agent)
        assert proxy.provider is agent.provider

    def test_exposes_name(self, agent):
        proxy = AgentProxy(agent)
        name = proxy.name
        # Should not raise; returns agent config name
        assert name is not None or name is None  # may be mock

    def test_blocks_set_user_trust(self, agent):
        proxy = AgentProxy(agent)
        with pytest.raises(AttributeError):
            proxy.set_user_trust("user1", "cli", TrustLevel.OWNER)

    def test_blocks_grant_capability(self, agent):
        proxy = AgentProxy(agent)
        with pytest.raises(AttributeError):
            proxy.grant_capability("user1", "cli", Capability.HTTP_REQUESTS)

    def test_blocks_deny_capability(self, agent):
        proxy = AgentProxy(agent)
        with pytest.raises(AttributeError):
            proxy.deny_capability("user1", "cli", Capability.HTTP_REQUESTS)

    def test_blocks_internal_managers(self, agent):
        proxy = AgentProxy(agent)
        with pytest.raises(AttributeError):
            _ = proxy._provider_manager
        with pytest.raises(AttributeError):
            _ = proxy._tool_executor
        with pytest.raises(AttributeError):
            _ = proxy._memory_manager

    def test_repr(self, agent):
        proxy = AgentProxy(agent)
        r = repr(proxy)
        assert "AgentProxy" in r


# ════════════════════════════════════════════════════════════════
# BACKUP & HEALTH
# ════════════════════════════════════════════════════════════════


class TestBackup:
    """Backup lifecycle methods."""

    def test_create_backup_delegates(self, agent):
        agent.backup.create_backup = MagicMock()
        mock_manifest = MagicMock()
        mock_manifest.id = "20260317_030000"
        mock_manifest.file_count = 5
        mock_manifest.total_size = 1024
        mock_manifest.status.value = "completed"
        agent.backup.create_backup.return_value = mock_manifest
        result = agent.create_backup()
        assert result is mock_manifest

    def test_restore_backup_delegates(self, agent):
        agent.backup.restore = MagicMock(return_value={"files_restored": 3})
        result = agent.restore_backup("20260317_030000")
        assert result["files_restored"] == 3

    def test_list_backups_delegates(self, agent):
        agent.backup.list_backups = MagicMock(return_value=[])
        assert agent.list_backups() == []

    def test_verify_backup_delegates(self, agent):
        agent.backup.verify_backup = MagicMock(return_value={"valid": True})
        assert agent.verify_backup("id")["valid"] is True

    def test_cleanup_old_backups_delegates(self, agent):
        agent.backup.cleanup_old_backups = MagicMock(return_value=2)
        assert agent.cleanup_old_backups() == 2


class TestHealthChecker:
    """Health checker initialization registers all expected checks."""

    def test_health_checker_created(self, agent):
        assert agent.health is not None

    def test_llm_provider_check_registered(self, agent):
        checks = agent.health._dependency_checks
        assert "llm_provider" in checks

    def test_bus_check_registered(self, agent):
        checks = agent.health._dependency_checks
        assert "bus" in checks

    def test_backup_freshness_check_registered(self, agent):
        checks = agent.health._dependency_checks
        assert "backup_freshness" in checks


# ════════════════════════════════════════════════════════════════
# EVAL DATASET
# ════════════════════════════════════════════════════════════════


class TestEvalDataset:
    """eval export tool and handler."""

    def test_export_disabled_returns_message(self, agent):
        agent._eval_dataset_builder = None
        result = agent.export_eval_dataset()
        assert "not enabled" in result.lower()

    def test_export_no_traces(self, agent):
        agent._eval_dataset_builder = MagicMock()
        agent._eval_dataset_builder.build_dataset.return_value = None
        result = agent.export_eval_dataset("test")
        assert "no traces" in result.lower()

    def test_export_success(self, agent):
        agent._eval_dataset_builder = MagicMock()
        agent._eval_dataset_builder.build_dataset.return_value = "/tmp/eval.json"
        result = agent.export_eval_dataset("test")
        assert "/tmp/eval.json" in result

    def test_handle_export_delegates(self, agent):
        agent._eval_dataset_builder = None
        result = agent._handle_export_eval_dataset({"name": "prod"})
        assert "not enabled" in result.lower()


# ════════════════════════════════════════════════════════════════
# MESH LIFECYCLE
# ════════════════════════════════════════════════════════════════


class TestMeshLifecycle:
    """Mesh gateway lifecycle methods."""

    def test_get_mesh_context_no_gateway(self, agent):
        agent.mesh_gateway = None
        assert agent.get_mesh_context("test query") == ""

    def test_get_mesh_context_no_bridge(self, agent):
        agent.mesh_gateway = MagicMock(spec=[])  # no memory_bridge attr
        assert agent.get_mesh_context("test query") == ""

    def test_get_mesh_context_delegates(self, agent):
        agent.mesh_gateway = MagicMock()
        agent.mesh_gateway.memory_bridge.get_mesh_context.return_value = "peer context"
        result = agent.get_mesh_context("test query")
        assert result == "peer context"

    def test_get_mesh_context_handles_error(self, agent):
        agent.mesh_gateway = MagicMock()
        agent.mesh_gateway.memory_bridge.get_mesh_context.side_effect = RuntimeError("fail")
        assert agent.get_mesh_context("test query") == ""


# ════════════════════════════════════════════════════════════════
# RETRIEVER (RAG)
# ════════════════════════════════════════════════════════════════


class TestRetrieveRagContext:
    """RAG context retrieval for system prompt injection."""

    def test_empty_query(self, agent):
        assert agent._retrieve_rag_context("") == ""

    def test_short_query(self, agent):
        assert agent._retrieve_rag_context("ab") == ""

    def test_no_documents_indexed(self, agent):
        agent.retriever = MagicMock()
        agent.retriever.count = 0
        assert agent._retrieve_rag_context("test query") == ""

    def test_returns_wrapped_context(self, agent):
        agent.retriever = MagicMock()
        agent.retriever.count = 5
        agent.retriever.get_context.return_value = "relevant doc chunk"
        result = agent._retrieve_rag_context("test query")
        assert "<retrieved_knowledge>" in result
        assert "relevant doc chunk" in result

    def test_handles_error_gracefully(self, agent):
        agent.retriever = MagicMock()
        agent.retriever.count = 5
        agent.retriever.get_context.side_effect = RuntimeError("embedding fail")
        assert agent._retrieve_rag_context("test query") == ""


# ════════════════════════════════════════════════════════════════
# SCHEDULER & REMINDERS
# ════════════════════════════════════════════════════════════════


class TestSchedulerHelpers:
    """Scheduler lifecycle and reminder helpers."""

    def test_add_reminder_no_scheduler(self, agent):
        agent.scheduler = None
        result = agent.add_reminder("test", "message", datetime.now(timezone.utc))
        assert result == "Scheduler not enabled"

    def test_start_scheduler_no_scheduler(self, agent):
        agent.scheduler = None
        agent.start_scheduler()  # Should not raise

    def test_stop_scheduler_no_scheduler(self, agent):
        agent.scheduler = None
        agent.stop_scheduler()  # Should not raise


# ════════════════════════════════════════════════════════════════
# SESSION HELPERS
# ════════════════════════════════════════════════════════════════


class TestSessionHelpers:
    """Session management convenience methods."""

    def test_get_session(self, agent):
        session = agent.get_session("user1", "cli")
        assert isinstance(session, SecureSession)

    def test_set_user_trust(self, agent):
        agent.set_user_trust("user1", "cli", TrustLevel.TRUSTED)
        session = agent.get_session("user1", "cli")
        assert session.trust_level == TrustLevel.TRUSTED

    def test_grant_capability(self, agent):
        session = agent.get_session("user1", "cli")
        session.set_trust_level(TrustLevel.TRUSTED)
        agent.grant_capability("user1", "cli", Capability.HTTP_REQUESTS)
        session = agent.get_session("user1", "cli")
        assert Capability.HTTP_REQUESTS.value in session._explicit_grants

    def test_deny_capability(self, agent):
        agent.grant_capability("user1", "cli", Capability.HTTP_REQUESTS)
        agent.deny_capability("user1", "cli", Capability.HTTP_REQUESTS)
        session = agent.get_session("user1", "cli")
        assert Capability.HTTP_REQUESTS.value not in session._explicit_grants


# ════════════════════════════════════════════════════════════════
# SWITCH JOB CLASS
# ════════════════════════════════════════════════════════════════


class TestSwitchJobClass:
    """switch_job_class updates creature state and config."""

    def test_clear_job_class(self, agent):
        with (
            patch("familiar.creature.state.load_creature") as mock_load,
            patch("familiar.creature.state.save_creature"),
        ):
            mock_load.return_value = MagicMock()
            result = agent.switch_job_class("")
            assert "cleared" in result.lower()

    def test_invalid_job_class(self, agent):
        with (
            patch("familiar.jobs.get_job_class", return_value=None),
            patch("familiar.jobs.list_job_classes", return_value=[]),
        ):
            result = agent.switch_job_class("nonexistent")
            assert "unknown" in result.lower()

    def test_valid_job_class(self, agent):
        mock_jc = MagicMock()
        mock_jc.name = "Helper"
        mock_jc.description = "General assistant"
        mock_jc.key = "helper"
        with (
            patch("familiar.jobs.get_job_class", return_value=mock_jc),
            patch("familiar.creature.state.load_creature") as mock_load,
            patch("familiar.creature.state.save_creature"),
        ):
            mock_load.return_value = MagicMock()
            result = agent.switch_job_class("helper")
            assert "Helper" in result
            assert agent.config.agent.job_class == "helper"


# ════════════════════════════════════════════════════════════════
# COMPLIANCE
# ════════════════════════════════════════════════════════════════


class TestCompliance:
    """Compliance mode validation."""

    def test_no_compliance_by_default(self, agent):
        assert agent.compliance_mode is None

    def test_hipaa_requires_encryption_key(self, minimal_config, mock_provider):
        """HIPAA mode without encryption key raises RuntimeError."""
        minimal_config.compliance = MagicMock()
        minimal_config.compliance.mode = "hipaa"
        patches = _agent_patches(mock_provider)
        [p.__enter__() if hasattr(p, "__enter__") else p.start() for p in patches]
        try:
            with pytest.raises(RuntimeError, match="encryption"):
                Agent(config=minimal_config)
        finally:
            for p in reversed(patches):
                try:
                    if hasattr(p, "__exit__"):
                        p.__exit__(None, None, None)
                except Exception:
                    pass


# ════════════════════════════════════════════════════════════════
# ERROR COMPAT
# ════════════════════════════════════════════════════════════════


class TestErrorCompat:
    """Backward-compat error helpers."""

    def test_error_prefixes_accessible(self, agent):
        assert hasattr(Agent, "_ERROR_PREFIXES")
        assert isinstance(Agent._ERROR_PREFIXES, (list, tuple, frozenset, set))

    def test_error_substrings_accessible(self, agent):
        assert hasattr(Agent, "_ERROR_SUBSTRINGS")
        assert isinstance(Agent._ERROR_SUBSTRINGS, (list, tuple, frozenset, set))

    def test_is_tool_error_delegates(self, agent):
        agent._tool_executor._is_tool_error = MagicMock(return_value=True)
        assert agent._is_tool_error("Error: test") is True


# ════════════════════════════════════════════════════════════════
# GUARDRAIL BLOCK EXCEPTION
# ════════════════════════════════════════════════════════════════


class TestGuardrailBlockException:
    """_GuardrailBlock is an internal sentinel."""

    def test_is_exception(self):
        assert issubclass(_GuardrailBlock, Exception)

    def test_carries_message(self):
        gb = _GuardrailBlock("Rate limited")
        assert str(gb) == "Rate limited"

    def test_does_not_propagate_through_chat(self, agent):
        with patch.object(agent, "_setup_request") as mock_setup:
            mock_setup.side_effect = _GuardrailBlock("blocked")
            result = agent.chat("test")
            assert result == "blocked"
