# Sprint 5: Calendar Sync

> **Priority**: 5 | **Owner**: Backend | **Complexity**: Medium-High
> **Prerequisite**: Sprint 0 (Proto & Schema Foundation)

---

## Validation Status (2025-12-27)

### ✅ 100% COMPLETE

| Component | Location | Status |
|-----------|----------|--------|
| Feature Flag | `config/settings.py` | ✅ `calendar_enabled` in `FeatureFlags` |
| OAuth Manager | `infrastructure/calendar/oauth_manager.py` | ✅ Full PKCE flow with Authlib |
| Google Adapter | `infrastructure/calendar/google_calendar.py` | ✅ Calendar API integration |
| Outlook Adapter | `infrastructure/calendar/outlook_calendar.py` | ✅ Graph API integration |
| Calendar Service | `application/services/calendar_service.py` | ✅ Full orchestration layer |
| gRPC Mixin | `grpc/_mixins/calendar.py` | ✅ OAuth + event listing RPCs |
| ORM Models | `persistence/models/integrations/` | ✅ Integration, secrets, events, links |
| Proto RPCs | `noteflow.proto` | ✅ InitiateOAuth, CompleteOAuth, ListCalendarEvents |
| Settings | `config/settings.py` | ✅ `CalendarSettings` with OAuth credentials |
| Frontend OAuth Hook | `hooks/use-oauth-flow.ts` | ✅ Full OAuth flow with deep-link listener |
| Frontend Calendar Hook | `hooks/use-calendar-sync.ts` | ✅ Event fetching with auto-refresh |
| Deep Link Handler | `use-oauth-flow.ts` via `@tauri-apps/plugin-deep-link` | ✅ CSRF + callback parsing |
| Rust Commands | `commands/calendar.rs` | ✅ 6 commands (OAuth + events) |
| Tauri Adapter | `api/tauri-adapter.ts:181-190` | ✅ All calendar wrappers |

---

## Objective

Enable bidirectional calendar synchronization with Google and Outlook calendars. Users can view upcoming meetings, and the system can use calendar proximity as a trigger signal for auto-recording.

---

## Library Selection

### OAuth Library: Authlib (Recommended)

| Library | Recommendation | Rationale |
|---------|---------------|-----------|
| **Authlib** | ✅ Use this | Built-in PKCE, async support, actively maintained, handles edge cases |
| Manual httpx | ❌ Don't use | Reinventing wheel, missing edge cases, more code to maintain |
| OAuthLib | ❌ Don't use | Low-level, no async, more boilerplate |

**References**:
- [Authlib OAuth 2.0 Client](https://docs.authlib.org/en/latest/client/oauth2.html)
- [Authlib PKCE Support](https://docs.authlib.org/en/latest/client/oauth2.html#oauth2session-for-authorization-code)
- [httpx AsyncClient Integration](https://docs.authlib.org/en/latest/client/httpx.html)

### Key Authlib Features

```python
from authlib.integrations.httpx_client import AsyncOAuth2Client

# PKCE flow with S256 challenge method
client = AsyncOAuth2Client(
    client_id=client_id,
    client_secret=client_secret,
    code_challenge_method='S256',  # Built-in PKCE
)

# Generate authorization URL with code_verifier
code_verifier = client.create_s256_code_challenge()[1]  # (challenge, verifier)
auth_url, state = client.create_authorization_url(
    authorization_endpoint,
    code_verifier=code_verifier,
)

# Exchange code for tokens (PKCE verification automatic)
token = await client.fetch_token(
    token_endpoint,
    authorization_response=callback_url,
    code_verifier=code_verifier,
)

# Token refresh (built-in)
new_token = await client.refresh_token(token_endpoint, refresh_token=refresh_token)
```

---

## Architecture: Protocol-Based Dependency Injection

All components use **Protocol-based dependency injection** for testability:

```
┌─────────────────────────────────────────────────────────────────┐
│                        gRPC Layer                                │
│  CalendarMixin(calendar_service: CalendarServiceProtocol)       │
└─────────────────────────────────────────────────────────────────┘
                               │
                               ▼
┌─────────────────────────────────────────────────────────────────┐
│                     Application Layer                            │
│  CalendarService(oauth_client: OAuthPort, uow: UoWFactory)      │
└─────────────────────────────────────────────────────────────────┘
                               │
                               ▼
┌─────────────────────────────────────────────────────────────────┐
│                   Infrastructure Layer                           │
│  AuthlibOAuthClient(client_id, secret) implements OAuthPort     │
│  GoogleCalendarAdapter(token) implements CalendarPort           │
│  OutlookCalendarAdapter(token) implements CalendarPort          │
└─────────────────────────────────────────────────────────────────┘
```

**Key Principles** (per [ArjanCodes DI Best Practices](https://arjancodes.com/blog/python-dependency-injection-best-practices/)):
- Constructor injection for all dependencies
- Protocol-based abstractions (structural typing)
- Factory functions for object creation
- No global state or singletons

---

## Current State Analysis

### What Exists

| Component | Location | Status |
|-----------|----------|--------|
| Frontend UI | `client/src/components/UpcomingMeetings.tsx` | Renders placeholder/mocked events |
| Config Panel | `client/src/components/IntegrationConfigPanel.tsx` | OAuth configuration forms exist |
| Trigger Service | `src/noteflow/infrastructure/triggers/calendar.py` | Has `parse_calendar_events()` for config, no live sync |
| Settings | `src/noteflow/config/settings.py` | No OAuth/calendar settings |

### Existing Persistence Infrastructure (REUSE)

The database schema and ORM models for calendar sync **already exist**:

| Table | ORM Model | Location | Purpose |
|-------|-----------|----------|---------|
| `noteflow.integrations` | `IntegrationModel` | `models/integrations/integration.py:33` | Stores integration config (type='calendar') |
| `noteflow.integration_secrets` | `IntegrationSecretModel` | `models/integrations/integration.py:108` | Stores OAuth tokens securely |
| `noteflow.integration_sync_runs` | `IntegrationSyncRunModel` | `models/integrations/integration.py:140` | Tracks sync operation history |
| `noteflow.calendar_events` | `CalendarEventModel` | `models/integrations/integration.py:188` | Caches synced calendar events |
| `noteflow.meeting_calendar_links` | `MeetingCalendarLinkModel` | `models/integrations/integration.py:257` | Links meetings to calendar events |

**Schema reference**: `docker/db/schema.sql:302-386`

### Gap

The persistence layer is complete, but there's no application/domain layer to:
1. **Implement OAuth flow and exchange codes for tokens** (CRITICAL)
2. Generate OAuth authorization URLs
3. Handle OAuth callback with PKCE
4. Fetch live events from Google/Outlook APIs
5. Populate the existing `calendar_events` table
6. Serve upcoming meetings via gRPC to the frontend

---

## Target/Affected Code

### Files to Create

| File | Purpose | Lines Est. |
|------|---------|------------|
| `src/noteflow/domain/calendar/__init__.py` | Calendar domain package | ~10 |
| `src/noteflow/domain/calendar/ports.py` | Calendar port interfaces | ~80 |
| `src/noteflow/domain/calendar/oauth.py` | OAuth flow domain models | ~60 |
| `src/noteflow/infrastructure/integrations/__init__.py` | Integrations infrastructure package | ~10 |
| `src/noteflow/infrastructure/integrations/oauth_manager.py` | OAuth flow manager | ~200 |
| `src/noteflow/infrastructure/integrations/google_calendar.py` | Google Calendar adapter | ~180 |
| `src/noteflow/infrastructure/integrations/outlook_calendar.py` | Outlook Calendar adapter | ~180 |
| `src/noteflow/application/services/calendar_service.py` | Calendar application service | ~250 |
| `src/noteflow/grpc/_mixins/calendar.py` | Calendar gRPC mixin | ~150 |
| `client/src/hooks/useCalendarSync.ts` | React hook for calendar | ~120 |
| `client/src/hooks/useOAuthFlow.ts` | React hook for OAuth | ~100 |
| `client/src/components/CalendarConnectionPanel.tsx` | OAuth UI component | ~150 |
| `client/src-tauri/src/commands/calendar.rs` | Rust calendar commands | ~120 |
| `tests/application/test_calendar_service.py` | Service unit tests | ~200 |
| `tests/infrastructure/integrations/test_oauth_manager.py` | OAuth unit tests | ~150 |
| `tests/infrastructure/integrations/test_google_calendar.py` | Adapter unit tests | ~150 |

### Files to Modify

| File | Change Type | Lines Est. |
|------|-------------|------------|
| `src/noteflow/grpc/service.py` | Add CalendarMixin | +5 |
| `src/noteflow/grpc/server.py` | Initialize calendar service | +20 |
| `src/noteflow/config/settings.py` | Add OAuth settings | +40 |
| `client/src/lib/tauri.ts` | Add calendar commands | +30 |
| `client/src/components/UpcomingMeetings.tsx` | Use real data | +30 |
| `pyproject.toml` | Add Google API dependencies | +5 |

---

## Implementation Tasks

### Task 0: Verify Prerequisites

**Prerequisite**: Sprint 0 must be complete with:

1. **Proto definitions** verified in `noteflow.proto`:
   ```protobuf
   rpc InitiateOAuth(InitiateOAuthRequest) returns (InitiateOAuthResponse);
   rpc CompleteOAuth(CompleteOAuthRequest) returns (CompleteOAuthResponse);
   rpc ListCalendarEvents(ListCalendarEventsRequest) returns (ListCalendarEventsResponse);
   ```

2. **Feature flag** verified:
   ```python
   from noteflow.config.settings import get_settings
   assert get_settings().feature_flags.calendar_enabled
   ```

**If any verification fails**: Complete Sprint 0 first.

---

### Task 1: OAuth Domain Models

**File**: `src/noteflow/domain/calendar/oauth.py`

```python
"""OAuth flow domain models."""

from __future__ import annotations

from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from datetime import datetime


class OAuthProvider(Enum):
    """Supported OAuth providers."""

    GOOGLE = "google"
    MICROSOFT = "microsoft"


class OAuthStatus(Enum):
    """OAuth connection status."""

    NOT_CONNECTED = "not_connected"
    PENDING = "pending"  # Authorization URL generated, awaiting callback
    CONNECTED = "connected"
    EXPIRED = "expired"  # Token expired, needs refresh
    ERROR = "error"


@dataclass(frozen=True, slots=True)
class OAuthState:
    """OAuth authorization state (for PKCE flow)."""

    state: str  # Random state parameter
    code_verifier: str  # PKCE code verifier
    provider: OAuthProvider
    redirect_uri: str
    created_at: datetime


@dataclass(frozen=True, slots=True)
class OAuthTokens:
    """OAuth token response."""

    access_token: str
    refresh_token: str | None
    expires_at: datetime
    token_type: str = "Bearer"
    scope: str = ""


@dataclass(frozen=True, slots=True)
class OAuthConnection:
    """OAuth connection status for a provider."""

    provider: OAuthProvider
    status: OAuthStatus
    email: str | None = None
    expires_at: datetime | None = None
    error_message: str | None = None
```

---

### Task 2: Domain Port Interfaces

**File**: `src/noteflow/domain/calendar/ports.py`

```python
"""Calendar port interfaces (hexagonal architecture)."""

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime
from typing import Protocol

from noteflow.domain.calendar.oauth import (
    OAuthConnection,
    OAuthProvider,
    OAuthState,
    OAuthTokens,
)


@dataclass(frozen=True, slots=True)
class CalendarEvent:
    """A calendar event."""

    id: str
    title: str
    start_time: datetime
    end_time: datetime
    attendees: tuple[str, ...]
    location: str | None = None
    description: str | None = None
    is_recurring: bool = False
    meeting_url: str | None = None


class CalendarPort(Protocol):
    """Port for calendar operations."""

    async def list_events(
        self,
        from_time: datetime,
        to_time: datetime,
        limit: int = 50,
    ) -> list[CalendarEvent]:
        """List calendar events in time range."""
        ...

    async def refresh_token(self) -> bool:
        """Refresh OAuth token if needed.

        Returns:
            True if token was refreshed or still valid.
        """
        ...

    def is_authenticated(self) -> bool:
        """Check if calendar is authenticated."""
        ...


class OAuthPort(Protocol):
    """Port for OAuth operations."""

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

        Args:
            provider: OAuth provider.
            redirect_uri: Callback URL after authorization.

        Returns:
            Tuple of (authorization_url, oauth_state).
        """
        ...

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

        Args:
            provider: OAuth provider.
            code: Authorization code from callback.
            state: OAuth state from authorization.

        Returns:
            OAuth tokens.

        Raises:
            ValueError: If state doesn't match or code is invalid.
        """
        ...

    async def refresh_tokens(
        self,
        provider: OAuthProvider,
        refresh_token: str,
    ) -> OAuthTokens:
        """Refresh OAuth tokens.

        Args:
            provider: OAuth provider.
            refresh_token: Refresh token.

        Returns:
            New OAuth tokens.
        """
        ...

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

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

        Returns:
            True if successfully revoked.
        """
        ...
```

---

### Task 3: OAuth Manager Implementation (Authlib)

**File**: `src/noteflow/infrastructure/integrations/oauth_manager.py`

This is the **critical OAuth implementation** using **Authlib** for robust PKCE support.

```python
"""OAuth flow manager for calendar integrations using Authlib.

Implements the complete OAuth 2.0 flow with PKCE:
1. Generate authorization URL with code_challenge (S256)
2. Exchange authorization code for tokens
3. Refresh tokens when expired
4. Revoke tokens on disconnect

Uses Authlib's AsyncOAuth2Client for all OAuth operations.
"""

from __future__ import annotations

import logging
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Protocol

from authlib.integrations.httpx_client import AsyncOAuth2Client
from authlib.oauth2.rfc7636 import create_s256_code_challenge

from noteflow.domain.calendar.oauth import (
    OAuthProvider,
    OAuthState,
    OAuthTokens,
)

if TYPE_CHECKING:
    from noteflow.config.settings import CalendarSettings

logger = logging.getLogger(__name__)


# Provider configuration
@dataclass(frozen=True, slots=True)
class ProviderConfig:
    """OAuth provider endpoint configuration."""

    authorization_endpoint: str
    token_endpoint: str
    revocation_endpoint: str | None
    scopes: tuple[str, ...]


PROVIDER_CONFIGS: dict[OAuthProvider, ProviderConfig] = {
    OAuthProvider.GOOGLE: ProviderConfig(
        authorization_endpoint="https://accounts.google.com/o/oauth2/v2/auth",
        token_endpoint="https://oauth2.googleapis.com/token",
        revocation_endpoint="https://oauth2.googleapis.com/revoke",
        scopes=(
            "https://www.googleapis.com/auth/calendar.readonly",
            "https://www.googleapis.com/auth/userinfo.email",
        ),
    ),
    OAuthProvider.MICROSOFT: ProviderConfig(
        authorization_endpoint="https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
        token_endpoint="https://login.microsoftonline.com/common/oauth2/v2.0/token",
        revocation_endpoint=None,  # Microsoft doesn't support token revocation
        scopes=(
            "https://graph.microsoft.com/Calendars.Read",
            "https://graph.microsoft.com/User.Read",
            "offline_access",
        ),
    ),
}


class OAuthClientFactory(Protocol):
    """Protocol for creating OAuth clients (enables testing)."""

    def create(
        self,
        client_id: str,
        client_secret: str,
        scope: str,
    ) -> AsyncOAuth2Client:
        """Create an OAuth client for the given credentials."""
        ...


class DefaultOAuthClientFactory:
    """Default factory using Authlib's AsyncOAuth2Client."""

    def create(
        self,
        client_id: str,
        client_secret: str,
        scope: str,
    ) -> AsyncOAuth2Client:
        """Create an AsyncOAuth2Client with PKCE support."""
        return AsyncOAuth2Client(
            client_id=client_id,
            client_secret=client_secret,
            scope=scope,
            code_challenge_method="S256",
            timeout=30.0,
        )


class OAuthManager:
    """Manage OAuth 2.0 flows for calendar integrations using Authlib.

    Implements PKCE (Proof Key for Code Exchange) via Authlib's built-in support.
    """

    def __init__(
        self,
        settings: CalendarSettings,
        client_factory: OAuthClientFactory | None = None,
    ) -> None:
        """Initialize OAuth manager.

        Args:
            settings: Calendar settings with OAuth credentials.
            client_factory: Factory for creating OAuth clients (injectable for testing).
        """
        self._settings = settings
        self._client_factory = client_factory or DefaultOAuthClientFactory()
        self._pending_states: dict[str, OAuthState] = {}

    def _get_credentials(self, provider: OAuthProvider) -> tuple[str, str]:
        """Get client credentials for a 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_authorization_url(
        self,
        provider: OAuthProvider,
        redirect_uri: str,
    ) -> tuple[str, OAuthState]:
        """Generate OAuth authorization URL with PKCE.

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

        Returns:
            Tuple of (authorization_url, oauth_state).
        """
        config = PROVIDER_CONFIGS[provider]
        client_id, client_secret = self._get_credentials(provider)

        # Create OAuth client with PKCE
        client = self._client_factory.create(
            client_id=client_id,
            client_secret=client_secret,
            scope=" ".join(config.scopes),
        )

        # Generate PKCE code verifier
        code_verifier = secrets.token_urlsafe(64)[:96]
        code_challenge = create_s256_code_challenge(code_verifier)

        # Build authorization URL
        auth_url, state = client.create_authorization_url(
            config.authorization_endpoint,
            redirect_uri=redirect_uri,
            code_challenge=code_challenge,
            code_challenge_method="S256",
            access_type="offline" if provider == OAuthProvider.GOOGLE else None,
            prompt="consent" if provider == OAuthProvider.GOOGLE else None,
        )

        # Store state for verification
        oauth_state = OAuthState(
            state=state,
            code_verifier=code_verifier,
            provider=provider,
            redirect_uri=redirect_uri,
            created_at=datetime.now(timezone.utc),
        )
        self._pending_states[state] = oauth_state

        logger.info("Generated OAuth URL for %s", provider.value)
        return auth_url, oauth_state

    async def exchange_code(
        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:
            ValueError: If state doesn't match or code exchange fails.
        """
        # Verify state
        oauth_state = self._pending_states.pop(state, None)
        if not oauth_state:
            msg = "Invalid or expired OAuth state"
            raise ValueError(msg)

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

        # Check state age (10 minute max)
        age = datetime.now(timezone.utc) - oauth_state.created_at
        if age > timedelta(minutes=10):
            msg = "OAuth state expired"
            raise ValueError(msg)

        config = PROVIDER_CONFIGS[provider]
        client_id, client_secret = self._get_credentials(provider)

        # Create OAuth client and fetch tokens
        async with AsyncOAuth2Client(
            client_id=client_id,
            client_secret=client_secret,
            timeout=30.0,
        ) as client:
            token = await client.fetch_token(
                config.token_endpoint,
                code=code,
                code_verifier=oauth_state.code_verifier,
                redirect_uri=oauth_state.redirect_uri,
            )

        expires_at = datetime.now(timezone.utc) + timedelta(
            seconds=token.get("expires_in", 3600)
        )

        logger.info("OAuth tokens obtained for %s", provider.value)

        return OAuthTokens(
            access_token=token["access_token"],
            refresh_token=token.get("refresh_token"),
            expires_at=expires_at,
            token_type=token.get("token_type", "Bearer"),
            scope=token.get("scope", ""),
        )

    async def refresh_tokens(
        self,
        provider: OAuthProvider,
        refresh_token: str,
    ) -> OAuthTokens:
        """Refresh OAuth tokens using Authlib.

        Args:
            provider: OAuth provider.
            refresh_token: Refresh token.

        Returns:
            New OAuth tokens.

        Raises:
            ValueError: If refresh fails.
        """
        config = PROVIDER_CONFIGS[provider]
        client_id, client_secret = self._get_credentials(provider)

        async with AsyncOAuth2Client(
            client_id=client_id,
            client_secret=client_secret,
            timeout=30.0,
        ) as client:
            token = await client.refresh_token(
                config.token_endpoint,
                refresh_token=refresh_token,
            )

        expires_at = datetime.now(timezone.utc) + timedelta(
            seconds=token.get("expires_in", 3600)
        )

        logger.info("OAuth tokens refreshed for %s", provider.value)

        return OAuthTokens(
            access_token=token["access_token"],
            refresh_token=token.get("refresh_token", refresh_token),
            expires_at=expires_at,
            token_type=token.get("token_type", "Bearer"),
            scope=token.get("scope", ""),
        )

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

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

        Returns:
            True if successfully revoked.
        """
        config = PROVIDER_CONFIGS[provider]

        if not config.revocation_endpoint:
            logger.info("%s doesn't support token revocation", provider.value)
            return True

        client_id, client_secret = self._get_credentials(provider)

        async with AsyncOAuth2Client(
            client_id=client_id,
            client_secret=client_secret,
            timeout=30.0,
        ) as client:
            try:
                await client.revoke_token(
                    config.revocation_endpoint,
                    token=access_token,
                )
                logger.info("OAuth tokens revoked for %s", provider.value)
                return True
            except Exception:
                logger.exception("OAuth token revocation failed for %s", provider.value)
                return False
```

---

### Task 4: Google Calendar Adapter

**File**: `src/noteflow/infrastructure/integrations/google_calendar.py`

```python
"""Google Calendar integration adapter."""

from __future__ import annotations

import logging
from datetime import datetime
from typing import TYPE_CHECKING, Any

from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError

from noteflow.domain.calendar.oauth import OAuthProvider, OAuthTokens
from noteflow.domain.calendar.ports import CalendarEvent

if TYPE_CHECKING:
    from googleapiclient._apis.calendar.v3 import CalendarResource

    from noteflow.infrastructure.integrations.oauth_manager import OAuthManager
    from noteflow.infrastructure.persistence.unit_of_work import UnitOfWork

logger = logging.getLogger(__name__)


class GoogleCalendarAdapter:
    """Google Calendar API adapter.

    Implements the CalendarPort protocol for Google Calendar integration.
    Handles automatic token refresh via OAuthManager.
    """

    TOKEN_URI = "https://oauth2.googleapis.com/token"

    def __init__(
        self,
        oauth_manager: OAuthManager,
        uow_factory: type[UnitOfWork],
        integration_id: str,
    ) -> None:
        """Initialize Google Calendar adapter.

        Args:
            oauth_manager: OAuth flow manager for token operations.
            uow_factory: Unit of Work factory for DB access.
            integration_id: Integration ID in database.
        """
        self._oauth_manager = oauth_manager
        self._uow_factory = uow_factory
        self._integration_id = integration_id
        self._service: CalendarResource | None = None
        self._tokens: OAuthTokens | None = None

    async def _load_tokens(self) -> OAuthTokens | None:
        """Load tokens from database."""
        async with self._uow_factory() as uow:
            secrets = await uow.integrations.get_secrets(self._integration_id)
            if not secrets:
                return None

            return OAuthTokens(
                access_token=secrets.get("access_token", ""),
                refresh_token=secrets.get("refresh_token"),
                expires_at=datetime.fromisoformat(secrets["expires_at"]),
                token_type=secrets.get("token_type", "Bearer"),
                scope=secrets.get("scope", ""),
            )

    async def _save_tokens(self, tokens: OAuthTokens) -> None:
        """Save tokens to database."""
        async with self._uow_factory() as uow:
            await uow.integrations.set_secrets(
                self._integration_id,
                {
                    "access_token": tokens.access_token,
                    "refresh_token": tokens.refresh_token,
                    "expires_at": tokens.expires_at.isoformat(),
                    "token_type": tokens.token_type,
                    "scope": tokens.scope,
                },
            )
            await uow.commit()

    async def _ensure_service(self) -> CalendarResource:
        """Initialize Google Calendar service with valid credentials."""
        if self._tokens is None:
            self._tokens = await self._load_tokens()

        if self._tokens is None:
            msg = "Google Calendar not authenticated"
            raise ValueError(msg)

        # Check if token needs refresh (with 5 min buffer)
        if self._tokens.expires_at < datetime.now() + timedelta(minutes=5):
            if self._tokens.refresh_token:
                logger.info("Refreshing Google Calendar tokens")
                self._tokens = await self._oauth_manager.refresh_tokens(
                    OAuthProvider.GOOGLE,
                    self._tokens.refresh_token,
                )
                await self._save_tokens(self._tokens)
                self._service = None  # Force service recreation
            else:
                msg = "Token expired and no refresh token available"
                raise ValueError(msg)

        if self._service is None:
            creds = Credentials(
                token=self._tokens.access_token,
                refresh_token=self._tokens.refresh_token,
                token_uri=self.TOKEN_URI,
            )
            self._service = build("calendar", "v3", credentials=creds)

        return self._service

    async def list_events(
        self,
        from_time: datetime,
        to_time: datetime,
        limit: int = 50,
    ) -> list[CalendarEvent]:
        """List calendar events from Google Calendar.

        Args:
            from_time: Start of time range (UTC).
            to_time: End of time range (UTC).
            limit: Maximum events to return.

        Returns:
            List of calendar events in the time range.
        """
        service = await self._ensure_service()

        try:
            events_result = (
                service.events()
                .list(
                    calendarId="primary",
                    timeMin=from_time.isoformat() + "Z",
                    timeMax=to_time.isoformat() + "Z",
                    maxResults=limit,
                    singleEvents=True,
                    orderBy="startTime",
                )
                .execute()
            )
        except HttpError as e:
            logger.exception("Failed to fetch Google Calendar events")
            raise RuntimeError(f"Google Calendar API error: {e}") from e

        return [self._parse_event(item) for item in events_result.get("items", [])]

    def _parse_event(self, item: dict[str, Any]) -> CalendarEvent:
        """Parse Google Calendar API event to domain model."""
        start = item.get("start", {})
        end = item.get("end", {})

        start_str = start.get("dateTime") or start.get("date")
        end_str = end.get("dateTime") or end.get("date")

        return CalendarEvent(
            id=item["id"],
            title=item.get("summary", "Untitled"),
            start_time=datetime.fromisoformat(start_str.replace("Z", "+00:00")),
            end_time=datetime.fromisoformat(end_str.replace("Z", "+00:00")),
            attendees=tuple(
                a.get("email", "") for a in item.get("attendees", [])
            ),
            location=item.get("location"),
            description=item.get("description"),
            is_recurring=item.get("recurringEventId") is not None,
            meeting_url=item.get("hangoutLink"),
        )

    async def refresh_token(self) -> bool:
        """Refresh OAuth token if expired.

        Returns:
            True if token is valid or was refreshed successfully.
        """
        try:
            await self._ensure_service()
            return True
        except ValueError:
            return False

    def is_authenticated(self) -> bool:
        """Check if Google Calendar is authenticated."""
        return self._tokens is not None


# Import timedelta for token refresh check
from datetime import timedelta
```

---

### Task 5: Calendar Application Service

**File**: `src/noteflow/application/services/calendar_service.py`

```python
"""Calendar application service.

Orchestrates calendar operations including:
- OAuth connection flow
- Provider management
- Event fetching and caching
- Token lifecycle management
"""

from __future__ import annotations

import logging
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING
from uuid import uuid4

from noteflow.config.settings import get_settings
from noteflow.domain.calendar.oauth import OAuthConnection, OAuthProvider, OAuthStatus

if TYPE_CHECKING:
    from noteflow.domain.calendar.ports import CalendarEvent, CalendarPort
    from noteflow.infrastructure.integrations.oauth_manager import OAuthManager
    from noteflow.infrastructure.persistence.unit_of_work import UnitOfWork

logger = logging.getLogger(__name__)


class CalendarService:
    """Orchestrates calendar operations across providers."""

    def __init__(
        self,
        oauth_manager: OAuthManager,
        uow_factory: type[UnitOfWork],
    ) -> None:
        """Initialize calendar service.

        Args:
            oauth_manager: OAuth flow manager.
            uow_factory: Unit of Work factory.
        """
        self._oauth_manager = oauth_manager
        self._uow_factory = uow_factory
        self._providers: dict[str, CalendarPort] = {}

    def register_provider(self, name: str, provider: CalendarPort) -> None:
        """Register a calendar provider.

        Args:
            name: Provider identifier (e.g., "google", "outlook").
            provider: Calendar port implementation.
        """
        self._providers[name] = provider
        logger.info("Registered calendar provider: %s", name)

    def get_providers(self) -> list[str]:
        """Get list of registered provider names."""
        return list(self._providers.keys())

    async def get_connection_status(
        self,
        provider: OAuthProvider,
    ) -> OAuthConnection:
        """Get OAuth connection status for a provider.

        Args:
            provider: OAuth provider.

        Returns:
            Connection status with details.
        """
        async with self._uow_factory() as uow:
            integration = await uow.integrations.get_by_provider(provider.value)

            if not integration:
                return OAuthConnection(
                    provider=provider,
                    status=OAuthStatus.NOT_CONNECTED,
                )

            secrets = await uow.integrations.get_secrets(integration.id)
            if not secrets or not secrets.get("access_token"):
                return OAuthConnection(
                    provider=provider,
                    status=OAuthStatus.NOT_CONNECTED,
                )

            expires_at = datetime.fromisoformat(secrets["expires_at"])
            if expires_at < datetime.now(timezone.utc):
                if secrets.get("refresh_token"):
                    return OAuthConnection(
                        provider=provider,
                        status=OAuthStatus.EXPIRED,
                        expires_at=expires_at,
                    )
                return OAuthConnection(
                    provider=provider,
                    status=OAuthStatus.ERROR,
                    error_message="Token expired and no refresh token available",
                )

            return OAuthConnection(
                provider=provider,
                status=OAuthStatus.CONNECTED,
                email=integration.config.get("email"),
                expires_at=expires_at,
            )

    def initiate_oauth(
        self,
        provider: OAuthProvider,
        redirect_uri: str,
    ) -> str:
        """Initiate OAuth flow for a provider.

        Args:
            provider: OAuth provider.
            redirect_uri: Callback URL after authorization.

        Returns:
            Authorization URL to redirect user to.
        """
        settings = get_settings()
        if not settings.feature_flags.calendar_enabled:
            msg = "Calendar sync is disabled by feature flag"
            raise RuntimeError(msg)

        auth_url, state = self._oauth_manager.get_authorization_url(
            provider,
            redirect_uri,
        )

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

    async def complete_oauth(
        self,
        provider: OAuthProvider,
        code: str,
        state: str,
    ) -> OAuthConnection:
        """Complete OAuth flow after user authorization.

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

        Returns:
            Connection status after completion.

        Raises:
            ValueError: If OAuth flow fails.
        """
        # Exchange code for tokens
        tokens = await self._oauth_manager.exchange_code(provider, code, state)

        # Create or update integration record
        async with self._uow_factory() as uow:
            integration = await uow.integrations.get_by_provider(provider.value)

            if not integration:
                # Create new integration
                integration_id = str(uuid4())
                await uow.integrations.create(
                    id=integration_id,
                    name=f"{provider.value.title()} Calendar",
                    type="calendar",
                    provider=provider.value,
                    status="active",
                    config={},
                )
            else:
                integration_id = integration.id

            # Store tokens
            await uow.integrations.set_secrets(
                integration_id,
                {
                    "access_token": tokens.access_token,
                    "refresh_token": tokens.refresh_token,
                    "expires_at": tokens.expires_at.isoformat(),
                    "token_type": tokens.token_type,
                    "scope": tokens.scope,
                },
            )
            await uow.commit()

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

        return OAuthConnection(
            provider=provider,
            status=OAuthStatus.CONNECTED,
            expires_at=tokens.expires_at,
        )

    async def disconnect(self, provider: OAuthProvider) -> bool:
        """Disconnect a calendar provider.

        Args:
            provider: OAuth provider to disconnect.

        Returns:
            True if successfully disconnected.
        """
        async with self._uow_factory() as uow:
            integration = await uow.integrations.get_by_provider(provider.value)
            if not integration:
                return False

            secrets = await uow.integrations.get_secrets(integration.id)
            if secrets and secrets.get("access_token"):
                # Revoke tokens
                await self._oauth_manager.revoke_tokens(
                    provider,
                    secrets["access_token"],
                )

            # Delete integration
            await uow.integrations.delete(integration.id)
            await uow.commit()

        # Remove from registered providers
        provider_name = provider.value
        if provider_name in self._providers:
            del self._providers[provider_name]

        logger.info("Disconnected calendar provider: %s", provider.value)
        return True

    async def list_upcoming_events(
        self,
        hours_ahead: int = 24,
        limit: int = 10,
        provider: str | None = None,
    ) -> list[CalendarEvent]:
        """List upcoming calendar events.

        Args:
            hours_ahead: How far ahead to look (default: 24 hours).
            limit: Maximum events to return per provider.
            provider: Specific provider name, or None for all providers.

        Returns:
            List of upcoming calendar events, sorted by start time.
        """
        now = datetime.now(timezone.utc)
        until = now + timedelta(hours=hours_ahead)

        events: list[CalendarEvent] = []

        providers = (
            {provider: self._providers[provider]}
            if provider and provider in self._providers
            else self._providers
        )

        for name, cal in providers.items():
            if not cal.is_authenticated():
                logger.debug("Skipping unauthenticated provider: %s", name)
                continue

            try:
                provider_events = await cal.list_events(now, until, limit)
                events.extend(provider_events)
                logger.debug("Fetched %d events from %s", len(provider_events), name)
            except Exception:
                logger.exception("Failed to fetch events from %s", name)
                # Continue with other providers

        # Sort by start time and limit total
        events.sort(key=lambda e: e.start_time)
        return events[:limit]

    async def refresh_provider_token(self, provider: str) -> bool:
        """Refresh OAuth token for a provider.

        Args:
            provider: Provider name.

        Returns:
            True if token is valid or was refreshed successfully.
        """
        if provider not in self._providers:
            logger.warning("Unknown provider: %s", provider)
            return False

        return await self._providers[provider].refresh_token()
```

---

### Task 6: gRPC Mixin with OAuth RPCs

**File**: `src/noteflow/grpc/_mixins/calendar.py`

```python
"""Calendar gRPC mixin with OAuth flow support."""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING

import grpc

from noteflow.domain.calendar.oauth import OAuthProvider, OAuthStatus
from noteflow.grpc.proto import noteflow_pb2

if TYPE_CHECKING:
    from noteflow.grpc._mixins.protocols import ServicerHost

logger = logging.getLogger(__name__)


class CalendarMixin:
    """Mixin for calendar RPC methods including OAuth flow."""

    async def InitiateOAuth(
        self: ServicerHost,
        request: noteflow_pb2.InitiateOAuthRequest,
        context: grpc.aio.ServicerContext,
    ) -> noteflow_pb2.InitiateOAuthResponse:
        """Initiate OAuth flow for a calendar provider."""
        try:
            provider = OAuthProvider(request.provider)
        except ValueError:
            context.set_code(grpc.StatusCode.INVALID_ARGUMENT)
            context.set_details(f"Unknown provider: {request.provider}")
            return noteflow_pb2.InitiateOAuthResponse()

        try:
            auth_url = self._calendar_service.initiate_oauth(
                provider=provider,
                redirect_uri=request.redirect_uri,
            )
        except RuntimeError as e:
            context.set_code(grpc.StatusCode.FAILED_PRECONDITION)
            context.set_details(str(e))
            return noteflow_pb2.InitiateOAuthResponse()

        return noteflow_pb2.InitiateOAuthResponse(
            authorization_url=auth_url,
        )

    async def CompleteOAuth(
        self: ServicerHost,
        request: noteflow_pb2.CompleteOAuthRequest,
        context: grpc.aio.ServicerContext,
    ) -> noteflow_pb2.CompleteOAuthResponse:
        """Complete OAuth flow after user authorization."""
        try:
            provider = OAuthProvider(request.provider)
        except ValueError:
            context.set_code(grpc.StatusCode.INVALID_ARGUMENT)
            context.set_details(f"Unknown provider: {request.provider}")
            return noteflow_pb2.CompleteOAuthResponse(success=False)

        try:
            connection = await self._calendar_service.complete_oauth(
                provider=provider,
                code=request.code,
                state=request.state,
            )
        except ValueError as e:
            context.set_code(grpc.StatusCode.INVALID_ARGUMENT)
            context.set_details(str(e))
            return noteflow_pb2.CompleteOAuthResponse(
                success=False,
                error_message=str(e),
            )

        return noteflow_pb2.CompleteOAuthResponse(
            success=connection.status == OAuthStatus.CONNECTED,
            connection=self._connection_to_proto(connection),
        )

    async def GetCalendarConnectionStatus(
        self: ServicerHost,
        request: noteflow_pb2.GetCalendarConnectionStatusRequest,
        context: grpc.aio.ServicerContext,
    ) -> noteflow_pb2.GetCalendarConnectionStatusResponse:
        """Get OAuth connection status for a provider."""
        try:
            provider = OAuthProvider(request.provider)
        except ValueError:
            context.set_code(grpc.StatusCode.INVALID_ARGUMENT)
            context.set_details(f"Unknown provider: {request.provider}")
            return noteflow_pb2.GetCalendarConnectionStatusResponse()

        connection = await self._calendar_service.get_connection_status(provider)

        return noteflow_pb2.GetCalendarConnectionStatusResponse(
            connection=self._connection_to_proto(connection),
        )

    async def DisconnectCalendar(
        self: ServicerHost,
        request: noteflow_pb2.DisconnectCalendarRequest,
        context: grpc.aio.ServicerContext,
    ) -> noteflow_pb2.DisconnectCalendarResponse:
        """Disconnect a calendar provider."""
        try:
            provider = OAuthProvider(request.provider)
        except ValueError:
            context.set_code(grpc.StatusCode.INVALID_ARGUMENT)
            context.set_details(f"Unknown provider: {request.provider}")
            return noteflow_pb2.DisconnectCalendarResponse(success=False)

        success = await self._calendar_service.disconnect(provider)

        return noteflow_pb2.DisconnectCalendarResponse(success=success)

    async def ListCalendarEvents(
        self: ServicerHost,
        request: noteflow_pb2.ListCalendarEventsRequest,
        context: grpc.aio.ServicerContext,
    ) -> noteflow_pb2.ListCalendarEventsResponse:
        """List upcoming calendar events."""
        hours_ahead = request.hours_ahead or 24
        limit = request.limit or 10
        provider = request.provider or None

        events = await self._calendar_service.list_upcoming_events(
            hours_ahead=hours_ahead,
            limit=limit,
            provider=provider,
        )

        proto_events = [
            noteflow_pb2.CalendarEvent(
                id=e.id,
                title=e.title,
                start_time=int(e.start_time.timestamp()),
                end_time=int(e.end_time.timestamp()),
                attendees=list(e.attendees),
                location=e.location or "",
                description=e.description or "",
                meeting_url=e.meeting_url or "",
                is_recurring=e.is_recurring,
            )
            for e in events
        ]

        return noteflow_pb2.ListCalendarEventsResponse(
            events=proto_events,
            total_count=len(proto_events),
        )

    async def GetCalendarProviders(
        self: ServicerHost,
        request: noteflow_pb2.GetCalendarProvidersRequest,
        context: grpc.aio.ServicerContext,
    ) -> noteflow_pb2.GetCalendarProvidersResponse:
        """Get list of calendar providers and connection status."""
        providers = []

        for provider in OAuthProvider:
            connection = await self._calendar_service.get_connection_status(provider)
            providers.append(
                noteflow_pb2.CalendarProvider(
                    name=provider.value,
                    is_authenticated=connection.status == OAuthStatus.CONNECTED,
                    status=connection.status.value,
                )
            )

        return noteflow_pb2.GetCalendarProvidersResponse(providers=providers)

    def _connection_to_proto(
        self,
        connection,
    ) -> noteflow_pb2.OAuthConnection:
        """Convert OAuthConnection to proto message."""
        return noteflow_pb2.OAuthConnection(
            provider=connection.provider.value,
            status=connection.status.value,
            email=connection.email or "",
            expires_at=int(connection.expires_at.timestamp()) if connection.expires_at else 0,
            error_message=connection.error_message or "",
        )
```

---

### Task 7: Settings Update

**File**: `src/noteflow/config/settings.py` (add to existing)

```python
class CalendarSettings(BaseSettings):
    """Calendar integration settings."""

    model_config = SettingsConfigDict(env_prefix="NOTEFLOW_CALENDAR_")

    # Google OAuth
    google_client_id: str = ""
    google_client_secret: str = ""

    # Microsoft OAuth
    outlook_client_id: str = ""
    outlook_client_secret: str = ""

    # OAuth redirect (for desktop app, use custom protocol)
    redirect_uri: str = "noteflow://oauth/callback"

    # Sync settings
    sync_hours_ahead: int = 24
    max_events: int = 20
    sync_interval_minutes: int = 15


# Add to get_calendar_settings()
@lru_cache
def get_calendar_settings() -> CalendarSettings:
    """Get cached calendar settings."""
    return CalendarSettings()
```

---

### Task 8: Frontend OAuth Hook

**File**: `client/src/hooks/useOAuthFlow.ts`

```typescript
import { useCallback, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';

export type OAuthProvider = 'google' | 'microsoft';
export type OAuthStatus = 'not_connected' | 'pending' | 'connected' | 'expired' | 'error';

export interface OAuthConnection {
  provider: OAuthProvider;
  status: OAuthStatus;
  email?: string;
  expiresAt?: number;
  errorMessage?: string;
}

interface UseOAuthFlowReturn {
  connection: OAuthConnection | null;
  isLoading: boolean;
  error: string | null;
  initiateOAuth: (provider: OAuthProvider) => Promise<void>;
  checkStatus: (provider: OAuthProvider) => Promise<void>;
  disconnect: (provider: OAuthProvider) => Promise<void>;
}

export function useOAuthFlow(): UseOAuthFlowReturn {
  const [connection, setConnection] = useState<OAuthConnection | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const initiateOAuth = useCallback(async (provider: OAuthProvider) => {
    setIsLoading(true);
    setError(null);

    try {
      // Get authorization URL
      const result = await invoke<{ authorizationUrl: string }>('initiate_oauth', {
        provider,
        redirectUri: 'noteflow://oauth/callback',
      });

      // Open in system browser
      await invoke('open_url', { url: result.authorizationUrl });

      // Set pending status
      setConnection({
        provider,
        status: 'pending',
      });

      // Listen for OAuth callback (from deep link handler)
      const unlisten = await listen<{ code: string; state: string }>('oauth-callback', async (event) => {
        try {
          const completeResult = await invoke<{ success: boolean; connection: OAuthConnection }>('complete_oauth', {
            provider,
            code: event.payload.code,
            state: event.payload.state,
          });

          if (completeResult.success) {
            setConnection(completeResult.connection);
          } else {
            setError('OAuth flow failed');
            setConnection({ provider, status: 'error', errorMessage: 'OAuth flow failed' });
          }
        } catch (err) {
          const message = err instanceof Error ? err.message : String(err);
          setError(message);
          setConnection({ provider, status: 'error', errorMessage: message });
        } finally {
          unlisten();
          setIsLoading(false);
        }
      });

      // Timeout after 5 minutes
      setTimeout(() => {
        unlisten();
        if (connection?.status === 'pending') {
          setError('OAuth flow timed out');
          setConnection({ provider, status: 'error', errorMessage: 'OAuth flow timed out' });
          setIsLoading(false);
        }
      }, 5 * 60 * 1000);

    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      setError(message);
      setIsLoading(false);
    }
  }, [connection?.status]);

  const checkStatus = useCallback(async (provider: OAuthProvider) => {
    setIsLoading(true);
    setError(null);

    try {
      const result = await invoke<{ connection: OAuthConnection }>('get_calendar_connection_status', {
        provider,
      });
      setConnection(result.connection);
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      setError(message);
    } finally {
      setIsLoading(false);
    }
  }, []);

  const disconnect = useCallback(async (provider: OAuthProvider) => {
    setIsLoading(true);
    setError(null);

    try {
      await invoke('disconnect_calendar', { provider });
      setConnection({ provider, status: 'not_connected' });
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      setError(message);
    } finally {
      setIsLoading(false);
    }
  }, []);

  return {
    connection,
    isLoading,
    error,
    initiateOAuth,
    checkStatus,
    disconnect,
  };
}
```

**File**: `client/src/hooks/useCalendarSync.ts`

```typescript
import { useCallback, useEffect, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';

export interface CalendarEvent {
  id: string;
  title: string;
  startTime: Date;
  endTime: Date;
  attendees: string[];
  location?: string;
  description?: string;
  meetingUrl?: string;
  isRecurring: boolean;
}

interface RawCalendarEvent {
  id: string;
  title: string;
  start_time: number;
  end_time: number;
  attendees: string[];
  location: string;
  description: string;
  meeting_url: string;
  is_recurring: boolean;
}

type SyncStatus = 'idle' | 'loading' | 'success' | 'error';

interface UseCalendarSyncOptions {
  hoursAhead?: number;
  limit?: number;
  provider?: string;
  autoRefresh?: boolean;
  refreshInterval?: number; // milliseconds
}

interface UseCalendarSyncReturn {
  events: CalendarEvent[];
  status: SyncStatus;
  error: string | null;
  refresh: () => Promise<void>;
  lastSynced: Date | null;
}

export function useCalendarSync(
  options: UseCalendarSyncOptions = {},
): UseCalendarSyncReturn {
  const {
    hoursAhead = 24,
    limit = 10,
    provider,
    autoRefresh = true,
    refreshInterval = 15 * 60 * 1000, // 15 minutes
  } = options;

  const [events, setEvents] = useState<CalendarEvent[]>([]);
  const [status, setStatus] = useState<SyncStatus>('idle');
  const [error, setError] = useState<string | null>(null);
  const [lastSynced, setLastSynced] = useState<Date | null>(null);

  const refresh = useCallback(async () => {
    setStatus('loading');
    setError(null);

    try {
      const rawEvents = await invoke<RawCalendarEvent[]>('list_calendar_events', {
        hoursAhead,
        limit,
        provider,
      });

      const transformedEvents: CalendarEvent[] = rawEvents.map((e) => ({
        id: e.id,
        title: e.title,
        startTime: new Date(e.start_time * 1000),
        endTime: new Date(e.end_time * 1000),
        attendees: e.attendees,
        location: e.location || undefined,
        description: e.description || undefined,
        meetingUrl: e.meeting_url || undefined,
        isRecurring: e.is_recurring,
      }));

      setEvents(transformedEvents);
      setLastSynced(new Date());
      setStatus('success');
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      setError(message);
      setStatus('error');
    }
  }, [hoursAhead, limit, provider]);

  // Initial fetch
  useEffect(() => {
    refresh();
  }, [refresh]);

  // Auto-refresh
  useEffect(() => {
    if (!autoRefresh) return;

    const interval = setInterval(refresh, refreshInterval);
    return () => clearInterval(interval);
  }, [autoRefresh, refreshInterval, refresh]);

  return {
    events,
    status,
    error,
    refresh,
    lastSynced,
  };
}
```

---

### Task 9: Frontend Connection Panel

**File**: `client/src/components/CalendarConnectionPanel.tsx`

```tsx
import { Calendar, Check, Loader2, LogOut, RefreshCw, X } from 'lucide-react';
import { useEffect } from 'react';

import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { OAuthProvider, useOAuthFlow } from '@/hooks/useOAuthFlow';

interface CalendarConnectionPanelProps {
  className?: string;
}

const PROVIDER_CONFIG = {
  google: {
    name: 'Google Calendar',
    icon: '📅',
    color: 'bg-blue-100 text-blue-800',
  },
  microsoft: {
    name: 'Outlook Calendar',
    icon: '📆',
    color: 'bg-purple-100 text-purple-800',
  },
} as const;

function ProviderCard({ provider }: { provider: OAuthProvider }) {
  const { connection, isLoading, error, initiateOAuth, checkStatus, disconnect } = useOAuthFlow();
  const config = PROVIDER_CONFIG[provider];

  // Check status on mount
  useEffect(() => {
    checkStatus(provider);
  }, [provider, checkStatus]);

  const currentConnection = connection?.provider === provider ? connection : null;
  const status = currentConnection?.status ?? 'not_connected';

  return (
    <div className="flex items-center justify-between p-4 border rounded-lg">
      <div className="flex items-center gap-3">
        <span className="text-2xl">{config.icon}</span>
        <div>
          <h4 className="font-medium">{config.name}</h4>
          {currentConnection?.email && (
            <p className="text-sm text-muted-foreground">{currentConnection.email}</p>
          )}
        </div>
      </div>

      <div className="flex items-center gap-2">
        {/* Status badge */}
        {status === 'connected' && (
          <Badge variant="secondary" className="bg-green-100 text-green-800">
            <Check className="w-3 h-3 mr-1" />
            Connected
          </Badge>
        )}
        {status === 'expired' && (
          <Badge variant="secondary" className="bg-amber-100 text-amber-800">
            <RefreshCw className="w-3 h-3 mr-1" />
            Token Expired
          </Badge>
        )}
        {status === 'error' && (
          <Badge variant="secondary" className="bg-red-100 text-red-800">
            <X className="w-3 h-3 mr-1" />
            Error
          </Badge>
        )}
        {status === 'pending' && (
          <Badge variant="secondary" className="bg-blue-100 text-blue-800">
            <Loader2 className="w-3 h-3 mr-1 animate-spin" />
            Authorizing...
          </Badge>
        )}

        {/* Action button */}
        {status === 'not_connected' && (
          <Button
            variant="outline"
            size="sm"
            onClick={() => initiateOAuth(provider)}
            disabled={isLoading}
          >
            {isLoading ? (
              <Loader2 className="w-4 h-4 animate-spin" />
            ) : (
              'Connect'
            )}
          </Button>
        )}

        {status === 'connected' && (
          <Button
            variant="ghost"
            size="sm"
            onClick={() => disconnect(provider)}
            disabled={isLoading}
          >
            <LogOut className="w-4 h-4" />
          </Button>
        )}

        {(status === 'expired' || status === 'error') && (
          <Button
            variant="outline"
            size="sm"
            onClick={() => initiateOAuth(provider)}
            disabled={isLoading}
          >
            Reconnect
          </Button>
        )}
      </div>
    </div>
  );
}

export function CalendarConnectionPanel({ className }: CalendarConnectionPanelProps) {
  return (
    <Card className={className}>
      <CardHeader>
        <CardTitle className="flex items-center gap-2">
          <Calendar className="w-5 h-5" />
          Calendar Connections
        </CardTitle>
        <CardDescription>
          Connect your calendars to see upcoming meetings and enable auto-recording.
        </CardDescription>
      </CardHeader>
      <CardContent className="space-y-3">
        <ProviderCard provider="google" />
        <ProviderCard provider="microsoft" />
      </CardContent>
    </Card>
  );
}
```

---

### Task 10: Rust Commands

**File**: `client/src-tauri/src/commands/calendar.rs`

```rust
use serde::{Deserialize, Serialize};
use tauri::State;

use crate::state::AppState;

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CalendarEvent {
    pub id: String,
    pub title: String,
    pub start_time: i64,
    pub end_time: i64,
    pub attendees: Vec<String>,
    pub location: Option<String>,
    pub description: Option<String>,
    pub meeting_url: Option<String>,
    pub is_recurring: bool,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OAuthConnection {
    pub provider: String,
    pub status: String,
    pub email: Option<String>,
    pub expires_at: Option<i64>,
    pub error_message: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CalendarProvider {
    pub name: String,
    pub is_authenticated: bool,
    pub status: String,
}

#[tauri::command]
pub async fn initiate_oauth(
    provider: String,
    redirect_uri: String,
    state: State<'_, AppState>,
) -> Result<InitiateOAuthResult, String> {
    let client = state.grpc_client.lock().await;
    let client = client.as_ref().ok_or("gRPC client not initialized")?;

    let request = proto::InitiateOAuthRequest {
        provider,
        redirect_uri,
    };

    let response = client
        .initiate_oauth(request)
        .await
        .map_err(|e| format!("Failed to initiate OAuth: {e}"))?;

    Ok(InitiateOAuthResult {
        authorization_url: response.authorization_url,
    })
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InitiateOAuthResult {
    pub authorization_url: String,
}

#[tauri::command]
pub async fn complete_oauth(
    provider: String,
    code: String,
    state_param: String,
    state: State<'_, AppState>,
) -> Result<CompleteOAuthResult, String> {
    let client = state.grpc_client.lock().await;
    let client = client.as_ref().ok_or("gRPC client not initialized")?;

    let request = proto::CompleteOAuthRequest {
        provider: provider.clone(),
        code,
        state: state_param,
    };

    let response = client
        .complete_oauth(request)
        .await
        .map_err(|e| format!("Failed to complete OAuth: {e}"))?;

    let connection = response.connection.map(|c| OAuthConnection {
        provider: c.provider,
        status: c.status,
        email: if c.email.is_empty() { None } else { Some(c.email) },
        expires_at: if c.expires_at == 0 { None } else { Some(c.expires_at) },
        error_message: if c.error_message.is_empty() { None } else { Some(c.error_message) },
    });

    Ok(CompleteOAuthResult {
        success: response.success,
        connection,
    })
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CompleteOAuthResult {
    pub success: bool,
    pub connection: Option<OAuthConnection>,
}

#[tauri::command]
pub async fn get_calendar_connection_status(
    provider: String,
    state: State<'_, AppState>,
) -> Result<GetConnectionStatusResult, String> {
    let client = state.grpc_client.lock().await;
    let client = client.as_ref().ok_or("gRPC client not initialized")?;

    let request = proto::GetCalendarConnectionStatusRequest { provider };

    let response = client
        .get_calendar_connection_status(request)
        .await
        .map_err(|e| format!("Failed to get connection status: {e}"))?;

    let connection = response.connection.map(|c| OAuthConnection {
        provider: c.provider,
        status: c.status,
        email: if c.email.is_empty() { None } else { Some(c.email) },
        expires_at: if c.expires_at == 0 { None } else { Some(c.expires_at) },
        error_message: if c.error_message.is_empty() { None } else { Some(c.error_message) },
    });

    Ok(GetConnectionStatusResult { connection })
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetConnectionStatusResult {
    pub connection: Option<OAuthConnection>,
}

#[tauri::command]
pub async fn disconnect_calendar(
    provider: String,
    state: State<'_, AppState>,
) -> Result<bool, String> {
    let client = state.grpc_client.lock().await;
    let client = client.as_ref().ok_or("gRPC client not initialized")?;

    let request = proto::DisconnectCalendarRequest { provider };

    let response = client
        .disconnect_calendar(request)
        .await
        .map_err(|e| format!("Failed to disconnect calendar: {e}"))?;

    Ok(response.success)
}

#[tauri::command]
pub async fn list_calendar_events(
    hours_ahead: Option<i32>,
    limit: Option<i32>,
    provider: Option<String>,
    state: State<'_, AppState>,
) -> Result<Vec<CalendarEvent>, String> {
    let client = state.grpc_client.lock().await;
    let client = client.as_ref().ok_or("gRPC client not initialized")?;

    let request = proto::ListCalendarEventsRequest {
        hours_ahead: hours_ahead.unwrap_or(24),
        limit: limit.unwrap_or(10),
        provider: provider.unwrap_or_default(),
    };

    let response = client
        .list_calendar_events(request)
        .await
        .map_err(|e| format!("Failed to list calendar events: {e}"))?;

    Ok(response
        .events
        .into_iter()
        .map(|e| CalendarEvent {
            id: e.id,
            title: e.title,
            start_time: e.start_time,
            end_time: e.end_time,
            attendees: e.attendees,
            location: if e.location.is_empty() { None } else { Some(e.location) },
            description: if e.description.is_empty() { None } else { Some(e.description) },
            meeting_url: if e.meeting_url.is_empty() { None } else { Some(e.meeting_url) },
            is_recurring: e.is_recurring,
        })
        .collect())
}

#[tauri::command]
pub async fn get_calendar_providers(
    state: State<'_, AppState>,
) -> Result<Vec<CalendarProvider>, String> {
    let client = state.grpc_client.lock().await;
    let client = client.as_ref().ok_or("gRPC client not initialized")?;

    let response = client
        .get_calendar_providers(proto::GetCalendarProvidersRequest {})
        .await
        .map_err(|e| format!("Failed to get calendar providers: {e}"))?;

    Ok(response
        .providers
        .into_iter()
        .map(|p| CalendarProvider {
            name: p.name,
            is_authenticated: p.is_authenticated,
            status: p.status,
        })
        .collect())
}
```

---

## Performance Targets

| Metric | Target | Measurement |
|--------|--------|-------------|
| OAuth flow completion | < 5s (excluding user auth) | Token exchange latency |
| Event fetch (cached) | < 100ms | p95 latency |
| Event fetch (live) | < 2s | p95 latency |
| Token refresh | < 1s | Background refresh latency |
| Auto-sync interval | 15 min | Configurable |

---

## Acceptance Criteria

### Functional

- [ ] User can initiate OAuth flow for Google Calendar
- [ ] User can initiate OAuth flow for Outlook Calendar
- [ ] OAuth callback correctly exchanges code for tokens
- [ ] PKCE flow protects against code interception
- [ ] Tokens stored securely in database
- [ ] Authenticated users see upcoming meetings
- [ ] Token refresh happens automatically when expired
- [ ] User can disconnect calendar provider
- [ ] Disconnection revokes tokens where supported
- [ ] Calendar events include title, time, attendees, and meeting URL

### Technical

- [ ] Proto regenerated and compiles cleanly
- [ ] CalendarPort protocol enables easy addition of new providers
- [ ] OAuth tokens stored securely (encrypted in DB)
- [ ] API errors logged with DEBUG level details
- [ ] Rate limiting respected for API calls
- [ ] Feature flag controls availability
- [ ] Deep link handler processes OAuth callbacks

### Quality Gates

- [ ] `pytest tests/quality/` passes
- [ ] `ruff check src/noteflow` clean
- [ ] `basedpyright` clean
- [ ] `npm run lint` passes (client)
- [ ] `cargo clippy` clean (Rust)
- [ ] No `# type: ignore` without justification
- [ ] All public functions have docstrings

---

## Test Plan

All tests use `pytest.mark.parametrize` for test variations - **no conditionals in tests**.

### Unit Tests (OAuth Manager)

**File**: `tests/infrastructure/integrations/test_oauth_manager.py`

```python
"""OAuth manager unit tests using pytest.mark.parametrize."""

from __future__ import annotations

from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock

import pytest

from noteflow.domain.calendar.oauth import OAuthProvider, OAuthState
from noteflow.infrastructure.integrations.oauth_manager import (
    OAuthManager,
    PROVIDER_CONFIGS,
)

if TYPE_CHECKING:
    from noteflow.config.settings import CalendarSettings


# ============================================================================
# Fixtures (module-scoped for expensive operations)
# ============================================================================


@pytest.fixture
def mock_client_factory() -> MagicMock:
    """Create mock OAuth client factory for testing."""
    factory = MagicMock()
    mock_client = MagicMock()
    mock_client.create_authorization_url.return_value = (
        "https://oauth.example.com/auth?state=test",
        "test_state",
    )
    factory.create.return_value = mock_client
    return factory


@pytest.fixture
def oauth_manager(
    mock_calendar_settings: CalendarSettings,
    mock_client_factory: MagicMock,
) -> OAuthManager:
    """Create OAuth manager with mock dependencies."""
    return OAuthManager(mock_calendar_settings, mock_client_factory)


# ============================================================================
# Authorization URL Tests
# ============================================================================


class TestAuthorizationUrl:
    """Test authorization URL generation with parametrized providers."""

    @pytest.mark.parametrize(
        ("provider", "expected_host", "expected_params"),
        [
            pytest.param(
                OAuthProvider.GOOGLE,
                "accounts.google.com",
                ["code_challenge", "access_type=offline"],
                id="google-with-pkce-and-offline",
            ),
            pytest.param(
                OAuthProvider.MICROSOFT,
                "login.microsoftonline.com",
                ["code_challenge"],
                id="microsoft-with-pkce",
            ),
        ],
    )
    def test_generates_valid_url(
        self,
        oauth_manager: OAuthManager,
        provider: OAuthProvider,
        expected_host: str,
        expected_params: list[str],
    ) -> None:
        """Generate authorization URL for each provider."""
        url, state = oauth_manager.get_authorization_url(
            provider,
            "http://localhost:3000/callback",
        )

        assert expected_host in url, f"URL should contain {expected_host}"
        assert state.provider == provider

    @pytest.mark.parametrize(
        ("provider",),
        [
            pytest.param(OAuthProvider.GOOGLE, id="google"),
            pytest.param(OAuthProvider.MICROSOFT, id="microsoft"),
        ],
    )
    def test_stores_pending_state(
        self,
        oauth_manager: OAuthManager,
        provider: OAuthProvider,
    ) -> None:
        """Authorization stores state for later verification."""
        _, state = oauth_manager.get_authorization_url(
            provider,
            "http://localhost:3000/callback",
        )

        assert state.state in oauth_manager._pending_states
        assert oauth_manager._pending_states[state.state].code_verifier


# ============================================================================
# Code Exchange Tests
# ============================================================================


class TestCodeExchange:
    """Test authorization code exchange with parametrized scenarios."""

    @pytest.mark.asyncio
    @pytest.mark.parametrize(
        ("invalid_state", "expected_error"),
        [
            pytest.param(
                "nonexistent_state",
                "Invalid or expired OAuth state",
                id="invalid-state",
            ),
            pytest.param(
                "",
                "Invalid or expired OAuth state",
                id="empty-state",
            ),
        ],
    )
    async def test_rejects_invalid_state(
        self,
        oauth_manager: OAuthManager,
        invalid_state: str,
        expected_error: str,
    ) -> None:
        """Code exchange rejects invalid state parameters."""
        with pytest.raises(ValueError, match=expected_error):
            await oauth_manager.exchange_code(
                OAuthProvider.GOOGLE,
                code="test_code",
                state=invalid_state,
            )

    @pytest.mark.asyncio
    async def test_rejects_expired_state(
        self,
        mock_calendar_settings: CalendarSettings,
        mock_client_factory: MagicMock,
    ) -> None:
        """Code exchange rejects expired state (>10 minutes old)."""
        manager = OAuthManager(mock_calendar_settings, mock_client_factory)

        # Manually create an expired state
        expired_state = OAuthState(
            state="expired_state",
            code_verifier="verifier",
            provider=OAuthProvider.GOOGLE,
            redirect_uri="http://localhost/callback",
            created_at=datetime.now(timezone.utc) - timedelta(minutes=15),
        )
        manager._pending_states["expired_state"] = expired_state

        with pytest.raises(ValueError, match="OAuth state expired"):
            await manager.exchange_code(
                OAuthProvider.GOOGLE,
                code="test_code",
                state="expired_state",
            )

    @pytest.mark.asyncio
    async def test_rejects_provider_mismatch(
        self,
        oauth_manager: OAuthManager,
    ) -> None:
        """Code exchange rejects mismatched provider."""
        # Generate state for Google
        _, state = oauth_manager.get_authorization_url(
            OAuthProvider.GOOGLE,
            "http://localhost:3000/callback",
        )

        # Try to exchange with Microsoft
        with pytest.raises(ValueError, match="Provider mismatch"):
            await oauth_manager.exchange_code(
                OAuthProvider.MICROSOFT,
                code="test_code",
                state=state.state,
            )


# ============================================================================
# Token Refresh Tests
# ============================================================================


class TestTokenRefresh:
    """Test token refresh with parametrized providers."""

    @pytest.mark.asyncio
    @pytest.mark.parametrize(
        ("provider",),
        [
            pytest.param(OAuthProvider.GOOGLE, id="google"),
            pytest.param(OAuthProvider.MICROSOFT, id="microsoft"),
        ],
    )
    async def test_uses_correct_endpoint(
        self,
        mock_calendar_settings: CalendarSettings,
        provider: OAuthProvider,
    ) -> None:
        """Token refresh uses provider-specific endpoint."""
        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock()
        mock_client.refresh_token = AsyncMock(return_value={
            "access_token": "new_token",
            "expires_in": 3600,
        })

        manager = OAuthManager(mock_calendar_settings)

        # Patch AsyncOAuth2Client to return our mock
        with pytest.mock.patch(
            "noteflow.infrastructure.integrations.oauth_manager.AsyncOAuth2Client",
            return_value=mock_client,
        ):
            tokens = await manager.refresh_tokens(provider, "old_refresh_token")

        expected_endpoint = PROVIDER_CONFIGS[provider].token_endpoint
        mock_client.refresh_token.assert_called_once()
        call_args = mock_client.refresh_token.call_args
        assert call_args[0][0] == expected_endpoint


# ============================================================================
# Token Revocation Tests
# ============================================================================


class TestTokenRevocation:
    """Test token revocation with parametrized providers."""

    @pytest.mark.asyncio
    @pytest.mark.parametrize(
        ("provider", "has_revocation_endpoint"),
        [
            pytest.param(OAuthProvider.GOOGLE, True, id="google-supports-revocation"),
            pytest.param(OAuthProvider.MICROSOFT, False, id="microsoft-no-revocation"),
        ],
    )
    async def test_revocation_behavior(
        self,
        mock_calendar_settings: CalendarSettings,
        provider: OAuthProvider,
        has_revocation_endpoint: bool,
    ) -> None:
        """Test revocation behavior per provider."""
        manager = OAuthManager(mock_calendar_settings)

        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock()
        mock_client.revoke_token = AsyncMock()

        with pytest.mock.patch(
            "noteflow.infrastructure.integrations.oauth_manager.AsyncOAuth2Client",
            return_value=mock_client,
        ):
            result = await manager.revoke_tokens(provider, "test_token")

        assert result is True  # Both succeed (Microsoft returns True without calling)
```

### Application Layer Tests

**File**: `tests/application/test_calendar_service.py`

```python
"""Calendar service application layer tests using pytest.mark.parametrize."""

from __future__ import annotations

from datetime import datetime, timezone
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock

import pytest

from noteflow.application.services.calendar_service import CalendarService
from noteflow.domain.calendar.oauth import OAuthProvider, OAuthStatus
from noteflow.domain.calendar.ports import CalendarEvent

if TYPE_CHECKING:
    from collections.abc import Callable


# ============================================================================
# Fixtures
# ============================================================================


@pytest.fixture
def mock_oauth_manager() -> MagicMock:
    """Create mock OAuth manager."""
    manager = MagicMock()
    manager.get_authorization_url.return_value = (
        "https://accounts.google.com/o/oauth2/auth?state=test",
        MagicMock(state="test_state"),
    )
    return manager


@pytest.fixture
def calendar_service(
    mock_oauth_manager: MagicMock,
    mock_uow_factory: Callable[[], AsyncMock],
) -> CalendarService:
    """Create calendar service with mock dependencies."""
    return CalendarService(mock_oauth_manager, mock_uow_factory)


def make_calendar_event(
    event_id: str,
    title: str,
    hours_from_now: int = 1,
) -> CalendarEvent:
    """Factory for creating test calendar events."""
    start = datetime.now(timezone.utc).replace(
        hour=10 + hours_from_now, minute=0, second=0, microsecond=0
    )
    return CalendarEvent(
        id=event_id,
        title=title,
        start_time=start,
        end_time=start.replace(hour=start.hour + 1),
        attendees=("test@example.com",),
    )


# ============================================================================
# OAuth Initiation Tests
# ============================================================================


class TestInitiateOAuth:
    """Test OAuth flow initiation with parametrized providers."""

    @pytest.mark.parametrize(
        ("provider", "expected_url_fragment"),
        [
            pytest.param(
                OAuthProvider.GOOGLE,
                "accounts.google.com",
                id="google",
            ),
            pytest.param(
                OAuthProvider.MICROSOFT,
                "login.microsoftonline.com",
                id="microsoft",
            ),
        ],
    )
    def test_returns_authorization_url(
        self,
        mock_oauth_manager: MagicMock,
        mock_uow_factory: Callable[[], AsyncMock],
        provider: OAuthProvider,
        expected_url_fragment: str,
    ) -> None:
        """Initiate OAuth returns correct authorization URL."""
        # Configure mock for this provider
        mock_oauth_manager.get_authorization_url.return_value = (
            f"https://{expected_url_fragment}/oauth?state=test",
            MagicMock(state="test_state"),
        )
        service = CalendarService(mock_oauth_manager, mock_uow_factory)

        url = service.initiate_oauth(provider, "http://localhost:3000/callback")

        assert expected_url_fragment in url
        mock_oauth_manager.get_authorization_url.assert_called_once_with(
            provider,
            "http://localhost:3000/callback",
        )


# ============================================================================
# Event Listing Tests
# ============================================================================


class TestListUpcomingEvents:
    """Test event listing with parametrized scenarios."""

    @pytest.mark.asyncio
    @pytest.mark.parametrize(
        ("is_authenticated", "expected_event_count", "should_call_list"),
        [
            pytest.param(True, 1, True, id="authenticated-returns-events"),
            pytest.param(False, 0, False, id="unauthenticated-skipped"),
        ],
    )
    async def test_provider_authentication_check(
        self,
        calendar_service: CalendarService,
        is_authenticated: bool,
        expected_event_count: int,
        should_call_list: bool,
    ) -> None:
        """Test event listing respects authentication status."""
        mock_provider = MagicMock()
        mock_provider.is_authenticated.return_value = is_authenticated
        mock_provider.list_events = AsyncMock(return_value=[
            make_calendar_event("evt-1", "Meeting 1"),
        ])

        calendar_service.register_provider("test", mock_provider)

        events = await calendar_service.list_upcoming_events()

        assert len(events) == expected_event_count
        assert mock_provider.list_events.called == should_call_list

    @pytest.mark.asyncio
    @pytest.mark.parametrize(
        ("provider_events", "expected_order"),
        [
            pytest.param(
                [
                    ("google", [make_calendar_event("g1", "Google Meeting", 2)]),
                    ("outlook", [make_calendar_event("o1", "Outlook Meeting", 1)]),
                ],
                ["Outlook Meeting", "Google Meeting"],
                id="sorts-by-start-time",
            ),
            pytest.param(
                [
                    ("provider1", [make_calendar_event("p1", "First", 1)]),
                    ("provider2", [make_calendar_event("p2", "Second", 2)]),
                    ("provider3", [make_calendar_event("p3", "Third", 3)]),
                ],
                ["First", "Second", "Third"],
                id="aggregates-multiple-providers",
            ),
        ],
    )
    async def test_event_aggregation_and_sorting(
        self,
        calendar_service: CalendarService,
        provider_events: list[tuple[str, list[CalendarEvent]]],
        expected_order: list[str],
    ) -> None:
        """Test events are aggregated and sorted by start time."""
        for provider_name, events in provider_events:
            mock_provider = MagicMock()
            mock_provider.is_authenticated.return_value = True
            mock_provider.list_events = AsyncMock(return_value=events)
            calendar_service.register_provider(provider_name, mock_provider)

        events = await calendar_service.list_upcoming_events(limit=10)

        assert [e.title for e in events] == expected_order


# ============================================================================
# Connection Status Tests
# ============================================================================


class TestGetConnectionStatus:
    """Test connection status retrieval with parametrized scenarios."""

    @pytest.mark.asyncio
    @pytest.mark.parametrize(
        ("integration_exists", "has_tokens", "token_expired", "expected_status"),
        [
            pytest.param(False, False, False, OAuthStatus.NOT_CONNECTED, id="no-integration"),
            pytest.param(True, False, False, OAuthStatus.NOT_CONNECTED, id="no-tokens"),
            pytest.param(True, True, False, OAuthStatus.CONNECTED, id="valid-tokens"),
            pytest.param(True, True, True, OAuthStatus.EXPIRED, id="expired-tokens"),
        ],
    )
    async def test_returns_correct_status(
        self,
        mock_oauth_manager: MagicMock,
        integration_exists: bool,
        has_tokens: bool,
        token_expired: bool,
        expected_status: OAuthStatus,
    ) -> None:
        """Test connection status reflects actual state."""
        # Configure mock UoW based on test parameters
        mock_uow = AsyncMock()
        mock_integration = MagicMock(id="int-1", config={}) if integration_exists else None
        mock_uow.integrations.get_by_provider = AsyncMock(return_value=mock_integration)

        if integration_exists and has_tokens:
            expires_at = datetime.now(timezone.utc)
            if not token_expired:
                expires_at = expires_at.replace(year=2030)  # Future date
            else:
                expires_at = expires_at.replace(year=2020)  # Past date

            mock_uow.integrations.get_secrets = AsyncMock(return_value={
                "access_token": "test_token",
                "refresh_token": "test_refresh",
                "expires_at": expires_at.isoformat(),
            })
        else:
            mock_uow.integrations.get_secrets = AsyncMock(return_value=None)

        mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
        mock_uow.__aexit__ = AsyncMock()

        service = CalendarService(mock_oauth_manager, lambda: mock_uow)

        connection = await service.get_connection_status(OAuthProvider.GOOGLE)

        assert connection.status == expected_status
```

---

## Dependencies

Add to `pyproject.toml`:
```toml
[project.optional-dependencies]
calendar = [
    "authlib>=1.3",           # OAuth 2.0 with PKCE support
    "httpx>=0.27",            # Async HTTP client (required by Authlib)
    "google-api-python-client>=2.100",  # Google Calendar API
    "google-auth>=2.23",      # Google authentication
]
```

---

## Blocks

- Sprint 0 (Proto & Schema Foundation) must be complete

---

## OAuth Prerequisites

Before implementing the OAuth flow, ensure:

1. **Google Cloud Console Setup**
   - Create OAuth 2.0 credentials (Web application type)
   - Add `noteflow://oauth/callback` to authorized redirect URIs
   - Enable Google Calendar API
   - Note: Google requires app verification for sensitive scopes

2. **Microsoft Azure AD Setup**
   - Register application in Azure Portal
   - Configure redirect URI: `noteflow://oauth/callback`
   - Add `Calendars.Read` and `User.Read` permissions
   - Admin consent may be required for organizational accounts

3. **Deep Link Handler** (Tauri)
   - Register `noteflow://` URI scheme in `tauri.conf.json`
   - Implement deep link event listener in Rust
   - Parse OAuth callback parameters (`code`, `state`)

4. **Environment Variables**
   ```bash
   NOTEFLOW_CALENDAR_GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
   NOTEFLOW_CALENDAR_GOOGLE_CLIENT_SECRET=GOCSPX-xxx
   NOTEFLOW_CALENDAR_OUTLOOK_CLIENT_ID=xxx-xxx-xxx
   NOTEFLOW_CALENDAR_OUTLOOK_CLIENT_SECRET=xxx
   ```

---

## Failure Modes

| Failure | Detection | Recovery |
|---------|-----------|----------|
| OAuth state mismatch | `state` param doesn't match stored | Reject with "Invalid or expired state" error |
| OAuth state expired | `created_at > 10 minutes ago` | Reject, user must restart OAuth flow |
| Code exchange failure | API returns error response | Display error message, allow retry |
| Token refresh failure | Refresh token revoked or expired | Mark connection as `EXPIRED`, prompt re-auth |
| Google API rate limit | `429 Too Many Requests` | Exponential backoff, cache events locally |
| Calendar API unavailable | `5xx` response or timeout | Return cached events, retry in background |
| Invalid redirect URI | Mismatch with registered URI | Log error, check OAuth app configuration |
| User denies consent | OAuth callback with `error=access_denied` | Clear pending state, show friendly message |

### Token Storage Security

OAuth tokens are stored in `integration_secrets` table. Consider:

- Tokens should be encrypted at rest using KeyStore
- Refresh tokens are long-lived credentials—treat as secrets
- Access tokens expire quickly (1 hour)—cache in memory

---

## Definition of Done

- [ ] All acceptance criteria met
- [ ] `pytest tests/quality/` passes
- [ ] `pytest tests/infrastructure/integrations/` passes
- [ ] `pytest tests/application/test_calendar_service.py` passes
- [ ] `ruff check .` passes
- [ ] `basedpyright` passes
- [ ] OAuth PKCE flow verified with real Google account
- [ ] Token refresh verified (simulate expiry)
- [ ] Deep link callback handler works in Tauri
- [ ] Frontend shows connection status correctly
- [ ] Feature flag controls availability
