"""GitHub adapter implementation using REST API v3 and GraphQL API v4."""

from __future__ import annotations

import builtins
import logging
import re
from datetime import date, datetime
from pathlib import Path
from typing import Any

import httpx

from ..core.adapter import BaseAdapter
from ..core.env_loader import load_adapter_config, validate_adapter_config
from ..core.models import (
    Comment,
    Epic,
    Milestone,
    Priority,
    SearchQuery,
    Task,
    TicketState,
)
from ..core.registry import AdapterRegistry

logger = logging.getLogger(__name__)


class GitHubStateMapping:
    """GitHub issue states and label-based extended states."""

    # GitHub native states
    OPEN = "open"
    CLOSED = "closed"

    # Extended states via labels
    STATE_LABELS = {
        TicketState.IN_PROGRESS: "in-progress",
        TicketState.READY: "ready",
        TicketState.TESTED: "tested",
        TicketState.WAITING: "waiting",
        TicketState.BLOCKED: "blocked",
    }

    # Priority labels
    PRIORITY_LABELS = {
        Priority.CRITICAL: ["P0", "critical", "urgent"],
        Priority.HIGH: ["P1", "high"],
        Priority.MEDIUM: ["P2", "medium"],
        Priority.LOW: ["P3", "low"],
    }


class GitHubGraphQLQueries:
    """GraphQL queries for GitHub API v4."""

    ISSUE_FRAGMENT = """
        fragment IssueFields on Issue {
            id
            number
            title
            body
            state
            createdAt
            updatedAt
            url
            author {
                login
            }
            assignees(first: 10) {
                nodes {
                    login
                    email
                }
            }
            labels(first: 20) {
                nodes {
                    name
                    color
                }
            }
            milestone {
                id
                number
                title
                state
                description
            }
            projectCards(first: 10) {
                nodes {
                    project {
                        name
                        url
                    }
                    column {
                        name
                    }
                }
            }
            comments(first: 100) {
                nodes {
                    id
                    body
                    author {
                        login
                    }
                    createdAt
                }
            }
            reactions(first: 10) {
                nodes {
                    content
                    user {
                        login
                    }
                }
            }
        }
    """

    GET_ISSUE = """
        query GetIssue($owner: String!, $repo: String!, $number: Int!) {
            repository(owner: $owner, name: $repo) {
                issue(number: $number) {
                    ...IssueFields
                }
            }
        }
    """

    SEARCH_ISSUES = """
        query SearchIssues($query: String!, $first: Int!, $after: String) {
            search(query: $query, type: ISSUE, first: $first, after: $after) {
                issueCount
                pageInfo {
                    hasNextPage
                    endCursor
                }
                nodes {
                    ... on Issue {
                        ...IssueFields
                    }
                }
            }
        }
    """

    GET_PROJECT_ITERATIONS = """
        query GetProjectIterations($projectId: ID!, $first: Int!, $after: String) {
            node(id: $projectId) {
                ... on ProjectV2 {
                    iterations(first: $first, after: $after) {
                        nodes {
                            id
                            title
                            startDate
                            duration
                        }
                        pageInfo {
                            hasNextPage
                            endCursor
                        }
                    }
                }
            }
        }
    """


class GitHubAdapter(BaseAdapter[Task]):
    """Adapter for GitHub Issues tracking system."""

    def __init__(self, config: dict[str, Any]):
        """Initialize GitHub adapter.

        Args:
        ----
            config: Configuration with:
                - token: GitHub PAT (or GITHUB_TOKEN env var)
                - owner: Repository owner (or GITHUB_OWNER env var)
                - repo: Repository name (or GITHUB_REPO env var)
                - api_url: Optional API URL for GitHub Enterprise
                - use_projects_v2: Enable Projects v2 (default: False)
                - custom_priority_scheme: Custom priority label mapping

        """
        super().__init__(config)

        # Load configuration with environment variable resolution
        full_config = load_adapter_config("github", config)

        # Validate required configuration
        missing_keys = validate_adapter_config("github", full_config)
        if missing_keys:
            missing = ", ".join(missing_keys)
            raise ValueError(
                f"GitHub adapter missing required configuration: {missing}"
            )

        # Get authentication token - support 'api_key' and 'token'
        self.token = (
            full_config.get("api_key")
            or full_config.get("token")
            or full_config.get("token")
        )

        # Get repository information
        self.owner = full_config.get("owner")
        self.repo = full_config.get("repo")

        # API URLs
        self.api_url = config.get("api_url", "https://api.github.com")
        self.graphql_url = (
            f"{self.api_url}/graphql"
            if "github.com" in self.api_url
            else f"{self.api_url}/api/graphql"
        )

        # Configuration options
        self.use_projects_v2 = config.get("use_projects_v2", False)
        self.custom_priority_scheme = config.get("custom_priority_scheme", {})

        # HTTP client with authentication
        self.headers = {
            "Authorization": f"Bearer {self.token}",
            "Accept": "application/vnd.github.v3+json",
            "X-GitHub-Api-Version": "2022-11-28",
        }

        self.client = httpx.AsyncClient(
            base_url=self.api_url,
            headers=self.headers,
            timeout=30.0,
        )

        # Cache for labels and milestones
        self._labels_cache: list[dict[str, Any]] | None = None
        self._milestones_cache: list[dict[str, Any]] | None = None
        self._rate_limit: dict[str, Any] = {}

    def validate_credentials(self) -> tuple[bool, str]:
        """Validate that required credentials are present.

        Returns:
        -------
            (is_valid, error_message) - Tuple of validation result and error message

        """
        if not self.token:
            return (
                False,
                "GITHUB_TOKEN is required. Set it in .env.local or environment.",
            )
        if not self.owner:
            return (
                False,
                "GitHub owner is required. Set GITHUB_OWNER in .env.local "
                "or configure with 'mcp-ticketer init --adapter github "
                "--github-owner <owner>'",
            )
        if not self.repo:
            return (
                False,
                "GitHub repo is required. Set GITHUB_REPO in .env.local "
                "or configure with 'mcp-ticketer init --adapter github "
                "--github-repo <repo>'",
            )
        return True, ""

    def _get_state_mapping(self) -> dict[TicketState, str]:
        """Map universal states to GitHub states."""
        return {
            TicketState.OPEN: GitHubStateMapping.OPEN,
            TicketState.IN_PROGRESS: GitHubStateMapping.OPEN,  # with label
            TicketState.READY: GitHubStateMapping.OPEN,  # with label
            TicketState.TESTED: GitHubStateMapping.OPEN,  # with label
            TicketState.DONE: GitHubStateMapping.CLOSED,
            TicketState.WAITING: GitHubStateMapping.OPEN,  # with label
            TicketState.BLOCKED: GitHubStateMapping.OPEN,  # with label
            TicketState.CLOSED: GitHubStateMapping.CLOSED,
        }

    def _get_state_label(self, state: TicketState) -> str | None:
        """Get the label name for extended states."""
        return GitHubStateMapping.STATE_LABELS.get(state)

    def _get_priority_from_labels(self, labels: list[str]) -> Priority:
        """Extract priority from issue labels."""
        label_names = [label.lower() for label in labels]

        # Check custom priority scheme first
        if self.custom_priority_scheme:
            for priority_str, label_patterns in self.custom_priority_scheme.items():
                for pattern in label_patterns:
                    if any(pattern.lower() in label for label in label_names):
                        return Priority(priority_str)

        # Check default priority labels
        for priority, priority_labels in GitHubStateMapping.PRIORITY_LABELS.items():
            for priority_label in priority_labels:
                if priority_label.lower() in label_names:
                    return priority

        return Priority.MEDIUM

    def _get_priority_label(self, priority: Priority) -> str:
        """Get label name for a priority level."""
        # Check custom scheme first
        if self.custom_priority_scheme:
            labels = self.custom_priority_scheme.get(priority.value, [])
            if labels:
                return labels[0]

        # Use default labels
        labels = GitHubStateMapping.PRIORITY_LABELS.get(priority, [])
        return (
            labels[0]
            if labels
            else f"P{['0', '1', '2', '3'][list(Priority).index(priority)]}"
        )

    def _milestone_to_epic(self, milestone: dict[str, Any]) -> Epic:
        """Convert GitHub milestone to Epic model.

        Args:
        ----
            milestone: GitHub milestone data

        Returns:
        -------
            Epic instance

        """
        return Epic(
            id=str(milestone["number"]),
            title=milestone["title"],
            description=milestone.get("description", ""),
            state=(
                TicketState.OPEN if milestone["state"] == "open" else TicketState.CLOSED
            ),
            created_at=datetime.fromisoformat(
                milestone["created_at"].replace("Z", "+00:00")
            ),
            updated_at=datetime.fromisoformat(
                milestone["updated_at"].replace("Z", "+00:00")
            ),
            metadata={
                "github": {
                    "number": milestone["number"],
                    "url": milestone.get("html_url"),
                    "open_issues": milestone.get("open_issues", 0),
                    "closed_issues": milestone.get("closed_issues", 0),
                }
            },
        )

    def _extract_state_from_issue(self, issue: dict[str, Any]) -> TicketState:
        """Extract ticket state from GitHub issue data."""
        # Check if closed
        if issue["state"] == "closed":
            return TicketState.CLOSED

        # Check labels for extended states
        labels = []
        if "labels" in issue:
            if isinstance(issue["labels"], list):
                labels = [
                    label.get("name", "") if isinstance(label, dict) else str(label)
                    for label in issue["labels"]
                ]
            elif isinstance(issue["labels"], dict) and "nodes" in issue["labels"]:
                labels = [label["name"] for label in issue["labels"]["nodes"]]

        label_names = [label.lower() for label in labels]

        # Check for extended state labels
        for state, label_name in GitHubStateMapping.STATE_LABELS.items():
            if label_name.lower() in label_names:
                return state

        return TicketState.OPEN

    def _task_from_github_issue(self, issue: dict[str, Any]) -> Task:
        """Convert GitHub issue to universal Task."""
        # Extract labels
        labels = []
        if "labels" in issue:
            if isinstance(issue["labels"], list):
                labels = [
                    label.get("name", "") if isinstance(label, dict) else str(label)
                    for label in issue["labels"]
                ]
            elif isinstance(issue["labels"], dict) and "nodes" in issue["labels"]:
                labels = [label["name"] for label in issue["labels"]["nodes"]]

        # Extract state
        state = self._extract_state_from_issue(issue)

        # Extract priority
        priority = self._get_priority_from_labels(labels)

        # Extract assignee
        assignee = None
        if "assignees" in issue:
            if isinstance(issue["assignees"], list) and issue["assignees"]:
                assignee = issue["assignees"][0].get("login")
            elif isinstance(issue["assignees"], dict) and "nodes" in issue["assignees"]:
                nodes = issue["assignees"]["nodes"]
                if nodes:
                    assignee = nodes[0].get("login")
        elif "assignee" in issue and issue["assignee"]:
            assignee = issue["assignee"].get("login")

        # Extract parent epic (milestone)
        parent_epic = None
        if issue.get("milestone"):
            parent_epic = str(issue["milestone"]["number"])

        # Parse dates
        created_at = None
        if issue.get("created_at"):
            created_at = datetime.fromisoformat(
                issue["created_at"].replace("Z", "+00:00")
            )
        elif issue.get("createdAt"):
            created_at = datetime.fromisoformat(
                issue["createdAt"].replace("Z", "+00:00")
            )

        updated_at = None
        if issue.get("updated_at"):
            updated_at = datetime.fromisoformat(
                issue["updated_at"].replace("Z", "+00:00")
            )
        elif issue.get("updatedAt"):
            updated_at = datetime.fromisoformat(
                issue["updatedAt"].replace("Z", "+00:00")
            )

        # Build metadata
        metadata = {
            "github": {
                "number": issue.get("number"),
                "url": issue.get("url") or issue.get("html_url"),
                "author": (
                    issue.get("user", {}).get("login")
                    if "user" in issue
                    else issue.get("author", {}).get("login")
                ),
                "labels": labels,
            }
        }

        # Add projects v2 info if available
        if "projectCards" in issue and issue["projectCards"].get("nodes"):
            metadata["github"]["projects"] = [
                {
                    "name": card["project"]["name"],
                    "column": card["column"]["name"],
                    "url": card["project"]["url"],
                }
                for card in issue["projectCards"]["nodes"]
            ]

        return Task(
            id=str(issue["number"]),
            title=issue["title"],
            description=issue.get("body") or issue.get("bodyText"),
            state=state,
            priority=priority,
            tags=labels,
            parent_epic=parent_epic,
            assignee=assignee,
            created_at=created_at,
            updated_at=updated_at,
            metadata=metadata,
        )

    async def _ensure_label_exists(
        self, label_name: str, color: str = "0366d6"
    ) -> None:
        """Ensure a label exists in the repository."""
        if not self._labels_cache:
            response = await self.client.get(f"/repos/{self.owner}/{self.repo}/labels")
            response.raise_for_status()
            self._labels_cache = response.json()

        # Check if label exists
        existing_labels = [label["name"].lower() for label in self._labels_cache]
        if label_name.lower() not in existing_labels:
            # Create the label
            response = await self.client.post(
                f"/repos/{self.owner}/{self.repo}/labels",
                json={"name": label_name, "color": color},
            )
            if response.status_code == 201:
                self._labels_cache.append(response.json())

    async def _graphql_request(
        self, query: str, variables: dict[str, Any]
    ) -> dict[str, Any]:
        """Execute a GraphQL query."""
        response = await self.client.post(
            self.graphql_url, json={"query": query, "variables": variables}
        )
        response.raise_for_status()

        data = response.json()
        if "errors" in data:
            raise ValueError(f"GraphQL errors: {data['errors']}")

        return data["data"]

    async def create(self, ticket: Task) -> Task:
        """Create a new GitHub issue."""
        # Validate credentials before attempting operation
        is_valid, error_message = self.validate_credentials()
        if not is_valid:
            raise ValueError(error_message)

        # Prepare labels
        labels = ticket.tags.copy() if ticket.tags else []

        # Add state label if needed
        state_label = self._get_state_label(ticket.state)
        if state_label:
            labels.append(state_label)
            await self._ensure_label_exists(state_label, "fbca04")

        # Add priority label
        priority_label = self._get_priority_label(ticket.priority)
        labels.append(priority_label)
        await self._ensure_label_exists(priority_label, "d73a4a")

        # Ensure all labels exist
        for label in labels:
            await self._ensure_label_exists(label)

        # Build issue data
        issue_data = {
            "title": ticket.title,
            "body": ticket.description or "",
            "labels": labels,
        }

        # Add assignee if specified
        if ticket.assignee:
            issue_data["assignees"] = [ticket.assignee]

        # Add milestone if parent_epic is specified
        if ticket.parent_epic:
            try:
                milestone_number = int(ticket.parent_epic)
                issue_data["milestone"] = milestone_number
            except ValueError:
                # Try to find milestone by title
                if not self._milestones_cache:
                    response = await self.client.get(
                        f"/repos/{self.owner}/{self.repo}/milestones"
                    )
                    response.raise_for_status()
                    self._milestones_cache = response.json()

                for milestone in self._milestones_cache:
                    if milestone["title"] == ticket.parent_epic:
                        issue_data["milestone"] = milestone["number"]
                        break

        # Create the issue
        response = await self.client.post(
            f"/repos/{self.owner}/{self.repo}/issues", json=issue_data
        )
        response.raise_for_status()

        created_issue = response.json()

        # If state requires closing, close the issue
        if ticket.state in [TicketState.DONE, TicketState.CLOSED]:
            await self.client.patch(
                f"/repos/{self.owner}/{self.repo}/issues/{created_issue['number']}",
                json={"state": "closed"},
            )
            created_issue["state"] = "closed"

        return self._task_from_github_issue(created_issue)

    async def read(self, ticket_id: str) -> Task | Epic | None:
        """Read a GitHub issue OR milestone by number with unified find.

        Tries to find the entity in the following order:
        1. Issue (most common case) - returns Task
        2. Milestone (project/epic) - returns Epic

        Args:
        ----
            ticket_id: GitHub issue number or milestone number (as string)

        Returns:
        -------
            Task if issue found,
            Epic if milestone found,
            None if not found as either type

        Examples:
        --------
            >>> # Read issue #123
            >>> task = await adapter.read("123")
            >>> isinstance(task, Task)  # True
            >>>
            >>> # Read milestone #5
            >>> epic = await adapter.read("5")
            >>> isinstance(epic, Epic)  # True (if 5 is milestone, not issue)

        """
        # Validate credentials before attempting operation
        is_valid, error_message = self.validate_credentials()
        if not is_valid:
            raise ValueError(error_message)

        try:
            entity_number = int(ticket_id)
        except ValueError:
            return None

        # Try reading as Issue first (most common case)
        try:
            response = await self.client.get(
                f"/repos/{self.owner}/{self.repo}/issues/{entity_number}"
            )
            if response.status_code == 200:
                response.raise_for_status()
                issue = response.json()
                logger.debug(f"Found GitHub entity as Issue: {ticket_id}")
                return self._task_from_github_issue(issue)
            elif response.status_code == 404:
                # Not found as issue, will try milestone next
                logger.debug(f"Not found as Issue ({ticket_id}), trying Milestone")
        except httpx.HTTPError as e:
            logger.debug(f"Error reading as Issue ({ticket_id}): {e}")

        # Try reading as Milestone (Epic)
        try:
            milestone = await self.get_milestone(entity_number)
            if milestone:
                logger.debug(f"Found GitHub entity as Milestone: {ticket_id}")
                return milestone
        except Exception as e:
            logger.debug(f"Error reading as Milestone ({ticket_id}): {e}")

        # Not found as either Issue or Milestone
        logger.warning(f"GitHub entity not found: {ticket_id}")
        return None

    async def update(self, ticket_id: str, updates: dict[str, Any]) -> Task | None:
        """Update a GitHub issue."""
        # Validate credentials before attempting operation
        is_valid, error_message = self.validate_credentials()
        if not is_valid:
            raise ValueError(error_message)

        try:
            issue_number = int(ticket_id)
        except ValueError:
            return None

        # Get current issue to preserve labels
        response = await self.client.get(
            f"/repos/{self.owner}/{self.repo}/issues/{issue_number}"
        )
        if response.status_code == 404:
            return None
        response.raise_for_status()

        current_issue = response.json()
        current_labels = [label["name"] for label in current_issue.get("labels", [])]

        # Build update data
        update_data = {}

        if "title" in updates:
            update_data["title"] = updates["title"]

        if "description" in updates:
            update_data["body"] = updates["description"]

        # Handle state updates
        if "state" in updates:
            new_state = updates["state"]
            if isinstance(new_state, str):
                new_state = TicketState(new_state)

            # Remove old state labels
            labels_to_update = [
                label
                for label in current_labels
                if label.lower()
                not in [sl.lower() for sl in GitHubStateMapping.STATE_LABELS.values()]
            ]

            # Add new state label if needed
            state_label = self._get_state_label(new_state)
            if state_label:
                await self._ensure_label_exists(state_label, "fbca04")
                labels_to_update.append(state_label)

            update_data["labels"] = labels_to_update

            # Update issue state if needed
            if new_state in [TicketState.DONE, TicketState.CLOSED]:
                update_data["state"] = "closed"
            else:
                update_data["state"] = "open"

        # Handle priority updates
        if "priority" in updates:
            new_priority = updates["priority"]
            if isinstance(new_priority, str):
                new_priority = Priority(new_priority)

            # Remove old priority labels
            labels_to_update = update_data.get("labels", current_labels)
            all_priority_labels = []
            for labels in GitHubStateMapping.PRIORITY_LABELS.values():
                all_priority_labels.extend([label.lower() for label in labels])

            labels_to_update = [
                label
                for label in labels_to_update
                if label.lower() not in all_priority_labels
                and not re.match(r"^P[0-3]$", label, re.IGNORECASE)
            ]

            # Add new priority label
            priority_label = self._get_priority_label(new_priority)
            await self._ensure_label_exists(priority_label, "d73a4a")
            labels_to_update.append(priority_label)

            update_data["labels"] = labels_to_update

        # Handle assignee updates
        if "assignee" in updates:
            if updates["assignee"]:
                update_data["assignees"] = [updates["assignee"]]
            else:
                update_data["assignees"] = []

        # Handle tags updates
        if "tags" in updates:
            # Preserve state and priority labels
            preserved_labels = []
            for label in current_labels:
                if label.lower() in [
                    sl.lower() for sl in GitHubStateMapping.STATE_LABELS.values()
                ]:
                    preserved_labels.append(label)
                elif any(
                    label.lower() in [pl.lower() for pl in labels]
                    for labels in GitHubStateMapping.PRIORITY_LABELS.values()
                ):
                    preserved_labels.append(label)
                elif re.match(r"^P[0-3]$", label, re.IGNORECASE):
                    preserved_labels.append(label)

            # Add new tags
            for tag in updates["tags"]:
                await self._ensure_label_exists(tag)

            update_data["labels"] = preserved_labels + updates["tags"]

        # Apply updates
        if update_data:
            response = await self.client.patch(
                f"/repos/{self.owner}/{self.repo}/issues/{issue_number}",
                json=update_data,
            )
            response.raise_for_status()

            updated_issue = response.json()
            return self._task_from_github_issue(updated_issue)

        return await self.read(ticket_id)

    async def delete(self, ticket_id: str) -> bool:
        """Delete (close) a GitHub issue."""
        # Validate credentials before attempting operation
        is_valid, error_message = self.validate_credentials()
        if not is_valid:
            raise ValueError(error_message)

        try:
            issue_number = int(ticket_id)
        except ValueError:
            return False

        try:
            response = await self.client.patch(
                f"/repos/{self.owner}/{self.repo}/issues/{issue_number}",
                json={"state": "closed", "state_reason": "not_planned"},
            )
            response.raise_for_status()
            return True
        except httpx.HTTPError:
            return False

    async def list(
        self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
    ) -> list[Task]:
        """List GitHub issues with filters."""
        # Build query parameters
        params: dict[str, Any] = {
            "per_page": min(limit, 100),  # GitHub max is 100
            "page": (offset // limit) + 1 if limit > 0 else 1,
        }

        if filters:
            # State filter
            if "state" in filters:
                state = filters["state"]
                if isinstance(state, str):
                    state = TicketState(state)

                if state in [TicketState.DONE, TicketState.CLOSED]:
                    params["state"] = "closed"
                else:
                    params["state"] = "open"
                    # Add label filter for extended states
                    state_label = self._get_state_label(state)
                    if state_label:
                        params["labels"] = state_label

            # Priority filter via labels
            if "priority" in filters:
                priority = filters["priority"]
                if isinstance(priority, str):
                    priority = Priority(priority)
                priority_label = self._get_priority_label(priority)

                if "labels" in params:
                    params["labels"] += f",{priority_label}"
                else:
                    params["labels"] = priority_label

            # Assignee filter
            if "assignee" in filters:
                params["assignee"] = filters["assignee"]

            # Milestone filter (parent_epic)
            if "parent_epic" in filters:
                params["milestone"] = filters["parent_epic"]

        response = await self.client.get(
            f"/repos/{self.owner}/{self.repo}/issues", params=params
        )
        response.raise_for_status()

        issues = response.json()

        # Store rate limit info
        self._rate_limit = {
            "limit": response.headers.get("X-RateLimit-Limit"),
            "remaining": response.headers.get("X-RateLimit-Remaining"),
            "reset": response.headers.get("X-RateLimit-Reset"),
        }

        # Filter out pull requests (they appear as issues in the API)
        issues = [issue for issue in issues if "pull_request" not in issue]

        return [self._task_from_github_issue(issue) for issue in issues]

    async def search(self, query: SearchQuery) -> builtins.list[Task]:
        """Search GitHub issues using advanced search syntax."""
        # Build GitHub search query
        search_parts = [f"repo:{self.owner}/{self.repo}", "is:issue"]

        # Text search
        if query.query:
            # Escape special characters for GitHub search
            escaped_query = query.query.replace('"', '\\"')
            search_parts.append(f'"{escaped_query}"')

        # State filter
        if query.state:
            if query.state in [TicketState.DONE, TicketState.CLOSED]:
                search_parts.append("is:closed")
            else:
                search_parts.append("is:open")
                # Add label filter for extended states
                state_label = self._get_state_label(query.state)
                if state_label:
                    search_parts.append(f'label:"{state_label}"')

        # Priority filter
        if query.priority:
            priority_label = self._get_priority_label(query.priority)
            search_parts.append(f'label:"{priority_label}"')

        # Assignee filter
        if query.assignee:
            search_parts.append(f"assignee:{query.assignee}")

        # Tags filter
        if query.tags:
            for tag in query.tags:
                search_parts.append(f'label:"{tag}"')

        # Build final search query
        github_query = " ".join(search_parts)

        # Use GraphQL for better search capabilities
        full_query = (
            GitHubGraphQLQueries.ISSUE_FRAGMENT + GitHubGraphQLQueries.SEARCH_ISSUES
        )

        variables = {
            "query": github_query,
            "first": min(query.limit, 100),
            "after": None,
        }

        # Handle pagination for offset
        if query.offset > 0:
            # We need to paginate through to get to the offset
            # This is inefficient but GitHub doesn't support direct offset
            pages_to_skip = query.offset // 100
            for _ in range(pages_to_skip):
                temp_result = await self._graphql_request(full_query, variables)
                page_info = temp_result["search"]["pageInfo"]
                if page_info["hasNextPage"]:
                    variables["after"] = page_info["endCursor"]
                else:
                    return []  # Offset beyond available results

        result = await self._graphql_request(full_query, variables)

        issues = []
        for node in result["search"]["nodes"]:
            if node:  # Some nodes might be null
                # Convert GraphQL format to REST format for consistency
                rest_format = {
                    "number": node["number"],
                    "title": node["title"],
                    "body": node["body"],
                    "state": node["state"].lower(),
                    "created_at": node["createdAt"],
                    "updated_at": node["updatedAt"],
                    "html_url": node["url"],
                    "labels": node.get("labels", {}).get("nodes", []),
                    "milestone": node.get("milestone"),
                    "assignees": node.get("assignees", {}).get("nodes", []),
                    "author": node.get("author"),
                }
                issues.append(self._task_from_github_issue(rest_format))

        return issues

    async def transition_state(
        self, ticket_id: str, target_state: TicketState
    ) -> Task | None:
        """Transition GitHub issue to a new state."""
        # Validate transition
        if not await self.validate_transition(ticket_id, target_state):
            return None

        # Update state
        return await self.update(ticket_id, {"state": target_state})

    async def add_comment(self, comment: Comment) -> Comment:
        """Add a comment to a GitHub issue."""
        try:
            issue_number = int(comment.ticket_id)
        except ValueError as e:
            raise ValueError(f"Invalid issue number: {comment.ticket_id}") from e

        # Create comment
        response = await self.client.post(
            f"/repos/{self.owner}/{self.repo}/issues/{issue_number}/comments",
            json={"body": comment.content},
        )
        response.raise_for_status()

        created_comment = response.json()

        return Comment(
            id=str(created_comment["id"]),
            ticket_id=comment.ticket_id,
            author=created_comment["user"]["login"],
            content=created_comment["body"],
            created_at=datetime.fromisoformat(
                created_comment["created_at"].replace("Z", "+00:00")
            ),
            metadata={
                "github": {
                    "id": created_comment["id"],
                    "url": created_comment["html_url"],
                    "author_avatar": created_comment["user"]["avatar_url"],
                }
            },
        )

    async def get_comments(
        self, ticket_id: str, limit: int = 10, offset: int = 0
    ) -> builtins.list[Comment]:
        """Get comments for a GitHub issue."""
        try:
            issue_number = int(ticket_id)
        except ValueError:
            return []

        params = {
            "per_page": min(limit, 100),
            "page": (offset // limit) + 1 if limit > 0 else 1,
        }

        try:
            response = await self.client.get(
                f"/repos/{self.owner}/{self.repo}/issues/{issue_number}/comments",
                params=params,
            )
            response.raise_for_status()

            comments = []
            for comment_data in response.json():
                comments.append(
                    Comment(
                        id=str(comment_data["id"]),
                        ticket_id=ticket_id,
                        author=comment_data["user"]["login"],
                        content=comment_data["body"],
                        created_at=datetime.fromisoformat(
                            comment_data["created_at"].replace("Z", "+00:00")
                        ),
                        metadata={
                            "github": {
                                "id": comment_data["id"],
                                "url": comment_data["html_url"],
                                "author_avatar": comment_data["user"]["avatar_url"],
                            }
                        },
                    )
                )

            return comments
        except httpx.HTTPError:
            return []

    async def get_rate_limit(self) -> dict[str, Any]:
        """Get current rate limit status."""
        response = await self.client.get("/rate_limit")
        response.raise_for_status()
        return response.json()

    async def create_milestone(self, epic: Epic) -> Epic:
        """Create a GitHub milestone as an Epic."""
        milestone_data = {
            "title": epic.title,
            "description": epic.description or "",
            "state": "open" if epic.state != TicketState.CLOSED else "closed",
        }

        response = await self.client.post(
            f"/repos/{self.owner}/{self.repo}/milestones", json=milestone_data
        )
        response.raise_for_status()

        created_milestone = response.json()
        return self._milestone_to_epic(created_milestone)

    async def get_milestone(self, milestone_number: int) -> Epic | None:
        """Get a GitHub milestone as an Epic."""
        try:
            response = await self.client.get(
                f"/repos/{self.owner}/{self.repo}/milestones/{milestone_number}"
            )
            if response.status_code == 404:
                return None
            response.raise_for_status()

            milestone = response.json()
            return self._milestone_to_epic(milestone)
        except httpx.HTTPError:
            return None

    async def list_milestones(
        self, state: str = "open", limit: int = 10, offset: int = 0
    ) -> builtins.list[Epic]:
        """List GitHub milestones as Epics."""
        params = {
            "state": state,
            "per_page": min(limit, 100),
            "page": (offset // limit) + 1 if limit > 0 else 1,
        }

        response = await self.client.get(
            f"/repos/{self.owner}/{self.repo}/milestones", params=params
        )
        response.raise_for_status()

        return [self._milestone_to_epic(milestone) for milestone in response.json()]

    async def delete_epic(self, epic_id: str) -> bool:
        """Delete a GitHub milestone (Epic).

        Args:
        ----
            epic_id: Milestone number (not ID) as a string

        Returns:
        -------
            True if successfully deleted, False otherwise

        Raises:
        ------
            ValueError: If credentials are invalid or epic_id is not a valid number

        """
        # Validate credentials
        is_valid, error_message = self.validate_credentials()
        if not is_valid:
            raise ValueError(error_message)

        try:
            # Extract milestone number from epic_id
            milestone_number = int(epic_id)
        except ValueError as e:
            raise ValueError(
                f"Invalid milestone number '{epic_id}'. GitHub milestones use numeric IDs."
            ) from e

        try:
            # Delete milestone using REST API
            response = await self.client.delete(
                f"/repos/{self.owner}/{self.repo}/milestones/{milestone_number}"
            )

            # GitHub returns 204 No Content on successful deletion
            if response.status_code == 204:
                return True

            # Handle 404 errors gracefully
            if response.status_code == 404:
                return False

            # Other errors - raise for visibility
            response.raise_for_status()
            return True

        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                # Milestone not found
                return False
            # Re-raise other HTTP errors
            raise ValueError(f"Failed to delete milestone: {e}") from e
        except Exception as e:
            raise ValueError(f"Failed to delete milestone: {e}") from e

    async def link_to_pull_request(self, issue_number: int, pr_number: int) -> bool:
        """Link an issue to a pull request using keywords."""
        # This is typically done through PR description keywords like "fixes #123"
        # We can add a comment to track the link
        comment = f"Linked to PR #{pr_number}"

        response = await self.client.post(
            f"/repos/{self.owner}/{self.repo}/issues/{issue_number}/comments",
            json={"body": comment},
        )

        return response.status_code == 201

    async def create_pull_request(
        self,
        ticket_id: str,
        base_branch: str = "main",
        head_branch: str | None = None,
        title: str | None = None,
        body: str | None = None,
        draft: bool = False,
    ) -> dict[str, Any]:
        """Create a pull request linked to an issue.

        Args:
        ----
            ticket_id: Issue number to link the PR to
            base_branch: Target branch for the PR (default: main)
            head_branch: Source branch name (auto-generated if not provided)
            title: PR title (uses ticket title if not provided)
            body: PR description (auto-generated with issue link if not provided)
            draft: Create as draft PR

        Returns:
        -------
            Dictionary with PR details including number, url, and branch

        """
        try:
            issue_number = int(ticket_id)
        except ValueError as e:
            raise ValueError(f"Invalid issue number: {ticket_id}") from e

        # Get the issue details
        issue = await self.read(ticket_id)
        if not issue:
            raise ValueError(f"Issue #{ticket_id} not found")

        # Auto-generate branch name if not provided
        if not head_branch:
            # Create branch name from issue number and title
            # e.g., "123-fix-authentication-bug"
            safe_title = "-".join(
                issue.title.lower()
                .replace("[", "")
                .replace("]", "")
                .replace("#", "")
                .replace("/", "-")
                .replace("\\", "-")
                .split()[:5]  # Limit to 5 words
            )
            head_branch = f"{issue_number}-{safe_title}"

        # Auto-generate title if not provided
        if not title:
            # Include issue number in PR title
            title = f"[#{issue_number}] {issue.title}"

        # Auto-generate body if not provided
        if not body:
            body = f"""## Summary

This PR addresses issue #{issue_number}.

**Issue:** #{issue_number} - {issue.title}
**Link:** {issue.metadata.get('github', {}).get('url', '')}

## Description

{issue.description or 'No description provided.'}

## Changes

- [ ] Implementation details to be added

## Testing

- [ ] Tests have been added/updated
- [ ] All tests pass

## Checklist

- [ ] Code follows project style guidelines
- [ ] Self-review completed
- [ ] Documentation updated if needed

Fixes #{issue_number}
"""

        # Check if the head branch exists
        try:
            branch_response = await self.client.get(
                f"/repos/{self.owner}/{self.repo}/branches/{head_branch}"
            )
            branch_exists = branch_response.status_code == 200
        except httpx.HTTPError:
            branch_exists = False

        if not branch_exists:
            # Get the base branch SHA
            base_response = await self.client.get(
                f"/repos/{self.owner}/{self.repo}/branches/{base_branch}"
            )
            base_response.raise_for_status()
            base_sha = base_response.json()["commit"]["sha"]

            # Create the new branch
            ref_response = await self.client.post(
                f"/repos/{self.owner}/{self.repo}/git/refs",
                json={
                    "ref": f"refs/heads/{head_branch}",
                    "sha": base_sha,
                },
            )

            if ref_response.status_code != 201:
                # Branch might already exist on remote, try to use it
                pass

        # Create the pull request
        pr_data = {
            "title": title,
            "body": body,
            "head": head_branch,
            "base": base_branch,
            "draft": draft,
        }

        pr_response = await self.client.post(
            f"/repos/{self.owner}/{self.repo}/pulls", json=pr_data
        )

        if pr_response.status_code == 422:
            # PR might already exist, try to get it
            search_response = await self.client.get(
                f"/repos/{self.owner}/{self.repo}/pulls",
                params={
                    "head": f"{self.owner}:{head_branch}",
                    "base": base_branch,
                    "state": "open",
                },
            )

            if search_response.status_code == 200:
                existing_prs = search_response.json()
                if existing_prs:
                    pr = existing_prs[0]
                    return {
                        "number": pr["number"],
                        "url": pr["html_url"],
                        "api_url": pr["url"],
                        "branch": head_branch,
                        "state": pr["state"],
                        "draft": pr.get("draft", False),
                        "title": pr["title"],
                        "existing": True,
                        "linked_issue": issue_number,
                    }

            raise ValueError(f"Failed to create PR: {pr_response.text}")

        pr_response.raise_for_status()
        pr = pr_response.json()

        # Add a comment to the issue about the PR
        pr_msg = f"Pull request #{pr['number']} has been created: " f"{pr['html_url']}"
        await self.add_comment(
            Comment(
                ticket_id=ticket_id,
                content=pr_msg,
                author="system",
            )
        )

        return {
            "number": pr["number"],
            "url": pr["html_url"],
            "api_url": pr["url"],
            "branch": head_branch,
            "state": pr["state"],
            "draft": pr.get("draft", False),
            "title": pr["title"],
            "linked_issue": issue_number,
        }

    async def link_existing_pull_request(
        self,
        ticket_id: str,
        pr_url: str,
    ) -> dict[str, Any]:
        """Link an existing pull request to a ticket.

        Args:
        ----
            ticket_id: Issue number to link the PR to
            pr_url: GitHub PR URL to link

        Returns:
        -------
            Dictionary with link status and PR details

        """
        try:
            issue_number = int(ticket_id)
        except ValueError as e:
            raise ValueError(f"Invalid issue number: {ticket_id}") from e

        # Parse PR URL to extract owner, repo, and PR number
        # Expected format: https://github.com/owner/repo/pull/123
        import re

        pr_pattern = r"github\.com/([^/]+)/([^/]+)/pull/(\d+)"
        match = re.search(pr_pattern, pr_url)

        if not match:
            raise ValueError(f"Invalid GitHub PR URL format: {pr_url}")

        pr_owner, pr_repo, pr_number = match.groups()

        # Verify the PR is from the same repository
        if pr_owner != self.owner or pr_repo != self.repo:
            raise ValueError(
                f"PR must be from the same repository ({self.owner}/{self.repo})"
            )

        # Get PR details
        pr_response = await self.client.get(
            f"/repos/{self.owner}/{self.repo}/pulls/{pr_number}"
        )

        if pr_response.status_code == 404:
            raise ValueError(f"Pull request #{pr_number} not found")

        pr_response.raise_for_status()
        pr = pr_response.json()

        # Update PR body to include issue reference if not already present
        current_body = pr.get("body", "")
        issue_ref = f"#{issue_number}"

        if issue_ref not in current_body:
            # Add issue reference to the body
            updated_body = current_body or ""
            if updated_body:
                updated_body += "\n\n"
            updated_body += f"Related to #{issue_number}"

            # Update the PR
            update_response = await self.client.patch(
                f"/repos/{self.owner}/{self.repo}/pulls/{pr_number}",
                json={"body": updated_body},
            )
            update_response.raise_for_status()

        # Add a comment to the issue about the PR
        await self.add_comment(
            Comment(
                ticket_id=ticket_id,
                content=f"Linked to pull request #{pr_number}: {pr_url}",
                author="system",
            )
        )

        return {
            "success": True,
            "pr_number": pr["number"],
            "pr_url": pr["html_url"],
            "pr_title": pr["title"],
            "pr_state": pr["state"],
            "linked_issue": issue_number,
            "message": f"Successfully linked PR #{pr_number} to issue #{issue_number}",
        }

    async def get_collaborators(self) -> builtins.list[dict[str, Any]]:
        """Get repository collaborators."""
        response = await self.client.get(
            f"/repos/{self.owner}/{self.repo}/collaborators"
        )
        response.raise_for_status()
        return response.json()

    async def get_current_user(self) -> dict[str, Any] | None:
        """Get current authenticated user information."""
        response = await self.client.get("/user")
        response.raise_for_status()
        return response.json()

    async def list_labels(self) -> builtins.list[dict[str, Any]]:
        """List all labels available in the repository.

        Returns:
        -------
            List of label dictionaries with 'id', 'name', and 'color' fields

        """
        if self._labels_cache:
            return self._labels_cache

        response = await self.client.get(f"/repos/{self.owner}/{self.repo}/labels")
        response.raise_for_status()
        labels = response.json()

        # Transform to standardized format
        standardized_labels = [
            {"id": label["name"], "name": label["name"], "color": label["color"]}
            for label in labels
        ]

        self._labels_cache = standardized_labels
        return standardized_labels

    async def update_milestone(
        self, milestone_number: int, updates: dict[str, Any]
    ) -> Epic | None:
        """Update a GitHub milestone (Epic).

        Args:
        ----
            milestone_number: Milestone number (not ID)
            updates: Dictionary with fields to update:
                - title: Milestone title
                - description: Milestone description (supports markdown)
                - state: TicketState value (maps to open/closed)
                - target_date: Due date in ISO format

        Returns:
        -------
            Updated Epic object or None if not found

        Raises:
        ------
            ValueError: If no fields to update
            httpx.HTTPError: If API request fails

        """
        update_data = {}

        # Map title directly
        if "title" in updates:
            update_data["title"] = updates["title"]

        # Map description (supports markdown)
        if "description" in updates:
            update_data["description"] = updates["description"]

        # Map state to GitHub milestone state
        if "state" in updates:
            state = updates["state"]
            if isinstance(state, TicketState):
                # GitHub only has open/closed
                update_data["state"] = (
                    "closed"
                    if state in [TicketState.DONE, TicketState.CLOSED]
                    else "open"
                )
            else:
                update_data["state"] = state

        # Map target_date to due_on
        if "target_date" in updates:
            # GitHub expects ISO 8601 format
            target_date = updates["target_date"]
            if isinstance(target_date, str):
                update_data["due_on"] = target_date
            elif hasattr(target_date, "isoformat"):
                update_data["due_on"] = target_date.isoformat()

        if not update_data:
            raise ValueError("At least one field must be updated")

        # Make API request
        response = await self.client.patch(
            f"/repos/{self.owner}/{self.repo}/milestones/{milestone_number}",
            json=update_data,
        )
        response.raise_for_status()

        # Convert response to Epic
        milestone_data = response.json()
        return self._milestone_to_epic(milestone_data)

    async def update_epic(self, epic_id: str, updates: dict[str, Any]) -> Epic | None:
        """Update a GitHub epic (milestone) by ID or number.

        This is a convenience wrapper around update_milestone() that accepts
        either a milestone number or the epic ID from the Epic object.

        Args:
        ----
            epic_id: Epic ID (e.g., "milestone-5") or milestone number as string
            updates: Dictionary with fields to update

        Returns:
        -------
            Updated Epic object or None if not found

        """
        # Extract milestone number from ID
        if epic_id.startswith("milestone-"):
            milestone_number = int(epic_id.replace("milestone-", ""))
        else:
            milestone_number = int(epic_id)

        return await self.update_milestone(milestone_number, updates)

    async def add_attachment_to_issue(
        self, issue_number: int, file_path: str, comment: str | None = None
    ) -> dict[str, Any]:
        """Attach file to GitHub issue via comment.

        GitHub doesn't have direct file attachment API. This method:
        1. Creates a comment with the file reference
        2. Returns metadata about the attachment

        Note: GitHub's actual file upload in comments requires browser-based
        drag-and-drop or git-lfs. This method creates a placeholder comment
        that users can edit to add actual file attachments through the UI.

        Args:
        ----
            issue_number: Issue number
            file_path: Path to file to attach
            comment: Optional comment text (defaults to "Attached: {filename}")

        Returns:
        -------
            Dictionary with comment data and file info

        Raises:
        ------
            FileNotFoundError: If file doesn't exist
            ValueError: If file too large (>25 MB)

        Note:
        ----
            GitHub file size limit: 25 MB
            Supported: Images, videos, documents

        """
        file_path_obj = Path(file_path)
        if not file_path_obj.exists():
            raise FileNotFoundError(f"File not found: {file_path}")

        # Check file size (25 MB limit)
        file_size = file_path_obj.stat().st_size
        if file_size > 25 * 1024 * 1024:  # 25 MB
            raise ValueError(
                f"File too large: {file_size} bytes (max 25 MB). "
                "Upload file externally and reference URL instead."
            )

        # Prepare comment body
        comment_body = comment or f"📎 Attached: `{file_path_obj.name}`"
        comment_body += (
            f"\n\n*Note: File `{file_path_obj.name}` ({file_size} bytes) "
            "needs to be manually uploaded through GitHub UI or referenced via URL.*"
        )

        # Create comment with file reference
        response = await self.client.post(
            f"/repos/{self.owner}/{self.repo}/issues/{issue_number}/comments",
            json={"body": comment_body},
        )
        response.raise_for_status()

        comment_data = response.json()

        return {
            "comment_id": comment_data["id"],
            "comment_url": comment_data["html_url"],
            "filename": file_path_obj.name,
            "file_size": file_size,
            "note": "File reference created. Upload file manually through GitHub UI.",
        }

    async def add_attachment_reference_to_milestone(
        self, milestone_number: int, file_url: str, description: str
    ) -> Epic | None:
        """Add file reference to milestone description.

        Since GitHub milestones don't support direct file attachments,
        this method appends a markdown link to the milestone description.

        Args:
        ----
            milestone_number: Milestone number
            file_url: URL to the file (external or GitHub-hosted)
            description: Description/title for the file

        Returns:
        -------
            Updated Epic object

        Example:
        -------
            await adapter.add_attachment_reference_to_milestone(
                5,
                "https://example.com/spec.pdf",
                "Technical Specification"
            )
            # Appends to description: "[Technical Specification](https://example.com/spec.pdf)"

        """
        # Get current milestone
        response = await self.client.get(
            f"/repos/{self.owner}/{self.repo}/milestones/{milestone_number}"
        )
        response.raise_for_status()
        milestone = response.json()

        # Append file reference to description
        current_desc = milestone.get("description", "")
        attachment_markdown = f"\n\n📎 [{description}]({file_url})"
        new_description = current_desc + attachment_markdown

        # Update milestone with new description
        return await self.update_milestone(
            milestone_number, {"description": new_description}
        )

    async def add_attachment(
        self, ticket_id: str, file_path: str, description: str | None = None
    ) -> dict[str, Any]:
        """Add attachment to GitHub ticket (issue or milestone).

        This method routes to appropriate attachment method based on ticket type:
        - Issues: Creates comment with file reference
        - Milestones: Not supported, raises NotImplementedError with guidance

        Args:
        ----
            ticket_id: Ticket identifier (issue number or milestone ID)
            file_path: Path to file to attach
            description: Optional description

        Returns:
        -------
            Attachment metadata

        Raises:
        ------
            NotImplementedError: For milestones (no native support)
            FileNotFoundError: If file doesn't exist

        """
        # Determine ticket type from ID format
        if ticket_id.startswith("milestone-"):
            raise NotImplementedError(
                "GitHub milestones do not support direct file attachments. "
                "Workaround: Upload file externally and use "
                "add_attachment_reference_to_milestone() to add URL to description."
            )

        # Assume it's an issue number
        issue_number = int(ticket_id.replace("issue-", ""))
        return await self.add_attachment_to_issue(issue_number, file_path, description)

    async def list_cycles(
        self, project_id: str | None = None, limit: int = 50
    ) -> builtins.list[dict[str, Any]]:
        """List GitHub Project iterations (cycles/sprints).

        GitHub Projects V2 uses "iterations" for sprint/cycle functionality.
        Requires a project node ID (not numeric ID).

        Args:
        ----
            project_id: GitHub Project V2 node ID (e.g., 'PVT_kwDOABcdefgh').
                       This is required for Projects V2. Can be found in the
                       project's GraphQL ID.
            limit: Maximum number of iterations to return (default: 50)

        Returns:
        -------
            List of iteration dictionaries with fields:
                - id: Iteration node ID
                - title: Iteration title/name
                - startDate: Start date (ISO format)
                - duration: Duration in days
                - endDate: Calculated end date (startDate + duration)

        Raises:
        ------
            ValueError: If project_id not provided or credentials invalid
            httpx.HTTPError: If GraphQL query fails

        Example:
        -------
            >>> iterations = await adapter.list_cycles(
            ...     project_id="PVT_kwDOABCD1234",
            ...     limit=10
            ... )
            >>> for iteration in iterations:
            ...     print(f"{iteration['title']}: {iteration['startDate']} ({iteration['duration']} days)")

        Note:
        ----
            GitHub Projects V2 node IDs can be obtained via the GitHub GraphQL API.
            This is different from project numbers shown in the UI.

        """
        # Validate credentials
        is_valid, error_message = self.validate_credentials()
        if not is_valid:
            raise ValueError(error_message)

        if not project_id:
            raise ValueError(
                "project_id is required for GitHub Projects V2. "
                "Provide a project node ID (e.g., 'PVT_kwDOABcdefgh'). "
                "Find this via GraphQL API: query { organization(login: 'org') { "
                "projectV2(number: 1) { id } } }"
            )

        # Execute GraphQL query to fetch iterations
        query = GitHubGraphQLQueries.GET_PROJECT_ITERATIONS
        variables = {"projectId": project_id, "first": min(limit, 100), "after": None}

        try:
            result = await self._graphql_request(query, variables)

            # Extract iterations from response
            project_node = result.get("node")
            if not project_node:
                raise ValueError(
                    f"Project not found with ID: {project_id}. "
                    "Verify the project ID is correct and you have access."
                )

            iterations_data = project_node.get("iterations", {})
            iteration_nodes = iterations_data.get("nodes", [])

            # Transform to standard format and calculate end dates
            iterations = []
            for iteration in iteration_nodes:
                # Calculate end date from start date + duration
                start_date = iteration.get("startDate")
                duration = iteration.get("duration", 0)

                end_date = None
                if start_date and duration:
                    from datetime import datetime, timedelta

                    start_dt = datetime.fromisoformat(start_date.replace("Z", "+00:00"))
                    end_dt = start_dt + timedelta(days=duration)
                    end_date = end_dt.isoformat()

                iterations.append(
                    {
                        "id": iteration["id"],
                        "title": iteration.get("title", ""),
                        "startDate": start_date,
                        "duration": duration,
                        "endDate": end_date,
                    }
                )

            return iterations

        except ValueError:
            # Re-raise validation errors
            raise
        except Exception as e:
            raise ValueError(f"Failed to list project iterations: {e}") from e

    async def get_issue_status(self, issue_number: int) -> dict[str, Any]:
        """Get rich status information for a GitHub issue.

        GitHub issues have binary states (open/closed) natively. Extended status
        tracking uses labels following the status:* convention (e.g., status:in_progress).

        Args:
        ----
            issue_number: GitHub issue number

        Returns:
        -------
            Dictionary with comprehensive status information:
                - state: Native GitHub state ('open' or 'closed')
                - status_label: Extended status from labels (in_progress, blocked, etc.)
                - extended_state: Universal TicketState value
                - labels: All issue labels
                - state_reason: For closed issues (completed or not_planned)
                - metadata: Additional issue metadata (assignees, milestone, etc.)

        Raises:
        ------
            ValueError: If credentials invalid or issue not found
            httpx.HTTPError: If API request fails

        Example:
        -------
            >>> status = await adapter.get_issue_status(123)
            >>> print(f"Issue #{status['number']}: {status['extended_state']}")
            >>> print(f"Native state: {status['state']}")
            >>> if status['status_label']:
            ...     print(f"Label-based status: {status['status_label']}")

        Note:
        ----
            GitHub's binary state model is extended via labels:
            - open + no label = OPEN
            - open + status:in-progress = IN_PROGRESS
            - open + status:blocked = BLOCKED
            - closed = CLOSED (check state_reason for details)

        """
        # Validate credentials
        is_valid, error_message = self.validate_credentials()
        if not is_valid:
            raise ValueError(error_message)

        try:
            # Fetch issue via REST API
            response = await self.client.get(
                f"/repos/{self.owner}/{self.repo}/issues/{issue_number}"
            )

            if response.status_code == 404:
                raise ValueError(f"Issue #{issue_number} not found")

            response.raise_for_status()
            issue = response.json()

            # Extract labels
            labels = [label["name"] for label in issue.get("labels", [])]

            # Derive extended state from issue data
            extended_state = self._extract_state_from_issue(issue)

            # Find status label if present
            status_label = None
            for _state, label_name in GitHubStateMapping.STATE_LABELS.items():
                if label_name.lower() in [label.lower() for label in labels]:
                    status_label = label_name
                    break

            # Build comprehensive status response
            status_info = {
                "number": issue["number"],
                "state": issue["state"],  # 'open' or 'closed'
                "status_label": status_label,  # Label-based extended status
                "extended_state": extended_state.value,  # Universal TicketState
                "labels": labels,
                "state_reason": issue.get(
                    "state_reason"
                ),  # 'completed' or 'not_planned'
                "metadata": {
                    "title": issue["title"],
                    "url": issue["html_url"],
                    "assignees": [
                        assignee["login"] for assignee in issue.get("assignees", [])
                    ],
                    "milestone": (
                        issue.get("milestone", {}).get("title")
                        if issue.get("milestone")
                        else None
                    ),
                    "created_at": issue["created_at"],
                    "updated_at": issue["updated_at"],
                    "closed_at": issue.get("closed_at"),
                },
            }

            return status_info

        except ValueError:
            # Re-raise validation errors
            raise
        except httpx.HTTPError as e:
            raise ValueError(f"Failed to get issue status: {e}") from e

    async def list_issue_statuses(self) -> builtins.list[dict[str, Any]]:
        """List available issue statuses in GitHub.

        Returns all possible issue statuses including native GitHub states
        and extended label-based states.

        Returns:
        -------
            List of status dictionaries with fields:
                - name: Status name (e.g., 'open', 'in_progress', 'closed')
                - type: Status type ('native' or 'extended')
                - label: Associated label name (for extended statuses)
                - description: Human-readable description
                - category: Status category (open, in_progress, done, etc.)

        Example:
        -------
            >>> statuses = await adapter.list_issue_statuses()
            >>> for status in statuses:
            ...     print(f"{status['name']}: {status['description']}")
            ...     if status['type'] == 'extended':
            ...         print(f"  Label: {status['label']}")

        Note:
        ----
            GitHub natively supports only 'open' and 'closed' states.
            Extended statuses are implemented via labels following the
            status:* naming convention (e.g., status:in-progress).

        """
        # Define native GitHub states
        statuses = [
            {
                "name": "open",
                "type": "native",
                "label": None,
                "description": "Issue is open and not yet completed",
                "category": "open",
            },
            {
                "name": "closed",
                "type": "native",
                "label": None,
                "description": "Issue is closed (completed or not planned)",
                "category": "done",
            },
        ]

        # Add extended label-based states
        for state, label_name in GitHubStateMapping.STATE_LABELS.items():
            statuses.append(
                {
                    "name": state.value,
                    "type": "extended",
                    "label": label_name,
                    "description": f"Issue is {state.value.replace('_', ' ')} (tracked via label)",
                    "category": state.value,
                }
            )

        return statuses

    async def list_project_labels(
        self, milestone_number: int | None = None
    ) -> builtins.list[dict[str, Any]]:
        """List labels used in a GitHub milestone (project/epic).

        If milestone_number is provided, returns only labels used by issues
        in that milestone. Otherwise, returns all repository labels.

        Args:
        ----
            milestone_number: Optional milestone number to filter labels.
                            If None, returns all repository labels.

        Returns:
        -------
            List of label dictionaries with fields:
                - id: Label identifier (name)
                - name: Label name
                - color: Label color (hex without #)
                - description: Label description (if available)
                - usage_count: Number of issues using this label (if milestone filtered)

        Example:
        -------
            >>> # Get all repository labels
            >>> all_labels = await adapter.list_project_labels()
            >>> print(f"Repository has {len(all_labels)} labels")
            >>>
            >>> # Get labels used in milestone 5
            >>> milestone_labels = await adapter.list_project_labels(milestone_number=5)
            >>> for label in milestone_labels:
            ...     print(f"{label['name']}: used by {label['usage_count']} issues")

        Note:
        ----
            Labels are repository-scoped in GitHub, not milestone-scoped.
            When filtering by milestone, this method queries issues in that
            milestone and extracts their unique labels.

        """
        # Validate credentials
        is_valid, error_message = self.validate_credentials()
        if not is_valid:
            raise ValueError(error_message)

        try:
            if milestone_number is None:
                # Return all repository labels (delegate to existing method)
                return await self.list_labels()

            # Query issues in the milestone
            params = {
                "milestone": str(milestone_number),
                "state": "all",
                "per_page": 100,
            }

            response = await self.client.get(
                f"/repos/{self.owner}/{self.repo}/issues", params=params
            )
            response.raise_for_status()
            issues = response.json()

            # Extract unique labels from issues
            label_usage = {}  # {label_name: {data, count}}
            for issue in issues:
                # Skip pull requests
                if "pull_request" in issue:
                    continue

                for label in issue.get("labels", []):
                    label_name = label["name"]
                    if label_name not in label_usage:
                        label_usage[label_name] = {
                            "id": label_name,
                            "name": label_name,
                            "color": label["color"],
                            "description": label.get("description", ""),
                            "usage_count": 0,
                        }
                    label_usage[label_name]["usage_count"] += 1

            # Convert to list and sort by usage count
            labels = list(label_usage.values())
            labels.sort(key=lambda x: x["usage_count"], reverse=True)

            return labels

        except httpx.HTTPError as e:
            raise ValueError(f"Failed to list project labels: {e}") from e

    # ========================================================================
    # New Milestone Methods (Phase 2 - GitHub Native Support)
    # ========================================================================

    async def milestone_create(
        self,
        name: str,
        target_date: date | None = None,
        labels: list[str] | None = None,
        description: str = "",
        project_id: str | None = None,
    ) -> Milestone:
        """Create milestone using GitHub Milestones API.

        GitHub milestones are repository-scoped and natively supported.

        Args:
        ----
            name: Milestone name/title
            target_date: Target completion date (optional)
            labels: Labels for local storage (GitHub doesn't store labels on milestones)
            description: Milestone description
            project_id: Project ID (ignored for GitHub, repo-scoped)

        Returns:
        -------
            Created Milestone object

        Raises:
        ------
            ValueError: If repository is not configured
            httpx.HTTPError: If API request fails

        """
        from datetime import datetime as dt

        if not self.repo:
            raise ValueError("Repository required for GitHub milestone operations")

        # GitHub API expects ISO 8601 datetime for due_on
        due_on = None
        if target_date:
            due_on = dt.combine(target_date, dt.min.time()).isoformat() + "Z"

        milestone_data = {
            "title": name,
            "description": description,
            "state": "open",
        }

        if due_on:
            milestone_data["due_on"] = due_on

        # Create milestone via GitHub API
        response = await self.client.post(
            f"/repos/{self.owner}/{self.repo}/milestones",
            json=milestone_data,
        )
        response.raise_for_status()

        gh_milestone = response.json()

        # Convert to Milestone model
        milestone = self._github_milestone_to_milestone(gh_milestone, labels)

        # Save to local storage for label tracking
        from pathlib import Path

        from ..core.milestone_manager import MilestoneManager

        config_dir = Path.home() / ".mcp-ticketer"
        manager = MilestoneManager(config_dir)
        manager.save_milestone(milestone)

        logger.info(f"Created GitHub milestone: {milestone.id} ({milestone.name})")
        return milestone

    async def milestone_get(self, milestone_id: str) -> Milestone | None:
        """Get milestone by ID (milestone number in GitHub).

        Args:
        ----
            milestone_id: Milestone number as string

        Returns:
        -------
            Milestone object or None if not found

        Raises:
        ------
            ValueError: If repository is not configured

        """
        from pathlib import Path

        from ..core.milestone_manager import MilestoneManager

        if not self.repo:
            raise ValueError("Repository required for GitHub milestone operations")

        try:
            # milestone_id is the milestone number in GitHub
            response = await self.client.get(
                f"/repos/{self.owner}/{self.repo}/milestones/{milestone_id}"
            )

            if response.status_code == 404:
                return None

            response.raise_for_status()
            gh_milestone = response.json()

            # Load labels from local storage
            config_dir = Path.home() / ".mcp-ticketer"
            manager = MilestoneManager(config_dir)
            local_milestone = manager.get_milestone(milestone_id)
            labels = local_milestone.labels if local_milestone else []

            return self._github_milestone_to_milestone(gh_milestone, labels)

        except httpx.HTTPError as e:
            logger.error(f"Failed to get milestone {milestone_id}: {e}")
            return None

    async def milestone_list(
        self,
        project_id: str | None = None,
        state: str | None = None,
    ) -> list[Milestone]:
        """List milestones from GitHub repository.

        Note: project_id is ignored for GitHub (repo-scoped).

        Args:
        ----
            project_id: Project ID (ignored, GitHub is repo-scoped)
            state: Filter by state (open, active, closed, completed)

        Returns:
        -------
            List of Milestone objects

        Raises:
        ------
            ValueError: If repository is not configured

        """
        from pathlib import Path

        from ..core.milestone_manager import MilestoneManager

        if not self.repo:
            raise ValueError("Repository required for GitHub milestone operations")

        # Map our states to GitHub states
        github_state = "all"
        if state in ["open", "active"]:
            github_state = "open"
        elif state in ["completed", "closed"]:
            github_state = "closed"

        params = {
            "state": github_state,
            "sort": "due_on",
            "direction": "asc",
            "per_page": 100,
        }

        response = await self.client.get(
            f"/repos/{self.owner}/{self.repo}/milestones",
            params=params,
        )
        response.raise_for_status()

        # Load labels from local storage
        config_dir = Path.home() / ".mcp-ticketer"
        manager = MilestoneManager(config_dir)

        milestones = []
        for gh_milestone in response.json():
            milestone_id = str(gh_milestone["number"])
            local_milestone = manager.get_milestone(milestone_id)
            labels = local_milestone.labels if local_milestone else []

            milestone = self._github_milestone_to_milestone(gh_milestone, labels)
            milestones.append(milestone)

        logger.info(
            f"Listed {len(milestones)} GitHub milestones (state={github_state})"
        )
        return milestones

    async def milestone_update(
        self,
        milestone_id: str,
        name: str | None = None,
        target_date: date | None = None,
        state: str | None = None,
        labels: list[str] | None = None,
        description: str | None = None,
    ) -> Milestone | None:
        """Update milestone properties.

        Args:
        ----
            milestone_id: Milestone number as string
            name: New milestone name
            target_date: New target date
            state: New state (open, closed)
            labels: New labels (stored locally)
            description: New description

        Returns:
        -------
            Updated Milestone object or None if not found

        Raises:
        ------
            ValueError: If repository is not configured

        """
        from datetime import datetime as dt
        from pathlib import Path

        from ..core.milestone_manager import MilestoneManager

        if not self.repo:
            raise ValueError("Repository required for GitHub milestone operations")

        update_data = {}

        if name:
            update_data["title"] = name
        if description is not None:
            update_data["description"] = description
        if target_date:
            due_on = dt.combine(target_date, dt.min.time()).isoformat() + "Z"
            update_data["due_on"] = due_on
        if state:
            # Map our states to GitHub states
            if state in ["completed", "closed"]:
                update_data["state"] = "closed"
            elif state in ["open", "active"]:
                update_data["state"] = "open"

        if not update_data and labels is None:
            raise ValueError("At least one field must be updated")

        # Update milestone via GitHub API
        if update_data:
            response = await self.client.patch(
                f"/repos/{self.owner}/{self.repo}/milestones/{milestone_id}",
                json=update_data,
            )
            response.raise_for_status()
            gh_milestone = response.json()
        else:
            # Only labels updated, fetch current milestone
            response = await self.client.get(
                f"/repos/{self.owner}/{self.repo}/milestones/{milestone_id}"
            )
            response.raise_for_status()
            gh_milestone = response.json()

        # Update labels in local storage
        config_dir = Path.home() / ".mcp-ticketer"
        manager = MilestoneManager(config_dir)

        if labels is not None:
            milestone = self._github_milestone_to_milestone(gh_milestone, labels)
            manager.save_milestone(milestone)
            logger.info(f"Updated GitHub milestone: {milestone_id} (including labels)")
            return milestone

        # Load existing labels
        local_milestone = manager.get_milestone(milestone_id)
        existing_labels = local_milestone.labels if local_milestone else []

        milestone = self._github_milestone_to_milestone(gh_milestone, existing_labels)
        logger.info(f"Updated GitHub milestone: {milestone_id}")
        return milestone

    async def milestone_delete(self, milestone_id: str) -> bool:
        """Delete milestone from GitHub repository.

        Args:
        ----
            milestone_id: Milestone number as string

        Returns:
        -------
            True if deleted, False if not found

        Raises:
        ------
            ValueError: If repository is not configured

        """
        from pathlib import Path

        from ..core.milestone_manager import MilestoneManager

        if not self.repo:
            raise ValueError("Repository required for GitHub milestone operations")

        try:
            response = await self.client.delete(
                f"/repos/{self.owner}/{self.repo}/milestones/{milestone_id}"
            )

            # GitHub returns 204 No Content on successful deletion
            if response.status_code == 204:
                # Remove from local storage
                config_dir = Path.home() / ".mcp-ticketer"
                manager = MilestoneManager(config_dir)
                manager.delete_milestone(milestone_id)

                logger.info(f"Deleted GitHub milestone: {milestone_id}")
                return True

            # Handle 404 errors gracefully
            if response.status_code == 404:
                logger.warning(f"Milestone {milestone_id} not found for deletion")
                return False

            response.raise_for_status()
            return True

        except httpx.HTTPError as e:
            logger.error(f"Failed to delete milestone {milestone_id}: {e}")
            return False

    async def milestone_get_issues(
        self,
        milestone_id: str,
        state: str | None = None,
    ) -> list[dict[str, Any]]:
        """Get issues in milestone.

        Args:
        ----
            milestone_id: Milestone number as string
            state: Filter by state (open, closed, all)

        Returns:
        -------
            List of issue dictionaries

        Raises:
        ------
            ValueError: If repository is not configured

        """
        if not self.repo:
            raise ValueError("Repository required for GitHub milestone operations")

        params = {
            "milestone": milestone_id,
            "state": state or "all",
            "per_page": 100,
        }

        response = await self.client.get(
            f"/repos/{self.owner}/{self.repo}/issues",
            params=params,
        )
        response.raise_for_status()

        # Convert GitHub issues to our format
        issues = []
        for gh_issue in response.json():
            # Skip pull requests (GitHub includes them in issues endpoint)
            if "pull_request" in gh_issue:
                continue

            issues.append(
                {
                    "id": str(gh_issue["number"]),
                    "identifier": f"#{gh_issue['number']}",
                    "title": gh_issue["title"],
                    "state": gh_issue["state"],
                    "labels": [label["name"] for label in gh_issue.get("labels", [])],
                    "created_at": gh_issue["created_at"],
                    "updated_at": gh_issue["updated_at"],
                }
            )

        logger.info(f"Retrieved {len(issues)} issues from milestone {milestone_id}")
        return issues

    def _github_milestone_to_milestone(
        self,
        gh_milestone: dict[str, Any],
        labels: list[str] | None = None,
    ) -> Milestone:
        """Convert GitHub Milestone to universal Milestone model.

        Args:
        ----
            gh_milestone: GitHub milestone data from API
            labels: Optional labels from local storage

        Returns:
        -------
            Milestone object

        """
        from datetime import datetime as dt

        from ..core.models import Milestone

        # Parse target date
        target_date = None
        if gh_milestone.get("due_on"):
            target_date = dt.fromisoformat(
                gh_milestone["due_on"].replace("Z", "+00:00")
            ).date()

        # Determine state
        state = "closed" if gh_milestone["state"] == "closed" else "open"
        if state == "open" and target_date:

            if target_date < date.today():
                state = "closed"  # Past due
            else:
                state = "active"

        # Calculate progress
        total = gh_milestone.get("open_issues", 0) + gh_milestone.get(
            "closed_issues", 0
        )
        closed = gh_milestone.get("closed_issues", 0)
        progress_pct = (closed / total * 100) if total > 0 else 0.0

        return Milestone(
            id=str(gh_milestone["number"]),
            name=gh_milestone["title"],
            description=gh_milestone.get("description", ""),
            target_date=target_date,
            state=state,
            labels=labels or [],
            total_issues=total,
            closed_issues=closed,
            progress_pct=progress_pct,
            project_id=self.repo,  # Repository name as project
            created_at=(
                dt.fromisoformat(
                    gh_milestone.get("created_at", "").replace("Z", "+00:00")
                )
                if gh_milestone.get("created_at")
                else None
            ),
            updated_at=(
                dt.fromisoformat(
                    gh_milestone.get("updated_at", "").replace("Z", "+00:00")
                )
                if gh_milestone.get("updated_at")
                else None
            ),
            platform_data={
                "github": {
                    "milestone_number": gh_milestone["number"],
                    "url": gh_milestone.get("html_url"),
                    "created_at": gh_milestone.get("created_at"),
                    "updated_at": gh_milestone.get("updated_at"),
                }
            },
        )

    async def close(self) -> None:
        """Close the HTTP client connection."""
        await self.client.aclose()


# Register the adapter
AdapterRegistry.register("github", GitHubAdapter)
