"""Microsoft Outlook Calendar API adapter.

Implements CalendarPort for Outlook using Microsoft Graph API.
"""

from __future__ import annotations

from datetime import UTC, datetime, timedelta
from typing import Final, TypedDict, cast

import httpx

from noteflow.config.constants import (
    DEFAULT_MEETING_TITLE,
    ERR_API_PREFIX,
    ERR_TOKEN_EXPIRED,
    HTTP_AUTHORIZATION,
    HTTP_BEARER_PREFIX,
    HTTP_STATUS_OK,
    HTTP_STATUS_UNAUTHORIZED,
)
from noteflow.domain.ports.calendar import CalendarEventInfo, CalendarPort
from noteflow.domain.value_objects import OAuthProvider
from noteflow.infrastructure.logging import get_logger, log_timing

logger = get_logger(__name__)

# HTTP client configuration
GRAPH_API_TIMEOUT: Final[float] = 30.0  # seconds
MAX_CONNECTIONS: Final[int] = 10
MAX_ERROR_BODY_LENGTH: Final[int] = 500
GRAPH_API_MAX_PAGE_SIZE: Final[int] = 100  # Graph API maximum


class _OutlookDateTime(TypedDict, total=False):
    dateTime: str
    timeZone: str


class _OutlookEmailAddress(TypedDict, total=False):
    address: str


class _OutlookAttendee(TypedDict, total=False):
    emailAddress: _OutlookEmailAddress


class _OutlookLocation(TypedDict, total=False):
    displayName: str


class _OutlookOnlineMeeting(TypedDict, total=False):
    joinUrl: str


class _OutlookEvent(TypedDict, total=False):
    id: str
    subject: str
    start: _OutlookDateTime
    end: _OutlookDateTime
    isAllDay: bool
    attendees: list[_OutlookAttendee]
    seriesMasterId: str
    location: _OutlookLocation
    bodyPreview: str
    onlineMeeting: _OutlookOnlineMeeting
    onlineMeetingUrl: str


class _OutlookEventsResponse(TypedDict, total=False):
    """Outlook API events response with pagination support.

    Note: The @odata.nextLink field is accessed dynamically via dict.get()
    since @ is not a valid Python identifier.
    """
    value: list[_OutlookEvent]


class _OutlookProfile(TypedDict, total=False):
    mail: str
    userPrincipalName: str


class OutlookCalendarError(Exception):
    """Outlook Calendar API error."""


def _truncate_error_body(body: str) -> str:
    """Truncate error body to prevent log bloat.

    Args:
        body: Raw error response body.

    Returns:
        Truncated body with indicator if truncation occurred.
    """
    if len(body) <= MAX_ERROR_BODY_LENGTH:
        return body
    return f"{body[:MAX_ERROR_BODY_LENGTH]}... (truncated)"


class OutlookCalendarAdapter(CalendarPort):
    """Microsoft Graph Calendar API adapter.

    Fetches calendar events and user info using Microsoft Graph API.
    Requires a valid OAuth access token with Calendars.Read scope.
    """

    GRAPH_API_BASE = "https://graph.microsoft.com/v1.0"

    async def list_events(
        self,
        access_token: str,
        hours_ahead: int = 24,
        limit: int = 20,
    ) -> list[CalendarEventInfo]:
        """Fetch upcoming calendar events from Outlook Calendar.

        Implements pagination via @odata.nextLink to ensure all events
        within the limit are retrieved.

        Args:
            access_token: Microsoft Graph OAuth token with Calendars.Read scope.
            hours_ahead: Hours to look ahead from current time.
            limit: Maximum events to return.

        Returns:
            List of Outlook calendar events ordered by start datetime.

        Raises:
            OutlookCalendarError: If API call fails.
        """
        now = datetime.now(UTC)
        start_time = now.strftime("%Y-%m-%dT%H:%M:%SZ")
        end_time = (now + timedelta(hours=hours_ahead)).strftime("%Y-%m-%dT%H:%M:%SZ")

        headers = {
            HTTP_AUTHORIZATION: f"{HTTP_BEARER_PREFIX}{access_token}",
            "Prefer": 'outlook.timezone="UTC"',
        }

        # Initial page request
        page_size = min(limit, GRAPH_API_MAX_PAGE_SIZE)
        url: str | None = f"{self.GRAPH_API_BASE}/me/calendarView"
        params: dict[str, str | int] | None = {
            "startDateTime": start_time,
            "endDateTime": end_time,
            "$top": page_size,
            "$orderby": "start/dateTime",
            "$select": (
                "id,subject,start,end,location,bodyPreview,"
                "attendees,isAllDay,seriesMasterId,onlineMeeting,onlineMeetingUrl"
            ),
        }

        all_events: list[CalendarEventInfo] = []

        with log_timing(
            "outlook_calendar_list_events",
            hours_ahead=hours_ahead,
            limit=limit,
        ):
            async with httpx.AsyncClient(
                timeout=httpx.Timeout(GRAPH_API_TIMEOUT),
                limits=httpx.Limits(max_connections=MAX_CONNECTIONS),
            ) as client:
                while url is not None:
                    response = await client.get(url, params=params, headers=headers)

                    if response.status_code == HTTP_STATUS_UNAUTHORIZED:
                        raise OutlookCalendarError(ERR_TOKEN_EXPIRED)

                    if response.status_code != HTTP_STATUS_OK:
                        error_body = _truncate_error_body(response.text)
                        logger.error("Microsoft Graph API error: %s", error_body)
                        raise OutlookCalendarError(f"{ERR_API_PREFIX}{error_body}")

                    data_value = response.json()
                    if not isinstance(data_value, dict):
                        logger.warning("Unexpected Microsoft Graph response payload")
                        break
                    data = cast(_OutlookEventsResponse, data_value)
                    items = data.get("value", [])

                    for item in items:
                        all_events.append(self._parse_event(item))
                        if len(all_events) >= limit:
                            logger.info(
                                "outlook_calendar_events_fetched",
                                event_count=len(all_events),
                                hours_ahead=hours_ahead,
                            )
                            return all_events

                    # Check for next page
                    next_link = data.get("@odata.nextLink") or data.get("@odata_nextLink")
                    url = str(next_link) if isinstance(next_link, str) else None
                    params = None  # nextLink includes query params

        logger.info(
            "outlook_calendar_events_fetched",
            event_count=len(all_events),
            hours_ahead=hours_ahead,
        )
        return all_events

    async def get_user_email(self, access_token: str) -> str:
        """Get authenticated user's email address.

        Args:
            access_token: Valid OAuth access token.

        Returns:
            User's email address.

        Raises:
            OutlookCalendarError: If API call fails.
        """
        url = f"{self.GRAPH_API_BASE}/me"
        params = {"$select": "mail,userPrincipalName"}
        headers = {HTTP_AUTHORIZATION: f"{HTTP_BEARER_PREFIX}{access_token}"}

        async with httpx.AsyncClient(
            timeout=httpx.Timeout(GRAPH_API_TIMEOUT),
            limits=httpx.Limits(max_connections=MAX_CONNECTIONS),
        ) as client:
            response = await client.get(url, params=params, headers=headers)

        if response.status_code == HTTP_STATUS_UNAUTHORIZED:
            raise OutlookCalendarError(ERR_TOKEN_EXPIRED)

        if response.status_code != HTTP_STATUS_OK:
            error_body = _truncate_error_body(response.text)
            logger.error("Microsoft Graph API error: %s", error_body)
            raise OutlookCalendarError(f"{ERR_API_PREFIX}{error_body}")

        data_value = response.json()
        if not isinstance(data_value, dict):
            raise OutlookCalendarError("Invalid user profile response")
        data = cast(_OutlookProfile, data_value)
        if email := data.get("mail") or data.get("userPrincipalName"):
            return str(email)
        else:
            raise OutlookCalendarError("No email in user profile response")

    def _parse_event(self, item: _OutlookEvent) -> CalendarEventInfo:
        """Parse Microsoft Graph event into CalendarEventInfo."""
        event_id = str(item.get("id", ""))
        title = str(item.get("subject", DEFAULT_MEETING_TITLE))

        # Parse start/end times
        start_data = item.get("start", {})
        end_data = item.get("end", {})

        start_time = self._parse_datetime(start_data)
        end_time = self._parse_datetime(end_data)

        # Check if all-day event
        is_all_day = bool(item.get("isAllDay", False))

        # Parse attendees
        attendees_data = item.get("attendees", [])
        attendees = self._parse_attendees(attendees_data)

        # Extract meeting URL
        meeting_url = self._extract_meeting_url(item)

        # Check if recurring (has seriesMasterId)
        is_recurring = bool(item.get("seriesMasterId"))

        # Location
        location_data = item.get("location", {})
        raw_location = location_data.get("displayName")
        location = str(raw_location) if raw_location else None

        # Description (bodyPreview)
        description = item.get("bodyPreview")

        return CalendarEventInfo(
            id=event_id,
            title=title,
            start_time=start_time,
            end_time=end_time,
            attendees=attendees,
            location=location,
            description=str(description) if description else None,
            meeting_url=meeting_url,
            is_recurring=is_recurring,
            is_all_day=is_all_day,
            provider=OAuthProvider.OUTLOOK,
            raw=dict(item),
        )

    def _parse_datetime(self, dt_data: _OutlookDateTime) -> datetime:
        """Parse datetime from Microsoft Graph format."""
        dt_str = dt_data.get("dateTime")
        timezone = dt_data.get("timeZone", "UTC")

        if not dt_str:
            return datetime.now(UTC)

        try:
            # Graph API returns ISO format without timezone suffix
            dt = datetime.fromisoformat(dt_str)
            # If no timezone info, assume UTC (we requested UTC in Prefer header)
            if dt.tzinfo is None:
                dt = dt.replace(tzinfo=UTC)
            return dt
        except ValueError:
            logger.warning("Failed to parse datetime: %s (tz: %s)", dt_str, timezone)
            return datetime.now(UTC)

    def _parse_attendees(self, attendees_data: list[_OutlookAttendee]) -> tuple[str, ...]:
        """Parse attendees from Microsoft Graph format."""
        emails: list[str] = []
        for attendee in attendees_data:
            email_address = attendee.get("emailAddress", {})
            if email := email_address.get("address"):
                emails.append(email)

        return tuple(emails)

    def _extract_meeting_url(self, item: _OutlookEvent) -> str | None:
        """Extract online meeting URL from event data."""
        if online_url := item.get("onlineMeetingUrl"):
            return online_url

        if online_meeting := item.get("onlineMeeting"):
            if join_url := online_meeting.get("joinUrl"):
                return join_url

        return None
