{% if not skip_docstring %}
"""HTTP client module for direct REST calls to CDF API.

This module replicates SDK patterns for OAuth, retries, and error handling
without requiring the Cognite SDK dependency.
"""

{% endif %}
import platform
import random
import sys
import time
from typing import TYPE_CHECKING

import requests

if TYPE_CHECKING:
    from collections.abc import MutableMapping

# Token expiry leeway (refresh 30 seconds before expiry)
_TOKEN_EXPIRY_LEEWAY_SECONDS = 30


def _get_user_agent() -> str:
    """Construct User-Agent string matching Python SDK format.
    
    Format: {sdk_name}/{version} {python_impl}/{python_version} ({build};{compiler}) {os}/{release}-{machine}-{arch}
    """
    sdk_version = "pygen-spark/0.0.0"  # TODO: Get from package version
    python_version = (
        f"{platform.python_implementation()}/{platform.python_version()} "
        f"({platform.python_build()};{platform.python_compiler()})"
    )
    os_version_info = [platform.release(), platform.machine(), platform.architecture()[0]]
    os_version_info = [s for s in os_version_info if s]  # Ignore empty strings
    os_version_info_str = "-".join(os_version_info)
    operating_system = f"{platform.system()}/{os_version_info_str}"
    
    return f"{sdk_version} {python_version} {operating_system}"

# Retry configuration
_STATUS_CODES_TO_RETRY = {429, 502, 503, 504}
_BACKOFF_FACTOR = 0.5
_MAX_BACKOFF_SECONDS = 60
_MAX_RETRIES_TOTAL = 10
_MAX_RETRIES_STATUS = 10
_MAX_RETRIES_READ = 10
_MAX_RETRIES_CONNECT = 10


class _RetryTracker:
    """Track retry attempts for different error types."""

    def __init__(self) -> None:
        self.status = 0
        self.read = 0
        self.connect = 0

    @property
    def total(self) -> int:
        """Total retry attempts across all error types."""
        return self.status + self.read + self.connect

    def _max_backoff_and_jitter(self, backoff_time: float) -> int:
        """Calculate backoff time with jitter."""
        return int(min(backoff_time, _MAX_BACKOFF_SECONDS) * random.uniform(0, 1.0))

    def get_backoff_time(self) -> int:
        """Calculate exponential backoff time with jitter."""
        backoff_time = _BACKOFF_FACTOR * (2**self.total)
        return self._max_backoff_and_jitter(backoff_time)

    def should_retry(self, status_code: int | None, is_auto_retryable: bool = False) -> bool:
        """Determine if request should be retried."""
        if self.total >= _MAX_RETRIES_TOTAL:
            return False
        if self.status > 0 and self.status >= _MAX_RETRIES_STATUS:
            return False
        if self.read > 0 and self.read >= _MAX_RETRIES_READ:
            return False
        if self.connect > 0 and self.connect >= _MAX_RETRIES_CONNECT:
            return False
        if status_code and status_code not in _STATUS_CODES_TO_RETRY and not is_auto_retryable:
            return False
        return True


def _classify_error(status_code: int | None, exception: Exception | None = None) -> str:
    """Classify error type for better error handling.
    
    Args:
        status_code: HTTP status code, or None for connection errors
        exception: Exception object if available
        
    Returns:
        Error category: AUTHENTICATION, CONFIGURATION, NETWORK, or UNKNOWN
    """
    if status_code in (401, 403):
        return "AUTHENTICATION"
    if status_code in (400, 422):
        return "CONFIGURATION"
    if status_code is None or isinstance(exception, (requests.exceptions.ConnectionError, requests.exceptions.Timeout)):
        return "NETWORK"
    return "UNKNOWN"


def _get_oauth_token(
    client_id: str,
    client_secret: str,
    tenant_id: str,
    cdf_cluster: str,
    cached_token: dict[str, str | float] | None = None,
) -> tuple[str, float]:
    """Get OAuth token with caching support.
    
    Args:
        client_id: OAuth2 client ID
        client_secret: OAuth2 client secret
        tenant_id: Azure AD tenant ID
        cdf_cluster: CDF cluster name
        cached_token: Cached token dict with 'access_token' and 'expires_at' keys
        
    Returns:
        Tuple of (access_token, expires_at timestamp)
    """
    # Check if cached token is still valid (with 30s leeway)
    if cached_token:
        access_token = cached_token.get("access_token")
        expires_at = cached_token.get("expires_at")
        if access_token and expires_at and expires_at > time.time() + _TOKEN_EXPIRY_LEEWAY_SECONDS:
            return str(access_token), float(expires_at)
    
    # Fetch new token from Azure AD
    token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
    scope = f"https://{cdf_cluster}.cognitedata.com/.default"
    
    data = {
        "client_id": client_id,
        "client_secret": client_secret,
        "scope": scope,
        "grant_type": "client_credentials",
    }
    
    try:
        response = requests.post(
            token_url,
            data=data,
            headers={"Content-Type": "application/x-www-form-urlencoded"},
            timeout=30,
        )
        response.raise_for_status()
        
        token_data = response.json()
        access_token = token_data["access_token"]
        expires_in = float(token_data["expires_in"])
        expires_at = time.time() + expires_in
        
        return access_token, expires_at
        
    except requests.exceptions.HTTPError as e:
        error_category = _classify_error(e.response.status_code if e.response else None, e)
        sys.stderr.write(f"[UDTF] ✗ {error_category}: Failed to acquire OAuth token: {e}\n")
        raise
    except requests.exceptions.RequestException as e:
        error_category = _classify_error(None, e)
        sys.stderr.write(f"[UDTF] ✗ {error_category}: Failed to acquire OAuth token: {e}\n")
        raise


def _make_request(
    method: str,
    url: str,
    token: str,
    json_data: dict | None = None,
    headers: dict[str, str] | None = None,
    timeout: float | None = None,
    accept_protobuf: bool = False,
) -> requests.Response:
    """Make HTTP request with retry logic and error handling.
    
    Args:
        method: HTTP method (GET, POST, etc.)
        url: Request URL
        token: OAuth access token
        json_data: JSON payload for POST requests
        headers: Additional headers
        timeout: Request timeout in seconds
        accept_protobuf: If True, request protobuf response format
        
    Returns:
        Response object
    """
    # Build headers
    request_headers: MutableMapping[str, str] = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
        "Accept": "application/protobuf" if accept_protobuf else "application/json",
        "x-cdp-app": "pygen-spark",
        "User-Agent": _get_user_agent(),
    }
    if headers:
        request_headers.update(headers)
    
    retry_tracker = _RetryTracker()
    
    while True:
        try:
            # Make request
            response = requests.request(
                method=method,
                url=url,
                json=json_data,
                headers=request_headers,
                timeout=timeout or 30,
            )
            
            # Check if response indicates auto-retryable error
            is_auto_retryable = False
            if response.headers.get("Content-Type", "").startswith("application/json"):
                try:
                    error_data = response.json()
                    is_auto_retryable = error_data.get("error", {}).get("isAutoRetryable", False)
                except Exception:
                    pass
            
            # Check if we should retry
            retry_tracker.status += 1
            if not retry_tracker.should_retry(response.status_code, is_auto_retryable):
                response.raise_for_status()
                return response
            
        except requests.exceptions.Timeout as e:
            retry_tracker.read += 1
            if not retry_tracker.should_retry(None, True):
                error_category = _classify_error(None, e)
                sys.stderr.write(f"[UDTF] ✗ {error_category}: Request timeout: {e}\n")
                raise
        except requests.exceptions.ConnectionError as e:
            retry_tracker.connect += 1
            if not retry_tracker.should_retry(None, True):
                error_category = _classify_error(None, e)
                sys.stderr.write(f"[UDTF] ✗ {error_category}: Connection error: {e}\n")
                raise
        except requests.exceptions.HTTPError as e:
            # Don't retry on client errors (4xx) except 429
            if e.response and 400 <= e.response.status_code < 500 and e.response.status_code != 429:
                error_category = _classify_error(e.response.status_code, e)
                # Log response body for 400/422 errors to help debug
                try:
                    error_body = e.response.text
                    if error_body:
                        sys.stderr.write(f"[UDTF] ✗ {error_category}: HTTP error: {e}\n")
                        sys.stderr.write(f"[UDTF] Response body: {error_body}\n")
                    else:
                        sys.stderr.write(f"[UDTF] ✗ {error_category}: HTTP error: {e}\n")
                except Exception:
                    sys.stderr.write(f"[UDTF] ✗ {error_category}: HTTP error: {e}\n")
                raise
            # Retry on 429 and 5xx
            retry_tracker.status += 1
            if not retry_tracker.should_retry(e.response.status_code if e.response else None, False):
                error_category = _classify_error(e.response.status_code if e.response else None, e)
                sys.stderr.write(f"[UDTF] ✗ {error_category}: HTTP error: {e}\n")
                raise
        
        # Backoff before retry
        backoff_time = retry_tracker.get_backoff_time()
        time.sleep(backoff_time)
        
        # Token might have expired during backoff, but we'll refresh it on next iteration
        # (token refresh happens in the calling code)
