"""Unit tests for github/triage.py — pure helpers + GraphQL wrappers (#249)."""
from __future__ import annotations

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

import pytest

from active_claude_github_mcp.github.core import GitHubError
from active_claude_github_mcp.github.triage import (
    add_label_to_issue,
    ensure_fixedin_label,
    extract_public_mirror_urls,
    find_label_id_or_none,
    fixedin_label_description,
    is_pre_release_version,
    list_triaged_closed_issues,
)


TOKEN = "ghp_test_token"


def _mock_response(data: dict, status_code: int = 200):
    mock = MagicMock()
    mock.status_code = status_code
    mock.json.return_value = data
    mock.text = json.dumps(data)
    return mock


class TestIsPreReleaseVersion:
    """#233 OQ5 — alpha/beta/rc are pre-release; GA is not."""

    @pytest.mark.parametrize("v", [
        "2.3.0a1", "2.3.0b2", "2.3.0rc1",
        "1.0.0a10", "10.20.30rc100",
    ])
    def test_pre_release_versions_recognized(self, v):
        assert is_pre_release_version(v)

    @pytest.mark.parametrize("v", [
        "2.3.0", "1.0.0", "10.20.30",
    ])
    def test_ga_versions_not_pre_release(self, v):
        assert not is_pre_release_version(v)

    @pytest.mark.parametrize("v", [
        "", "  ", "not-a-version", "2.3", "2.3.0.dev0", "2.3.0.post1",
    ])
    def test_invalid_or_non_pep440_pre_release_returns_false(self, v):
        # Empty / invalid / non-PEP-440 pre-release suffixes return False.
        # Pre-release in this context is specifically alpha/beta/rc.
        assert not is_pre_release_version(v)

    def test_whitespace_around_version_handled(self):
        assert is_pre_release_version("  2.3.0a1  ")
        assert not is_pre_release_version("  2.3.0  ")


class TestExtractPublicMirrorUrls:
    """#233 OQ1 — body cross-link is canonical; filter to publish targets."""

    PUBLISH_TARGETS = ["telejester-claude-skills/telejester-claude-skills"]

    def test_extracts_single_url(self):
        body = (
            "## Origin\n\n"
            "Triaged from public mirror Issue "
            "[telejester-claude-skills#3]"
            "(https://github.com/telejester-claude-skills/"
            "telejester-claude-skills/issues/3)."
        )
        out = extract_public_mirror_urls(body, self.PUBLISH_TARGETS)
        assert len(out) == 1
        assert out[0]["number"] == 3
        assert out[0]["owner"] == "telejester-claude-skills"
        assert out[0]["repo"] == "telejester-claude-skills"

    def test_filters_to_publish_targets(self):
        body = (
            "Mentions https://github.com/other-org/some-repo/issues/42 "
            "but the canonical cross-link is "
            "https://github.com/telejester-claude-skills/"
            "telejester-claude-skills/issues/3"
        )
        out = extract_public_mirror_urls(body, self.PUBLISH_TARGETS)
        assert len(out) == 1
        assert out[0]["number"] == 3

    def test_dedupes_repeated_urls(self):
        url = (
            "https://github.com/telejester-claude-skills/"
            "telejester-claude-skills/issues/3"
        )
        body = f"first ref {url}\n\nsecond ref {url}\n"
        out = extract_public_mirror_urls(body, self.PUBLISH_TARGETS)
        assert len(out) == 1

    def test_empty_body_returns_empty(self):
        assert extract_public_mirror_urls("", self.PUBLISH_TARGETS) == []

    def test_no_publish_target_match_returns_empty(self):
        body = "https://github.com/random-owner/random-repo/issues/1"
        out = extract_public_mirror_urls(body, self.PUBLISH_TARGETS)
        assert out == []

    def test_multiple_publish_targets_supported(self):
        targets = [
            "owner-a/repo-a",
            "owner-b/repo-b",
        ]
        body = (
            "https://github.com/owner-a/repo-a/issues/1 "
            "https://github.com/owner-b/repo-b/issues/2 "
            "https://github.com/other/x/issues/99"
        )
        out = extract_public_mirror_urls(body, targets)
        assert len(out) == 2
        numbers = sorted(e["number"] for e in out)
        assert numbers == [1, 2]

    def test_owner_repo_match_case_insensitive(self):
        body = (
            "https://github.com/Telejester-Claude-Skills/"
            "Telejester-Claude-Skills/issues/3"
        )
        out = extract_public_mirror_urls(body, self.PUBLISH_TARGETS)
        assert len(out) == 1


class TestFindLabelIdOrNone:
    @pytest.mark.asyncio
    async def test_found_returns_id(self):
        response = {"data": {"repository": {"label": {"id": "LA_x"}}}}
        with patch.object(httpx.AsyncClient, "post", new=AsyncMock(return_value=_mock_response(response))):
            out = await find_label_id_or_none(TOKEN, "o", "r", "fixedin:2.3.0")
        assert out == "LA_x"

    @pytest.mark.asyncio
    async def test_missing_returns_none(self):
        response = {"data": {"repository": {"label": None}}}
        with patch.object(httpx.AsyncClient, "post", new=AsyncMock(return_value=_mock_response(response))):
            out = await find_label_id_or_none(TOKEN, "o", "r", "fixedin:9.9.9")
        assert out is None

    @pytest.mark.asyncio
    async def test_empty_label_name_raises(self):
        with pytest.raises(ValueError, match="label_name"):
            await find_label_id_or_none(TOKEN, "o", "r", "")


class TestEnsureFixedinLabel:
    @pytest.mark.asyncio
    async def test_returns_existing_label_without_creating(self):
        # Label exists — no createLabel mutation should fire.
        find_response = {"data": {"repository": {"label": {"id": "LA_existing"}}}}
        with patch.object(httpx.AsyncClient, "post", new=AsyncMock(return_value=_mock_response(find_response))):
            out = await ensure_fixedin_label(TOKEN, "R_repo", "o", "r", "2.3.0")
        assert out == "LA_existing"

    @pytest.mark.asyncio
    async def test_creates_label_when_absent(self):
        # First call: label not found. Second call: createLabel mutation.
        find_response = {"data": {"repository": {"label": None}}}
        create_response = {"data": {"createLabel": {"label": {"id": "LA_new"}}}}
        captured: list = []

        def _capture(url, *, headers=None, json=None, **_):
            captured.append(json)
            # First call is the find; second is the create.
            if len(captured) == 1:
                return _mock_response(find_response)
            return _mock_response(create_response)

        with patch.object(httpx.AsyncClient, "post", new=AsyncMock(side_effect=_capture)):
            out = await ensure_fixedin_label(TOKEN, "R_repo", "o", "r", "2.3.0")

        assert out == "LA_new"
        assert len(captured) == 2
        # Verify the create payload carries the expected color + description.
        create_vars = captured[1]["variables"]
        assert create_vars["name"] == "fixedin:2.3.0"
        assert create_vars["color"] == "0E8A16"
        assert "v2.3.0" in create_vars["description"]


def test_fixedin_label_description_format():
    desc = fixedin_label_description("2.3.0")
    assert "v2.3.0" in desc
    assert "upgrade" in desc


class TestListTriagedClosedIssues:
    @pytest.mark.asyncio
    async def test_search_query_filters_milestone_and_label(self):
        response = {"data": {"search": {"nodes": []}}}
        captured = {}

        def _capture(url, *, headers=None, json=None, **_):
            captured["q"] = json["variables"]["q"]
            return _mock_response(response)

        with patch.object(httpx.AsyncClient, "post", new=AsyncMock(side_effect=_capture)):
            await list_triaged_closed_issues(TOKEN, "o", "r", "2.3.0")

        q = captured["q"]
        assert "is:issue" in q
        assert "is:closed" in q
        assert "repo:o/r" in q
        assert 'milestone:"2.3.0"' in q
        assert "label:area:public-mirror" in q

    @pytest.mark.asyncio
    async def test_returns_shaped_results(self):
        response = {"data": {"search": {"nodes": [
            {
                "number": 231,
                "title": "feature: update_discussion_body",
                "url": "https://github.com/o/r/issues/231",
                "body": "## Origin\n[mirror#3](https://github.com/o/m/issues/3)",
            },
        ]}}}
        with patch.object(httpx.AsyncClient, "post", new=AsyncMock(return_value=_mock_response(response))):
            out = await list_triaged_closed_issues(TOKEN, "o", "r", "2.3.0")

        assert len(out) == 1
        assert out[0]["number"] == 231
        assert out[0]["body"].startswith("## Origin")

    @pytest.mark.asyncio
    async def test_empty_owner_raises(self):
        with pytest.raises(ValueError, match="owner and repo"):
            await list_triaged_closed_issues(TOKEN, "", "r", "2.3.0")

    @pytest.mark.asyncio
    async def test_empty_version_raises(self):
        with pytest.raises(ValueError, match="version"):
            await list_triaged_closed_issues(TOKEN, "o", "r", "")

    @pytest.mark.asyncio
    async def test_invalid_limit_raises(self):
        with pytest.raises(ValueError, match="limit"):
            await list_triaged_closed_issues(TOKEN, "o", "r", "2.3.0", limit=0)
        with pytest.raises(ValueError, match="limit"):
            await list_triaged_closed_issues(TOKEN, "o", "r", "2.3.0", limit=101)


class TestAddLabelToIssue:
    @pytest.mark.asyncio
    async def test_passes_labelable_and_label_id(self):
        response = {"data": {"addLabelsToLabelable": {"clientMutationId": None}}}
        captured = {}

        def _capture(url, *, headers=None, json=None, **_):
            captured["vars"] = json["variables"]
            return _mock_response(response)

        with patch.object(httpx.AsyncClient, "post", new=AsyncMock(side_effect=_capture)):
            await add_label_to_issue(TOKEN, "I_issue", "LA_label")

        assert captured["vars"]["labelable"] == "I_issue"
        assert captured["vars"]["labels"] == ["LA_label"]

    @pytest.mark.asyncio
    async def test_empty_args_raise(self):
        with pytest.raises(ValueError, match="empty"):
            await add_label_to_issue(TOKEN, "", "LA_x")
        with pytest.raises(ValueError, match="empty"):
            await add_label_to_issue(TOKEN, "I_x", "")


if __name__ == "__main__":
    import sys
    sys.exit(pytest.main([__file__, "-v"]))
