import asyncio
import base64
import logging
import time
from typing import Optional
from urllib.parse import urlparse, parse_qs

import aiohttp
import tenacity
from fastapi import Request, Response, HTTPException
from starlette.background import BackgroundTask

from .const import SUPPORTED_RESPONSE_HEADERS
from .mpd_processor import process_manifest, process_playlist, process_segment, process_init_segment
from .schemas import HLSManifestParams, MPDManifestParams, MPDPlaylistParams, MPDSegmentParams, MPDInitParams
from .utils.cache_utils import (
    get_cached_mpd,
    get_cached_init_segment,
    get_cached_segment,
    set_cached_segment,
    get_cached_processed_segment,
    set_cached_processed_segment,
)
from .utils.dash_prebuffer import dash_prebuffer
from .utils.http_utils import (
    Streamer,
    DownloadError,
    download_file_with_retry,
    request_with_retry,
    EnhancedStreamingResponse,
    ProxyRequestHeaders,
    create_streamer,
    apply_header_manipulation,
)
from .utils.m3u8_processor import M3U8Processor, generate_graceful_end_playlist
from .utils.mpd_utils import pad_base64
from .utils.redis_utils import (
    acquire_stream_gate,
    release_stream_gate,
    get_cached_head,
    set_cached_head,
    check_and_set_cooldown,
    is_redis_configured,
)
from .utils.stream_transformers import StreamTransformer, get_transformer
from .utils.rate_limit_handlers import get_rate_limit_handler

from .configs import settings

logger = logging.getLogger(__name__)


def handle_exceptions(exception: Exception, context: str = "") -> Response:
    """
    Handle exceptions and return appropriate HTTP responses.

    Uses appropriate log levels based on exception type:
    - DEBUG: Expected errors like 404 Not Found
    - WARNING: Transient errors like timeouts, connection issues
    - ERROR: Only for truly unexpected errors

    Args:
        exception (Exception): The exception that was raised.
        context (str): Optional context string for better error messages.

    Returns:
        Response: An HTTP response corresponding to the exception type.
    """
    ctx = f" [{context}]" if context else ""

    if isinstance(exception, aiohttp.ClientResponseError):
        if exception.status == 404:
            logger.debug(f"Upstream 404{ctx}: {exception.request_info.url if exception.request_info else 'unknown'}")
            return Response(status_code=404, content="Upstream resource not found")
        elif exception.status in (429, 509):
            # Rate limited by upstream - pass through so the player can retry on its own
            logger.warning(f"Upstream rate limited ({exception.status}){ctx}")
            return Response(status_code=exception.status, content=f"Upstream rate limited: {exception.status}")
        elif exception.status in (502, 503, 504):
            # Upstream server errors - log at warning level as these are often transient
            logger.warning(f"Upstream server error{ctx}: {exception.status}")
            return Response(status_code=exception.status, content=f"Upstream server error: {exception.status}")
        else:
            logger.warning(f"Upstream HTTP error{ctx}: {exception}")
        return Response(status_code=exception.status, content=f"Upstream service error: {exception}")
    elif isinstance(exception, DownloadError):
        # DownloadError is expected for various upstream issues
        logger.warning(f"Download error{ctx}: {exception}")
        return Response(status_code=exception.status_code, content=str(exception))
    elif isinstance(exception, tenacity.RetryError):
        logger.warning(f"Max retries exceeded{ctx}")
        return Response(status_code=502, content="Max retries exceeded while downloading content")
    elif isinstance(exception, asyncio.TimeoutError):
        logger.warning(f"Timeout error{ctx}: upstream did not respond in time")
        return Response(status_code=504, content="Gateway timeout")
    elif isinstance(exception, aiohttp.ClientError):
        # Client errors are often network issues - warning level
        logger.warning(f"Client error{ctx}: {exception}")
        return Response(status_code=502, content=f"Upstream connection error: {exception}")
    elif isinstance(exception, HTTPException):
        # HTTPException is intentionally raised (e.g. segment unavailable) - not unexpected
        if exception.status_code >= 500:
            logger.warning(f"HTTP exception{ctx}: {exception.status_code}: {exception.detail}")
        else:
            logger.debug(f"HTTP exception{ctx}: {exception.status_code}: {exception.detail}")
        return Response(status_code=exception.status_code, content=exception.detail)
    elif isinstance(exception, ValueError) and "HTML instead of m3u8" in str(exception):
        # Expected error when upstream returns error page instead of playlist
        logger.warning(f"Upstream returned HTML{ctx}: stream may be offline or unavailable")
        return Response(status_code=502, content=str(exception))
    else:
        # Only use exception() (with traceback) for truly unexpected errors
        logger.exception(f"Unexpected error{ctx}: {exception}")
        return Response(status_code=502, content=f"Internal server error: {exception}")


async def handle_hls_stream_proxy(
    request: Request,
    hls_params: HLSManifestParams,
    proxy_headers: ProxyRequestHeaders,
    transformer_id: Optional[str] = None,
) -> Response:
    """
    Handle HLS stream proxy requests.

    This function processes HLS manifest files and streams content based on the request parameters.

    Args:
        request (Request): The incoming FastAPI request object.
        hls_params (HLSManifestParams): Parameters for the HLS manifest.
        proxy_headers (ProxyRequestHeaders): Headers to be used in the proxy request.
        transformer_id (str, optional): ID of the stream transformer to use for segment streaming.

    Returns:
        Union[Response, EnhancedStreamingResponse]: Either a processed m3u8 playlist or a streaming response.
    """
    streamer = await create_streamer()
    # Handle range requests
    content_range = proxy_headers.request.get("range", "bytes=0-")
    if "nan" in content_range.casefold():
        # Handle invalid range requests "bytes=NaN-NaN"
        raise HTTPException(status_code=416, detail="Invalid Range Header")
    proxy_headers.request.update({"range": content_range})

    try:
        # Auto-detect and resolve Vavoo links
        if "vavoo.to" in hls_params.destination:
            try:
                from mediaflow_proxy.extractors.vavoo import VavooExtractor

                vavoo_extractor = VavooExtractor(proxy_headers.request)
                resolved_data = await vavoo_extractor.extract(hls_params.destination)
                resolved_url = resolved_data["destination_url"]
                logger.info(f"Auto-resolved Vavoo URL: {hls_params.destination} -> {resolved_url}")
                # Update destination with resolved URL
                hls_params.destination = resolved_url
            except Exception as e:
                logger.warning(f"Failed to auto-resolve Vavoo URL: {e}")
                # Continue with original URL if resolution fails

        # Parse skip_segments from JSON string to list
        skip_segments_list = hls_params.get_skip_segments()

        # Get transformer instance if specified
        transformer = get_transformer(transformer_id)

        # If force_playlist_proxy is enabled, skip detection and directly process as m3u8
        if hls_params.force_playlist_proxy:
            return await fetch_and_process_m3u8(
                streamer,
                hls_params.destination,
                proxy_headers,
                request,
                hls_params.key_url,
                hls_params.force_playlist_proxy,
                hls_params.key_only_proxy,
                hls_params.no_proxy,
                skip_segments_list,
                transformer,
                hls_params.start_offset,
            )

        parsed_url = urlparse(hls_params.destination)
        # Check if the URL is a valid m3u8 playlist or m3u file
        if parsed_url.path.endswith((".m3u", ".m3u8", ".m3u_plus")) or parse_qs(parsed_url.query).get("type", [""])[
            0
        ] in ["m3u", "m3u8", "m3u_plus"]:
            return await fetch_and_process_m3u8(
                streamer,
                hls_params.destination,
                proxy_headers,
                request,
                hls_params.key_url,
                hls_params.force_playlist_proxy,
                hls_params.key_only_proxy,
                hls_params.no_proxy,
                skip_segments_list,
                transformer,
                hls_params.start_offset,
            )

        # Create initial streaming response to check content type
        await streamer.create_streaming_response(hls_params.destination, proxy_headers.request)
        response_headers = prepare_response_headers(
            streamer.response.headers, proxy_headers.response, proxy_headers.remove, proxy_headers.propagate
        )

        if "mpegurl" in response_headers.get("content-type", "").lower():
            return await fetch_and_process_m3u8(
                streamer,
                hls_params.destination,
                proxy_headers,
                request,
                hls_params.key_url,
                hls_params.force_playlist_proxy,
                hls_params.key_only_proxy,
                hls_params.no_proxy,
                skip_segments_list,
                transformer,
                hls_params.start_offset,
            )

        # If we're removing content-range but upstream returned 206, change to 200
        # (206 Partial Content requires Content-Range header per HTTP spec)
        status_code = streamer.response.status
        if status_code == 206 and "content-range" in [h.lower() for h in proxy_headers.remove]:
            status_code = 200

        return EnhancedStreamingResponse(
            streamer.stream_content(transformer),
            status_code=status_code,
            headers=response_headers,
            background=BackgroundTask(streamer.close),
        )
    except Exception as e:
        await streamer.close()
        return handle_exceptions(e)


async def handle_stream_request(
    method: str,
    video_url: str,
    proxy_headers: ProxyRequestHeaders,
    transformer_id: Optional[str] = None,
    rate_limit_handler_id: Optional[str] = None,
) -> Response:
    """
    Handle general stream requests.

    This function processes both HEAD and GET requests for video streams.
    Uses Redis for cross-worker coordination to prevent CDN rate-limiting (e.g., Vidoza 509).

    Rate limiting behavior is controlled by the rate_limit_handler parameter:
    - If specified, uses that handler's settings
    - If not specified, auto-detects based on video URL hostname
    - If no handler matches, no rate limiting is applied (fast path)

    The coordination strategy (when rate limiting is enabled):
    1. HEAD requests: Check/use Redis cache, skip upstream entirely if cached
    2. GET requests: Check cooldown FIRST, return 503 if in cooldown
    3. Only ONE request proceeds to upstream at a time via gate
    4. After upstream responds, set cooldown to prevent rapid follow-up requests

    Args:
        method (str): The HTTP method (e.g., 'GET' or 'HEAD').
        video_url (str): The URL of the video to stream.
        proxy_headers (ProxyRequestHeaders): Headers to be used in the proxy request.
        transformer_id (str, optional): ID of the stream transformer to use for content manipulation.
        rate_limit_handler_id (str, optional): ID of the rate limit handler to use (e.g., "vidoza", "aggressive").
            If not specified, auto-detects based on video URL hostname.

    Returns:
        Union[Response, EnhancedStreamingResponse]: Either a HEAD response with headers or a streaming response.
    """
    host = urlparse(video_url).hostname or "unknown"
    gate_acquired = False

    # Get rate limit handler (explicit ID, auto-detect from URL, or default no-op)
    rate_handler = get_rate_limit_handler(rate_limit_handler_id, video_url)

    # Check if rate limiting features are needed and Redis is available
    needs_rate_limiting = (
        rate_handler.cooldown_seconds > 0 or rate_handler.use_head_cache or rate_handler.use_stream_gate
    )
    redis_available = is_redis_configured()

    if needs_rate_limiting:
        logger.info(
            f"[handle_stream] Rate limiting ENABLED for {host}: "
            f"cooldown={rate_handler.cooldown_seconds}s, gate={rate_handler.use_stream_gate}, "
            f"head_cache={rate_handler.use_head_cache}, redis={redis_available}"
        )

    if needs_rate_limiting and not redis_available:
        logger.warning(f"[handle_stream] Rate limiting requested for {host} but Redis not configured - skipping")
        needs_rate_limiting = False

    # Cooldown key - prevents rapid-fire requests to same CDN URL
    cooldown_key = f"stream_cooldown:{video_url}"

    # 1. Check Redis HEAD cache first (if enabled by handler)
    cached = None
    if needs_rate_limiting and rate_handler.use_head_cache:
        cached = await get_cached_head(video_url)

    if method == "HEAD":
        if cached:
            logger.info(f"[handle_stream] Serving cached HEAD response for {host}")
            response_headers = prepare_response_headers(
                cached["headers"], proxy_headers.response, proxy_headers.remove, proxy_headers.propagate
            )
            return Response(headers=response_headers, status_code=cached["status"])
        # No cached HEAD - for rate-limited hosts, wait for cache via gate instead of hitting upstream
        if needs_rate_limiting and rate_handler.use_stream_gate:
            # Try to acquire gate - if we get it, we make the upstream HEAD request
            # If another request holds the gate, we wait and then check cache again
            gate_acquired = await acquire_stream_gate(video_url, timeout=30.0)
            if gate_acquired:
                # We got the gate - check cache again (another request may have populated it while we waited)
                cached = await get_cached_head(video_url)
                if cached:
                    await release_stream_gate(video_url)
                    logger.info(f"[handle_stream] Serving cached HEAD response after gate wait for {host}")
                    response_headers = prepare_response_headers(
                        cached["headers"], proxy_headers.response, proxy_headers.remove, proxy_headers.propagate
                    )
                    return Response(headers=response_headers, status_code=cached["status"])
                # Cache still empty - we'll make the upstream request (gate is held)
            else:
                # Gate timeout - check cache one more time before giving up
                cached = await get_cached_head(video_url)
                if cached:
                    logger.info(f"[handle_stream] Serving cached HEAD after gate timeout for {host}")
                    response_headers = prepare_response_headers(
                        cached["headers"], proxy_headers.response, proxy_headers.remove, proxy_headers.propagate
                    )
                    return Response(headers=response_headers, status_code=cached["status"])
                logger.warning(f"[handle_stream] HEAD gate timeout for {host}, no cached headers available")
                return Response(status_code=503, content="Upstream host is busy, try again later")
        # No rate limiting - proceed to upstream without gate
    else:
        # For GET requests with rate limiting: wait for cooldown and acquire gate
        if needs_rate_limiting and rate_handler.use_stream_gate:
            # Wait for gate - this serializes all requests to the same URL
            gate_acquired = await acquire_stream_gate(video_url, timeout=30.0)
            if not gate_acquired:
                logger.warning(f"[handle_stream] Gate timeout for {host}, upstream may be slow")
                return Response(status_code=503, content="Upstream host is busy, try again later")

            # Got the gate - now check/set cooldown
            # If in cooldown, wait for it to expire before proceeding
            if rate_handler.cooldown_seconds > 0:
                max_wait = rate_handler.cooldown_seconds + 1  # Wait slightly longer than cooldown
                wait_start = time.time()

                while not await check_and_set_cooldown(cooldown_key, rate_handler.cooldown_seconds):
                    # Still in cooldown - wait a bit and retry
                    elapsed = time.time() - wait_start
                    if elapsed >= max_wait:
                        # Cooldown still active after max wait - give up
                        await release_stream_gate(video_url)
                        logger.warning(f"[handle_stream] Cooldown wait timeout for {host}")
                        return Response(
                            status_code=503,
                            content="Stream busy, try again later",
                            headers={"Retry-After": str(rate_handler.retry_after_seconds)},
                        )
                    logger.debug(f"[handle_stream] Waiting for cooldown to expire for {host}...")
                    await asyncio.sleep(0.5)  # Poll every 500ms

                # Cooldown acquired - we can proceed to upstream
                logger.info(f"[handle_stream] Cooldown acquired for {host} after {time.time() - wait_start:.1f}s wait")

    streamer = await create_streamer(video_url)

    try:
        # Auto-detect and resolve Vavoo links
        if "vavoo.to" in video_url:
            try:
                from mediaflow_proxy.extractors.vavoo import VavooExtractor

                vavoo_extractor = VavooExtractor(proxy_headers.request)
                resolved_data = await vavoo_extractor.extract(video_url)
                resolved_url = resolved_data["destination_url"]
                logger.info(f"Auto-resolved Vavoo URL: {video_url} -> {resolved_url}")
                # Update video_url with resolved URL
                video_url = resolved_url
            except Exception as e:
                logger.warning(f"Failed to auto-resolve Vavoo URL: {e}")
                # Continue with original URL if resolution fails

        # Log timing for debugging seek performance
        start_time = time.time()
        range_header = proxy_headers.request.get("range", "not set")
        logger.info(f"[handle_stream] Starting upstream {method} request - range: {range_header}")

        # Track if this is an auto-added "bytes=0-" range (client didn't send range)
        # We detect this by checking if range equals exactly "bytes=0-" which indicates
        # a proxy-added default range, not a client seeking request
        auto_added_range = proxy_headers.request.get("range") == "bytes=0-"

        # Use the same HTTP method for upstream request (HEAD for HEAD, GET for GET)
        # This prevents unnecessary data download when client just wants headers
        await streamer.create_streaming_response(video_url, proxy_headers.request, method=method)

        elapsed = time.time() - start_time
        logger.info(f"[handle_stream] Upstream responded in {elapsed:.2f}s - status: {streamer.response.status}")
        logger.debug(f"Upstream response headers: {dict(streamer.response.headers)}")

        response_headers = prepare_response_headers(
            streamer.response.headers, proxy_headers.response, proxy_headers.remove, proxy_headers.propagate
        )
        logger.debug(f"Prepared response headers: {response_headers}")

        # When client didn't send a Range header but upstream returns 206 Partial Content:
        # - Convert status to 200 (full content, not partial)
        # - Remove content-range header to avoid confusing the client
        # This handles cases where we added bytes=0- range but upstream still treats it as a range request
        status_code = streamer.response.status
        if status_code == 206:
            if "content-range" in [h.lower() for h in proxy_headers.remove]:
                # Explicitly requested to remove content-range
                status_code = 200
                # Also remove content-range from response headers if present
                response_headers.pop("content-range", None)
            elif auto_added_range:
                # We auto-added bytes=0- range but got 206 - convert to 200
                # This happens when client didn't send a range but upstream responds with 206
                status_code = 200
                # Remove content-range to avoid confusing client
                response_headers.pop("content-range", None)
                # Update content-length to total size (remove range suffix if present)
                content_range = streamer.response.headers.get("Content-Range", "")
                if "/" in content_range:
                    # Extract total size from "bytes X-Y/total"
                    total_size = content_range.split("/")[-1].strip()
                    response_headers["content-length"] = total_size

        # Get transformer instance if specified
        transformer = get_transformer(transformer_id)

        # Cache headers in Redis for future HEAD probes (if rate limiting enabled)
        if needs_rate_limiting and rate_handler.use_head_cache and status_code in (200, 206):
            await set_cached_head(video_url, dict(streamer.response.headers), status_code)

        if method == "HEAD":
            # HEAD requests always release gate immediately
            if gate_acquired:
                await release_stream_gate(video_url)
                gate_acquired = False
            await streamer.close()
            return Response(headers=response_headers, status_code=status_code)
        else:
            # For GET requests: check if we need exclusive streaming
            if gate_acquired and needs_rate_limiting and rate_handler.exclusive_stream:
                # EXCLUSIVE MODE: Keep gate held during entire stream
                # Release gate in background task when stream ends
                logger.info(f"[handle_stream] Exclusive stream mode - gate held during stream for {host}")

                async def cleanup_exclusive():
                    await streamer.close()
                    await release_stream_gate(video_url)
                    logger.info(f"[handle_stream] Exclusive stream ended - gate released for {host}")

                gate_acquired = False  # Background task will release it
                return EnhancedStreamingResponse(
                    streamer.stream_content(transformer),
                    headers=response_headers,
                    status_code=status_code,
                    background=BackgroundTask(cleanup_exclusive),
                )
            else:
                # NORMAL MODE: Release gate after headers, stream continues freely
                if gate_acquired:
                    await release_stream_gate(video_url)
                    gate_acquired = False
                return EnhancedStreamingResponse(
                    streamer.stream_content(transformer),
                    headers=response_headers,
                    status_code=status_code,
                    background=BackgroundTask(streamer.close),
                )
    except Exception as e:
        await streamer.close()
        return handle_exceptions(e)
    finally:
        # Safety: release gate if not already released (error path before headers received)
        if gate_acquired:
            await release_stream_gate(video_url)


def prepare_response_headers(
    original_headers, proxy_response_headers, remove_headers=None, propagate_headers=None
) -> dict:
    """
    Prepare response headers for the proxy response.

    This function filters the original headers, ensures proper transfer encoding,
    and merges them with the proxy response headers.

    Args:
        original_headers: The original headers from the upstream response (aiohttp CIMultiDictProxy).
        proxy_response_headers (dict): Additional headers to be included in the proxy response.
        remove_headers (list, optional): List of header names to remove from the response. Defaults to None.
        propagate_headers (dict, optional): Headers that propagate to segments (rp_ prefix). Defaults to None.

    Returns:
        dict: The prepared headers for the proxy response.
    """
    remove_set = set(h.lower() for h in (remove_headers or []))
    response_headers = {}

    # Handle aiohttp CIMultiDictProxy
    for k, v in original_headers.items():
        k_lower = k.lower()
        if k_lower in SUPPORTED_RESPONSE_HEADERS and k_lower not in remove_set:
            response_headers[k_lower] = v

    # Apply propagate headers first (for segments), then response headers (response takes precedence)
    if propagate_headers:
        response_headers.update(propagate_headers)
    response_headers.update(proxy_response_headers)
    return response_headers


async def proxy_stream(
    method: str,
    destination: str,
    proxy_headers: ProxyRequestHeaders,
    transformer_id: Optional[str] = None,
    rate_limit_handler_id: Optional[str] = None,
):
    """
    Proxies the stream request to the given video URL.

    Args:
        method (str): The HTTP method (e.g., GET, HEAD).
        destination (str): The URL of the stream to be proxied.
        proxy_headers (ProxyRequestHeaders): The headers to include in the request.
        transformer_id (str, optional): ID of the stream transformer to use.
        rate_limit_handler_id (str, optional): ID of the rate limit handler to use (e.g., "vidoza").
            If not specified, auto-detects based on destination URL hostname.

    Returns:
        Response: The HTTP response with the streamed content.
    """
    return await handle_stream_request(method, destination, proxy_headers, transformer_id, rate_limit_handler_id)


async def fetch_and_process_m3u8(
    streamer: Streamer,
    url: str,
    proxy_headers: ProxyRequestHeaders,
    request: Request,
    key_url: str = None,
    force_playlist_proxy: bool = None,
    key_only_proxy: bool = False,
    no_proxy: bool = False,
    skip_segments: list = None,
    transformer: Optional[StreamTransformer] = None,
    start_offset: float = None,
):
    """
    Fetches and processes the m3u8 playlist on-the-fly, converting it to an HLS playlist.

    Args:
        streamer (Streamer): The HTTP client to use for streaming.
        url (str): The URL of the m3u8 playlist.
        proxy_headers (ProxyRequestHeaders): The headers to include in the request.
        request (Request): The incoming HTTP request.
        key_url (str, optional): The HLS Key URL to replace the original key URL. Defaults to None.
        force_playlist_proxy (bool, optional): Force all playlist URLs to be proxied through MediaFlow. Defaults to None.
        key_only_proxy (bool, optional): Only proxy the key URL, leaving segment URLs direct. Defaults to False.
        no_proxy (bool, optional): If True, returns the manifest without proxying any URLs. Defaults to False.
        skip_segments (list, optional): List of time segments to skip. Each item should have
                                        'start', 'end' (in seconds), and optionally 'type'.
        transformer (StreamTransformer, optional): Transformer to apply to the stream content.
        start_offset (float, optional): Time offset in seconds for EXT-X-START tag. Use negative
                                       values for live streams to start behind the live edge.

    Returns:
        Response: The HTTP response with the processed m3u8 playlist.
    """
    try:
        # Create streaming response if not already created
        if not streamer.response:
            await streamer.create_streaming_response(url, proxy_headers.request)

        # Initialize processor and response headers
        # skip_segments is already a list of dicts with 'start' and 'end' keys
        processor = M3U8Processor(
            request, key_url, force_playlist_proxy, key_only_proxy, no_proxy, skip_segments, start_offset
        )
        base_headers = {
            "content-disposition": "inline",
            "accept-ranges": "none",
            "content-type": "application/vnd.apple.mpegurl",
        }
        # Don't include propagate headers for manifests - they should only apply to segments
        response_headers = apply_header_manipulation(base_headers, proxy_headers, include_propagate=False)

        # Get the generator for processing
        m3u8_generator = processor.process_m3u8_streaming(
            streamer.stream_content(transformer), str(streamer.response.url)
        )

        # Pre-fetch the first chunk to validate the content before starting the response
        # This allows us to return a proper HTTP error if the upstream returns HTML
        first_chunk = None
        try:
            first_chunk = await m3u8_generator.__anext__()
        except ValueError as e:
            # Upstream returned HTML instead of m3u8 - expected error, log at warning level
            logger.warning(f"Upstream error for {url}: {e}")
            await streamer.close()
            # Return graceful end playlist if enabled, otherwise raise error
            if settings.graceful_stream_end:
                graceful_content = generate_graceful_end_playlist("Stream offline or unavailable")
                return Response(
                    content=graceful_content,
                    media_type="application/vnd.apple.mpegurl",
                    headers=response_headers,
                )
            raise HTTPException(status_code=502, detail=str(e))
        except StopAsyncIteration:
            # Empty response - this shouldn't happen for valid m3u8
            logger.warning(f"Upstream returned empty m3u8 playlist: {url}")
            await streamer.close()
            # Return graceful end playlist if enabled, otherwise raise error
            if settings.graceful_stream_end:
                graceful_content = generate_graceful_end_playlist("Empty upstream response")
                return Response(
                    content=graceful_content,
                    media_type="application/vnd.apple.mpegurl",
                    headers=response_headers,
                )
            raise HTTPException(status_code=502, detail="Upstream returned empty m3u8 playlist")

        # Create a wrapper that yields the first chunk then continues with the rest
        async def prefetched_generator():
            yield first_chunk
            try:
                async for chunk in m3u8_generator:
                    yield chunk
            except ValueError as e:
                # This shouldn't happen since we already validated the first chunk,
                # but handle it gracefully if it does
                logger.warning(f"ValueError during m3u8 streaming (after initial validation): {e}")

        # Create streaming response with on-the-fly processing
        return EnhancedStreamingResponse(
            prefetched_generator(),
            headers=response_headers,
            background=BackgroundTask(streamer.close),
        )
    except HTTPException:
        raise
    except Exception as e:
        await streamer.close()
        return handle_exceptions(e)


def _normalize_drm_key_value(value: str) -> str:
    """
    Normalize a DRM key_id or key value to lowercase hex.

    Accepts either:
    - A 32-char hex string (returned as-is, lowercased).
    - A base64url-encoded value (decoded to hex).
    - A comma-separated list of the above, for multi-key DRM scenarios.

    Commas are treated as list separators, NOT as characters to pass into
    base64 decoding.  Previously the ``len() != 32`` check was applied to
    the entire comma-joined string, and ``urlsafe_b64decode`` silently
    strips non-alphabet characters (including commas), causing adjacent keys
    to be concatenated into an oversized byte string.

    Args:
        value: The key value string, or None.

    Returns:
        Normalized hex string (or comma-joined hex strings for multi-key),
        or the original value unchanged if it is falsy.
    """
    if not value:
        return value
    if "," in value:
        parts = [_normalize_single_key(p.strip()) for p in value.split(",") if p.strip()]
        return ",".join(parts)
    return _normalize_single_key(value)


def _normalize_single_key(value: str) -> str:
    """Convert a single key_id or key to a 32-char lowercase hex string."""
    if len(value) != 32:
        return base64.urlsafe_b64decode(pad_base64(value)).hex()
    return value.lower()


async def handle_drm_key_data(key_id, key, drm_info):
    """
    Handles the DRM key data, retrieving the key ID and key from the DRM info if not provided.

    Args:
        key_id (str): The DRM key ID.
        key (str): The DRM key.
        drm_info (dict): The DRM information from the MPD manifest.

    Returns:
        tuple: The key ID and key.
    """
    if drm_info and not drm_info.get("isDrmProtected"):
        return None, None

    if not key_id or not key:
        if "keyId" in drm_info and "key" in drm_info:
            key_id = drm_info["keyId"]
            key = drm_info["key"]
        elif "laUrl" in drm_info and "keyId" in drm_info:
            # License URL with keyId - license acquisition should have been attempted already
            # If we still don't have a key, it means acquisition failed
            pass
        else:
            # Try to use extracted KID if available (from MPD/init segment analysis)
            if not key_id and drm_info.get("extracted_kids"):
                # Use the first extracted KID
                extracted_kids = drm_info["extracted_kids"]
                if extracted_kids:
                    key_id = extracted_kids[0]
                    logger.info(f"Using extracted KID from MPD/init segment: {key_id}")

            # Still require the actual decryption key
            if not key:
                if drm_info.get("extracted_kids"):
                    license_urls = drm_info.get("license_urls", [])
                    license_url_msg = f" License server: {license_urls[0]}" if license_urls else ""
                    raise HTTPException(
                        status_code=400,
                        detail=(
                            f"Key ID (KID) was automatically extracted: {key_id}. "
                            f"However, the actual decryption key must be provided via the 'key' parameter. "
                            f"The key cannot be extracted from the MPD or init segment and must be obtained "
                            f"from the license server or source website.{license_url_msg}"
                        ),
                    )
                else:
                    raise HTTPException(
                        status_code=400, detail="Unable to determine key_id and key, and they were not provided"
                    )

    return key_id, key


async def get_manifest(
    request: Request,
    manifest_params: MPDManifestParams,
    proxy_headers: ProxyRequestHeaders,
):
    """
    Retrieves and processes the MPD manifest, converting it to an HLS manifest.

    Args:
        request (Request): The incoming HTTP request.
        manifest_params (MPDManifestParams): The parameters for the manifest request.
        proxy_headers (ProxyRequestHeaders): The headers to include in the request.

    Returns:
        Response: The HTTP response with the HLS manifest.
    """
    try:
        mpd_dict = await get_cached_mpd(
            manifest_params.destination,
            headers=proxy_headers.request,
            parse_drm=not manifest_params.key_id and not manifest_params.key,
        )
    except DownloadError as e:
        raise HTTPException(status_code=e.status_code, detail=f"Failed to download MPD: {e.message}")
    drm_info = mpd_dict.get("drmInfo", {})

    # Get skip segments if provided
    skip_segments = manifest_params.get_skip_segments()

    if drm_info and not drm_info.get("isDrmProtected"):
        # For non-DRM protected MPD, we still create an HLS manifest
        return await process_manifest(
            request, mpd_dict, proxy_headers, None, None, manifest_params.resolution, skip_segments
        )

    key_id, key = await handle_drm_key_data(manifest_params.key_id, manifest_params.key, drm_info)

    # Normalize key_id and key: convert from base64 to hex when needed.
    # Each value may be a comma-separated list for multi-key DRM; each part is
    # normalized independently so that commas are never passed to urlsafe_b64decode
    # (base64 silently ignores non-alphabet characters, stripping commas and
    # concatenating the keys into a single oversized value).
    key_id = _normalize_drm_key_value(key_id)
    key = _normalize_drm_key_value(key)

    return await process_manifest(
        request, mpd_dict, proxy_headers, key_id, key, manifest_params.resolution, skip_segments
    )


async def get_playlist(
    request: Request,
    playlist_params: MPDPlaylistParams,
    proxy_headers: ProxyRequestHeaders,
):
    """
    Retrieves and processes the MPD manifest, converting it to an HLS playlist for a specific profile.

    Args:
        request (Request): The incoming HTTP request.
        playlist_params (MPDPlaylistParams): The parameters for the playlist request.
        proxy_headers (ProxyRequestHeaders): The headers to include in the request.

    Returns:
        Response: The HTTP response with the HLS playlist.
    """
    try:
        mpd_dict = await get_cached_mpd(
            playlist_params.destination,
            headers=proxy_headers.request,
            parse_drm=not playlist_params.key_id and not playlist_params.key,
            parse_segment_profile_id=playlist_params.profile_id,
        )
    except DownloadError as e:
        raise HTTPException(status_code=e.status_code, detail=f"Failed to download MPD: {e.message}")

    # Get skip segments if provided
    skip_segments = playlist_params.get_skip_segments()

    return await process_playlist(
        request, mpd_dict, playlist_params.profile_id, proxy_headers, skip_segments, playlist_params.start_offset
    )


async def get_segment(
    segment_params: MPDSegmentParams,
    proxy_headers: ProxyRequestHeaders,
    force_remux_ts: bool = None,
):
    """
    Retrieves and processes a media segment, decrypting it if necessary.

    Uses event-based coordination with the DASH prebuffer to prevent duplicate
    downloads. The prebuffer's get_or_download() handles cache checks, waiting
    for existing downloads, and starting new downloads as needed.

    Args:
        segment_params (MPDSegmentParams): The parameters for the segment request.
        proxy_headers (ProxyRequestHeaders): The headers to include in the request.
        force_remux_ts (bool, optional): If True, force remuxing to MPEG-TS regardless
            of global settings. Used by /mpd/segment.ts endpoint. Defaults to None.

    Returns:
        Response: The HTTP response with the processed segment.
    """
    try:
        live_cache_ttl = settings.mpd_live_init_cache_ttl if segment_params.is_live else None
        segment_url = segment_params.segment_url
        should_remux = force_remux_ts if force_remux_ts is not None else settings.remux_to_ts

        # Check processed segment cache first (avoids re-decrypting/re-remuxing)
        is_processed = bool(segment_params.key_id or should_remux)
        if is_processed:
            processed_content = await get_cached_processed_segment(segment_url, segment_params.key_id, should_remux)
            if processed_content:
                logger.info(f"Serving processed segment from cache: {segment_url}")
                mimetype = "video/mp2t" if should_remux else segment_params.mime_type
                response_headers = apply_header_manipulation({}, proxy_headers)
                return Response(content=processed_content, media_type=mimetype, headers=response_headers)

        # Use event-based coordination for segment download
        # get_or_download() handles:
        # - Cache check
        # - Waiting for existing downloads (via asyncio.Event)
        # - Starting new download if needed
        # - Caching the result
        # Use a short timeout (1s) for player requests to avoid blocking if prebuffer is busy
        # This ensures players get fast responses even when background prefetching is active
        if settings.enable_dash_prebuffer:
            segment_content = await dash_prebuffer.get_or_download(segment_url, proxy_headers.request, timeout=1.0)
        else:
            # Prebuffer disabled - check cache then download directly
            segment_content = await get_cached_segment(segment_url)
            if not segment_content:
                try:
                    segment_content = await download_file_with_retry(segment_url, proxy_headers.request)
                    # Cache for future requests (synchronous to ensure it's cached before returning)
                    if segment_content and segment_params.is_live:
                        # Use create_task for non-blocking cache write, but segment is already downloaded
                        asyncio.create_task(
                            set_cached_segment(segment_url, segment_content, ttl=settings.dash_segment_cache_ttl)
                        )
                except Exception as dl_err:
                    logger.warning(f"Direct download failed when prebuffer disabled: {dl_err}")
                    segment_content = None

        # If prebuffer returned None (lock timeout or coordination failure),
        # check cache one more time - the download may have completed while we waited
        # Then fall back to a direct download if still not cached.
        # This is critical for live streams where the prebuffer may be busy
        # downloading other segments/profiles.
        if not segment_content:
            # Final cache check - download may have completed during lock wait
            segment_content = await get_cached_segment(segment_url)
            if segment_content:
                logger.info(f"Segment found in cache after prebuffer timeout: {segment_url}")
            else:
                logger.info(f"Prebuffer returned no content, falling back to direct download: {segment_url}")
                try:
                    segment_content = await download_file_with_retry(segment_url, proxy_headers.request)
                    # Cache on success for future requests
                    if segment_content and segment_params.is_live:
                        asyncio.create_task(
                            set_cached_segment(segment_url, segment_content, ttl=settings.dash_segment_cache_ttl)
                        )
                except Exception as dl_err:
                    logger.warning(f"Direct download fallback also failed: {dl_err}")

        if not segment_content:
            # Return 404 instead of 502 so players can skip and continue
            # Most video players handle 404s gracefully by skipping the segment
            raise HTTPException(status_code=404, detail="Segment unavailable")

        # Fetch init segment (uses its own cache)
        init_content = await get_cached_init_segment(
            segment_params.init_url,
            proxy_headers.request,
            cache_token=segment_params.key_id,
            ttl=live_cache_ttl,
            byte_range=segment_params.init_range,
        )

        # Trigger continuous prefetch for live streams
        if settings.enable_dash_prebuffer and segment_params.is_live:
            for mpd_url in dash_prebuffer.active_streams:
                asyncio.create_task(
                    dash_prebuffer.prefetch_upcoming_segments(
                        mpd_url,
                        segment_url,
                        proxy_headers.request,
                    )
                )
                break  # Only need to trigger once

    except Exception as e:
        return handle_exceptions(e)

    try:
        response = await process_segment(
            init_content,
            segment_content,
            segment_params.mime_type,
            proxy_headers,
            segment_params.key_id,
            segment_params.key,
            use_map=segment_params.use_map,
            remux_ts=force_remux_ts,
        )
    except Exception as e:
        return handle_exceptions(e)

    # Cache processed segment for future requests (avoids re-decrypting/re-remuxing)
    if is_processed and response.status_code == 200:
        asyncio.create_task(
            set_cached_processed_segment(
                segment_url,
                response.body,
                segment_params.key_id,
                should_remux,
                ttl=settings.processed_segment_cache_ttl,
            )
        )

    return response


async def get_init_segment(
    init_params: MPDInitParams,
    proxy_headers: ProxyRequestHeaders,
):
    """
    Retrieves and processes an initialization segment for EXT-X-MAP.

    Args:
        init_params (MPDInitParams): The parameters for the init segment request.
        proxy_headers (ProxyRequestHeaders): The headers to include in the request.

    Returns:
        Response: The HTTP response with the processed init segment.
    """
    try:
        live_cache_ttl = settings.mpd_live_init_cache_ttl if init_params.is_live else None
        init_content = await get_cached_init_segment(
            init_params.init_url,
            proxy_headers.request,
            cache_token=init_params.key_id,
            ttl=live_cache_ttl,
            byte_range=init_params.init_range,
        )
    except Exception as e:
        return handle_exceptions(e)

    return await process_init_segment(
        init_content,
        init_params.mime_type,
        proxy_headers,
        init_params.key_id,
        init_params.key,
        init_params.init_url,
    )


IP_LOOKUP_SERVICES = [
    {"url": "https://api.ipify.org?format=json", "key": "ip"},
    {"url": "https://ipinfo.io/json", "key": "ip"},
    {"url": "https://httpbin.org/ip", "key": "origin"},
]


async def get_public_ip():
    """
    Retrieves the public IP address of the MediaFlow proxy.
    Tries multiple services for reliability.

    Returns:
        dict: A dictionary with the public IP address {"ip": "x.x.x.x"}.

    Raises:
        DownloadError: If all IP lookup services fail.
    """
    for service in IP_LOOKUP_SERVICES:
        try:
            response = await request_with_retry("GET", service["url"], {})
            content = await response.text()
            import json

            data = json.loads(content)
            ip = data.get(service["key"])
            if ip:
                return {"ip": ip.strip()}
        except Exception:
            continue

    raise DownloadError(503, "Failed to retrieve public IP from all services")
