"""OAuth2 authentication helpers for enterprise platform integration.

Provides OAuth2 Authorization Code + PKCE flows for:
  - Microsoft Azure AD / Entra ID (Azure DevOps, Microsoft 365)
  - GitHub (Personal Access Token validation + OAuth App flow)
  - Generic OAuth2 providers

SECURITY:
  - All tokens are stored in-memory only via TokenManager.
  - Client secrets are never logged.
  - PKCE is used where supported (recommended for all flows).
  - Redirect URIs use localhost loopback for desktop/IDE flows.
"""

from __future__ import annotations

import base64
import hashlib
import json
import logging
import os
import secrets
import urllib.parse
from typing import Any

import httpx

from mcp_bridge.platforms.auth.token_manager import TokenManager

logger = logging.getLogger(__name__)


class OAuth2Config:
    """Configuration for an OAuth2 provider."""

    def __init__(
        self,
        *,
        provider: str,
        authorize_url: str,
        token_url: str,
        client_id: str,
        client_secret: str | None = None,
        scopes: list[str] | None = None,
        redirect_uri: str = "http://localhost:19421/callback",
        use_pkce: bool = True,
        extra_params: dict[str, str] | None = None,
    ) -> None:
        self.provider = provider
        self.authorize_url = authorize_url
        self.token_url = token_url
        self.client_id = client_id
        self.client_secret = client_secret
        self.scopes = scopes or []
        self.redirect_uri = redirect_uri
        self.use_pkce = use_pkce
        self.extra_params = extra_params or {}


# ---------------------------------------------------------------------------
# Preset configurations for common providers
# ---------------------------------------------------------------------------

MICROSOFT_COMMON_TENANT = "common"


def microsoft_oauth2_config(
    tenant_id: str = MICROSOFT_COMMON_TENANT,
    client_id: str = "",
    scopes: list[str] | None = None,
) -> OAuth2Config:
    """Create OAuth2 config for Microsoft Azure AD / Entra ID.

    Args:
        tenant_id: Azure AD tenant ID or 'common' for multi-tenant.
        client_id: Application (client) ID from Azure AD app registration.
        scopes: OAuth2 scopes. Defaults to User.Read + openid + profile.

    Returns:
        OAuth2Config for Microsoft.
    """
    return OAuth2Config(
        provider="microsoft",
        authorize_url=f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/authorize",
        token_url=f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token",
        client_id=client_id,
        scopes=scopes or ["User.Read", "openid", "profile", "offline_access"],
        use_pkce=True,
    )


def microsoft_ado_config(
    tenant_id: str = MICROSOFT_COMMON_TENANT,
    client_id: str = "",
) -> OAuth2Config:
    """Create OAuth2 config for Azure DevOps.

    Uses the Azure DevOps default scope for full API access.

    Args:
        tenant_id: Azure AD tenant ID.
        client_id: Application (client) ID.

    Returns:
        OAuth2Config for Azure DevOps.
    """
    return OAuth2Config(
        provider="azure-devops",
        authorize_url=f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/authorize",
        token_url=f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token",
        client_id=client_id,
        scopes=["499b84ac-1321-427f-aa17-267ca6975798/.default"],
        use_pkce=True,
    )


def github_oauth2_config(
    client_id: str = "",
    client_secret: str | None = None,
    scopes: list[str] | None = None,
) -> OAuth2Config:
    """Create OAuth2 config for GitHub.

    Args:
        client_id: GitHub OAuth App client ID.
        client_secret: GitHub OAuth App client secret.
        scopes: GitHub scopes. Defaults to repo + read:user.

    Returns:
        OAuth2Config for GitHub.
    """
    return OAuth2Config(
        provider="github",
        authorize_url="https://github.com/login/oauth/authorize",
        token_url="https://github.com/login/oauth/access_token",
        client_id=client_id,
        client_secret=client_secret,
        scopes=scopes or ["repo", "read:user"],
        use_pkce=False,  # GitHub doesn't support PKCE for OAuth Apps
    )


# ---------------------------------------------------------------------------
# PKCE helpers
# ---------------------------------------------------------------------------


def generate_pkce_pair() -> tuple[str, str]:
    """Generate a PKCE code_verifier and code_challenge pair.

    Returns:
        Tuple of (code_verifier, code_challenge) using S256 method.
    """
    code_verifier = secrets.token_urlsafe(64)[:128]
    digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
    code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
    return code_verifier, code_challenge


# ---------------------------------------------------------------------------
# OAuth2 flow orchestrator
# ---------------------------------------------------------------------------


class OAuth2Flow:
    """Orchestrates the OAuth2 Authorization Code + PKCE flow.

    Typical usage from the VS Code extension:
    1. Extension calls `build_authorize_url()` to get the login URL
    2. Extension opens the URL in a browser
    3. User authenticates and is redirected to localhost callback
    4. Extension captures the authorization code from the redirect
    5. Extension calls `exchange_code(code)` to get tokens
    6. Tokens are stored in TokenManager

    The extension handles the HTTP callback server on port 19421.
    This class is stateless except for the PKCE verifier per flow instance.
    """

    def __init__(self, config: OAuth2Config, token_manager: TokenManager) -> None:
        self._config = config
        self._token_manager = token_manager
        self._state: str = secrets.token_urlsafe(32)
        self._code_verifier: str | None = None
        self._code_challenge: str | None = None

        if config.use_pkce:
            self._code_verifier, self._code_challenge = generate_pkce_pair()

    @property
    def state(self) -> str:
        """The state parameter for CSRF protection."""
        return self._state

    def build_authorize_url(self) -> str:
        """Build the authorization URL to redirect the user to.

        Returns:
            Full URL to open in the browser for user authentication.
        """
        params: dict[str, str] = {
            "client_id": self._config.client_id,
            "response_type": "code",
            "redirect_uri": self._config.redirect_uri,
            "state": self._state,
        }

        if self._config.scopes:
            params["scope"] = " ".join(self._config.scopes)

        if self._code_challenge:
            params["code_challenge"] = self._code_challenge
            params["code_challenge_method"] = "S256"

        params.update(self._config.extra_params)

        return f"{self._config.authorize_url}?{urllib.parse.urlencode(params)}"

    async def exchange_code(
        self,
        code: str,
        received_state: str | None = None,
    ) -> dict[str, Any]:
        """Exchange an authorization code for access + refresh tokens.

        Args:
            code: The authorization code from the redirect callback.
            received_state: The state parameter from the callback (for CSRF validation).

        Returns:
            Dict with token_type, access_token, refresh_token, expires_in, scope.

        Raises:
            ValueError: If state validation fails.
            httpx.HTTPError: If the token request fails.
        """
        # Validate state (CSRF protection)
        if received_state is not None and received_state != self._state:
            raise ValueError(
                "OAuth2 state mismatch: possible CSRF attack. "
                "Expected state does not match received state."
            )

        data: dict[str, str] = {
            "grant_type": "authorization_code",
            "client_id": self._config.client_id,
            "code": code,
            "redirect_uri": self._config.redirect_uri,
        }

        if self._config.client_secret:
            data["client_secret"] = self._config.client_secret

        if self._code_verifier:
            data["code_verifier"] = self._code_verifier

        async with httpx.AsyncClient(timeout=30.0) as client:
            headers = {"Accept": "application/json"}
            response = await client.post(
                self._config.token_url,
                data=data,
                headers=headers,
            )
            response.raise_for_status()
            token_data = response.json()

        # Store tokens in memory
        import time

        access_token = token_data.get("access_token", "")
        expires_in = token_data.get("expires_in")
        expires_at = (time.time() + int(expires_in)) if expires_in else None

        self._token_manager.store(
            token_type=f"{self._config.provider}_access_token",
            value=access_token,
            expires_at=expires_at,
            scopes=token_data.get("scope", "").split(),
            metadata={"provider": self._config.provider},
        )

        # Store refresh token if present
        refresh_token = token_data.get("refresh_token")
        if refresh_token:
            self._token_manager.store(
                token_type=f"{self._config.provider}_refresh_token",
                value=refresh_token,
                metadata={"provider": self._config.provider},
            )

        logger.info(
            "OAuth2 tokens acquired: provider=%s, expires_in=%s",
            self._config.provider,
            expires_in,
        )

        return {
            "provider": self._config.provider,
            "token_type": token_data.get("token_type", "Bearer"),
            "expires_in": expires_in,
            "scope": token_data.get("scope", ""),
            "has_refresh_token": refresh_token is not None,
        }

    async def refresh_access_token(self) -> dict[str, Any]:
        """Refresh an expired access token using the stored refresh token.

        Returns:
            Dict with updated token metadata.

        Raises:
            RuntimeError: If no refresh token is available.
        """
        refresh_key = f"{self._config.provider}_refresh_token"
        refresh_token = self._token_manager.get(refresh_key)
        if not refresh_token:
            raise RuntimeError(
                f"No refresh token for provider '{self._config.provider}'. "
                "Re-authenticate with the full OAuth2 flow."
            )

        data: dict[str, str] = {
            "grant_type": "refresh_token",
            "client_id": self._config.client_id,
            "refresh_token": refresh_token,
        }

        if self._config.client_secret:
            data["client_secret"] = self._config.client_secret

        if self._config.scopes:
            data["scope"] = " ".join(self._config.scopes)

        async with httpx.AsyncClient(timeout=30.0) as client:
            response = await client.post(
                self._config.token_url,
                data=data,
                headers={"Accept": "application/json"},
            )
            response.raise_for_status()
            token_data = response.json()

        import time

        access_token = token_data.get("access_token", "")
        expires_in = token_data.get("expires_in")
        expires_at = (time.time() + int(expires_in)) if expires_in else None

        self._token_manager.store(
            token_type=f"{self._config.provider}_access_token",
            value=access_token,
            expires_at=expires_at,
            scopes=token_data.get("scope", "").split(),
            metadata={"provider": self._config.provider},
        )

        # Update refresh token if a new one was issued
        new_refresh = token_data.get("refresh_token")
        if new_refresh:
            self._token_manager.store(
                token_type=refresh_key,
                value=new_refresh,
                metadata={"provider": self._config.provider},
            )

        logger.info(
            "OAuth2 token refreshed: provider=%s, expires_in=%s",
            self._config.provider,
            expires_in,
        )

        return {
            "provider": self._config.provider,
            "refreshed": True,
            "expires_in": expires_in,
        }


# ---------------------------------------------------------------------------
# GitHub PAT validation helper
# ---------------------------------------------------------------------------


async def validate_github_pat(pat: str) -> dict[str, Any]:
    """Validate a GitHub Personal Access Token and return user info.

    Args:
        pat: The GitHub PAT to validate.

    Returns:
        Dict with login, name, scopes, and validity status.
    """
    async with httpx.AsyncClient(timeout=15.0) as client:
        response = await client.get(
            "https://api.github.com/user",
            headers={
                "Authorization": f"Bearer {pat}",
                "Accept": "application/vnd.github.v3+json",
            },
        )

        if response.status_code == 200:
            user = response.json()
            scopes = response.headers.get("X-OAuth-Scopes", "").split(", ")
            return {
                "valid": True,
                "login": user.get("login"),
                "name": user.get("name"),
                "scopes": scopes,
            }
        elif response.status_code == 401:
            return {"valid": False, "error": "Invalid or expired token"}
        else:
            return {"valid": False, "error": f"HTTP {response.status_code}"}


# ---------------------------------------------------------------------------
# Swagger UI spec URL resolver
# ---------------------------------------------------------------------------


async def resolve_swagger_ui_spec_url(swagger_ui_url: str) -> str | None:
    """Resolve the actual OpenAPI spec URL from a Swagger UI page.

    Swagger UI pages load the spec from a configured URL. This helper
    fetches the HTML and extracts the spec URL.

    Args:
        swagger_ui_url: URL of the Swagger UI page (e.g., /swagger-ui.html).

    Returns:
        The spec URL if found, or None.
    """
    async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
        response = await client.get(swagger_ui_url)
        html = response.text

    # Common patterns for spec URL in Swagger UI:
    # 1. url: "https://..." in SwaggerUIBundle config
    # 2. configUrl or spec parameter
    import re

    # Pattern 1: url: "..." in JS config
    match = re.search(r'url\s*[:=]\s*["\']([^"\']+)["\']', html)
    if match:
        spec_url = match.group(1)
        # Resolve relative URLs
        if spec_url.startswith("/"):
            from urllib.parse import urlparse

            parsed = urlparse(swagger_ui_url)
            spec_url = f"{parsed.scheme}://{parsed.netloc}{spec_url}"
        return spec_url

    # Pattern 2: /v2/api-docs or /v3/api-docs
    match = re.search(r'["\'](/v[23]/api-docs[^"\']*)["\']', html)
    if match:
        from urllib.parse import urlparse

        parsed = urlparse(swagger_ui_url)
        return f"{parsed.scheme}://{parsed.netloc}{match.group(1)}"

    # Pattern 3: configUrl
    match = re.search(r'configUrl\s*[:=]\s*["\']([^"\']+)["\']', html)
    if match:
        config_url = match.group(1)
        if config_url.startswith("/"):
            from urllib.parse import urlparse

            parsed = urlparse(swagger_ui_url)
            config_url = f"{parsed.scheme}://{parsed.netloc}{config_url}"

        # Fetch config to get the actual spec URL
        config_response = await client.get(config_url)
        if config_response.status_code == 200:
            config_data = config_response.json()
            urls = config_data.get("urls", [])
            if urls:
                return urls[0].get("url")

    return None
