"""Graph API client for Microsoft Graph API communication.

This module provides the GraphClient class for making authenticated HTTP
requests to Microsoft Graph API with automatic header injection and retry logic.
"""

from __future__ import annotations

import logging
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from typing import TYPE_CHECKING, Any, AsyncIterator

import httpx
from tenacity import (
    RetryCallState,
    retry,
    retry_if_result,
    stop_after_attempt,
)

from teams_phone.constants import DEFAULT_TIMEOUT, GRAPH_API_BASE, MAX_RETRIES
from teams_phone.exceptions import (
    AuthenticationError,
    AuthorizationError,
    ContractError,
    NotFoundError,
    RateLimitError,
    TeamsPhoneError,
    ValidationError,
)
from teams_phone.services.auth_service import AuthService

if TYPE_CHECKING:
    from teams_phone.infrastructure.debug_logger import DebugLogger

logger = logging.getLogger(__name__)

# Status codes that should trigger automatic retry
RETRYABLE_STATUS_CODES = frozenset({429, 500, 502, 503, 504})


def _is_retryable_status(response: httpx.Response) -> bool:
    """Check if response status code should trigger a retry.

    Args:
        response: The HTTP response to check.

    Returns:
        True if the status code is retryable (429, 500, 502, 503, 504).
    """
    return response.status_code in RETRYABLE_STATUS_CODES


def _get_retry_after(response: httpx.Response) -> float | None:
    """Parse Retry-After header value.

    Handles both integer seconds and HTTP-date formats per RFC 7231.

    Args:
        response: The HTTP response containing the Retry-After header.

    Returns:
        Number of seconds to wait, or None if header is missing/unparseable.
    """
    retry_after = response.headers.get("Retry-After")
    if retry_after is None:
        return None

    # Try integer seconds first (most common)
    try:
        return float(retry_after)
    except ValueError:
        pass

    # Try HTTP-date format (e.g., "Wed, 21 Oct 2015 07:28:00 GMT")
    try:
        retry_date = parsedate_to_datetime(retry_after)
        now = datetime.now(timezone.utc)
        delta: float = (retry_date - now).total_seconds()
        return max(0.0, delta)
    except (ValueError, TypeError):
        return None


def _custom_wait(retry_state: RetryCallState) -> float:
    """Custom wait callback that uses Retry-After header when available.

    For 429 responses with Retry-After header, uses that value.
    Otherwise falls back to exponential backoff: 1s, 2s, 4s.

    Args:
        retry_state: The tenacity retry state with outcome.

    Returns:
        Number of seconds to wait before next retry.
    """
    outcome = retry_state.outcome
    if outcome and not outcome.failed:
        response: httpx.Response = outcome.result()
        if response.status_code == 429:
            retry_after = _get_retry_after(response)
            if retry_after is not None:
                return retry_after

    # Exponential backoff: 1s, 2s, 4s (2^0, 2^1, 2^2)
    return float(min(4.0, 2 ** (retry_state.attempt_number - 1)))


def _log_retry(retry_state: RetryCallState) -> None:
    """Log retry attempts before sleeping.

    Args:
        retry_state: The tenacity retry state with outcome.
    """
    outcome = retry_state.outcome
    if outcome and not outcome.failed:
        response: httpx.Response = outcome.result()
        wait_time = _custom_wait(retry_state)
        logger.warning(
            "Retrying request after HTTP %d (attempt %d/%d, waiting %.1fs)",
            response.status_code,
            retry_state.attempt_number,
            MAX_RETRIES,
            wait_time,
        )


def _raise_for_exhausted_retries(retry_state: RetryCallState) -> None:
    """Raise appropriate exception when all retries are exhausted.

    Args:
        retry_state: The tenacity retry state with final outcome.

    Raises:
        RateLimitError: If exhausted on 429.
        TeamsPhoneError: If exhausted on 5xx.
    """
    outcome = retry_state.outcome
    if outcome and not outcome.failed:
        response: httpx.Response = outcome.result()
        status_code = response.status_code

        if status_code == 429:
            raise RateLimitError(
                f"Rate limit exceeded after {MAX_RETRIES} retries",
                remediation="Wait and retry later, or reduce request rate",
            )
        else:
            # Extract error details from response body for debugging
            error_detail = ""
            try:
                data = response.json()
                if isinstance(data, dict) and "error" in data:
                    error_obj = data["error"]
                    if isinstance(error_obj, dict):
                        error_detail = f": {error_obj.get('message', '')}"
            except (ValueError, TypeError):
                if response.text:
                    error_detail = f": {response.text[:200]}"

            raise TeamsPhoneError(
                f"Graph API unavailable (HTTP {status_code}) after {MAX_RETRIES} retries{error_detail}"
            )


class GraphClient:
    """HTTP client for Microsoft Graph API requests.

    Handles authentication header injection, URL construction, and
    async HTTP operations. Implements async context manager protocol
    for proper resource management.

    Attributes:
        auth_service: The AuthService instance for token acquisition.
    """

    def __init__(
        self,
        auth_service: AuthService,
        *,
        timeout: int | None = None,
        debug_logger: DebugLogger | None = None,
    ) -> None:
        """Initialize the GraphClient.

        Args:
            auth_service: The AuthService for authentication token acquisition.
            timeout: Request timeout in seconds. Defaults to DEFAULT_TIMEOUT (30s)
                if not specified.
            debug_logger: Optional DebugLogger for request/response logging.
        """
        self.auth_service = auth_service
        self._timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
        self._client: httpx.AsyncClient | None = None
        self._debug_logger = debug_logger

    def _build_url(self, endpoint: str) -> str:
        """Construct full URL from base and endpoint.

        Args:
            endpoint: The API endpoint path, with or without leading slash.

        Returns:
            Full URL combining GRAPH_API_BASE and the endpoint.
        """
        return f"{GRAPH_API_BASE}/{endpoint.lstrip('/')}"

    def _get_headers(self) -> dict[str, str]:
        """Get request headers with authorization token.

        Returns:
            Headers dict with Authorization Bearer token and Content-Type.
        """
        token = self.auth_service.get_token()
        return {
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json",
        }

    def _handle_error(self, response: httpx.Response) -> None:
        """Parse HTTP error response and raise appropriate exception.

        Maps HTTP status codes to the exception hierarchy with OData error
        message extraction and remediation hints.

        Args:
            response: The HTTP response with a non-success status code.

        Raises:
            ValidationError: For 400 (bad request) or 409 (conflict).
            AuthenticationError: For 401 (unauthorized).
            AuthorizationError: For 403 (forbidden).
            NotFoundError: For 404 (not found).
            RateLimitError: For 429 (too many requests).
            TeamsPhoneError: For 5xx server errors or unknown status codes.
        """
        status_code = response.status_code

        # Extract error message from OData error format or use status text
        error_message = response.reason_phrase or "Unknown error"
        error_code: str | None = None

        try:
            data = response.json()
            if isinstance(data, dict) and "error" in data:
                error_obj = data["error"]
                if isinstance(error_obj, dict):
                    error_message = error_obj.get("message", error_message)
                    error_code = error_obj.get("code")
        except (ValueError, TypeError):
            # Non-JSON response, use response text if available
            if response.text:
                error_message = response.text[:200]  # Truncate long responses

        # Build base message with error code if available
        if error_code:
            base_message = f"{error_code}: {error_message}"
        else:
            base_message = error_message

        # Map status codes to exceptions with remediation hints
        if status_code == 400:
            raise ValidationError(
                f"Bad request: {base_message}",
            )
        elif status_code == 401:
            raise AuthenticationError(
                f"Authentication failed: {base_message}",
                remediation="Run `teams-phone auth login` to re-authenticate",
            )
        elif status_code == 403:
            raise AuthorizationError(
                f"Access denied: {base_message}",
                remediation="Verify application has required Graph API permissions",
            )
        elif status_code == 404:
            raise NotFoundError(
                f"Resource not found: {base_message}",
            )
        elif status_code == 409:
            raise ValidationError(
                f"Conflict: {base_message}",
            )
        elif status_code == 429:
            raise RateLimitError(
                f"Rate limit exceeded: {base_message}",
                remediation="Wait and retry later, or reduce request rate",
            )
        elif 500 <= status_code < 600:
            raise TeamsPhoneError(
                f"Graph API error (HTTP {status_code}): {base_message}",
            )
        else:
            raise TeamsPhoneError(
                f"Unexpected error (HTTP {status_code}): {base_message}",
            )

    async def _request(
        self,
        method: str,
        url: str,
        **kwargs: Any,
    ) -> httpx.Response:
        """Make an async HTTP request with retry logic.

        Implements automatic retry for transient failures:
        - 429 (rate limit): Respects Retry-After header, max 3 retries
        - 500, 502, 503, 504 (server errors): Exponential backoff (1s, 2s, 4s)
        - 401 (unauthorized): Single token refresh retry

        Args:
            method: HTTP method (GET, POST, etc.).
            url: Full URL for the request.
            **kwargs: Additional arguments passed to httpx.AsyncClient.request().

        Returns:
            The httpx.Response object.

        Raises:
            RuntimeError: If client is not initialized (not in async context).
            RateLimitError: If rate limit exceeded after max retries.
            TeamsPhoneError: If server error persists after max retries.
            AuthenticationError: If 401 persists after token refresh.
        """
        if self._client is None:
            raise RuntimeError(
                "GraphClient must be used as async context manager. "
                "Use 'async with GraphClient(...) as client:'"
            )

        # Handle 401 with single token refresh retry
        response = await self._request_with_retry(method, url, **kwargs)

        if response.status_code == 401:
            logger.warning("Received 401, refreshing token and retrying once")
            try:
                self.auth_service.refresh_token()
            except AuthenticationError:
                raise AuthenticationError(
                    "Authentication failed after token refresh",
                    remediation="Run `teams-phone auth login` to re-authenticate",
                )

            # Retry once with refreshed token
            response = await self._request_with_retry(method, url, **kwargs)

            if response.status_code == 401:
                raise AuthenticationError(
                    "Authentication failed after token refresh",
                    remediation="Run `teams-phone auth login` to re-authenticate",
                )

        return response

    @retry(
        retry=retry_if_result(_is_retryable_status),
        stop=stop_after_attempt(MAX_RETRIES),
        wait=_custom_wait,
        before_sleep=_log_retry,
        retry_error_callback=_raise_for_exhausted_retries,
    )
    async def _request_with_retry(
        self,
        method: str,
        url: str,
        **kwargs: Any,
    ) -> httpx.Response:
        """Execute HTTP request with automatic retry for transient failures.

        This method is wrapped with tenacity retry decorator for 429 and 5xx.
        Do not call directly - use _request() which handles 401 separately.

        Args:
            method: HTTP method (GET, POST, etc.).
            url: Full URL for the request.
            **kwargs: Additional arguments passed to httpx.AsyncClient.request().

        Returns:
            The httpx.Response object (may be retryable status).
        """
        headers = self._get_headers()
        return await self._client.request(method, url, headers=headers, **kwargs)  # type: ignore[union-attr]

    async def get(
        self,
        endpoint: str,
        *,
        params: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        """Make an async GET request to a Graph API endpoint.

        Args:
            endpoint: The API endpoint path.
            params: Optional query parameters.

        Returns:
            JSON response as a dictionary.

        Raises:
            ValidationError: For 400 or 409 responses.
            AuthenticationError: For 401 responses.
            AuthorizationError: For 403 responses.
            NotFoundError: For 404 responses.
            RateLimitError: For 429 responses (after retry exhaustion).
            TeamsPhoneError: For 5xx responses (after retry exhaustion).
        """
        url = self._build_url(endpoint)
        start_time = None
        if self._debug_logger:
            start_time = self._debug_logger.log_graph_request("GET", url)
        response = await self._request("GET", url, params=params)
        if self._debug_logger and start_time is not None:
            self._debug_logger.log_graph_response(response.status_code, start_time)
        if not response.is_success:
            self._handle_error(response)
        result: dict[str, Any] = response.json()
        return result

    async def post(
        self,
        endpoint: str,
        body: dict[str, Any],
    ) -> dict[str, Any]:
        """Make an async POST request to a Graph API endpoint.

        Args:
            endpoint: The API endpoint path.
            body: Request body as a dictionary.

        Returns:
            JSON response as a dictionary.

        Raises:
            ValidationError: For 400 or 409 responses.
            AuthenticationError: For 401 responses.
            AuthorizationError: For 403 responses.
            NotFoundError: For 404 responses.
            RateLimitError: For 429 responses (after retry exhaustion).
            TeamsPhoneError: For 5xx responses (after retry exhaustion).
        """
        url = self._build_url(endpoint)
        start_time = None
        if self._debug_logger:
            import json as json_module

            body_size = len(json_module.dumps(body).encode("utf-8"))
            start_time = self._debug_logger.log_graph_request(
                "POST", url, body_size=body_size
            )
        response = await self._request("POST", url, json=body)
        if self._debug_logger and start_time is not None:
            self._debug_logger.log_graph_response(response.status_code, start_time)
        if not response.is_success:
            self._handle_error(response)
        result: dict[str, Any] = response.json()
        return result

    async def post_with_response(
        self,
        endpoint: str,
        body: dict[str, Any],
    ) -> httpx.Response:
        """Make an async POST request and return the raw httpx.Response.

        Use this method when you need access to response headers, such as
        the Location header in 202 Accepted responses for async operations.

        Args:
            endpoint: The API endpoint path.
            body: Request body as a dictionary.

        Returns:
            The raw httpx.Response object with headers accessible.

        Raises:
            ValidationError: For 400 or 409 responses.
            AuthenticationError: For 401 responses.
            AuthorizationError: For 403 responses.
            NotFoundError: For 404 responses.
            RateLimitError: For 429 responses (after retry exhaustion).
            TeamsPhoneError: For 5xx responses (after retry exhaustion).
        """
        url = self._build_url(endpoint)
        start_time = None
        if self._debug_logger:
            import json as json_module

            body_size = len(json_module.dumps(body).encode("utf-8"))
            start_time = self._debug_logger.log_graph_request(
                "POST", url, body_size=body_size
            )
        response = await self._request("POST", url, json=body)
        if self._debug_logger and start_time is not None:
            self._debug_logger.log_graph_response(response.status_code, start_time)
        if not response.is_success:
            self._handle_error(response)
        return response

    async def get_paginated(
        self,
        endpoint: str,
        *,
        params: dict[str, Any] | None = None,
        max_items: int | None = None,
    ) -> AsyncIterator[dict[str, Any]]:
        """Make paginated GET requests, yielding individual items.

        Handles Microsoft Graph API pagination by following @odata.nextLink
        until all items are retrieved or max_items limit is reached.

        Args:
            endpoint: The API endpoint path.
            params: Optional query parameters for the initial request.
            max_items: Maximum number of items to yield. If None, yields all items.
                If 0, returns immediately without making any requests.

        Yields:
            Individual items from the 'value' array in each response.

        Raises:
            ContractError: If response is missing the 'value' key.
            RateLimitError: If rate limit exceeded after max retries.
            TeamsPhoneError: If server error persists after max retries.
            AuthenticationError: If authentication fails after token refresh.
        """
        url = self._build_url(endpoint)
        async for item in self.get_paginated_url(
            url, params=params, max_items=max_items
        ):
            yield item

    async def get_paginated_url(
        self,
        url: str,
        *,
        params: dict[str, Any] | None = None,
        max_items: int | None = None,
    ) -> AsyncIterator[dict[str, Any]]:
        """Make paginated GET requests to a full URL, yielding individual items.

        Like get_paginated but accepts a full URL instead of a relative endpoint.
        Useful for calling different API versions (e.g., v1.0 vs beta).

        Args:
            url: The full URL to request.
            params: Optional query parameters for the initial request.
            max_items: Maximum number of items to yield. If None, yields all items.
                If 0, returns immediately without making any requests.

        Yields:
            Individual items from the 'value' array in each response.

        Raises:
            ContractError: If response is missing the 'value' key.
            RateLimitError: If rate limit exceeded after max retries.
            TeamsPhoneError: If server error persists after max retries.
            AuthenticationError: If authentication fails after token refresh.
        """
        if max_items == 0:
            return

        current_url: str | None = url
        items_yielded = 0
        is_first_request = True

        while current_url is not None:
            start_time = None
            if self._debug_logger:
                start_time = self._debug_logger.log_graph_request("GET", current_url)

            # For initial request, use params; for nextLink, URL has params embedded
            if is_first_request and params is not None:
                response = await self._request("GET", current_url, params=params)
                is_first_request = False
            else:
                response = await self._request("GET", current_url)

            if not response.is_success:
                if self._debug_logger and start_time is not None:
                    self._debug_logger.log_graph_response(
                        response.status_code, start_time
                    )
                self._handle_error(response)
            data: dict[str, Any] = response.json()

            if "value" not in data:
                raise ContractError(
                    f"Graph API response missing 'value' key for URL: {url}",
                    remediation="This may indicate an API contract change. "
                    "Please report this issue.",
                )

            items_in_page = len(data["value"])
            if self._debug_logger and start_time is not None:
                self._debug_logger.log_graph_response(
                    response.status_code, start_time, items_count=items_in_page
                )

            for item in data["value"]:
                yield item
                items_yielded += 1

                if max_items is not None and items_yielded >= max_items:
                    return

            # Get next page URL (full URL, not relative endpoint)
            current_url = data.get("@odata.nextLink")

    async def __aenter__(self) -> GraphClient:
        """Enter async context manager.

        Creates the underlying httpx.AsyncClient with configured timeout.

        Returns:
            This GraphClient instance.
        """
        self._client = httpx.AsyncClient(timeout=self._timeout)
        return self

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: Any,
    ) -> None:
        """Exit async context manager.

        Closes the underlying httpx.AsyncClient.

        Args:
            exc_type: Exception type if an exception was raised.
            exc_val: Exception instance if an exception was raised.
            exc_tb: Traceback if an exception was raised.
        """
        if self._client is not None:
            await self._client.aclose()
            self._client = None
