"""Integration tests for epic_issues functionality with Linear projects.

This module tests the epic_issues tool's ability to:
1. Fetch issues from Linear projects (epics)
2. Handle projects with no issues
3. Handle invalid project IDs
4. Work correctly with the Linear adapter's get_epic and read methods
5. Return properly formatted MCP responses

Context:
- epic_issues tool calls adapter.read(epic_id) to get the epic
- For Linear projects, read() populates child_issues with issue IDs
- The tool then fetches each issue by ID and returns them

Test Project ID: "13ddc89e7271" from https://linear.app/1m-hyperdev/project/epstein-island-13ddc89e7271/issues
"""

from __future__ import annotations

from unittest.mock import AsyncMock, patch

import pytest

from mcp_ticketer.adapters.linear.adapter import LinearAdapter
from mcp_ticketer.core.models import Epic, Task, TicketState, TicketType


class TestEpicIssuesLinear:
    """Integration tests for epic_issues with Linear adapter."""

    @pytest.fixture
    def mock_config(self) -> dict[str, str]:
        """Mock configuration for Linear adapter."""
        return {
            "api_key": "lin_api_test_key_12345678901234567890",
            "team_key": "1M",
            "team_id": "test-team-uuid-1234",
            "workspace": "1m-hyperdev",
        }

    @pytest.fixture
    def adapter(self, mock_config: dict[str, str]) -> LinearAdapter:
        """Create a LinearAdapter instance with mocked client."""
        with patch("mcp_ticketer.adapters.linear.adapter.LinearGraphQLClient"):
            adapter = LinearAdapter(mock_config)
            adapter._initialized = True
            adapter.team_id = "test-team-uuid-1234"
            adapter._workflow_states = {
                "unstarted": {"id": "state-unstarted", "type": "unstarted"},
                "started": {"id": "state-started", "type": "started"},
                "completed": {"id": "state-completed", "type": "completed"},
            }
            adapter._labels_cache = []
            adapter._users_cache = {}
            return adapter

    @pytest.mark.asyncio
    async def test_epic_issues_project_with_issues_returns_list(
        self, adapter: LinearAdapter
    ) -> None:
        """Test: epic_issues for project with issues returns non-empty list.

        Verifies that when a Linear project has issues:
        1. read(epic_id) returns Epic with populated child_issues
        2. Each issue ID in child_issues can be read
        3. Response includes all issues with proper fields
        """
        project_id = "13ddc89e7271"

        # Mock project data with issues
        project_data = {
            "id": "project-uuid-123",
            "name": "Epstein Island",
            "description": "Test project with issues",
            "state": "started",
            "slugId": f"epstein-island-{project_id}",
            "createdAt": "2025-01-15T00:00:00.000Z",
            "updatedAt": "2025-01-15T00:00:00.000Z",
            "url": f"https://linear.app/1m-hyperdev/project/epstein-island-{project_id}/issues",
            "teams": {"nodes": [{"id": "test-team-uuid-1234", "name": "Test Team"}]},
        }

        # Mock project issues
        issue_1_data = {
            "id": "issue-uuid-1",
            "identifier": "1M-101",
            "title": "First issue in project",
            "description": "Description 1",
            "state": {"id": "state-started", "type": "started", "name": "In Progress"},
            "priority": 2,
            "createdAt": "2025-01-15T00:00:00.000Z",
            "updatedAt": "2025-01-15T00:00:00.000Z",
            "url": "https://linear.app/1m-hyperdev/issue/1M-101",
            "project": {"id": "project-uuid-123", "name": "Epstein Island"},
        }

        issue_2_data = {
            "id": "issue-uuid-2",
            "identifier": "1M-102",
            "title": "Second issue in project",
            "description": "Description 2",
            "state": {"id": "state-started", "type": "started", "name": "In Progress"},
            "priority": 1,
            "createdAt": "2025-01-15T01:00:00.000Z",
            "updatedAt": "2025-01-15T01:00:00.000Z",
            "url": "https://linear.app/1m-hyperdev/issue/1M-102",
            "project": {"id": "project-uuid-123", "name": "Epstein Island"},
        }

        # Mock _get_project_issues to return issues
        # Note: Linear uses 'identifier' (e.g., "1M-101") as the 'id' field in Task
        mock_issue_1 = Task(
            id="1M-101",  # Linear identifier becomes id
            title="First issue in project",
            description="Description 1",
            state=TicketState.IN_PROGRESS,
            ticket_type=TicketType.ISSUE,
        )
        mock_issue_2 = Task(
            id="1M-102",  # Linear identifier becomes id
            title="Second issue in project",
            description="Description 2",
            state=TicketState.IN_PROGRESS,
            ticket_type=TicketType.ISSUE,
        )

        # Mock get_project to return project data
        adapter.get_project = AsyncMock(return_value=project_data)

        # Mock _get_project_issues to return issue list
        adapter._get_project_issues = AsyncMock(return_value=[mock_issue_1, mock_issue_2])

        # Mock read() for individual issue fetches
        async def mock_read(ticket_id: str) -> Task | Epic | None:
            if ticket_id == project_id or ticket_id == "project-uuid-123":
                # Return Epic with child_issues populated
                epic = Epic(
                    id="project-uuid-123",
                    title="Epstein Island",
                    description="Test project with issues",
                    state=TicketState.IN_PROGRESS,
                    child_issues=["1M-101", "1M-102"],  # Linear identifiers as IDs
                )
                return epic
            elif ticket_id == "1M-101":
                return mock_issue_1
            elif ticket_id == "1M-102":
                return mock_issue_2
            return None

        adapter.read = AsyncMock(side_effect=mock_read)

        # Call read() to simulate epic_issues tool behavior
        epic = await adapter.read(project_id)

        # Verify epic returned with child_issues
        assert epic is not None
        assert isinstance(epic, Epic)
        assert len(epic.child_issues) == 2
        assert "1M-101" in epic.child_issues
        assert "1M-102" in epic.child_issues

        # Simulate epic_issues tool fetching each issue
        issues = []
        for issue_id in epic.child_issues:
            issue = await adapter.read(issue_id)
            if issue:
                issues.append(issue)

        # Verify issues were fetched
        assert len(issues) == 2
        assert all(isinstance(issue, Task) for issue in issues)
        assert issues[0].id == "1M-101"  # Linear identifier is id
        assert issues[1].id == "1M-102"
        assert issues[0].title == "First issue in project"
        assert issues[1].title == "Second issue in project"

    @pytest.mark.asyncio
    async def test_epic_issues_project_with_no_issues_returns_empty_list(
        self, adapter: LinearAdapter
    ) -> None:
        """Test: epic_issues for project with no issues returns empty list.

        Verifies that when a Linear project has no issues:
        1. read(epic_id) returns Epic with empty child_issues
        2. epic_issues tool returns empty issues list
        3. Response is valid with count=0
        """
        project_id = "empty-project-abc"

        # Mock empty project data
        project_data = {
            "id": "project-uuid-empty",
            "name": "Empty Project",
            "description": "Project with no issues",
            "state": "planned",
            "slugId": f"empty-project-{project_id}",
            "createdAt": "2025-01-15T00:00:00.000Z",
            "updatedAt": "2025-01-15T00:00:00.000Z",
            "url": f"https://linear.app/1m-hyperdev/project/empty-project-{project_id}",
            "teams": {"nodes": [{"id": "test-team-uuid-1234", "name": "Test Team"}]},
        }

        # Mock get_project to return empty project
        adapter.get_project = AsyncMock(return_value=project_data)

        # Mock _get_project_issues to return empty list
        adapter._get_project_issues = AsyncMock(return_value=[])

        # Mock read() to return epic with no child_issues
        async def mock_read(ticket_id: str) -> Epic | None:
            if ticket_id == project_id or ticket_id == "project-uuid-empty":
                return Epic(
                    id="project-uuid-empty",
                    title="Empty Project",
                    description="Project with no issues",
                    state=TicketState.OPEN,
                    child_issues=[],  # No issues
                )
            return None

        adapter.read = AsyncMock(side_effect=mock_read)

        # Call read() to simulate epic_issues tool behavior
        epic = await adapter.read(project_id)

        # Verify epic returned with empty child_issues
        assert epic is not None
        assert isinstance(epic, Epic)
        assert len(epic.child_issues) == 0

        # Simulate epic_issues tool - should get empty list
        issues = []
        for issue_id in epic.child_issues:
            issue = await adapter.read(issue_id)
            if issue:
                issues.append(issue)

        # Verify no issues returned
        assert len(issues) == 0

    @pytest.mark.asyncio
    async def test_epic_issues_invalid_project_id_returns_none(
        self, adapter: LinearAdapter
    ) -> None:
        """Test: epic_issues with invalid project ID returns None.

        Verifies that when project ID doesn't exist:
        1. read(epic_id) returns None
        2. epic_issues tool can handle None response
        3. Proper error can be returned to user
        """
        invalid_project_id = "nonexistent-xyz"

        # Mock get_project to return None (not found)
        adapter.get_project = AsyncMock(return_value=None)

        # Mock read() to return None for invalid ID
        adapter.read = AsyncMock(return_value=None)

        # Call read() to simulate epic_issues tool behavior
        epic = await adapter.read(invalid_project_id)

        # Verify None returned
        assert epic is None

    @pytest.mark.asyncio
    async def test_epic_issues_response_format_matches_mcp_expectations(
        self, adapter: LinearAdapter
    ) -> None:
        """Test: epic_issues response includes required fields for MCP.

        Verifies that the response structure matches MCP tool expectations:
        1. status: "completed" or "error"
        2. issues: list of issue objects
        3. count: number of issues
        4. adapter metadata (adapter, adapter_name, ticket_id)
        """
        project_id = "format-test-123"

        # Mock project with one issue
        mock_issue = Task(
            id="1M-200",  # Linear identifier is id
            title="Format test issue",
            description="Test description",
            state=TicketState.OPEN,
            ticket_type=TicketType.ISSUE,
            metadata={
                "linear": {
                    "url": "https://linear.app/1m-hyperdev/issue/1M-200",
                    "created_at": "2025-01-15T00:00:00.000Z",
                }
            },
        )

        # Mock read() to return epic with issue
        async def mock_read(ticket_id: str) -> Task | Epic | None:
            if ticket_id == project_id:
                return Epic(
                    id="project-format-test",
                    title="Format Test Project",
                    description="Testing response format",
                    state=TicketState.IN_PROGRESS,
                    child_issues=["1M-200"],  # Linear identifier
                )
            elif ticket_id == "1M-200":
                return mock_issue
            return None

        adapter.read = AsyncMock(side_effect=mock_read)

        # Simulate epic_issues tool execution
        epic = await adapter.read(project_id)
        assert epic is not None

        # Fetch issues
        issues = []
        for issue_id in epic.child_issues:
            issue = await adapter.read(issue_id)
            if issue:
                issues.append(issue.model_dump())

        # Verify response structure (as epic_issues tool would return)
        # Note: The actual tool adds metadata, but we're testing the data flow
        assert len(issues) == 1
        assert issues[0]["id"] == "1M-200"  # id field contains Linear identifier
        assert issues[0]["title"] == "Format test issue"
        assert issues[0]["state"] == "open"
        assert "metadata" in issues[0]

    @pytest.mark.asyncio
    async def test_epic_issues_preserves_child_issues_attribute(
        self, adapter: LinearAdapter
    ) -> None:
        """Test: Epic.child_issues attribute is properly preserved.

        Verifies that other adapters (JIRA, GitHub, etc.) can still use
        child_issues attribute even if they populate it differently.

        This ensures backward compatibility with:
        - JIRA epic.get_epic() which may use different field names
        - GitHub milestone which may not have child_issues
        - Asana project which may populate children differently
        """
        # Test with Epic that has child_issues set manually
        manual_epic = Epic(
            id="manual-epic-1",
            title="Manual Epic",
            description="Epic with manually set child_issues",
            state=TicketState.OPEN,
            child_issues=["manual-issue-1", "manual-issue-2", "manual-issue-3"],
        )

        # Verify attribute exists and is accessible
        assert hasattr(manual_epic, "child_issues")
        assert len(manual_epic.child_issues) == 3

        # Test with Epic that has no child_issues (default)
        default_epic = Epic(
            id="default-epic-1",
            title="Default Epic",
            description="Epic with default child_issues",
            state=TicketState.OPEN,
        )

        # Verify attribute exists with default value
        assert hasattr(default_epic, "child_issues")
        assert isinstance(default_epic.child_issues, list)
        assert len(default_epic.child_issues) == 0

        # Test getattr fallback (as used in epic_issues tool)
        child_issue_ids = getattr(manual_epic, "child_issues", [])
        assert len(child_issue_ids) == 3

        child_issue_ids_default = getattr(default_epic, "child_issues", [])
        assert len(child_issue_ids_default) == 0

    @pytest.mark.asyncio
    async def test_epic_issues_handles_partial_issue_fetch_failure(
        self, adapter: LinearAdapter
    ) -> None:
        """Test: epic_issues handles cases where some issues fail to fetch.

        Verifies that if reading individual issues fails:
        1. Tool continues processing other issues
        2. Only successfully fetched issues are included
        3. No exception is raised
        """
        project_id = "partial-fail-project"

        # Mock read() with some failures
        async def mock_read(ticket_id: str) -> Task | Epic | None:
            if ticket_id == project_id:
                return Epic(
                    id="project-partial-fail",
                    title="Partial Fail Project",
                    description="Some issues fail to load",
                    state=TicketState.IN_PROGRESS,
                    child_issues=["1M-301", "1M-302", "1M-303"],  # Linear identifiers
                )
            elif ticket_id == "1M-301":
                return Task(
                    id="1M-301",  # Linear identifier is id
                    title="Success issue 1",
                    description="",
                    state=TicketState.OPEN,
                    ticket_type=TicketType.ISSUE,
                )
            elif ticket_id == "1M-302":
                # Simulate fetch failure
                return None
            elif ticket_id == "1M-303":
                return Task(
                    id="1M-303",  # Linear identifier is id
                    title="Success issue 3",
                    description="",
                    state=TicketState.OPEN,
                    ticket_type=TicketType.ISSUE,
                )
            return None

        adapter.read = AsyncMock(side_effect=mock_read)

        # Simulate epic_issues tool execution
        epic = await adapter.read(project_id)
        assert epic is not None

        # Fetch issues (some will fail)
        issues = []
        for issue_id in epic.child_issues:
            issue = await adapter.read(issue_id)
            if issue:  # epic_issues tool filters out None
                issues.append(issue)

        # Verify only successful issues included
        assert len(issues) == 2
        assert issues[0].id == "1M-301"  # Linear identifier is id
        assert issues[1].id == "1M-303"


class TestEpicIssuesMCPToolIntegration:
    """Test epic_issues MCP tool with hierarchy_tools.py implementation."""

    @pytest.mark.asyncio
    async def test_epic_issues_tool_returns_proper_mcp_response(self) -> None:
        """Test: epic_issues tool returns properly formatted MCP response.

        This test validates the actual MCP tool implementation in hierarchy_tools.py
        """
        from unittest.mock import AsyncMock, patch

        from mcp_ticketer.core.models import Epic, Task, TicketState, TicketType

        # Create mock adapter
        mock_adapter = AsyncMock()
        mock_adapter.adapter_type = "linear"
        mock_adapter.adapter_display_name = "Linear"

        # Mock epic with issues
        mock_epic = Epic(
            id="test-epic-123",
            title="Test Epic",
            description="Epic for testing",
            state=TicketState.IN_PROGRESS,
            child_issues=["TEST-1", "TEST-2"],  # Linear identifiers
        )

        # Mock issues
        mock_issue_1 = Task(
            id="TEST-1",  # Linear identifier is id
            title="Test Issue 1",
            description="",
            state=TicketState.OPEN,
            ticket_type=TicketType.ISSUE,
        )
        mock_issue_2 = Task(
            id="TEST-2",  # Linear identifier is id
            title="Test Issue 2",
            description="",
            state=TicketState.IN_PROGRESS,
            ticket_type=TicketType.ISSUE,
        )

        # Configure mock adapter.read() behavior
        async def mock_read(ticket_id: str) -> Task | Epic | None:
            if ticket_id == "test-epic-123":
                return mock_epic
            elif ticket_id == "TEST-1":
                return mock_issue_1
            elif ticket_id == "TEST-2":
                return mock_issue_2
            return None

        mock_adapter.read = AsyncMock(side_effect=mock_read)

        # Patch get_adapter to return mock
        with patch(
            "mcp_ticketer.mcp.server.tools.hierarchy_tools.get_adapter",
            return_value=mock_adapter,
        ):
            from mcp_ticketer.mcp.server.tools.hierarchy_tools import epic_issues

            # Call epic_issues tool
            result = await epic_issues("test-epic-123")

        # Verify MCP response format
        assert result["status"] == "completed"
        assert "adapter" in result
        assert result["adapter"] == "linear"
        assert "adapter_name" in result
        assert result["adapter_name"] == "Linear"
        assert "issues" in result
        assert isinstance(result["issues"], list)
        assert len(result["issues"]) == 2
        assert "count" in result
        assert result["count"] == 2

        # Verify issue data - id field contains Linear identifier
        assert result["issues"][0]["id"] == "TEST-1"
        assert result["issues"][1]["id"] == "TEST-2"

    @pytest.mark.asyncio
    async def test_epic_issues_tool_handles_epic_not_found(self) -> None:
        """Test: epic_issues tool returns error when epic not found."""
        from unittest.mock import AsyncMock, patch

        # Create mock adapter that returns None
        mock_adapter = AsyncMock()
        mock_adapter.adapter_type = "linear"
        mock_adapter.adapter_display_name = "Linear"
        mock_adapter.read = AsyncMock(return_value=None)

        # Patch get_adapter
        with patch(
            "mcp_ticketer.mcp.server.tools.hierarchy_tools.get_adapter",
            return_value=mock_adapter,
        ):
            from mcp_ticketer.mcp.server.tools.hierarchy_tools import epic_issues

            # Call epic_issues tool with invalid ID
            result = await epic_issues("nonexistent-epic")

        # Verify error response
        assert result["status"] == "error"
        assert "error" in result
        assert "not found" in result["error"].lower()
