"""Tests for timmy.agents.base — BaseAgent and SubAgent.

Covers:
- Initialization and default values
- Tool registry integration
- Event bus connection and subscription
- run() with retry logic (transient + fatal errors)
- Event emission on successful run
- get_capabilities / get_status
- SubAgent.execute_task delegation
"""

from unittest.mock import AsyncMock, MagicMock, patch

import httpx
import pytest

# ── helpers ──────────────────────────────────────────────────────────────────


def _mock_settings(**overrides):
    """Create a settings mock with sensible defaults."""
    s = MagicMock()
    s.ollama_model = "qwen3:30b"
    s.ollama_url = "http://localhost:11434"
    s.ollama_num_ctx = 0
    s.telemetry_enabled = False
    for k, v in overrides.items():
        setattr(s, k, v)
    return s


def _make_agent_class():
    """Import after patches are in place."""
    from timmy.agents.base import SubAgent

    return SubAgent


def _make_base_class():
    from timmy.agents.base import BaseAgent

    return BaseAgent


# ── patch context ────────────────────────────────────────────────────────────

# All tests patch Agno's Agent so we never touch Ollama.
_AGENT_PATCH = "timmy.agents.base.Agent"
_OLLAMA_PATCH = "timmy.agents.base.Ollama"
_SETTINGS_PATCH = "timmy.agents.base.settings"
_REGISTRY_PATCH = "timmy.agents.base.tool_registry"


# ── Initialization ───────────────────────────────────────────────────────────


class TestBaseAgentInit:
    @patch(_REGISTRY_PATCH, None)
    @patch(_SETTINGS_PATCH, _mock_settings())
    @patch(_OLLAMA_PATCH)
    @patch(_AGENT_PATCH)
    def test_defaults(self, mock_agent_cls, mock_ollama):
        SubAgent = _make_agent_class()
        agent = SubAgent(
            agent_id="test-1",
            name="TestBot",
            role="tester",
            system_prompt="You are a test agent.",
        )
        assert agent.agent_id == "test-1"
        assert agent.name == "TestBot"
        assert agent.role == "tester"
        assert agent.tools == []
        assert agent.model == "qwen3:30b"
        assert agent.max_history == 10
        assert agent.event_bus is None

    @patch(_REGISTRY_PATCH, None)
    @patch(_SETTINGS_PATCH, _mock_settings())
    @patch(_OLLAMA_PATCH)
    @patch(_AGENT_PATCH)
    def test_custom_model(self, mock_agent_cls, mock_ollama):
        SubAgent = _make_agent_class()
        agent = SubAgent(
            agent_id="a",
            name="A",
            role="r",
            system_prompt="p",
            model="llama3:8b",
        )
        assert agent.model == "llama3:8b"

    @patch(_REGISTRY_PATCH, None)
    @patch(_SETTINGS_PATCH, _mock_settings())
    @patch(_OLLAMA_PATCH)
    @patch(_AGENT_PATCH)
    def test_custom_max_history(self, mock_agent_cls, mock_ollama):
        SubAgent = _make_agent_class()
        agent = SubAgent(agent_id="a", name="A", role="r", system_prompt="p", max_history=5)
        assert agent.max_history == 5

    @patch(_REGISTRY_PATCH, None)
    @patch(_SETTINGS_PATCH, _mock_settings())
    @patch(_OLLAMA_PATCH)
    @patch(_AGENT_PATCH)
    def test_tools_list_stored(self, mock_agent_cls, mock_ollama):
        SubAgent = _make_agent_class()
        agent = SubAgent(
            agent_id="a",
            name="A",
            role="r",
            system_prompt="p",
            tools=["calculator", "search"],
        )
        assert agent.tools == ["calculator", "search"]


# ── _create_agent internals ──────────────────────────────────────────────────


class TestCreateAgent:
    @patch(_REGISTRY_PATCH, None)
    @patch(_SETTINGS_PATCH, _mock_settings(ollama_num_ctx=4096))
    @patch(_OLLAMA_PATCH)
    @patch(_AGENT_PATCH)
    def test_num_ctx_passed_when_set(self, mock_agent_cls, mock_ollama):
        SubAgent = _make_agent_class()
        SubAgent(agent_id="a", name="A", role="r", system_prompt="p")
        # Ollama should have been called with options
        _, kwargs = mock_ollama.call_args
        assert kwargs.get("options") == {"num_ctx": 4096}

    @patch(_REGISTRY_PATCH, None)
    @patch(_SETTINGS_PATCH, _mock_settings(ollama_num_ctx=0))
    @patch(_OLLAMA_PATCH)
    @patch(_AGENT_PATCH)
    def test_num_ctx_omitted_when_zero(self, mock_agent_cls, mock_ollama):
        SubAgent = _make_agent_class()
        SubAgent(agent_id="a", name="A", role="r", system_prompt="p")
        _, kwargs = mock_ollama.call_args
        assert "options" not in kwargs

    @patch(_SETTINGS_PATCH, _mock_settings())
    @patch(_OLLAMA_PATCH)
    @patch(_AGENT_PATCH)
    def test_tool_registry_lookup(self, mock_agent_cls, mock_ollama):
        mock_registry = MagicMock()
        handler1 = MagicMock()
        handler2 = None  # Simulate missing tool
        mock_registry.get_handler.side_effect = [handler1, handler2]

        with patch(_REGISTRY_PATCH, mock_registry):
            SubAgent = _make_agent_class()
            SubAgent(
                agent_id="a",
                name="A",
                role="r",
                system_prompt="p",
                tools=["calc", "missing"],
            )

        assert mock_registry.get_handler.call_count == 2
        # Agent should have been created with just the one handler
        _, kwargs = mock_agent_cls.call_args
        assert kwargs["tools"] == [handler1]

    @patch(_SETTINGS_PATCH, _mock_settings())
    @patch(_OLLAMA_PATCH)
    @patch(_AGENT_PATCH)
    def test_no_tools_passes_none(self, mock_agent_cls, mock_ollama):
        with patch(_REGISTRY_PATCH, None):
            SubAgent = _make_agent_class()
            SubAgent(agent_id="a", name="A", role="r", system_prompt="p")
        _, kwargs = mock_agent_cls.call_args
        assert kwargs["tools"] is None


# ── Event bus ────────────────────────────────────────────────────────────────


class TestEventBus:
    @patch(_REGISTRY_PATCH, None)
    @patch(_SETTINGS_PATCH, _mock_settings())
    @patch(_OLLAMA_PATCH)
    @patch(_AGENT_PATCH)
    def test_connect_event_bus(self, mock_agent_cls, mock_ollama):
        SubAgent = _make_agent_class()
        agent = SubAgent(agent_id="bot-1", name="B", role="r", system_prompt="p")
        bus = MagicMock()
        bus.subscribe.return_value = lambda fn: fn  # decorator pattern

        agent.connect_event_bus(bus)

        assert agent.event_bus is bus
        assert bus.subscribe.call_count == 2
        # Check subscription patterns
        patterns = [call.args[0] for call in bus.subscribe.call_args_list]
        assert "agent.bot-1.*" in patterns
        assert "agent.task.assigned" in patterns


# ── run() retry logic ────────────────────────────────────────────────────────


class TestRun:
    @patch(_REGISTRY_PATCH, None)
    @patch(_SETTINGS_PATCH, _mock_settings())
    @patch(_OLLAMA_PATCH)
    @patch(_AGENT_PATCH)
    @pytest.mark.asyncio
    async def test_run_success(self, mock_agent_cls, mock_ollama):
        SubAgent = _make_agent_class()
        agent = SubAgent(agent_id="a", name="A", role="r", system_prompt="p")
        mock_result = MagicMock()
        mock_result.content = "Hello world"
        agent.agent.run.return_value = mock_result

        response = await agent.run("Hi")

        assert response == "Hello world"
        agent.agent.run.assert_called_once_with("Hi", stream=False)

    @patch(_REGISTRY_PATCH, None)
    @patch(_SETTINGS_PATCH, _mock_settings())
    @patch(_OLLAMA_PATCH)
    @patch(_AGENT_PATCH)
    @pytest.mark.asyncio
    async def test_run_result_without_content(self, mock_agent_cls, mock_ollama):
        """When result has no .content, fall back to str()."""
        SubAgent = _make_agent_class()
        agent = SubAgent(agent_id="a", name="A", role="r", system_prompt="p")
        agent.agent.run.return_value = "plain string"

        response = await agent.run("Hi")

        assert response == "plain string"

    @patch(_REGISTRY_PATCH, None)
    @patch(_SETTINGS_PATCH, _mock_settings())
    @patch(_OLLAMA_PATCH)
    @patch(_AGENT_PATCH)
    @pytest.mark.asyncio
    async def test_run_retries_transient_error(self, mock_agent_cls, mock_ollama):
        """Transient errors (ConnectError etc.) should be retried."""
        SubAgent = _make_agent_class()
        agent = SubAgent(agent_id="a", name="A", role="r", system_prompt="p")

        mock_result = MagicMock()
        mock_result.content = "recovered"
        agent.agent.run.side_effect = [
            httpx.ConnectError("refused"),
            mock_result,
        ]

        with patch("asyncio.sleep", new_callable=AsyncMock):
            response = await agent.run("Hi")

        assert response == "recovered"
        assert agent.agent.run.call_count == 2

    @patch(_REGISTRY_PATCH, None)
    @patch(_SETTINGS_PATCH, _mock_settings())
    @patch(_OLLAMA_PATCH)
    @patch(_AGENT_PATCH)
    @pytest.mark.asyncio
    async def test_run_retries_read_timeout(self, mock_agent_cls, mock_ollama):
        """ReadTimeout (GPU contention) should be retried."""
        SubAgent = _make_agent_class()
        agent = SubAgent(agent_id="a", name="A", role="r", system_prompt="p")

        mock_result = MagicMock()
        mock_result.content = "ok"
        agent.agent.run.side_effect = [
            httpx.ReadTimeout("timeout"),
            mock_result,
        ]

        with patch("asyncio.sleep", new_callable=AsyncMock):
            response = await agent.run("Hi")

        assert response == "ok"

    @patch(_REGISTRY_PATCH, None)
    @patch(_SETTINGS_PATCH, _mock_settings())
    @patch(_OLLAMA_PATCH)
    @patch(_AGENT_PATCH)
    @pytest.mark.asyncio
    async def test_run_exhausts_retries_transient(self, mock_agent_cls, mock_ollama):
        """After 3 transient failures, should raise."""
        SubAgent = _make_agent_class()
        agent = SubAgent(agent_id="a", name="A", role="r", system_prompt="p")

        agent.agent.run.side_effect = httpx.ConnectError("down")

        with patch("asyncio.sleep", new_callable=AsyncMock):
            with pytest.raises(httpx.ConnectError):
                await agent.run("Hi")

        assert agent.agent.run.call_count == 3

    @patch(_REGISTRY_PATCH, None)
    @patch(_SETTINGS_PATCH, _mock_settings())
    @patch(_OLLAMA_PATCH)
    @patch(_AGENT_PATCH)
    @pytest.mark.asyncio
    async def test_run_retries_non_transient_error(self, mock_agent_cls, mock_ollama):
        """Non-transient errors also get retried (with different backoff)."""
        SubAgent = _make_agent_class()
        agent = SubAgent(agent_id="a", name="A", role="r", system_prompt="p")

        agent.agent.run.side_effect = ValueError("bad input")

        with patch("asyncio.sleep", new_callable=AsyncMock):
            with pytest.raises(ValueError, match="bad input"):
                await agent.run("Hi")

        assert agent.agent.run.call_count == 3

    @patch(_REGISTRY_PATCH, None)
    @patch(_SETTINGS_PATCH, _mock_settings())
    @patch(_OLLAMA_PATCH)
    @patch(_AGENT_PATCH)
    @pytest.mark.asyncio
    async def test_run_emits_event_on_success(self, mock_agent_cls, mock_ollama):
        """Successful run should publish response event to bus."""
        SubAgent = _make_agent_class()
        agent = SubAgent(agent_id="bot-1", name="B", role="r", system_prompt="p")

        mock_bus = AsyncMock()
        agent.event_bus = mock_bus

        mock_result = MagicMock()
        mock_result.content = "answer"
        agent.agent.run.return_value = mock_result

        await agent.run("question")

        mock_bus.publish.assert_called_once()
        event = mock_bus.publish.call_args[0][0]
        assert event.type == "agent.bot-1.response"
        assert event.data["input"] == "question"
        assert event.data["output"] == "answer"

    @patch(_REGISTRY_PATCH, None)
    @patch(_SETTINGS_PATCH, _mock_settings())
    @patch(_OLLAMA_PATCH)
    @patch(_AGENT_PATCH)
    @pytest.mark.asyncio
    async def test_run_no_event_without_bus(self, mock_agent_cls, mock_ollama):
        """No bus connected = no event emitted (no crash)."""
        SubAgent = _make_agent_class()
        agent = SubAgent(agent_id="a", name="A", role="r", system_prompt="p")

        mock_result = MagicMock()
        mock_result.content = "ok"
        agent.agent.run.return_value = mock_result

        # Should not raise
        response = await agent.run("Hi")
        assert response == "ok"


# ── _handle_retry_or_raise ────────────────────────────────────────────────


class TestHandleRetryOrRaise:
    def test_raises_on_last_attempt(self):
        BaseAgent = _make_base_class()
        with pytest.raises(ValueError, match="boom"):
            BaseAgent._handle_retry_or_raise(
                ValueError("boom"),
                attempt=3,
                max_retries=3,
                transient=False,
            )

    def test_raises_on_last_attempt_transient(self):
        BaseAgent = _make_base_class()
        exc = httpx.ConnectError("down")
        with pytest.raises(httpx.ConnectError):
            BaseAgent._handle_retry_or_raise(
                exc,
                attempt=3,
                max_retries=3,
                transient=True,
            )

    def test_no_raise_on_early_attempt(self):
        BaseAgent = _make_base_class()
        # Should return None (no raise) on non-final attempt
        result = BaseAgent._handle_retry_or_raise(
            ValueError("retry me"),
            attempt=1,
            max_retries=3,
            transient=False,
        )
        assert result is None

    def test_no_raise_on_early_transient(self):
        BaseAgent = _make_base_class()
        result = BaseAgent._handle_retry_or_raise(
            httpx.ReadTimeout("busy"),
            attempt=2,
            max_retries=3,
            transient=True,
        )
        assert result is None


# ── get_capabilities / get_status ────────────────────────────────────────────


class TestStatusAndCapabilities:
    @patch(_REGISTRY_PATCH, None)
    @patch(_SETTINGS_PATCH, _mock_settings())
    @patch(_OLLAMA_PATCH)
    @patch(_AGENT_PATCH)
    def test_get_capabilities(self, mock_agent_cls, mock_ollama):
        SubAgent = _make_agent_class()
        agent = SubAgent(agent_id="a", name="A", role="r", system_prompt="p", tools=["t1", "t2"])
        assert agent.get_capabilities() == ["t1", "t2"]

    @patch(_REGISTRY_PATCH, None)
    @patch(_SETTINGS_PATCH, _mock_settings())
    @patch(_OLLAMA_PATCH)
    @patch(_AGENT_PATCH)
    def test_get_status(self, mock_agent_cls, mock_ollama):
        SubAgent = _make_agent_class()
        agent = SubAgent(
            agent_id="bot-1",
            name="TestBot",
            role="assistant",
            system_prompt="p",
            tools=["calc"],
        )
        status = agent.get_status()
        assert status["agent_id"] == "bot-1"
        assert status["name"] == "TestBot"
        assert status["role"] == "assistant"
        assert status["model"] == "qwen3:30b"
        assert status["status"] == "ready"
        assert status["tools"] == ["calc"]
        assert "emotional_profile" in status
        assert status["emotional_profile"]["current_emotion"] == "calm"


# ── SubAgent.execute_task ────────────────────────────────────────────────────


class TestSubAgentExecuteTask:
    @patch(_REGISTRY_PATCH, None)
    @patch(_SETTINGS_PATCH, _mock_settings())
    @patch(_OLLAMA_PATCH)
    @patch(_AGENT_PATCH)
    @pytest.mark.asyncio
    async def test_execute_task_delegates_to_run(self, mock_agent_cls, mock_ollama):
        SubAgent = _make_agent_class()
        agent = SubAgent(agent_id="bot-1", name="B", role="r", system_prompt="p")

        mock_result = MagicMock()
        mock_result.content = "task done"
        agent.agent.run.return_value = mock_result

        result = await agent.execute_task("t-1", "do the thing", {"extra": True})

        assert result == {
            "task_id": "t-1",
            "agent": "bot-1",
            "result": "task done",
            "status": "completed",
        }


# ── Task assignment handler ──────────────────────────────────────────────────


class TestTaskAssignment:
    @patch(_REGISTRY_PATCH, None)
    @patch(_SETTINGS_PATCH, _mock_settings())
    @patch(_OLLAMA_PATCH)
    @patch(_AGENT_PATCH)
    @pytest.mark.asyncio
    async def test_handles_assigned_task(self, mock_agent_cls, mock_ollama):
        """Agent should process tasks assigned to it."""
        SubAgent = _make_agent_class()

        agent = SubAgent(agent_id="bot-1", name="B", role="r", system_prompt="p")

        mock_result = MagicMock()
        mock_result.content = "done"
        agent.agent.run.return_value = mock_result

        from infrastructure.events.bus import Event

        event = Event(
            type="agent.task.assigned",
            source="coordinator",
            data={
                "agent_id": "bot-1",
                "task_id": "task-42",
                "description": "Fix the bug",
            },
        )

        await agent._handle_task_assignment(event)
        agent.agent.run.assert_called_once_with("Fix the bug", stream=False)

    @patch(_REGISTRY_PATCH, None)
    @patch(_SETTINGS_PATCH, _mock_settings())
    @patch(_OLLAMA_PATCH)
    @patch(_AGENT_PATCH)
    @pytest.mark.asyncio
    async def test_ignores_task_for_other_agent(self, mock_agent_cls, mock_ollama):
        """Agent should ignore tasks assigned to someone else."""
        SubAgent = _make_agent_class()
        agent = SubAgent(agent_id="bot-1", name="B", role="r", system_prompt="p")

        from infrastructure.events.bus import Event

        event = Event(
            type="agent.task.assigned",
            source="coordinator",
            data={
                "agent_id": "bot-2",
                "task_id": "task-99",
                "description": "Not my job",
            },
        )

        await agent._handle_task_assignment(event)
        agent.agent.run.assert_not_called()
