"""Tests for research triage — action item extraction and Gitea issue filing."""

import json
from unittest.mock import AsyncMock, MagicMock, patch

import httpx
import pytest

from timmy.research_triage import (
    ActionItem,
    _parse_llm_response,
    _validate_action_item,
    create_gitea_issue,
    extract_action_items,
    triage_research_report,
)

# ---------------------------------------------------------------------------
# ActionItem
# ---------------------------------------------------------------------------

SAMPLE_REPORT = """
## Research: MCP Abstraction Layer

### Finding 1: FastMCP overhead is negligible
FastMCP averages 26.45ms per tool call. Total overhead <3% of budget.

### Finding 2: Agno tool calling is broken
Agno issues #2231, #2625 document persistent breakage with Ollama.
Fix: Use Ollama's `format` parameter with Pydantic JSON schemas.

### Recommendation
Implement three-tier router for structured output.
"""

SAMPLE_LLM_RESPONSE = json.dumps(
    [
        {
            "title": "[Router] Implement three-tier structured output router",
            "body": (
                "**What:** Build a three-tier router that uses Ollama's "
                "`format` parameter for structured output.\n"
                "**Why:** Agno's native tool calling is broken (#2231, #2625). "
                "Pydantic JSON schemas with `format` bypass the issue.\n"
                "**Suggested approach:** Add format parameter support to "
                "CascadeRouter.\n"
                "**Acceptance criteria:** Tool calls return valid JSON matching "
                "the Pydantic schema."
            ),
            "labels": ["actionable", "feature", "kimi-ready"],
            "priority": "high",
            "source_urls": ["https://github.com/agno-agi/agno/issues/2231"],
        },
    ]
)


class TestActionItem:
    def test_to_issue_body_basic(self):
        item = ActionItem(title="Test", body="Test body")
        body = item.to_issue_body()
        assert "Test body" in body
        assert "Auto-triaged" in body

    def test_to_issue_body_with_source_issue(self):
        item = ActionItem(title="Test", body="Test body")
        body = item.to_issue_body(source_issue=946)
        assert "#946" in body
        assert "Origin" in body

    def test_to_issue_body_with_source_urls(self):
        item = ActionItem(
            title="Test",
            body="Body",
            source_urls=["https://example.com/finding"],
        )
        body = item.to_issue_body()
        assert "https://example.com/finding" in body
        assert "Source Evidence" in body


# ---------------------------------------------------------------------------
# _parse_llm_response
# ---------------------------------------------------------------------------


class TestParseLlmResponse:
    def test_plain_json(self):
        items = _parse_llm_response('[{"title": "foo"}]')
        assert len(items) == 1
        assert items[0]["title"] == "foo"

    def test_fenced_json(self):
        raw = '```json\n[{"title": "bar"}]\n```'
        items = _parse_llm_response(raw)
        assert len(items) == 1
        assert items[0]["title"] == "bar"

    def test_empty_array(self):
        assert _parse_llm_response("[]") == []

    def test_non_array_returns_empty(self):
        assert _parse_llm_response('{"title": "not an array"}') == []

    def test_invalid_json_raises(self):
        with pytest.raises(json.JSONDecodeError):
            _parse_llm_response("not json at all")


# ---------------------------------------------------------------------------
# _validate_action_item
# ---------------------------------------------------------------------------


class TestValidateActionItem:
    def test_valid_item(self):
        raw = {
            "title": "[Area] A specific clear title",
            "body": "Detailed body with enough content to be useful.",
            "labels": ["actionable", "bug"],
            "priority": "high",
        }
        item = _validate_action_item(raw)
        assert item is not None
        assert item.title == "[Area] A specific clear title"
        assert item.priority == "high"
        assert "actionable" in item.labels

    def test_short_title_rejected(self):
        raw = {"title": "Short", "body": "Detailed body with enough content here."}
        assert _validate_action_item(raw) is None

    def test_short_body_rejected(self):
        raw = {"title": "A perfectly fine title here", "body": "Too short"}
        assert _validate_action_item(raw) is None

    def test_missing_title_rejected(self):
        raw = {"body": "Detailed body with enough content to be useful."}
        assert _validate_action_item(raw) is None

    def test_non_dict_rejected(self):
        assert _validate_action_item("not a dict") is None

    def test_actionable_label_auto_added(self):
        raw = {
            "title": "A perfectly fine title here",
            "body": "Detailed body with enough content to be useful.",
            "labels": ["bug"],
        }
        item = _validate_action_item(raw)
        assert item is not None
        assert "actionable" in item.labels

    def test_labels_as_csv_string(self):
        raw = {
            "title": "A perfectly fine title here",
            "body": "Detailed body with enough content to be useful.",
            "labels": "bug, feature",
        }
        item = _validate_action_item(raw)
        assert item is not None
        assert "bug" in item.labels
        assert "feature" in item.labels

    def test_invalid_priority_defaults_medium(self):
        raw = {
            "title": "A perfectly fine title here",
            "body": "Detailed body with enough content to be useful.",
            "priority": "urgent",
        }
        item = _validate_action_item(raw)
        assert item is not None
        assert item.priority == "medium"


# ---------------------------------------------------------------------------
# extract_action_items
# ---------------------------------------------------------------------------


class TestExtractActionItems:
    @pytest.mark.asyncio
    async def test_extracts_items_from_report(self):
        mock_llm = AsyncMock(return_value=SAMPLE_LLM_RESPONSE)
        items = await extract_action_items(SAMPLE_REPORT, llm_caller=mock_llm)
        assert len(items) == 1
        assert "three-tier" in items[0].title.lower()
        assert items[0].priority == "high"
        mock_llm.assert_called_once()

    @pytest.mark.asyncio
    async def test_empty_report_returns_empty(self):
        items = await extract_action_items("")
        assert items == []

    @pytest.mark.asyncio
    async def test_llm_failure_returns_empty(self):
        mock_llm = AsyncMock(side_effect=RuntimeError("LLM down"))
        items = await extract_action_items(SAMPLE_REPORT, llm_caller=mock_llm)
        assert items == []

    @pytest.mark.asyncio
    async def test_llm_returns_empty_string(self):
        mock_llm = AsyncMock(return_value="")
        items = await extract_action_items(SAMPLE_REPORT, llm_caller=mock_llm)
        assert items == []

    @pytest.mark.asyncio
    async def test_llm_returns_invalid_json(self):
        mock_llm = AsyncMock(return_value="not valid json")
        items = await extract_action_items(SAMPLE_REPORT, llm_caller=mock_llm)
        assert items == []

    @pytest.mark.asyncio
    async def test_caps_at_five_items(self):
        many_items = [
            {
                "title": f"[Area] Action item number {i} is specific",
                "body": f"Detailed body for action item {i} with enough words.",
                "labels": ["actionable"],
                "priority": "medium",
            }
            for i in range(10)
        ]
        mock_llm = AsyncMock(return_value=json.dumps(many_items))
        items = await extract_action_items(SAMPLE_REPORT, llm_caller=mock_llm)
        assert len(items) <= 5


# ---------------------------------------------------------------------------
# create_gitea_issue
# ---------------------------------------------------------------------------


class TestCreateGiteaIssue:
    @pytest.mark.asyncio
    async def test_creates_issue_via_api(self):
        item = ActionItem(
            title="[Test] Create a test issue",
            body="This is a test issue body with details.",
            labels=["actionable"],
        )
        issue_resp = MagicMock()
        issue_resp.status_code = 201
        issue_resp.json.return_value = {"number": 42, "title": item.title}

        mock_client = AsyncMock()
        mock_client.post.return_value = issue_resp

        with (
            patch("timmy.research_triage.settings") as mock_settings,
            patch(
                "timmy.research_triage._resolve_label_ids", new_callable=AsyncMock, return_value=[1]
            ),
            patch("timmy.research_triage.httpx.AsyncClient") as mock_cls,
        ):
            mock_settings.gitea_enabled = True
            mock_settings.gitea_token = "test-token"
            mock_settings.gitea_repo = "owner/repo"
            mock_settings.gitea_url = "http://localhost:3000"
            mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
            mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)
            result = await create_gitea_issue(item, source_issue=946)

        assert result is not None
        assert result["number"] == 42

    @pytest.mark.asyncio
    async def test_returns_none_when_disabled(self):
        item = ActionItem(title="[Test] Disabled test", body="Body content here.")
        with patch("timmy.research_triage.settings") as mock_settings:
            mock_settings.gitea_enabled = False
            mock_settings.gitea_token = ""
            result = await create_gitea_issue(item)
        assert result is None

    @pytest.mark.asyncio
    async def test_handles_connection_error(self):
        item = ActionItem(
            title="[Test] Connection fail",
            body="Body content for connection test.",
        )
        mock_client = AsyncMock()
        mock_client.post.side_effect = httpx.ConnectError("refused")

        with (
            patch("timmy.research_triage.settings") as mock_settings,
            patch(
                "timmy.research_triage._resolve_label_ids", new_callable=AsyncMock, return_value=[]
            ),
            patch("timmy.research_triage.httpx.AsyncClient") as mock_cls,
        ):
            mock_settings.gitea_enabled = True
            mock_settings.gitea_token = "test-token"
            mock_settings.gitea_repo = "owner/repo"
            mock_settings.gitea_url = "http://localhost:3000"
            mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
            mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)
            result = await create_gitea_issue(item)
        assert result is None


# ---------------------------------------------------------------------------
# triage_research_report (integration)
# ---------------------------------------------------------------------------


class TestTriageResearchReport:
    @pytest.mark.asyncio
    async def test_dry_run_extracts_without_filing(self):
        mock_llm = AsyncMock(return_value=SAMPLE_LLM_RESPONSE)
        results = await triage_research_report(
            SAMPLE_REPORT, source_issue=946, llm_caller=mock_llm, dry_run=True
        )
        assert len(results) == 1
        assert results[0]["action_item"] is not None
        assert results[0]["gitea_issue"] is None

    @pytest.mark.asyncio
    async def test_empty_report_returns_empty(self):
        results = await triage_research_report("", llm_caller=AsyncMock(return_value="[]"))
        assert results == []

    @pytest.mark.asyncio
    async def test_end_to_end_with_mock_gitea(self):
        mock_llm = AsyncMock(return_value=SAMPLE_LLM_RESPONSE)

        issue_resp = MagicMock()
        issue_resp.status_code = 201
        issue_resp.json.return_value = {"number": 99, "title": "test"}

        mock_client = AsyncMock()
        mock_client.post.return_value = issue_resp

        with (
            patch("timmy.research_triage.settings") as mock_settings,
            patch(
                "timmy.research_triage._resolve_label_ids", new_callable=AsyncMock, return_value=[]
            ),
            patch("timmy.research_triage.httpx.AsyncClient") as mock_cls,
        ):
            mock_settings.gitea_enabled = True
            mock_settings.gitea_token = "test-token"
            mock_settings.gitea_repo = "owner/repo"
            mock_settings.gitea_url = "http://localhost:3000"
            mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
            mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)
            results = await triage_research_report(
                SAMPLE_REPORT, source_issue=946, llm_caller=mock_llm
            )

        assert len(results) == 1
        assert results[0]["gitea_issue"]["number"] == 99
