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

import builtins
import logging
import re
from datetime import 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, 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

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


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