"""GitHub App authentication using JWT and installation tokens.

This module handles the two-phase authentication flow for GitHub Apps:

1. **App-level authentication**: Create a short-lived JWT signed with the App's
   private key. This authenticates as the App itself and is used to request
   installation-specific tokens.

2. **Installation-level authentication**: Exchange the JWT for an installation
   access token that grants permissions to specific repositories where the App
   is installed.

The module provides token caching to minimize API calls. Installation tokens
are cached and automatically refreshed when they approach expiration.

Security considerations:
- Private keys should be stored securely (e.g., AWS Secrets Manager)
- JWTs are short-lived (10 min max) as required by GitHub
- Installation tokens are scoped to the specific installation's repositories

Typical usage:
    >>> from mergeguide.github.auth import GitHubAuth
    >>>
    >>> auth = GitHubAuth(app_id="123456", private_key=private_key_pem)
    >>>
    >>> # Get token for a specific installation
    >>> token = await auth.get_installation_token(installation_id=789)
    >>>
    >>> # Use token in API requests
    >>> headers = {"Authorization": f"Bearer {token}"}

Reference:
    https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app
"""

from __future__ import annotations

import time
from dataclasses import dataclass
from typing import Any

import httpx
import jwt

# Base URL for GitHub REST API
GITHUB_API_BASE = "https://api.github.com"

# Maximum JWT lifetime allowed by GitHub (10 minutes)
JWT_EXPIRY_SECONDS = 600

# Refresh installation tokens this many seconds before expiry
# to avoid using expired tokens due to network latency
TOKEN_REFRESH_MARGIN_SECONDS = 60


@dataclass
class InstallationToken:
    """Cached installation access token with expiration metadata.

    Attributes:
        token: The installation access token string.
        expires_at: Unix timestamp when the token expires.
    """

    token: str
    expires_at: float  # Unix timestamp


class GitHubAuth:
    """Handles GitHub App authentication with token caching.

    This class manages the complete authentication lifecycle for GitHub Apps:
    - Creating JWTs for app-level authentication
    - Exchanging JWTs for installation access tokens
    - Caching tokens to minimize API calls
    - Automatic token refresh before expiration

    The authentication flow:
    1. Create a JWT signed with the App's RSA private key
    2. Use the JWT to call GitHub's access_tokens endpoint
    3. Receive an installation token valid for ~1 hour
    4. Cache the token for subsequent requests
    5. Refresh when the token approaches expiration

    Attributes:
        app_id: GitHub App ID (numeric string).
        private_key: PEM-encoded RSA private key for JWT signing.

    Example:
        >>> auth = GitHubAuth(
        ...     app_id="123456",
        ...     private_key=open("private-key.pem").read()
        ... )
        >>> token = await auth.get_installation_token(789)
        >>> # token is cached, subsequent calls return cached value
        >>> token2 = await auth.get_installation_token(789)
        >>> assert token == token2
    """

    def __init__(self, app_id: str, private_key: str) -> None:
        """Initialize GitHub App authentication.

        Args:
            app_id: GitHub App ID as a string. Found on the App's settings
                page or in webhook payloads.
            private_key: PEM-encoded RSA private key generated for the App.
                Must match the public key registered with GitHub.
        """
        self.app_id = app_id
        self.private_key = private_key
        # Cache of installation_id -> InstallationToken
        self._token_cache: dict[int, InstallationToken] = {}

    def create_jwt(self) -> str:
        """Create a JWT for GitHub App authentication.

        Creates a signed JWT that authenticates as the GitHub App itself
        (not a specific installation). The JWT is used as a bearer token
        when calling the access_tokens endpoint.

        JWT structure:
        - iat: Issued at time (60 seconds in the past for clock skew)
        - exp: Expiration time (10 minutes from now, GitHub's maximum)
        - iss: Issuer claim set to the App ID

        Returns:
            Signed JWT string using RS256 algorithm.

        Note:
            JWTs are intentionally short-lived and should be created fresh
            for each access_tokens request. Do not cache JWTs.
        """
        now = int(time.time())
        payload = {
            "iat": now - 60,  # Issued 60s ago to handle clock skew
            "exp": now + JWT_EXPIRY_SECONDS,  # Expires in 10 minutes
            "iss": self.app_id,  # GitHub App ID
        }
        return jwt.encode(payload, self.private_key, algorithm="RS256")

    async def get_installation_token(
        self,
        installation_id: int,
        http_client: httpx.AsyncClient | None = None,
    ) -> str:
        """Get an installation access token, using cache when possible.

        Returns a cached token if available and not close to expiration.
        Otherwise, exchanges a fresh JWT for a new installation token.

        Tokens are refreshed TOKEN_REFRESH_MARGIN_SECONDS (60s) before
        expiration to avoid race conditions with network latency.

        Args:
            installation_id: GitHub App installation ID. Found in webhook
                payloads or from listing App installations.
            http_client: Optional pre-configured httpx.AsyncClient.
                If None, creates a temporary client for this request.

        Returns:
            Installation access token valid for API requests.

        Raises:
            GitHubAuthError: If the token exchange fails (e.g., invalid
                credentials, installation not found, network error).
        """
        # Check cache for unexpired token
        cached = self._token_cache.get(installation_id)
        if cached and cached.expires_at > time.time() + TOKEN_REFRESH_MARGIN_SECONDS:
            return cached.token

        # Cache miss or token expiring soon - fetch new token
        token = await self._exchange_jwt_for_token(installation_id, http_client)
        return token

    async def _exchange_jwt_for_token(
        self,
        installation_id: int,
        http_client: httpx.AsyncClient | None = None,
    ) -> str:
        """Exchange a JWT for an installation access token via GitHub API.

        Makes a POST request to GitHub's access_tokens endpoint using the
        App JWT for authentication. The response contains an installation
        token valid for approximately 1 hour.

        Args:
            installation_id: GitHub App installation ID to get a token for.
            http_client: Optional httpx client. If None, creates one.

        Returns:
            The installation access token string.

        Raises:
            GitHubAuthError: If the API request fails.
        """
        app_jwt = self.create_jwt()
        url = f"{GITHUB_API_BASE}/app/installations/{installation_id}/access_tokens"

        headers = {
            "Authorization": f"Bearer {app_jwt}",
            "Accept": "application/vnd.github+json",
            "X-GitHub-Api-Version": "2022-11-28",
        }

        # Track whether we need to close the client
        should_close = http_client is None
        client = http_client or httpx.AsyncClient()

        try:
            response = await client.post(url, headers=headers)
            response.raise_for_status()
            data: dict[str, Any] = response.json()

            token = data["token"]

            # Parse expiration time from ISO 8601 format
            expires_at_str = data.get("expires_at", "")
            if expires_at_str:
                from datetime import datetime

                # GitHub returns ISO 8601 with Z suffix (e.g., "2024-01-15T12:00:00Z")
                expires_at = datetime.fromisoformat(
                    expires_at_str.replace("Z", "+00:00")
                ).timestamp()
            else:
                # Fallback: assume 1 hour from now if not provided
                expires_at = time.time() + 3600

            # Cache the token for future requests
            self._token_cache[installation_id] = InstallationToken(
                token=token,
                expires_at=expires_at,
            )

            return token

        except httpx.HTTPStatusError as e:
            raise GitHubAuthError(
                f"Failed to get installation token: {e.response.status_code} {e.response.text}"
            ) from e
        finally:
            # Clean up client if we created it
            if should_close:
                await client.aclose()

    def clear_token_cache(self, installation_id: int | None = None) -> None:
        """Clear cached installation tokens.

        Use this to force token refresh, for example after detecting
        an authentication error or when testing.

        Args:
            installation_id: Specific installation ID to clear from cache.
                If None, clears all cached tokens.
        """
        if installation_id is not None:
            self._token_cache.pop(installation_id, None)
        else:
            self._token_cache.clear()


class GitHubAuthError(Exception):
    """Exception raised when GitHub App authentication fails.

    This exception is raised when:
    - JWT creation fails (invalid private key)
    - Token exchange API call fails (invalid credentials, network error)
    - Installation not found or App not installed

    The exception message contains details about the failure including
    HTTP status codes and response body when available.
    """

    pass
