"""OAuth manager with PKCE support for calendar providers.

Implements the OAuthPort protocol using httpx for async HTTP requests.
Uses PKCE (Proof Key for Code Exchange) for secure OAuth 2.0 flow.
"""

from __future__ import annotations

import base64
import hashlib
import secrets
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, ClassVar
from urllib.parse import urlencode

import httpx

from noteflow.config.constants import (
    DEFAULT_OAUTH_TOKEN_EXPIRY_SECONDS,
    ERR_TOKEN_REFRESH_PREFIX,
    HTTP_STATUS_NO_CONTENT,
    HTTP_STATUS_OK,
    OAUTH_FIELD_ACCESS_TOKEN,
    OAUTH_FIELD_REFRESH_TOKEN,
    OAUTH_FIELD_SCOPE,
    OAUTH_FIELD_TOKEN_TYPE,
)
from noteflow.domain.ports.calendar import OAuthPort
from noteflow.domain.value_objects import OAuthProvider, OAuthState, OAuthTokens
from noteflow.infrastructure.logging import get_logger, log_timing

if TYPE_CHECKING:
    from noteflow.config.settings import CalendarIntegrationSettings

logger = get_logger(__name__)


class OAuthError(Exception):
    """OAuth operation failed."""


class OAuthManager(OAuthPort):
    """OAuth manager implementing PKCE flow for Google and Outlook.

    Manages OAuth authorization URLs, token exchange, refresh, and revocation.
    State tokens are stored in-memory with TTL for security.

    Deployment Note:
        State tokens are stored in-memory (self._pending_states dict). This works
        correctly for single-worker deployments (current Tauri desktop model) but
        would require database-backed state storage for multi-worker/load-balanced
        deployments where OAuth initiate and complete requests may hit different
        workers.
    """

    # OAuth endpoints
    GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
    GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
    GOOGLE_REVOKE_URL = "https://oauth2.googleapis.com/revoke"

    OUTLOOK_AUTH_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"
    OUTLOOK_TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
    OUTLOOK_REVOKE_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/logout"

    # OAuth scopes
    GOOGLE_SCOPES: ClassVar[list[str]] = [
        "https://www.googleapis.com/auth/calendar.readonly",
        "https://www.googleapis.com/auth/userinfo.email",
        "openid",
    ]
    OUTLOOK_SCOPES: ClassVar[list[str]] = [
        "Calendars.Read",
        "User.Read",
        "offline_access",
        "openid",
    ]

    # State TTL (10 minutes)
    STATE_TTL_SECONDS = 600

    def __init__(self, settings: CalendarIntegrationSettings) -> None:
        """Initialize OAuth manager with calendar settings.

        Args:
            settings: Calendar settings with OAuth credentials.
        """
        self._settings = settings
        self._pending_states: dict[str, OAuthState] = {}

    def get_pending_state(self, state_token: str) -> OAuthState | None:
        """Get pending OAuth state by token.

        Args:
            state_token: State token from initiate_auth.

        Returns:
            OAuthState if found, None otherwise.
        """
        return self._pending_states.get(state_token)

    def has_pending_state(self, state_token: str) -> bool:
        """Check if a pending state exists.

        Args:
            state_token: State token from initiate_auth.

        Returns:
            True if state exists, False otherwise.
        """
        return state_token in self._pending_states

    def set_pending_state(self, state_token: str, oauth_state: OAuthState) -> None:
        """Set pending OAuth state for testing purposes.

        Args:
            state_token: State token to set.
            oauth_state: OAuth state to store.
        """
        self._pending_states[state_token] = oauth_state

    def initiate_auth(
        self,
        provider: OAuthProvider,
        redirect_uri: str,
    ) -> tuple[str, str]:
        """Generate OAuth authorization URL with PKCE.

        Args:
            provider: OAuth provider (google or outlook).
            redirect_uri: Callback URL after authorization.

        Returns:
            Tuple of (authorization_url, state_token).

        Raises:
            OAuthError: If provider credentials are not configured.
        """
        self._cleanup_expired_states()
        self._validate_provider_config(provider)

        # Generate PKCE code verifier and challenge
        code_verifier = self._generate_code_verifier()
        code_challenge = self._generate_code_challenge(code_verifier)

        # Generate state token for CSRF protection
        state_token = secrets.token_urlsafe(32)

        # Store state for validation during callback
        now = datetime.now(UTC)
        oauth_state = OAuthState(
            state=state_token,
            provider=provider,
            redirect_uri=redirect_uri,
            code_verifier=code_verifier,
            created_at=now,
            expires_at=now + timedelta(seconds=self.STATE_TTL_SECONDS),
        )
        self._pending_states[state_token] = oauth_state

        # Build authorization URL
        auth_url = self._build_auth_url(
            provider=provider,
            redirect_uri=redirect_uri,
            state=state_token,
            code_challenge=code_challenge,
        )

        logger.info("Initiated OAuth flow for provider=%s", provider.value)
        return auth_url, state_token

    async def complete_auth(
        self,
        provider: OAuthProvider,
        code: str,
        state: str,
    ) -> OAuthTokens:
        """Exchange authorization code for tokens.

        Args:
            provider: OAuth provider.
            code: Authorization code from callback.
            state: State parameter from callback.

        Returns:
            OAuth tokens.

        Raises:
            OAuthError: If state is invalid, expired, or token exchange fails.
        """
        # Validate and retrieve state
        oauth_state = self._pending_states.pop(state, None)
        if oauth_state is None:
            raise OAuthError("Invalid or expired state token")

        if oauth_state.is_state_expired():
            raise OAuthError("State token has expired")

        if oauth_state.provider != provider:
            raise OAuthError(
                f"Provider mismatch: expected {oauth_state.provider}, got {provider}"
            )

        # Exchange code for tokens
        tokens = await self._exchange_code(
            provider=provider,
            code=code,
            redirect_uri=oauth_state.redirect_uri,
            code_verifier=oauth_state.code_verifier,
        )

        logger.info("Completed OAuth flow for provider=%s", provider.value)
        return tokens

    async def refresh_tokens(
        self,
        provider: OAuthProvider,
        refresh_token: str,
    ) -> OAuthTokens:
        """Refresh expired access token.

        Args:
            provider: OAuth provider.
            refresh_token: Refresh token from previous exchange.

        Returns:
            New OAuth tokens.

        Raises:
            OAuthError: If refresh fails.
        """
        token_url = self._get_token_url(provider)
        client_id, client_secret = self._get_credentials(provider)

        data = {
            "grant_type": OAUTH_FIELD_REFRESH_TOKEN,
            OAUTH_FIELD_REFRESH_TOKEN: refresh_token,
            "client_id": client_id,
        }

        # Google requires client_secret for refresh
        if provider == OAuthProvider.GOOGLE:
            data["client_secret"] = client_secret

        with log_timing("oauth_token_refresh", provider=provider.value):
            async with httpx.AsyncClient() as client:
                response = await client.post(token_url, data=data)

        if response.status_code != HTTP_STATUS_OK:
            error_detail = response.text
            logger.error(
                "oauth_token_refresh_failed",
                provider=provider.value,
                error=error_detail,
            )
            raise OAuthError(f"{ERR_TOKEN_REFRESH_PREFIX}{error_detail}")

        token_data = response.json()
        tokens = self._parse_token_response(token_data, refresh_token)

        logger.info("oauth_tokens_refreshed", provider=provider.value)
        return tokens

    async def revoke_tokens(
        self,
        provider: OAuthProvider,
        access_token: str,
    ) -> bool:
        """Revoke OAuth tokens with provider.

        Args:
            provider: OAuth provider.
            access_token: Access token to revoke.

        Returns:
            True if revoked successfully.
        """
        revoke_url = self._get_revoke_url(provider)

        async with httpx.AsyncClient() as client:
            if provider == OAuthProvider.GOOGLE:
                response = await client.post(
                    revoke_url,
                    params={"token": access_token},
                )
            else:
                # Outlook uses logout endpoint with token hint
                response = await client.get(
                    revoke_url,
                    params={"post_logout_redirect_uri": self._settings.redirect_uri},
                )

        if response.status_code in (HTTP_STATUS_OK, HTTP_STATUS_NO_CONTENT):
            logger.info("Revoked tokens for provider=%s", provider.value)
            return True

        logger.warning(
            "Token revocation returned status=%d for provider=%s",
            response.status_code,
            provider.value,
        )
        return False

    def _validate_provider_config(self, provider: OAuthProvider) -> None:
        """Validate that provider credentials are configured."""
        client_id, client_secret = self._get_credentials(provider)
        if not client_id or not client_secret:
            raise OAuthError(
                f"OAuth credentials not configured for {provider.value}"
            )

    def _get_credentials(self, provider: OAuthProvider) -> tuple[str, str]:
        """Get client credentials for provider."""
        if provider == OAuthProvider.GOOGLE:
            return (
                self._settings.google_client_id,
                self._settings.google_client_secret,
            )
        return (
            self._settings.outlook_client_id,
            self._settings.outlook_client_secret,
        )

    def _get_auth_url(self, provider: OAuthProvider) -> str:
        """Get authorization URL for provider."""
        if provider == OAuthProvider.GOOGLE:
            return self.GOOGLE_AUTH_URL
        return self.OUTLOOK_AUTH_URL

    def _get_token_url(self, provider: OAuthProvider) -> str:
        """Get token URL for provider."""
        if provider == OAuthProvider.GOOGLE:
            return self.GOOGLE_TOKEN_URL
        return self.OUTLOOK_TOKEN_URL

    def _get_revoke_url(self, provider: OAuthProvider) -> str:
        """Get revoke URL for provider."""
        if provider == OAuthProvider.GOOGLE:
            return self.GOOGLE_REVOKE_URL
        return self.OUTLOOK_REVOKE_URL

    def _get_scopes(self, provider: OAuthProvider) -> list[str]:
        """Get OAuth scopes for provider."""
        if provider == OAuthProvider.GOOGLE:
            return self.GOOGLE_SCOPES
        return self.OUTLOOK_SCOPES

    def _build_auth_url(
        self,
        provider: OAuthProvider,
        redirect_uri: str,
        state: str,
        code_challenge: str,
    ) -> str:
        """Build OAuth authorization URL with PKCE parameters."""
        client_id, _ = self._get_credentials(provider)
        scopes = self._get_scopes(provider)
        base_url = self._get_auth_url(provider)

        params = {
            "client_id": client_id,
            "redirect_uri": redirect_uri,
            "response_type": "code",
            OAUTH_FIELD_SCOPE: " ".join(scopes),
            "state": state,
            "code_challenge": code_challenge,
            "code_challenge_method": "S256",
        }

        # Provider-specific parameters
        if provider == OAuthProvider.GOOGLE:
            params["access_type"] = "offline"
            params["prompt"] = "consent"
        elif provider == OAuthProvider.OUTLOOK:
            params["response_mode"] = "query"

        return f"{base_url}?{urlencode(params)}"

    async def _exchange_code(
        self,
        provider: OAuthProvider,
        code: str,
        redirect_uri: str,
        code_verifier: str,
    ) -> OAuthTokens:
        """Exchange authorization code for tokens."""
        token_url = self._get_token_url(provider)
        client_id, client_secret = self._get_credentials(provider)

        data = {
            "grant_type": "authorization_code",
            "code": code,
            "redirect_uri": redirect_uri,
            "client_id": client_id,
            "code_verifier": code_verifier,
        }

        # Google requires client_secret even with PKCE
        if provider == OAuthProvider.GOOGLE:
            data["client_secret"] = client_secret

        async with httpx.AsyncClient() as client:
            response = await client.post(token_url, data=data)

        if response.status_code != HTTP_STATUS_OK:
            error_detail = response.text
            logger.error(
                "Token exchange failed for provider=%s: %s",
                provider.value,
                error_detail,
            )
            raise OAuthError(f"Token exchange failed: {error_detail}")

        token_data = response.json()
        return self._parse_token_response(token_data)

    def _parse_token_response(
        self,
        data: dict[str, object],
        existing_refresh_token: str | None = None,
    ) -> OAuthTokens:
        """Parse token response into OAuthTokens."""
        access_token = str(data.get(OAUTH_FIELD_ACCESS_TOKEN, ""))
        if not access_token:
            raise OAuthError("No access_token in response")

        # Calculate expiry time
        expires_in_raw = data.get("expires_in", DEFAULT_OAUTH_TOKEN_EXPIRY_SECONDS)
        expires_in = (
            int(expires_in_raw)
            if isinstance(expires_in_raw, (int, float, str))
            else DEFAULT_OAUTH_TOKEN_EXPIRY_SECONDS
        )
        expires_at = datetime.now(UTC) + timedelta(seconds=expires_in)

        # Refresh token may not be returned on refresh
        refresh_token = data.get(OAUTH_FIELD_REFRESH_TOKEN)
        if isinstance(refresh_token, str):
            final_refresh_token: str | None = refresh_token
        else:
            final_refresh_token = existing_refresh_token

        return OAuthTokens(
            access_token=access_token,
            refresh_token=final_refresh_token,
            token_type=str(data.get(OAUTH_FIELD_TOKEN_TYPE, "Bearer")),
            expires_at=expires_at,
            scope=str(data.get(OAUTH_FIELD_SCOPE, "")),
        )

    @staticmethod
    def _generate_code_verifier() -> str:
        """Generate a cryptographically random code verifier for PKCE."""
        return secrets.token_urlsafe(64)

    @staticmethod
    def _generate_code_challenge(verifier: str) -> str:
        """Generate code challenge from verifier using S256 method."""
        digest = hashlib.sha256(verifier.encode("ascii")).digest()
        return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")

    def _cleanup_expired_states(self) -> None:
        """Remove expired state tokens."""
        now = datetime.now(UTC)
        expired_keys = [
            key
            for key, state in self._pending_states.items()
            if state.is_state_expired() or now > state.expires_at
        ]
        for key in expired_keys:
            del self._pending_states[key]
