"""Connexity call-processor API helpers.

This module provides small utilities used by the SDK to serialize payloads
(e.g., UUIDs) and send them to the Connexity call-processor over HTTP.
"""

import asyncio
import json
import random
from typing import Any
from uuid import UUID

import aiohttp

from connexity.calls.models import CallSessionData
from connexity.constants import CONNEXITY_UPLOAD_URL, CONNEXITY_URL
from connexity.utils.logging_config import get_logger

logger = get_logger(__name__)

# HTTP status codes that should trigger retries
RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}

# Maximum number of retry attempts
MAX_RETRIES = 3

# Exponential backoff configuration
BACKOFF_MULTIPLIER = 1
BACKOFF_MIN_SECONDS = 2
BACKOFF_MAX_SECONDS = 10


def convert_uuids(obj: Any) -> Any:
    """Recursively convert UUID objects to strings.

    This is useful before JSON-encoding payloads, since UUID is not JSON
    serializable by default.

    Args:
        obj: An arbitrary object that may contain UUID instances.

    Returns:
        The same structure as `obj`, but with UUID instances converted to
        their string representation.
    """
    if isinstance(obj, UUID):
        return str(obj)

    if isinstance(obj, dict):
        return {k: convert_uuids(v) for k, v in obj.items()}

    if isinstance(obj, list):
        return [convert_uuids(item) for item in obj]

    return obj


def _parse_retry_after(header_value: str | None) -> float | None:
    """Parse Retry-After header value to seconds.

    Supports both numeric seconds and HTTP-date formats, but only returns
    numeric seconds for simplicity. Returns None if parsing fails.

    Args:
        header_value: The Retry-After header value.

    Returns:
        Number of seconds to wait, or None if parsing fails.
    """
    if not header_value:
        return None

    try:
        # Try parsing as numeric seconds first
        return float(header_value)
    except ValueError:
        # Could implement HTTP-date parsing here if needed
        return None


def _calculate_backoff_delay(attempt: int, retry_after: float | None = None) -> float:
    """Calculate backoff delay with jitter for a given attempt.

    Args:
        attempt: Zero-indexed attempt number.
        retry_after: Optional Retry-After header value in seconds.

    Returns:
        Delay in seconds before next retry.
    """
    if retry_after is not None:
        return retry_after

    base_delay = BACKOFF_MULTIPLIER * (2**attempt)
    delay = max(BACKOFF_MIN_SECONDS, min(base_delay, BACKOFF_MAX_SECONDS))
    jitter = delay * 0.1 * (2 * random.random() - 1)
    return delay + jitter


async def _send_request_with_retry(
    url: str,
    headers: dict[str, str],
    json_data: dict[str, Any],
    timeout: aiohttp.ClientTimeout,
) -> tuple[int, str]:
    """Internal function to send HTTP request with retry logic.

    Implements exponential backoff with jitter and respects Retry-After headers
    for rate limiting (429) and service unavailable (503) responses.

    Args:
        url: Call-processor URL to send request to.
        headers: HTTP headers including authentication.
        json_data: JSON payload to send.
        timeout: Request timeout configuration.

    Returns:
        Tuple of (status_code, response_body_text).

    Raises:
        aiohttp.ClientError: For network-related errors after all retries exhausted.
        TimeoutError: If request times out after all retries exhausted.
    """
    last_exception: Exception | None = None

    async with aiohttp.ClientSession(timeout=timeout) as session:
        for attempt in range(MAX_RETRIES):
            try:
                async with session.post(url, headers=headers, json=json_data) as response:
                    status = response.status
                    # Read response body while response is still valid
                    try:
                        body_text = await response.text()
                    except Exception:
                        body_text = ""

                    # Success - return immediately
                    if status == 200:
                        return (status, body_text)

                    # Check if status is retryable
                    if status in RETRYABLE_STATUS_CODES:
                        retry_after: float | None = None
                        if status in {429, 503}:
                            retry_after = _parse_retry_after(
                                response.headers.get("Retry-After")
                            )

                        delay = _calculate_backoff_delay(attempt, retry_after)

                        # Don't retry on last attempt
                        if attempt < MAX_RETRIES - 1:
                            logger.debug(
                                f"Retryable status {status} received, "
                                f"retrying in {delay:.2f}s (attempt {attempt + 1}/{MAX_RETRIES})"
                            )
                            await asyncio.sleep(delay)
                            continue

                    # Non-retryable status or last attempt - return status and body
                    return (status, body_text)

            except (aiohttp.ClientError, TimeoutError) as e:
                last_exception = e
                # Don't retry on last attempt
                if attempt < MAX_RETRIES - 1:
                    delay = _calculate_backoff_delay(attempt)
                    logger.debug(
                        f"Network error occurred: {e}, "
                        f"retrying in {delay:.2f}s (attempt {attempt + 1}/{MAX_RETRIES})"
                    )
                    await asyncio.sleep(delay)
                else:
                    # Last attempt failed - re-raise
                    raise

    # Should never reach here, but satisfy type checker
    if last_exception:
        raise last_exception
    raise RuntimeError("Unexpected retry loop exit")


async def _send_multipart_with_retry(
    url: str,
    headers: dict[str, str],
    session_json: str,
    audio_bytes: bytes,
    audio_filename: str,
    audio_content_type: str,
    timeout: aiohttp.ClientTimeout,
) -> tuple[int, str]:
    """Internal function to send multipart form data with retry logic.

    Sends the session data as a form field and audio as a file upload.

    Args:
        url: Call-processor upload URL.
        headers: HTTP headers including authentication.
        session_json: JSON-encoded session data string.
        audio_bytes: Raw audio file bytes.
        audio_filename: Filename for the audio upload.
        audio_content_type: MIME type of the audio file.
        timeout: Request timeout configuration.

    Returns:
        Tuple of (status_code, response_body_text).

    Raises:
        aiohttp.ClientError: For network-related errors after all retries exhausted.
        TimeoutError: If request times out after all retries exhausted.
    """
    last_exception: Exception | None = None

    async with aiohttp.ClientSession(timeout=timeout) as session:
        for attempt in range(MAX_RETRIES):
            try:
                form_data = aiohttp.FormData()
                form_data.add_field("session_data", session_json)
                form_data.add_field(
                    "audio_file",
                    audio_bytes,
                    filename=audio_filename,
                    content_type=audio_content_type,
                )

                async with session.post(url, headers=headers, data=form_data) as response:
                    status = response.status
                    try:
                        body_text = await response.text()
                    except Exception:
                        body_text = ""

                    if status == 200:
                        return (status, body_text)

                    if status in RETRYABLE_STATUS_CODES:
                        retry_after: float | None = None
                        if status in {429, 503}:
                            retry_after = _parse_retry_after(
                                response.headers.get("Retry-After")
                            )

                        delay = _calculate_backoff_delay(attempt, retry_after)

                        if attempt < MAX_RETRIES - 1:
                            logger.debug(
                                f"Retryable status {status} on upload, "
                                f"retrying in {delay:.2f}s (attempt {attempt + 1}/{MAX_RETRIES})"
                            )
                            await asyncio.sleep(delay)
                            continue

                    return (status, body_text)

            except (aiohttp.ClientError, TimeoutError) as e:
                last_exception = e
                if attempt < MAX_RETRIES - 1:
                    delay = _calculate_backoff_delay(attempt)
                    logger.debug(
                        f"Network error on upload: {e}, "
                        f"retrying in {delay:.2f}s (attempt {attempt + 1}/{MAX_RETRIES})"
                    )
                    await asyncio.sleep(delay)
                else:
                    raise

    if last_exception:
        raise last_exception
    raise RuntimeError("Unexpected retry loop exit")


async def send_to_call_processor(
    data: CallSessionData | dict[str, Any],
    api_key: str,
    url: str = CONNEXITY_URL,
    audio_data: tuple[bytes, str] | None = None,
) -> None:
    """Send call-session data to Connexity's call-processor API.

    If ``audio_data`` is provided, it is uploaded to the call-processor as a
    multipart form submission.  The call-processor stores the audio in Supabase
    and replaces the ``recording_url`` with a Supabase signed URL.

    If no audio is available, the data is sent as a regular JSON payload.

    Audio downloading is the responsibility of each telephony observer
    (e.g. Twilio, Daily) since recording URLs often require
    provider-specific authentication.

    Args:
        data: A `CallSessionData` model or a dict payload to send.
        api_key: API key used for the `X-API-KEY` authentication header.
        url: Call-processor URL (defaults to `CONNEXITY_URL`).
        audio_data: Pre-downloaded audio as ``(bytes, content_type)`` tuple.
            Each telephony observer is responsible for downloading the
            recording with the appropriate credentials and passing the
            bytes here.

    Returns:
        None
    """
    try:
        if isinstance(data, CallSessionData):
            data = data.model_dump()
        # Ensure UUID values are JSON-serializable before sending.
        converted_data = convert_uuids(data)

        # Extended timeout to handle slower responses
        timeout = aiohttp.ClientTimeout(total=60)
        headers = {"X-API-KEY": api_key}

        recording_url = converted_data.get("recording_url")

        if audio_data is not None:
            audio_bytes, content_type = audio_data

            # Determine filename from URL or use default
            audio_filename = "recording.wav"
            if recording_url and "." in recording_url.split("/")[-1].split("?")[0]:
                url_filename = recording_url.split("/")[-1].split("?")[0]
                if url_filename:
                    audio_filename = url_filename

            # Clear recording_url from payload since the call-processor will set it
            # after uploading to Supabase
            converted_data["recording_url"] = None
            session_json = json.dumps(converted_data, default=str)

            logger.debug(
                f"Sending multipart upload to Connexity call-processor: "
                f"audio_size={len(audio_bytes)} bytes, filename={audio_filename}"
            )

            upload_url = CONNEXITY_UPLOAD_URL
            status, body = await _send_multipart_with_retry(
                url=upload_url,
                headers=headers,
                session_json=session_json,
                audio_bytes=audio_bytes,
                audio_filename=audio_filename,
                audio_content_type=content_type,
                timeout=timeout,
            )
        else:
            # No audio to upload - send as regular JSON
            logger.debug(
                f"Sending JSON payload to Connexity call-processor: "
                f"{json.dumps(converted_data, indent=2)}"
            )

            status, body = await _send_request_with_retry(
                url=url,
                headers=headers,
                json_data=converted_data,
                timeout=timeout,
            )

        if status == 200:
            logger.info("Data sent successfully to Connexity call-processor (status 200)")
        else:
            logger.error(
                f"Failed to send data to Connexity call-processor: "
                f"status={status}, error={body[:500] if body else '(empty response)'}"
            )

    except TimeoutError as e:
        logger.error(f"Failed to send data to Connexity call-processor: timeout error - {e}")

    except aiohttp.ClientError as e:
        logger.error(f"Failed to send data to Connexity call-processor: network error - {e}")

    except Exception as e:
        logger.error(
            f"Failed to send data to Connexity call-processor: unexpected error - {e}", exc_info=True
        )
