"""GitHub repository cloning with token refresh support.

This module provides functionality for cloning private GitHub repositories
using short-lived tokens, with automatic token refresh on authentication failure.
"""

import re
import subprocess
from dataclasses import dataclass
from datetime import datetime
from typing import Optional

import aiohttp
import structlog

structlogger = structlog.get_logger()


class GitCloneError(Exception):
    """Exception raised when git clone fails."""

    pass


class TokenRefreshError(Exception):
    """Exception raised when token refresh fails."""

    pass


@dataclass
class GitHubCredentials:
    """Holds GitHub credentials for repository access.

    Stored in memory only - no persistence.
    """

    token: str
    expires_at: datetime
    refresh_url: Optional[str] = None
    owner: Optional[str] = None
    repo: Optional[str] = None


# In-memory storage for GitHub credentials (per-instance)
_github_credentials: Optional[GitHubCredentials] = None


def get_github_credentials() -> Optional[GitHubCredentials]:
    """Get the current GitHub credentials from memory.

    Returns:
        The current GitHubCredentials, or None if not set.
    """
    return _github_credentials


def set_github_credentials(credentials: Optional[GitHubCredentials]) -> None:
    """Store GitHub credentials in memory.

    Args:
        credentials: The credentials to store, or None to clear.
    """
    global _github_credentials
    _github_credentials = credentials
    if credentials:
        structlogger.debug(
            "github_clone.credentials_set",
            owner=credentials.owner,
            repo=credentials.repo,
            expires_at=credentials.expires_at.isoformat(),
            has_refresh_url=bool(credentials.refresh_url),
        )


def clear_github_credentials() -> None:
    """Clear stored GitHub credentials from memory."""
    global _github_credentials
    _github_credentials = None
    structlogger.debug("github_clone.credentials_cleared")


async def refresh_github_token(refresh_url: str) -> GitHubCredentials:
    """Request a new token from the model service.

    Args:
        refresh_url: URL to call for token refresh.

    Returns:
        New GitHubCredentials with fresh token.

    Raises:
        TokenRefreshError: If token refresh fails.
    """
    structlogger.debug("github_clone.refreshing_token", refresh_url=refresh_url)

    try:
        async with aiohttp.ClientSession() as session:
            async with session.get(refresh_url, timeout=30) as response:
                if response.status != 200:
                    error_body = await response.text()
                    raise TokenRefreshError(
                        f"Token refresh failed: {response.status} - {error_body}"
                    )

                data = await response.json()
                token = data["token"]
                expires_at_str = data["expires_at"]
                expires_at = datetime.fromisoformat(
                    expires_at_str.replace("Z", "+00:00")
                )
                owner = data.get("owner")
                repo = data.get("repo")

                credentials = GitHubCredentials(
                    token=token,
                    expires_at=expires_at,
                    refresh_url=refresh_url,
                    owner=owner,
                    repo=repo,
                )

                # Update in-memory storage
                set_github_credentials(credentials)

                structlogger.info(
                    "github_clone.token_refreshed",
                    expires_at=expires_at.isoformat(),
                    owner=owner,
                    repo=repo,
                )

                return credentials

    except aiohttp.ClientError as e:
        raise TokenRefreshError(f"Network error during token refresh: {e}")


def _build_authenticated_url(
    repo_url: str,
    token: str,
) -> str:
    """Build an authenticated git URL by injecting the token.

    Args:
        repo_url: Original repository URL.
        token: GitHub access token.

    Returns:
        URL with embedded token for authentication.
    """
    # Handle HTTPS URLs: https://github.com/owner/repo.git
    if repo_url.startswith("https://"):
        # Remove any existing credentials
        url_without_auth = re.sub(r"https://[^@]+@", "https://", repo_url)
        # Insert token
        return url_without_auth.replace("https://", f"https://x-access-token:{token}@")

    # If it's already an authenticated URL, just replace the token
    if "@" in repo_url:
        return re.sub(r"://[^@]+@", f"://x-access-token:{token}@", repo_url)

    return repo_url


def _run_git_clone(
    repo_url: str,
    target_path: str,
    branch: Optional[str] = None,
) -> subprocess.CompletedProcess:
    """Run git clone command.

    Args:
        repo_url: Repository URL (may contain credentials).
        target_path: Directory to clone into.
        branch: Branch to checkout. If None, uses default branch.

    Returns:
        CompletedProcess with result.
    """
    cmd = ["git", "clone"]
    if branch:
        cmd.extend(["--branch", branch, "--single-branch"])
    cmd.extend([repo_url, target_path])

    # Don't capture credentials in logs
    safe_url = re.sub(r"://[^@]+@", "://***@", repo_url)
    structlogger.debug(
        "github_clone.running",
        command=f"git clone {safe_url} {target_path}",
        branch=branch,
    )

    # Use /tmp as cwd to avoid issues if the builder's cwd was modified/deleted
    return subprocess.run(
        cmd,
        capture_output=True,
        text=True,
        cwd="/tmp",
    )


async def clone_with_token_refresh(
    repo_url: str,
    target_path: str,
    token: str,
    branch: Optional[str] = None,
    refresh_url: Optional[str] = None,
    max_retries: int = 1,
) -> None:
    """Clone a repository with automatic token refresh on auth failure.

    Args:
        repo_url: Repository URL (HTTPS format).
        target_path: Directory to clone into.
        token: Initial GitHub access token.
        branch: Branch to checkout.
        refresh_url: URL to call for token refresh (on model service).
        max_retries: Maximum number of retry attempts after token refresh.

    Raises:
        GitCloneError: If cloning fails after all retries.
    """
    current_token = token
    retries = 0

    while retries <= max_retries:
        auth_url = _build_authenticated_url(repo_url, current_token)
        result = _run_git_clone(auth_url, target_path, branch)

        if result.returncode == 0:
            structlogger.info(
                "github_clone.success",
                target_path=target_path,
                branch=branch,
            )
            return

        stderr = result.stderr
        stderr_lower = stderr.lower()

        # Check if branch doesn't exist (empty repo case)
        is_branch_not_found = (
            "remote branch" in stderr_lower and "not found" in stderr_lower
        )

        if is_branch_not_found and branch:
            # Empty repository - clone without branch, then create branch locally
            structlogger.info(
                "github_clone.empty_repo_detected",
                branch=branch,
                message="Branch not found, cloning empty repo and creating branch",
            )

            # Clean up failed attempt
            import shutil

            shutil.rmtree(target_path, ignore_errors=True)

            # Clone without specifying branch
            result = _run_git_clone(auth_url, target_path, branch=None)
            if result.returncode != 0:
                safe_stderr = re.sub(r"://[^@]+@", "://***@", result.stderr)
                raise GitCloneError(f"Git clone failed: {safe_stderr}")

            # Create and checkout the branch locally
            _create_branch_in_empty_repo(target_path, branch)

            structlogger.info(
                "github_clone.success_empty_repo",
                target_path=target_path,
                branch=branch,
            )
            return

        # Check if it's an authentication error
        is_auth_error = (
            "authentication failed" in stderr_lower
            or "could not read from remote" in stderr_lower
            or "403" in stderr_lower
            or "401" in stderr_lower
            or "invalid credentials" in stderr_lower
        )

        if is_auth_error and refresh_url and retries < max_retries:
            structlogger.warning(
                "github_clone.auth_failed_retrying",
                retry=retries + 1,
                max_retries=max_retries,
            )
            try:
                # Refresh the token
                new_credentials = await refresh_github_token(refresh_url)
                current_token = new_credentials.token
                retries += 1

                # Clean up failed clone attempt
                import shutil

                shutil.rmtree(target_path, ignore_errors=True)
                continue
            except TokenRefreshError as e:
                structlogger.error(
                    "github_clone.token_refresh_failed",
                    error=str(e),
                )
                raise GitCloneError(f"Token refresh failed: {e}")

        # Not an auth error or no refresh URL - fail immediately
        safe_stderr = re.sub(r"://[^@]+@", "://***@", stderr)
        structlogger.error(
            "github_clone.failed",
            returncode=result.returncode,
            stderr=safe_stderr,
        )
        raise GitCloneError(f"Git clone failed: {safe_stderr}")

    raise GitCloneError("Git clone failed after max retries")


def _create_branch_in_empty_repo(target_path: str, branch: str) -> None:
    """Create and checkout a branch in an empty repository.

    For empty repos, git clone creates a repo with no branch. We need to
    create the initial branch manually.

    Args:
        target_path: Path to the cloned repository.
        branch: Branch name to create.
    """
    import subprocess

    # For empty repos, we need to checkout -b to create the branch
    # Git may have initialized with a different default branch name
    result = subprocess.run(
        ["git", "checkout", "-b", branch],
        capture_output=True,
        text=True,
        cwd=target_path,
    )

    if result.returncode != 0:
        # Branch might already exist if git's default matches
        if "already exists" not in result.stderr.lower():
            structlogger.warning(
                "github_clone.create_branch_warning",
                branch=branch,
                stderr=result.stderr,
            )
        # Try just checking out if branch exists
        subprocess.run(
            ["git", "checkout", branch],
            capture_output=True,
            text=True,
            cwd=target_path,
        )

    structlogger.debug(
        "github_clone.branch_created",
        target_path=target_path,
        branch=branch,
    )


class GitPushError(Exception):
    """Exception raised when git push fails."""

    pass


def _is_auth_error(stderr: str) -> bool:
    """Check if the error message indicates an authentication failure.

    Args:
        stderr: The stderr output from git command.

    Returns:
        True if this appears to be an auth error.
    """
    stderr_lower = stderr.lower()
    return (
        "authentication failed" in stderr_lower
        or "could not read from remote" in stderr_lower
        or "403" in stderr_lower
        or "401" in stderr_lower
        or "invalid credentials" in stderr_lower
    )


def _is_non_fast_forward_error(stderr: str) -> bool:
    """Check if the error indicates a non-fast-forward push rejection.

    This happens when the remote has commits that are not in the local branch.

    Args:
        stderr: The stderr output from git push.

    Returns:
        True if this is a non-fast-forward error.
    """
    stderr_lower = stderr.lower()
    return (
        "non-fast-forward" in stderr_lower
        or "failed to push" in stderr_lower
        or "rejected" in stderr_lower
        or "fetch first" in stderr_lower
        or "updates were rejected" in stderr_lower
    )


def _run_git_push(
    repo_url: str,
    branch: str,
    cwd: str,
) -> subprocess.CompletedProcess:
    """Run git push command.

    Args:
        repo_url: Repository URL with credentials.
        branch: Branch to push.
        cwd: Working directory (the git repo).

    Returns:
        CompletedProcess with result.
    """
    cmd = ["git", "push", repo_url, branch]

    # Don't capture credentials in logs
    safe_url = re.sub(r"://[^@]+@", "://***@", repo_url)
    structlogger.debug(
        "github_push.running",
        command=f"git push {safe_url} {branch}",
        cwd=cwd,
    )

    return subprocess.run(
        cmd,
        capture_output=True,
        text=True,
        cwd=cwd,
    )


async def push_with_token_refresh(
    repo_url: str,
    branch: str,
    token: str,
    cwd: str,
    refresh_url: Optional[str] = None,
    max_retries: int = 1,
) -> None:
    """Push to a repository with automatic token refresh on auth failure.

    Args:
        repo_url: Repository URL (HTTPS format, without credentials).
        branch: Branch to push.
        token: Initial GitHub access token.
        cwd: Working directory (the git repo).
        refresh_url: URL to call for token refresh (on model service).
        max_retries: Maximum number of retry attempts after token refresh.

    Raises:
        GitPushError: If push fails after all retries.
    """
    current_token = token
    retries = 0

    while retries <= max_retries:
        auth_url = _build_authenticated_url(repo_url, current_token)
        result = _run_git_push(auth_url, branch, cwd)

        if result.returncode == 0:
            structlogger.info(
                "github_push.success",
                branch=branch,
            )
            return

        stderr = result.stderr

        # Check for non-fast-forward errors - these should not be retried
        if _is_non_fast_forward_error(stderr):
            safe_stderr = re.sub(r"://[^@]+@", "://***@", stderr)
            structlogger.error(
                "github_push.non_fast_forward",
                stderr=safe_stderr,
            )
            raise GitPushError(
                f"Push rejected: remote has changes not present locally. "
                f"Please pull the latest changes first. Details: {safe_stderr}"
            )

        # Check if it's an authentication error
        if _is_auth_error(stderr) and refresh_url and retries < max_retries:
            structlogger.warning(
                "github_push.auth_failed_retrying",
                retry=retries + 1,
                max_retries=max_retries,
            )
            try:
                # Refresh the token
                new_credentials = await refresh_github_token(refresh_url)
                current_token = new_credentials.token
                retries += 1
                continue
            except TokenRefreshError as e:
                structlogger.error(
                    "github_push.token_refresh_failed",
                    error=str(e),
                )
                raise GitPushError(f"Token refresh failed: {e}")

        # Other error - fail immediately
        safe_stderr = re.sub(r"://[^@]+@", "://***@", stderr)
        structlogger.error(
            "github_push.failed",
            returncode=result.returncode,
            stderr=safe_stderr,
        )
        raise GitPushError(f"Git push failed: {safe_stderr}")

    raise GitPushError("Git push failed after max retries")


async def ensure_valid_token() -> Optional[str]:
    """Ensure we have a valid token, refreshing if needed.

    Returns:
        A valid token, or None if no credentials are set.

    Raises:
        TokenRefreshError: If token refresh is needed but fails.
    """
    credentials = get_github_credentials()
    if not credentials:
        return None

    # Check if token is about to expire (5 min buffer)
    now = datetime.now(credentials.expires_at.tzinfo)
    time_until_expiry = (credentials.expires_at - now).total_seconds()

    if time_until_expiry > 300:  # More than 5 minutes remaining
        return credentials.token

    # Token is expired or about to expire - refresh
    if credentials.refresh_url:
        new_credentials = await refresh_github_token(credentials.refresh_url)
        return new_credentials.token

    # No refresh URL and token is expiring
    structlogger.warning(
        "github_clone.token_expiring_no_refresh_url",
        expires_at=credentials.expires_at.isoformat(),
    )
    return credentials.token
