"""Unit tests for src/timmy/paperclip.py.

Refs #1236
"""

from __future__ import annotations

import asyncio
import sys
from types import ModuleType
from unittest.mock import AsyncMock, MagicMock, patch

import httpx
import pytest

# ── Stub serpapi before any import of paperclip (it imports research_tools) ───

_serpapi_stub = ModuleType("serpapi")
_google_search_mock = MagicMock()
_serpapi_stub.GoogleSearch = _google_search_mock
sys.modules.setdefault("serpapi", _serpapi_stub)

pytestmark = pytest.mark.unit


# ── PaperclipTask ─────────────────────────────────────────────────────────────


class TestPaperclipTask:
    """PaperclipTask dataclass holds task data."""

    def test_task_creation(self):
        from timmy.paperclip import PaperclipTask

        task = PaperclipTask(id="task-123", kind="research", context={"key": "value"})
        assert task.id == "task-123"
        assert task.kind == "research"
        assert task.context == {"key": "value"}

    def test_task_creation_empty_context(self):
        from timmy.paperclip import PaperclipTask

        task = PaperclipTask(id="task-456", kind="other", context={})
        assert task.id == "task-456"
        assert task.kind == "other"
        assert task.context == {}


# ── PaperclipClient ───────────────────────────────────────────────────────────


class TestPaperclipClient:
    """PaperclipClient interacts with the Paperclip API."""

    def test_init_uses_settings(self):
        from timmy.paperclip import PaperclipClient

        with patch("timmy.paperclip.settings") as mock_settings:
            mock_settings.paperclip_url = "http://test.example:3100"
            mock_settings.paperclip_api_key = "test-api-key"
            mock_settings.paperclip_agent_id = "agent-123"
            mock_settings.paperclip_company_id = "company-456"
            mock_settings.paperclip_timeout = 45

            client = PaperclipClient()
            assert client.base_url == "http://test.example:3100"
            assert client.api_key == "test-api-key"
            assert client.agent_id == "agent-123"
            assert client.company_id == "company-456"
            assert client.timeout == 45

    @pytest.mark.asyncio
    async def test_get_tasks_makes_correct_request(self):
        from timmy.paperclip import PaperclipClient

        with patch("timmy.paperclip.settings") as mock_settings:
            mock_settings.paperclip_url = "http://test.example:3100"
            mock_settings.paperclip_api_key = "test-api-key"
            mock_settings.paperclip_agent_id = "agent-123"
            mock_settings.paperclip_company_id = "company-456"
            mock_settings.paperclip_timeout = 30

            client = PaperclipClient()

            mock_response = MagicMock()
            mock_response.json.return_value = [
                {"id": "task-1", "kind": "research", "context": {"issue_number": 42}},
                {"id": "task-2", "kind": "other", "context": {}},
            ]

            mock_client = AsyncMock()
            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
            mock_client.__aexit__ = AsyncMock(return_value=False)
            mock_client.get = AsyncMock(return_value=mock_response)

            with patch("httpx.AsyncClient", return_value=mock_client):
                tasks = await client.get_tasks()

            mock_client.get.assert_called_once_with(
                "http://test.example:3100/api/tasks",
                headers={"Authorization": "Bearer test-api-key"},
                params={
                    "agent_id": "agent-123",
                    "company_id": "company-456",
                    "status": "queued",
                },
            )
            mock_response.raise_for_status.assert_called_once()
            assert len(tasks) == 2
            assert tasks[0].id == "task-1"
            assert tasks[0].kind == "research"
            assert tasks[1].id == "task-2"

    @pytest.mark.asyncio
    async def test_get_tasks_empty_response(self):
        from timmy.paperclip import PaperclipClient

        with patch("timmy.paperclip.settings") as mock_settings:
            mock_settings.paperclip_url = "http://test.example:3100"
            mock_settings.paperclip_api_key = "test-api-key"
            mock_settings.paperclip_agent_id = "agent-123"
            mock_settings.paperclip_company_id = "company-456"
            mock_settings.paperclip_timeout = 30

            client = PaperclipClient()

            mock_response = MagicMock()
            mock_response.json.return_value = []

            mock_client = AsyncMock()
            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
            mock_client.__aexit__ = AsyncMock(return_value=False)
            mock_client.get = AsyncMock(return_value=mock_response)

            with patch("httpx.AsyncClient", return_value=mock_client):
                tasks = await client.get_tasks()

            assert tasks == []

    @pytest.mark.asyncio
    async def test_get_tasks_raises_on_http_error(self):
        from timmy.paperclip import PaperclipClient

        with patch("timmy.paperclip.settings") as mock_settings:
            mock_settings.paperclip_url = "http://test.example:3100"
            mock_settings.paperclip_api_key = "test-api-key"
            mock_settings.paperclip_agent_id = "agent-123"
            mock_settings.paperclip_company_id = "company-456"
            mock_settings.paperclip_timeout = 30

            client = PaperclipClient()

            mock_client = AsyncMock()
            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
            mock_client.__aexit__ = AsyncMock(return_value=False)
            mock_client.get = AsyncMock(side_effect=httpx.HTTPError("Connection failed"))

            with patch("httpx.AsyncClient", return_value=mock_client):
                with pytest.raises(httpx.HTTPError):
                    await client.get_tasks()

    @pytest.mark.asyncio
    async def test_update_task_status_makes_correct_request(self):
        from timmy.paperclip import PaperclipClient

        with patch("timmy.paperclip.settings") as mock_settings:
            mock_settings.paperclip_url = "http://test.example:3100"
            mock_settings.paperclip_api_key = "test-api-key"
            mock_settings.paperclip_timeout = 30

            client = PaperclipClient()

            mock_client = AsyncMock()
            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
            mock_client.__aexit__ = AsyncMock(return_value=False)
            mock_client.patch = AsyncMock(return_value=MagicMock())

            with patch("httpx.AsyncClient", return_value=mock_client):
                await client.update_task_status("task-123", "completed", "Task result here")

            mock_client.patch.assert_called_once_with(
                "http://test.example:3100/api/tasks/task-123",
                headers={"Authorization": "Bearer test-api-key"},
                json={"status": "completed", "result": "Task result here"},
            )

    @pytest.mark.asyncio
    async def test_update_task_status_without_result(self):
        from timmy.paperclip import PaperclipClient

        with patch("timmy.paperclip.settings") as mock_settings:
            mock_settings.paperclip_url = "http://test.example:3100"
            mock_settings.paperclip_api_key = "test-api-key"
            mock_settings.paperclip_timeout = 30

            client = PaperclipClient()

            mock_client = AsyncMock()
            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
            mock_client.__aexit__ = AsyncMock(return_value=False)
            mock_client.patch = AsyncMock(return_value=MagicMock())

            with patch("httpx.AsyncClient", return_value=mock_client):
                await client.update_task_status("task-123", "running")

            mock_client.patch.assert_called_once_with(
                "http://test.example:3100/api/tasks/task-123",
                headers={"Authorization": "Bearer test-api-key"},
                json={"status": "running", "result": None},
            )


# ── ResearchOrchestrator ───────────────────────────────────────────────────────


class TestResearchOrchestrator:
    """ResearchOrchestrator coordinates research tasks."""

    def test_init_creates_instances(self):
        from timmy.paperclip import ResearchOrchestrator

        orchestrator = ResearchOrchestrator()
        assert orchestrator is not None

    @pytest.mark.asyncio
    async def test_get_gitea_issue_makes_correct_request(self):
        from timmy.paperclip import ResearchOrchestrator

        with patch("timmy.paperclip.settings") as mock_settings:
            mock_settings.gitea_repo = "owner/repo"
            mock_settings.gitea_url = "http://gitea.example:3000"
            mock_settings.gitea_token = "gitea-token"

            orchestrator = ResearchOrchestrator()

            mock_response = MagicMock()
            mock_response.json.return_value = {"number": 42, "title": "Test Issue"}

            mock_client = AsyncMock()
            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
            mock_client.__aexit__ = AsyncMock(return_value=False)
            mock_client.get = AsyncMock(return_value=mock_response)

            with patch("httpx.AsyncClient", return_value=mock_client):
                issue = await orchestrator.get_gitea_issue(42)

            mock_client.get.assert_called_once_with(
                "http://gitea.example:3000/api/v1/repos/owner/repo/issues/42",
                headers={"Authorization": "token gitea-token"},
            )
            mock_response.raise_for_status.assert_called_once()
            assert issue["number"] == 42
            assert issue["title"] == "Test Issue"

    @pytest.mark.asyncio
    async def test_get_gitea_issue_raises_on_http_error(self):
        from timmy.paperclip import ResearchOrchestrator

        with patch("timmy.paperclip.settings") as mock_settings:
            mock_settings.gitea_repo = "owner/repo"
            mock_settings.gitea_url = "http://gitea.example:3000"
            mock_settings.gitea_token = "gitea-token"

            orchestrator = ResearchOrchestrator()

            mock_client = AsyncMock()
            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
            mock_client.__aexit__ = AsyncMock(return_value=False)
            mock_client.get = AsyncMock(side_effect=httpx.HTTPError("Not found"))

            with patch("httpx.AsyncClient", return_value=mock_client):
                with pytest.raises(httpx.HTTPError):
                    await orchestrator.get_gitea_issue(999)

    @pytest.mark.asyncio
    async def test_post_gitea_comment_makes_correct_request(self):
        from timmy.paperclip import ResearchOrchestrator

        with patch("timmy.paperclip.settings") as mock_settings:
            mock_settings.gitea_repo = "owner/repo"
            mock_settings.gitea_url = "http://gitea.example:3000"
            mock_settings.gitea_token = "gitea-token"

            orchestrator = ResearchOrchestrator()

            mock_client = AsyncMock()
            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
            mock_client.__aexit__ = AsyncMock(return_value=False)
            mock_client.post = AsyncMock(return_value=MagicMock())

            with patch("httpx.AsyncClient", return_value=mock_client):
                await orchestrator.post_gitea_comment(42, "Test comment body")

            mock_client.post.assert_called_once_with(
                "http://gitea.example:3000/api/v1/repos/owner/repo/issues/42/comments",
                headers={"Authorization": "token gitea-token"},
                json={"body": "Test comment body"},
            )

    @pytest.mark.asyncio
    async def test_run_research_pipeline_returns_report(self):
        from timmy.paperclip import ResearchOrchestrator

        orchestrator = ResearchOrchestrator()

        mock_search_results = "Search result 1\nSearch result 2"
        mock_llm_response = MagicMock()
        mock_llm_response.text = "Research report summary"

        mock_llm_client = MagicMock()
        mock_llm_client.completion = AsyncMock(return_value=mock_llm_response)

        with patch("timmy.paperclip.google_web_search", new=AsyncMock(return_value=mock_search_results)):
            with patch("timmy.paperclip.get_llm_client", return_value=mock_llm_client):
                report = await orchestrator.run_research_pipeline("test query")

        assert report == "Research report summary"
        mock_llm_client.completion.assert_called_once()
        call_args = mock_llm_client.completion.call_args
        # The prompt is passed as first positional arg, check it contains expected content
        prompt = call_args[0][0] if call_args[0] else call_args[1].get("messages", [""])[0]
        assert "Summarize" in prompt
        assert "Search result 1" in prompt

    @pytest.mark.asyncio
    async def test_run_returns_error_when_missing_issue_number(self):
        from timmy.paperclip import ResearchOrchestrator

        orchestrator = ResearchOrchestrator()
        result = await orchestrator.run({})
        assert result == "Missing issue_number in task context"

    @pytest.mark.asyncio
    async def test_run_executes_full_pipeline_with_triage_results(self):
        from timmy.paperclip import ResearchOrchestrator

        with patch("timmy.paperclip.settings") as mock_settings:
            mock_settings.gitea_repo = "owner/repo"
            mock_settings.gitea_url = "http://gitea.example:3000"
            mock_settings.gitea_token = "gitea-token"

            orchestrator = ResearchOrchestrator()

            mock_issue = {"number": 42, "title": "Test Research Topic"}
            mock_report = "Research report content"
            mock_triage_results = [
                {
                    "action_item": MagicMock(title="Action 1"),
                    "gitea_issue": {"number": 101},
                },
                {
                    "action_item": MagicMock(title="Action 2"),
                    "gitea_issue": {"number": 102},
                },
            ]

            orchestrator.get_gitea_issue = AsyncMock(return_value=mock_issue)
            orchestrator.run_research_pipeline = AsyncMock(return_value=mock_report)
            orchestrator.post_gitea_comment = AsyncMock()

            with patch("timmy.paperclip.triage_research_report", new=AsyncMock(return_value=mock_triage_results)):
                result = await orchestrator.run({"issue_number": 42})

            assert "Research complete for issue #42" in result
            orchestrator.get_gitea_issue.assert_called_once_with(42)
            orchestrator.run_research_pipeline.assert_called_once_with("Test Research Topic")
            orchestrator.post_gitea_comment.assert_called_once()
            comment_body = orchestrator.post_gitea_comment.call_args[0][1]
            assert "Research complete for issue #42" in comment_body
            assert "#101" in comment_body
            assert "#102" in comment_body

    @pytest.mark.asyncio
    async def test_run_executes_full_pipeline_without_triage_results(self):
        from timmy.paperclip import ResearchOrchestrator

        with patch("timmy.paperclip.settings") as mock_settings:
            mock_settings.gitea_repo = "owner/repo"
            mock_settings.gitea_url = "http://gitea.example:3000"
            mock_settings.gitea_token = "gitea-token"

            orchestrator = ResearchOrchestrator()

            mock_issue = {"number": 42, "title": "Test Research Topic"}
            mock_report = "Research report content"

            orchestrator.get_gitea_issue = AsyncMock(return_value=mock_issue)
            orchestrator.run_research_pipeline = AsyncMock(return_value=mock_report)
            orchestrator.post_gitea_comment = AsyncMock()

            with patch("timmy.paperclip.triage_research_report", new=AsyncMock(return_value=[])):
                result = await orchestrator.run({"issue_number": 42})

            assert "Research complete for issue #42" in result
            comment_body = orchestrator.post_gitea_comment.call_args[0][1]
            assert "No new issues were created" in comment_body


# ── PaperclipPoller ────────────────────────────────────────────────────────────


class TestPaperclipPoller:
    """PaperclipPoller polls for and executes tasks."""

    def test_init_creates_client_and_orchestrator(self):
        from timmy.paperclip import PaperclipPoller

        with patch("timmy.paperclip.settings") as mock_settings:
            mock_settings.paperclip_poll_interval = 60

            poller = PaperclipPoller()
            assert poller.client is not None
            assert poller.orchestrator is not None
            assert poller.poll_interval == 60

    @pytest.mark.asyncio
    async def test_poll_returns_early_when_disabled(self):
        from timmy.paperclip import PaperclipPoller

        with patch("timmy.paperclip.settings") as mock_settings:
            mock_settings.paperclip_poll_interval = 0

            poller = PaperclipPoller()
            poller.client.get_tasks = AsyncMock()

            await poller.poll()

            poller.client.get_tasks.assert_not_called()

    @pytest.mark.asyncio
    async def test_poll_processes_research_tasks(self):
        from timmy.paperclip import PaperclipPoller, PaperclipTask

        with patch("timmy.paperclip.settings") as mock_settings:
            mock_settings.paperclip_poll_interval = 1

            poller = PaperclipPoller()

            mock_task = PaperclipTask(id="task-1", kind="research", context={"issue_number": 42})
            poller.client.get_tasks = AsyncMock(return_value=[mock_task])
            poller.run_research_task = AsyncMock()

            # Stop after first iteration
            call_count = 0

            async def mock_sleep(duration):
                nonlocal call_count
                call_count += 1
                if call_count >= 1:
                    raise asyncio.CancelledError("Stop the loop")

            import asyncio

            with patch("asyncio.sleep", mock_sleep):
                with pytest.raises(asyncio.CancelledError):
                    await poller.poll()

            poller.client.get_tasks.assert_called_once()
            poller.run_research_task.assert_called_once_with(mock_task)

    @pytest.mark.asyncio
    async def test_poll_logs_http_error_and_continues(self, caplog):
        import logging

        from timmy.paperclip import PaperclipPoller

        with patch("timmy.paperclip.settings") as mock_settings:
            mock_settings.paperclip_poll_interval = 1

            poller = PaperclipPoller()
            poller.client.get_tasks = AsyncMock(side_effect=httpx.HTTPError("Connection failed"))

            call_count = 0

            async def mock_sleep(duration):
                nonlocal call_count
                call_count += 1
                if call_count >= 1:
                    raise asyncio.CancelledError("Stop the loop")

            with patch("asyncio.sleep", mock_sleep):
                with caplog.at_level(logging.WARNING, logger="timmy.paperclip"):
                    with pytest.raises(asyncio.CancelledError):
                        await poller.poll()

            assert any("Error polling Paperclip" in rec.message for rec in caplog.records)

    @pytest.mark.asyncio
    async def test_run_research_task_success(self):
        from timmy.paperclip import PaperclipPoller, PaperclipTask

        poller = PaperclipPoller()

        mock_task = PaperclipTask(id="task-1", kind="research", context={"issue_number": 42})

        poller.client.update_task_status = AsyncMock()
        poller.orchestrator.run = AsyncMock(return_value="Research completed successfully")

        await poller.run_research_task(mock_task)

        assert poller.client.update_task_status.call_count == 2
        poller.client.update_task_status.assert_any_call("task-1", "running")
        poller.client.update_task_status.assert_any_call("task-1", "completed", "Research completed successfully")
        poller.orchestrator.run.assert_called_once_with({"issue_number": 42})

    @pytest.mark.asyncio
    async def test_run_research_task_failure(self, caplog):
        import logging

        from timmy.paperclip import PaperclipPoller, PaperclipTask

        poller = PaperclipPoller()

        mock_task = PaperclipTask(id="task-1", kind="research", context={"issue_number": 42})

        poller.client.update_task_status = AsyncMock()
        poller.orchestrator.run = AsyncMock(side_effect=Exception("Something went wrong"))

        with caplog.at_level(logging.ERROR, logger="timmy.paperclip"):
            await poller.run_research_task(mock_task)

        assert poller.client.update_task_status.call_count == 2
        poller.client.update_task_status.assert_any_call("task-1", "running")
        poller.client.update_task_status.assert_any_call("task-1", "failed", "Something went wrong")
        assert any("Error running research task" in rec.message for rec in caplog.records)


# ── start_paperclip_poller ─────────────────────────────────────────────────────


class TestStartPaperclipPoller:
    """start_paperclip_poller creates and starts the poller."""

    @pytest.mark.asyncio
    async def test_starts_poller_when_enabled(self):
        from timmy.paperclip import start_paperclip_poller

        with patch("timmy.paperclip.settings") as mock_settings:
            mock_settings.paperclip_enabled = True

            mock_poller = MagicMock()
            mock_poller.poll = AsyncMock()

            created_tasks = []
            original_create_task = asyncio.create_task

            def capture_create_task(coro):
                created_tasks.append(coro)
                return original_create_task(coro)

            with patch("timmy.paperclip.PaperclipPoller", return_value=mock_poller):
                with patch("asyncio.create_task", side_effect=capture_create_task):
                    await start_paperclip_poller()

            assert len(created_tasks) == 1

    @pytest.mark.asyncio
    async def test_does_nothing_when_disabled(self):
        from timmy.paperclip import start_paperclip_poller

        with patch("timmy.paperclip.settings") as mock_settings:
            mock_settings.paperclip_enabled = False

            with patch("timmy.paperclip.PaperclipPoller") as mock_poller_class:
                with patch("asyncio.create_task") as mock_create_task:
                    await start_paperclip_poller()

            mock_poller_class.assert_not_called()
            mock_create_task.assert_not_called()
