"""Unit tests for github.py — context-recovery list helpers.

Covers list_thread_comments, list_open_issues, list_recent_discussions,
list_milestones_with_progress. These are the reads that back the
recover_context tool (#58).
"""
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.recovery import (
    fetch_recover_context_bundle,
    get_milestone_by_title,
    list_milestones_with_progress,
    list_open_issues,
    list_recent_discussions,
    list_thread_comments,
)


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 TestListThreadComments:
    def _make_response(self, nodes):
        return {"data": {"node": {"comments": {"nodes": nodes}}}}

    @pytest.mark.asyncio

    async def test_discussion(self):
        nodes = [
            {
                "id": "DC_1",
                "body": "hi",
                "createdAt": "2026-01-01T00:00:00Z",
                "author": {"login": "alice"},
            },
            {
                "id": "DC_2",
                "body": "hey",
                "createdAt": "2026-01-02T00:00:00Z",
                "author": None,  # author can be null
            },
        ]
        with patch.object(httpx.AsyncClient, "post", new=AsyncMock(return_value=_mock_response(self._make_response(nodes)))):
            out = await list_thread_comments(TOKEN, "D_abc", "discussion")
        assert len(out) == 2
        assert out[0]["comment_id"] == "DC_1"
        assert out[0]["author"] == "alice"
        assert out[1]["author"] == ""  # null mapped to empty string

    @pytest.mark.asyncio

    async def test_issue(self):
        nodes = [
            {
                "id": "IC_1",
                "body": "report",
                "createdAt": "2026-02-01T00:00:00Z",
                "author": {"login": "bob"},
            },
        ]
        with patch.object(httpx.AsyncClient, "post", new=AsyncMock(return_value=_mock_response(self._make_response(nodes)))):
            out = await list_thread_comments(TOKEN, "I_abc", "issue")
        assert out[0]["author"] == "bob"

    @pytest.mark.asyncio

    async def test_missing_thread_raises(self):
        with patch.object(httpx.AsyncClient, "post", new=AsyncMock(return_value=_mock_response({"data": {"node": None}}))):
            with pytest.raises(GitHubError, match="not found"):
                await list_thread_comments(TOKEN, "D_missing", "discussion")

    @pytest.mark.asyncio

    async def test_invalid_type_raises(self):
        with pytest.raises(ValueError, match="thread_type"):
            await list_thread_comments(TOKEN, "D_abc", "pull_request")

    @pytest.mark.asyncio

    async def test_empty_thread_id_raises(self):
        with pytest.raises(ValueError, match="thread_id"):
            await list_thread_comments(TOKEN, "", "discussion")

    @pytest.mark.asyncio

    async def test_limit_out_of_range_raises(self):
        with pytest.raises(ValueError, match="limit"):
            await list_thread_comments(TOKEN, "D_abc", "discussion", limit=0)
        with pytest.raises(ValueError, match="limit"):
            await list_thread_comments(TOKEN, "D_abc", "discussion", limit=101)


class TestListOpenIssues:
    @pytest.mark.asyncio
    async def test_maps_fields_and_labels(self):
        response_data = {
            "data": {
                "repository": {
                    "issues": {
                        "nodes": [
                            {
                                "number": 7,
                                "title": "Alpha",
                                "url": "https://github.com/o/r/issues/7",
                                "updatedAt": "2026-04-20T00:00:00Z",
                                "labels": {"nodes": [{"name": "bug"}, {"name": "area:mcp"}]},
                            },
                            {
                                "number": 8,
                                "title": "Beta",
                                "url": "https://github.com/o/r/issues/8",
                                "updatedAt": "2026-04-19T00:00:00Z",
                                "labels": {"nodes": []},
                            },
                        ]
                    }
                }
            }
        }
        with patch.object(httpx.AsyncClient, "post", new=AsyncMock(return_value=_mock_response(response_data))):
            issues = await list_open_issues(TOKEN, "o", "r", limit=50)
        assert len(issues) == 2
        assert issues[0]["number"] == 7
        assert issues[0]["labels"] == ["bug", "area:mcp"]
        assert issues[1]["labels"] == []

    @pytest.mark.asyncio

    async def test_empty_repo_returns_empty_list(self):
        response_data = {"data": {"repository": {"issues": {"nodes": []}}}}
        with patch.object(httpx.AsyncClient, "post", new=AsyncMock(return_value=_mock_response(response_data))):
            assert await list_open_issues(TOKEN, "o", "r") == []

    @pytest.mark.asyncio

    async def test_empty_owner_raises(self):
        with pytest.raises(ValueError, match="owner"):
            await list_open_issues(TOKEN, "", "r")

    @pytest.mark.asyncio

    async def test_limit_out_of_range_raises(self):
        with pytest.raises(ValueError, match="limit"):
            await list_open_issues(TOKEN, "o", "r", limit=0)
        with pytest.raises(ValueError, match="limit"):
            await list_open_issues(TOKEN, "o", "r", limit=101)


class TestListRecentDiscussions:
    def _make_response(self, nodes):
        return {"data": {"repository": {"discussions": {"nodes": nodes}}}}

    @pytest.mark.asyncio

    async def test_filters_older_than_cutoff(self):
        from datetime import datetime, timedelta, timezone
        now = datetime.now(timezone.utc)
        fresh = (now - timedelta(days=3)).isoformat().replace("+00:00", "Z")
        stale = (now - timedelta(days=30)).isoformat().replace("+00:00", "Z")
        nodes = [
            {
                "number": 1,
                "title": "Fresh",
                "url": "u1",
                "updatedAt": fresh,
                "category": {"name": "Architecture"},
            },
            {
                "number": 2,
                "title": "Stale",
                "url": "u2",
                "updatedAt": stale,
                "category": {"name": "Ideas"},
            },
        ]
        with patch.object(httpx.AsyncClient, "post", new=AsyncMock(return_value=_mock_response(self._make_response(nodes)))):
            out = await list_recent_discussions(TOKEN, "o", "r", since_days=7)
        assert len(out) == 1
        assert out[0]["number"] == 1
        assert out[0]["category"] == "Architecture"

    @pytest.mark.asyncio

    async def test_empty_repo_returns_empty(self):
        with patch.object(httpx.AsyncClient, "post", new=AsyncMock(return_value=_mock_response(self._make_response([])))):
            assert await list_recent_discussions(TOKEN, "o", "r") == []

    @pytest.mark.asyncio

    async def test_zero_since_days_raises(self):
        with pytest.raises(ValueError, match="since_days"):
            await list_recent_discussions(TOKEN, "o", "r", since_days=0)

    @pytest.mark.asyncio

    async def test_empty_owner_raises(self):
        with pytest.raises(ValueError, match="owner"):
            await list_recent_discussions(TOKEN, "", "r")


class TestListMilestonesWithProgress:
    @pytest.mark.asyncio
    async def test_maps_fields(self):
        response_data = {
            "data": {
                "repository": {
                    "milestones": {
                        "nodes": [
                            {
                                "title": "1.2.0",
                                "dueOn": "2026-05-01T00:00:00Z",
                                "progressPercentage": 44.4,
                            },
                            {
                                "title": "1.3.0",
                                "dueOn": None,
                                "progressPercentage": 0,
                            },
                        ]
                    }
                }
            }
        }
        with patch.object(httpx.AsyncClient, "post", new=AsyncMock(return_value=_mock_response(response_data))):
            out = await list_milestones_with_progress(TOKEN, "o", "r")
        assert len(out) == 2
        assert out[0]["title"] == "1.2.0"
        assert out[0]["due_on"] == "2026-05-01T00:00:00Z"
        assert out[0]["percent_complete"] == pytest.approx(44.4)
        assert out[1]["due_on"] is None
        assert out[1]["percent_complete"] == 0.0

    @pytest.mark.asyncio

    async def test_empty_owner_raises(self):
        with pytest.raises(ValueError, match="owner"):
            await list_milestones_with_progress(TOKEN, "", "r")

    @pytest.mark.asyncio

    async def test_empty_repo_returns_empty_list(self):
        response_data = {"data": {"repository": {"milestones": {"nodes": []}}}}
        with patch.object(httpx.AsyncClient, "post", new=AsyncMock(return_value=_mock_response(response_data))):
            assert await list_milestones_with_progress(TOKEN, "o", "r") == []


class TestGetMilestoneByTitle:
    def _response(self, milestones: list[dict]):
        return {"data": {"repository": {"milestones": {"nodes": milestones}}}}

    @pytest.mark.asyncio

    async def test_exact_match_returns_details(self):
        resp = self._response([
            {
                "number": 2,
                "title": "1.3.0",
                "state": "OPEN",
                "issues": {
                    "nodes": [
                        {"number": 103, "title": "skill: prose", "url": "u/103"},
                        {"number": 107, "title": "MCP: pre-flight", "url": "u/107"},
                    ]
                },
            },
        ])
        with patch.object(httpx.AsyncClient, "post", new=AsyncMock(return_value=_mock_response(resp))):
            out = await get_milestone_by_title(TOKEN, "o", "r", "1.3.0")
        assert out is not None
        assert out["number"] == 2
        assert out["state"] == "OPEN"
        assert out["open_issues"] == [
            {"number": 103, "title": "skill: prose", "url": "u/103"},
            {"number": 107, "title": "MCP: pre-flight", "url": "u/107"},
        ]

    @pytest.mark.asyncio

    async def test_no_match_returns_none(self):
        resp = self._response([
            {"number": 1, "title": "1.2.0", "state": "CLOSED",
             "issues": {"nodes": []}},
        ])
        with patch.object(httpx.AsyncClient, "post", new=AsyncMock(return_value=_mock_response(resp))):
            assert await get_milestone_by_title(TOKEN, "o", "r", "1.3.0") is None

    @pytest.mark.asyncio

    async def test_closed_milestone_no_open_issues(self):
        resp = self._response([
            {"number": 2, "title": "1.3.0", "state": "CLOSED",
             "issues": {"nodes": []}},
        ])
        with patch.object(httpx.AsyncClient, "post", new=AsyncMock(return_value=_mock_response(resp))):
            out = await get_milestone_by_title(TOKEN, "o", "r", "1.3.0")
        assert out["state"] == "CLOSED"
        assert out["open_issues"] == []

    @pytest.mark.asyncio

    async def test_empty_title_raises(self):
        with pytest.raises(ValueError, match="title"):
            await get_milestone_by_title(TOKEN, "o", "r", "   ")

    @pytest.mark.asyncio

    async def test_empty_owner_raises(self):
        with pytest.raises(ValueError, match="owner"):
            await get_milestone_by_title(TOKEN, "", "r", "1.3.0")


class TestFetchRecoverContextBundle:
    """#181 — single-round-trip aliased GraphQL bundle for recover_context."""

    def _bundle_response(self) -> dict:
        """A representative GraphQL response with all four aliases populated."""
        return {
            "data": {
                "tocDiscussion": {"body": "# TOC body"},
                "repository": {
                    "openIssues": {
                        "nodes": [
                            {
                                "number": 42,
                                "title": "An issue",
                                "url": "https://example/i/42",
                                "updatedAt": "2026-04-27T00:00:00Z",
                                "labels": {"nodes": [{"name": "bug"}]},
                            },
                        ],
                    },
                    "recentDiscussions": {
                        "nodes": [
                            {
                                "number": 100,
                                "title": "A discussion",
                                "url": "https://example/d/100",
                                "updatedAt": "2026-04-26T00:00:00Z",
                                "category": {"name": "Architecture"},
                            },
                        ],
                    },
                    "milestones": {
                        "nodes": [
                            {
                                "title": "2.1.0",
                                "dueOn": None,
                                "progressPercentage": 33.0,
                            },
                        ],
                    },
                },
            }
        }

    @pytest.mark.asyncio
    async def test_returns_all_four_payloads_from_one_request(self):
        mock_post = AsyncMock(return_value=_mock_response(self._bundle_response()))
        with patch.object(httpx.AsyncClient, "post", new=mock_post):
            out = await fetch_recover_context_bundle(
                TOKEN, "owner", "repo", "D_toc_id_123456789", since_days=30
            )
        assert mock_post.call_count == 1, "must collapse to one round-trip"
        assert out["toc_body"] == "# TOC body"
        assert out["open_issues"][0]["number"] == 42
        assert out["open_issues"][0]["labels"] == ["bug"]
        assert out["recent_discussions"][0]["category"] == "Architecture"
        assert out["milestones"][0]["title"] == "2.1.0"
        assert out["milestones"][0]["percent_complete"] == 33.0

    @pytest.mark.asyncio
    async def test_query_has_aliased_fields(self):
        bundle_response = self._bundle_response()
        mock_post = AsyncMock(return_value=_mock_response(bundle_response))
        with patch.object(httpx.AsyncClient, "post", new=mock_post):
            await fetch_recover_context_bundle(
                TOKEN, "o", "r", "D_id_long_enough_xx", since_days=7
            )

        # `patch.object(httpx.AsyncClient, "post", new=AsyncMock(...))`
        # replaces the unbound method; the first positional arg the
        # mock receives is the client instance, then the GraphQL URL.
        # `json={"query": ..., "variables": {...}}` lives in kwargs.
        kwargs = mock_post.call_args.kwargs
        query = (kwargs.get("json") or {}).get("query", "")
        # All four aliases must appear in the single GraphQL document.
        assert "tocDiscussion:" in query
        assert "openIssues:" in query
        assert "recentDiscussions:" in query
        assert "milestones(" in query

    @pytest.mark.asyncio
    async def test_missing_toc_discussion_returns_none_body(self):
        response = self._bundle_response()
        response["data"]["tocDiscussion"] = None
        mock_post = AsyncMock(return_value=_mock_response(response))
        with patch.object(httpx.AsyncClient, "post", new=mock_post):
            out = await fetch_recover_context_bundle(
                TOKEN, "o", "r", "D_id_long_enough_xx"
            )
        assert out["toc_body"] is None
        # The other three lists still come through.
        assert len(out["open_issues"]) == 1

    @pytest.mark.asyncio
    async def test_recent_discussions_filtered_by_since_days(self):
        from datetime import datetime, timedelta, timezone

        now = datetime.now(timezone.utc)
        recent = (now - timedelta(days=2)).isoformat().replace("+00:00", "Z")
        old = (now - timedelta(days=30)).isoformat().replace("+00:00", "Z")
        response = self._bundle_response()
        response["data"]["repository"]["recentDiscussions"] = {
            "nodes": [
                {
                    "number": 1, "title": "fresh", "url": "u",
                    "updatedAt": recent,
                    "category": {"name": "Ideas"},
                },
                {
                    "number": 2, "title": "stale", "url": "u",
                    "updatedAt": old,
                    "category": {"name": "Ideas"},
                },
            ],
        }
        mock_post = AsyncMock(return_value=_mock_response(response))
        with patch.object(httpx.AsyncClient, "post", new=mock_post):
            out = await fetch_recover_context_bundle(
                TOKEN, "o", "r", "D_id_long_enough_xx", since_days=7
            )
        # Only the fresh discussion makes the cutoff.
        assert [d["number"] for d in out["recent_discussions"]] == [1]

    @pytest.mark.asyncio
    async def test_empty_owner_raises(self):
        with pytest.raises(ValueError, match="owner"):
            await fetch_recover_context_bundle(
                TOKEN, "", "r", "D_id_long_enough_xx"
            )

    @pytest.mark.asyncio
    async def test_empty_toc_id_raises(self):
        with pytest.raises(ValueError, match="toc_discussion_id"):
            await fetch_recover_context_bundle(TOKEN, "o", "r", "")

    @pytest.mark.asyncio
    async def test_invalid_limit_raises(self):
        with pytest.raises(ValueError, match="issue_limit"):
            await fetch_recover_context_bundle(
                TOKEN, "o", "r", "D_id_long_enough_xx", issue_limit=0
            )

    @pytest.mark.asyncio
    async def test_zero_since_days_raises(self):
        with pytest.raises(ValueError, match="since_days"):
            await fetch_recover_context_bundle(
                TOKEN, "o", "r", "D_id_long_enough_xx", since_days=0
            )


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