using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Common.Net;
using Microsoft.Extensions.Logging;

namespace Jellyfin.Plugin.SRFPlay.Services;

/// <summary>
/// Service for proxying SRF Play streams and managing authentication.
/// </summary>
public class StreamProxyService : IStreamProxyService
{
    private readonly ILogger<StreamProxyService> _logger;
    private readonly IStreamUrlResolver _streamResolver;
    private readonly IMediaCompositionFetcher _compositionFetcher;
    private readonly IHttpClientFactory _httpClientFactory;
    private readonly ConcurrentDictionary<string, StreamInfo> _streamMappings;

    /// <summary>
    /// Initializes a new instance of the <see cref="StreamProxyService"/> class.
    /// </summary>
    /// <param name="logger">The logger.</param>
    /// <param name="streamResolver">The stream URL resolver.</param>
    /// <param name="compositionFetcher">The media composition fetcher.</param>
    /// <param name="httpClientFactory">The HTTP client factory.</param>
    public StreamProxyService(
        ILogger<StreamProxyService> logger,
        IStreamUrlResolver streamResolver,
        IMediaCompositionFetcher compositionFetcher,
        IHttpClientFactory httpClientFactory)
    {
        _logger = logger;
        _streamResolver = streamResolver;
        _compositionFetcher = compositionFetcher;
        _httpClientFactory = httpClientFactory;
        _streamMappings = new ConcurrentDictionary<string, StreamInfo>();
    }

    /// <summary>
    /// Registers a stream for deferred authentication (authenticates on first playback request).
    /// </summary>
    /// <param name="itemId">The item ID.</param>
    /// <param name="unauthenticatedUrl">The unauthenticated stream URL.</param>
    /// <param name="urn">The SRF URN for this content.</param>
    /// <param name="isLiveStream">Whether this is a livestream.</param>
    public void RegisterStreamDeferred(string itemId, string unauthenticatedUrl, string? urn = null, bool isLiveStream = false)
    {
        var streamInfo = new StreamInfo
        {
            AuthenticatedUrl = string.Empty, // Will be populated on first access
            UnauthenticatedUrl = unauthenticatedUrl,
            RegisteredAt = DateTime.UtcNow,
            TokenExpiresAt = null,
            Urn = urn,
            IsLiveStream = isLiveStream,
            LastLivestreamFetchAt = null,
            NeedsAuthentication = true
        };

        RegisterWithGuidFormats(itemId, streamInfo);

        _logger.LogDebug(
            "Registered deferred stream for item {ItemId} (URN: {Urn}, will authenticate on first access)",
            itemId,
            urn ?? "null");
    }

    /// <summary>
    /// Gets stream metadata for an item (URN and isLiveStream flag).
    /// Used when propagating stream registration to transcoding sessions.
    /// </summary>
    /// <param name="itemId">The item ID.</param>
    /// <returns>A tuple of (URN, IsLiveStream), or null if not found.</returns>
    public (string? Urn, bool IsLiveStream)? GetStreamMetadata(string itemId)
    {
        if (_streamMappings.TryGetValue(itemId, out var streamInfo))
        {
            return (streamInfo.Urn, streamInfo.IsLiveStream);
        }

        // Try GUID normalization
        var normalizedId = NormalizeGuid(itemId);
        if (normalizedId != null)
        {
            foreach (var kvp in _streamMappings)
            {
                var normalizedKey = NormalizeGuid(kvp.Key);
                if (normalizedKey != null && normalizedKey == normalizedId)
                {
                    return (kvp.Value.Urn, kvp.Value.IsLiveStream);
                }
            }
        }

        return null;
    }

    /// <summary>
    /// Gets the authenticated URL for an item.
    /// </summary>
    /// <param name="itemId">The item ID.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>The authenticated URL, or null if not found or expired.</returns>
    public async Task<string?> GetAuthenticatedUrlAsync(string itemId, CancellationToken cancellationToken = default)
    {
        _logger.LogInformation("GetAuthenticatedUrlAsync called for itemId: {ItemId}", itemId);

        // Try direct lookup first
        if (_streamMappings.TryGetValue(itemId, out var streamInfo))
        {
            // Log detailed StreamInfo state to diagnose stale alias issues
            var tokenTimeLeft = streamInfo.TokenExpiresAt.HasValue
                ? (streamInfo.TokenExpiresAt.Value - DateTime.UtcNow).TotalSeconds
                : -1;
            _logger.LogInformation(
                "Found stream by direct lookup for itemId: {ItemId} - NeedsAuth={NeedsAuth}, IsLive={IsLive}, Urn={Urn}, TokenLeft={TokenLeft:F0}s, AuthUrl={HasAuth}",
                itemId,
                streamInfo.NeedsAuthentication,
                streamInfo.IsLiveStream,
                string.IsNullOrEmpty(streamInfo.Urn) ? "(empty)" : "set",
                tokenTimeLeft,
                !string.IsNullOrEmpty(streamInfo.AuthenticatedUrl));

            // Check for stale alias: only look for fresher stream if current token is EXPIRED or EXPIRING SOON
            // Don't replace a valid token (>5s left) with a new deferred registration
            if (!streamInfo.NeedsAuthentication && tokenTimeLeft < 5)
            {
                var freshStream = FindFreshestStream();
                if (freshStream != null && freshStream.Value.Value.NeedsAuthentication)
                {
                    _logger.LogWarning(
                        "Token expiring soon ({TokenLeft:F0}s), switching to fresher deferred stream {ItemId} -> {FreshKey}",
                        tokenTimeLeft,
                        itemId,
                        freshStream.Value.Key);
                    _streamMappings.AddOrUpdate(itemId, freshStream.Value.Value, (key, old) => freshStream.Value.Value);
                    return await ValidateAndReturnStreamAsync(itemId, freshStream.Value.Value, cancellationToken).ConfigureAwait(false);
                }
            }

            return await ValidateAndReturnStreamAsync(itemId, streamInfo, cancellationToken).ConfigureAwait(false);
        }

        _logger.LogWarning("No direct match for itemId: {ItemId}, trying fallbacks... (Registered streams: {Count})", itemId, _streamMappings.Count);

        // Fallback: Try to find by GUID variations (with/without dashes)
        // This handles cases where Jellyfin uses different GUID formats
        var normalizedId = NormalizeGuid(itemId);
        if (normalizedId != null)
        {
            foreach (var kvp in _streamMappings)
            {
                var normalizedKey = NormalizeGuid(kvp.Key);
                if (normalizedKey != null && normalizedKey == normalizedId)
                {
                    _logger.LogInformation(
                        "Found stream by GUID normalization - Requested: {RequestedId}, Registered: {RegisteredId}",
                        itemId,
                        kvp.Key);
                    var url = await ValidateAndReturnStreamAsync(kvp.Key, kvp.Value, cancellationToken).ConfigureAwait(false);
                    if (url != null)
                    {
                        return url; // Found valid stream
                    }

                    // Stream found but expired, continue to next fallback
                    _logger.LogDebug("GUID-normalized stream was expired, trying other fallbacks");
                    break; // Exit foreach, continue to next fallback strategy
                }
            }
        }

        // Fallback: Live TV channelId_guid format lookup
        // For Live TV streams, itemId format is "channelId_urnGuid"
        // Try matching by suffix (urnGuid part) or prefix (channelId part)
        if (itemId.Contains('_', StringComparison.Ordinal))
        {
            var parts = itemId.Split('_', 2);
            var prefix = parts[0]; // channelId part
            var suffix = parts.Length > 1 ? parts[1] : null; // urnGuid part

            foreach (var kvp in _streamMappings)
            {
                // Check if registered key contains the same suffix (urnGuid)
                if (suffix != null && kvp.Key.Contains(suffix, StringComparison.OrdinalIgnoreCase))
                {
                    _logger.LogInformation(
                        "Found stream by Live TV suffix match - Requested: {RequestedId}, Registered: {RegisteredId}",
                        itemId,
                        kvp.Key);
                    var url = await ValidateAndReturnStreamAsync(kvp.Key, kvp.Value, cancellationToken).ConfigureAwait(false);
                    if (url != null)
                    {
                        // Also register with the requested itemId for future lookups
                        _streamMappings.AddOrUpdate(itemId, kvp.Value, (key, old) => kvp.Value);
                        return url;
                    }
                }

                // Check if registered key starts with the same prefix (channelId)
                if (kvp.Key.StartsWith(prefix + "_", StringComparison.OrdinalIgnoreCase))
                {
                    _logger.LogInformation(
                        "Found stream by Live TV prefix match - Requested: {RequestedId}, Registered: {RegisteredId}",
                        itemId,
                        kvp.Key);
                    var url = await ValidateAndReturnStreamAsync(kvp.Key, kvp.Value, cancellationToken).ConfigureAwait(false);
                    if (url != null)
                    {
                        _streamMappings.AddOrUpdate(itemId, kvp.Value, (key, old) => kvp.Value);
                        return url;
                    }
                }
            }
        }

        // Last resort: Use active stream fallbacks (helps when Jellyfin creates random transcoding session IDs)
        var activeStreams = _streamMappings.Where(kvp =>
        {
            if (!kvp.Value.TokenExpiresAt.HasValue)
            {
                return true; // No expiry
            }

            return DateTime.UtcNow < kvp.Value.TokenExpiresAt.Value;
        }).ToList();

        if (activeStreams.Count == 1)
        {
            _logger.LogInformation(
                "Transcoding session detected: Aliasing {TranscodingId} -> {OriginalId} (single active stream)",
                itemId,
                activeStreams[0].Key);

            // Register the transcoding session ID as an alias (update if stale alias exists)
            _streamMappings.AddOrUpdate(itemId, activeStreams[0].Value, (key, old) => activeStreams[0].Value);

            return await ValidateAndReturnStreamAsync(activeStreams[0].Key, activeStreams[0].Value, cancellationToken).ConfigureAwait(false);
        }

        // If multiple active streams, use the most recently registered one (likely the one being transcoded)
        // This handles cases where Jellyfin creates a random transcoding session ID seconds after registration
        if (activeStreams.Count > 1)
        {
            var mostRecent = activeStreams.OrderByDescending(kvp => kvp.Value.RegisteredAt).First();
            var age = DateTime.UtcNow - mostRecent.Value.RegisteredAt;

            // Only use this fallback if the stream was registered very recently (within 30 seconds)
            // This indicates it's likely the stream currently being set up for transcoding
            if (age.TotalSeconds < 30)
            {
                _logger.LogInformation(
                    "Transcoding session detected: Aliasing {TranscodingId} -> {OriginalId} (registered {Seconds:F1}s ago)",
                    itemId,
                    mostRecent.Key,
                    age.TotalSeconds);

                // Register the transcoding session ID as an alias (update if stale alias exists)
                _streamMappings.AddOrUpdate(itemId, mostRecent.Value, (key, old) => mostRecent.Value);

                return await ValidateAndReturnStreamAsync(mostRecent.Key, mostRecent.Value, cancellationToken).ConfigureAwait(false);
            }
        }

        _logger.LogWarning(
            "No stream mapping found for item {ItemId}. Active streams: {Count}. Registered IDs: {RegisteredIds}",
            itemId,
            activeStreams.Count,
            string.Join(", ", _streamMappings.Keys));
        return null;
    }

    /// <summary>
    /// Validates a stream and returns its URL if valid.
    /// </summary>
    private async Task<string?> ValidateAndReturnStreamAsync(string itemId, StreamInfo streamInfo, CancellationToken cancellationToken)
    {
        // Handle deferred authentication (first playback after browsing)
        if (streamInfo.NeedsAuthentication)
        {
            _logger.LogInformation(
                "First playback for item {ItemId} - authenticating stream on-demand",
                itemId);

            var authenticatedUrl = await AuthenticateOnDemandAsync(itemId, streamInfo, cancellationToken).ConfigureAwait(false);
            if (authenticatedUrl != null)
            {
                return authenticatedUrl;
            }

            _logger.LogWarning("Failed to authenticate stream on-demand for item {ItemId}", itemId);
            return null;
        }

        // For livestreams, use smart caching to avoid hammering the API
        // Only fetch fresh if token is expiring soon or hasn't been fetched recently
        if (streamInfo.IsLiveStream && !string.IsNullOrEmpty(streamInfo.Urn))
        {
            var now = DateTime.UtcNow;
            var tokenTimeLeft = streamInfo.TokenExpiresAt.HasValue
                ? (streamInfo.TokenExpiresAt.Value - now).TotalSeconds
                : 30; // Assume 30s if no expiry

            var timeSinceLastFetch = streamInfo.LastLivestreamFetchAt.HasValue
                ? (now - streamInfo.LastLivestreamFetchAt.Value).TotalSeconds
                : double.MaxValue;

            // Use cached URL if: token has >10s left AND we fetched within last 15 seconds
            if (tokenTimeLeft > 10 && timeSinceLastFetch < 15)
            {
                _logger.LogDebug(
                    "Livestream {ItemId}: Using cached URL (token expires in {TokenTimeLeft:F0}s, last fetch {TimeSinceFetch:F0}s ago)",
                    itemId,
                    tokenTimeLeft,
                    timeSinceLastFetch);
                return streamInfo.AuthenticatedUrl;
            }

            _logger.LogInformation(
                "Livestream {ItemId}: Fetching fresh URL (token expires in {TokenTimeLeft:F0}s, last fetch {TimeSinceFetch:F0}s ago)",
                itemId,
                tokenTimeLeft,
                timeSinceLastFetch);

            var freshUrl = await FetchFreshStreamUrlAsync(itemId, streamInfo, cancellationToken).ConfigureAwait(false);
            if (freshUrl != null)
            {
                return freshUrl;
            }

            _logger.LogWarning("Failed to fetch fresh URL for livestream {ItemId}, falling back to cached URL", itemId);
            // Fall through to use cached URL as fallback
        }

        // Check if token has expired or is about to expire
        if (streamInfo.TokenExpiresAt.HasValue)
        {
            var now = DateTime.UtcNow;
            var timeUntilExpiry = streamInfo.TokenExpiresAt.Value - now;

            // Proactive refresh: refresh if token has expired OR will expire within 5 seconds
            // This prevents race conditions during rapid segment fetching in transcoding
            var shouldRefresh = now >= streamInfo.TokenExpiresAt.Value || timeUntilExpiry.TotalSeconds <= 5;

            if (shouldRefresh)
            {
                var reason = now >= streamInfo.TokenExpiresAt.Value
                    ? "expired"
                    : $"expiring in {timeUntilExpiry.TotalSeconds:F1}s";

                _logger.LogWarning(
                    "Token {Reason} for item {ItemId} (expires at {ExpiresAt}, now is {Now}) - attempting to refresh",
                    reason,
                    itemId,
                    streamInfo.TokenExpiresAt.Value,
                    now);

                // Try to refresh the token
                var refreshedUrl = await RefreshTokenAsync(itemId, streamInfo, cancellationToken).ConfigureAwait(false);
                if (refreshedUrl != null)
                {
                    _logger.LogInformation("Successfully refreshed token for item {ItemId}", itemId);
                    return refreshedUrl;
                }

                if (streamInfo.IsLiveStream)
                {
                    // For livestreams, keep the mapping and flag for re-authentication
                    // rather than removing it — the next request will trigger a fresh auth
                    _logger.LogWarning("Failed to refresh token for livestream {ItemId}, will re-authenticate on next request", itemId);
                    streamInfo.NeedsAuthentication = true;
                    return streamInfo.AuthenticatedUrl;
                }

                _logger.LogWarning("Failed to refresh token for item {ItemId}, removing mapping", itemId);
                _streamMappings.TryRemove(itemId, out _);
                return null;
            }

            _logger.LogDebug(
                "Token valid for item {ItemId} (expires at {ExpiresAt}, {TimeLeft:F1}s remaining)",
                itemId,
                streamInfo.TokenExpiresAt.Value,
                timeUntilExpiry.TotalSeconds);
        }

        return streamInfo.AuthenticatedUrl;
    }

    /// <summary>
    /// Fetches a fresh stream URL from the SRF API for livestreams.
    /// </summary>
    private async Task<string?> FetchFreshStreamUrlAsync(string itemId, StreamInfo streamInfo, CancellationToken cancellationToken)
    {
        if (string.IsNullOrEmpty(streamInfo.Urn))
        {
            return null;
        }

        try
        {
            // Use short cache duration (5 min) for livestreams
            var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(streamInfo.Urn, cancellationToken, 5).ConfigureAwait(false);

            if (mediaComposition?.HasChapters != true)
            {
                _logger.LogWarning("No chapters found when refreshing livestream URL for URN: {Urn}", streamInfo.Urn);
                return null;
            }

            var chapter = mediaComposition.ChapterList[0];
            var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
            var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference);

            if (string.IsNullOrEmpty(streamUrl))
            {
                _logger.LogWarning("No stream URL found when refreshing livestream for URN: {Urn}", streamInfo.Urn);
                return null;
            }

            // Authenticate the fresh URL
            var authenticatedUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).ConfigureAwait(false);

            // Update the stored stream info with the fresh data
            var newTokenExpiry = ExtractTokenExpiry(authenticatedUrl);
            streamInfo.AuthenticatedUrl = authenticatedUrl;
            streamInfo.UnauthenticatedUrl = StripAuthenticationFromUrl(authenticatedUrl);
            streamInfo.TokenExpiresAt = newTokenExpiry;
            streamInfo.LastLivestreamFetchAt = DateTime.UtcNow;

            _logger.LogInformation(
                "Fetched fresh livestream URL for item {ItemId} (URN: {Urn}, new expiry: {Expiry})",
                itemId,
                streamInfo.Urn,
                newTokenExpiry);

            return authenticatedUrl;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error fetching fresh stream URL for livestream {ItemId} (URN: {Urn})", itemId, streamInfo.Urn);
            return null;
        }
    }

    /// <summary>
    /// Attempts to refresh an expired token.
    /// </summary>
    private async Task<string?> RefreshTokenAsync(string itemId, StreamInfo streamInfo, CancellationToken cancellationToken)
    {
        if (string.IsNullOrEmpty(streamInfo.UnauthenticatedUrl))
        {
            _logger.LogWarning("Cannot refresh token for {ItemId} - no unauthenticated URL stored", itemId);
            return null;
        }

        try
        {
            // Re-authenticate the stream URL
            var newAuthenticatedUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(
                streamInfo.UnauthenticatedUrl,
                cancellationToken).ConfigureAwait(false);

            if (string.IsNullOrEmpty(newAuthenticatedUrl))
            {
                return null;
            }

            // Update the stream info with the new token
            var newTokenExpiry = ExtractTokenExpiry(newAuthenticatedUrl);
            streamInfo.AuthenticatedUrl = newAuthenticatedUrl;
            streamInfo.TokenExpiresAt = newTokenExpiry;

            _logger.LogInformation(
                "Refreshed token for item {ItemId} (new expiry: {ExpiresAt} UTC)",
                itemId,
                newTokenExpiry);

            return newAuthenticatedUrl;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error refreshing token for item {ItemId}", itemId);
            return null;
        }
    }

    /// <summary>
    /// Authenticates a stream on-demand (first playback after browsing).
    /// </summary>
    private async Task<string?> AuthenticateOnDemandAsync(string itemId, StreamInfo streamInfo, CancellationToken cancellationToken)
    {
        if (string.IsNullOrEmpty(streamInfo.UnauthenticatedUrl))
        {
            _logger.LogWarning("Cannot authenticate on-demand for {ItemId} - no unauthenticated URL stored", itemId);
            return null;
        }

        try
        {
            // Authenticate the stream URL
            var authenticatedUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(
                streamInfo.UnauthenticatedUrl,
                cancellationToken).ConfigureAwait(false);

            if (string.IsNullOrEmpty(authenticatedUrl))
            {
                return null;
            }

            // Update the stream info - no longer needs authentication
            var tokenExpiry = ExtractTokenExpiry(authenticatedUrl);
            streamInfo.AuthenticatedUrl = authenticatedUrl;
            streamInfo.TokenExpiresAt = tokenExpiry;
            streamInfo.NeedsAuthentication = false;

            if (streamInfo.IsLiveStream)
            {
                streamInfo.LastLivestreamFetchAt = DateTime.UtcNow;
            }

            _logger.LogInformation(
                "Authenticated stream on-demand for item {ItemId} (expires at {ExpiresAt} UTC)",
                itemId,
                tokenExpiry);

            return authenticatedUrl;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error authenticating stream on-demand for item {ItemId}", itemId);
            return null;
        }
    }

    /// <summary>
    /// Strips authentication parameters from a URL to get the base unauthenticated URL.
    /// </summary>
    private string StripAuthenticationFromUrl(string url)
    {
        try
        {
            var uri = new Uri(url);
            var query = uri.Query;

            // Remove hdnts authentication parameter (Akamai token authentication)
            if (query.Contains("hdnts=", StringComparison.OrdinalIgnoreCase))
            {
                // Keep other parameters like caption, webvttbaseurl
                var queryParams = System.Web.HttpUtility.ParseQueryString(query);
                queryParams.Remove("hdnts");

                var newQuery = queryParams.Count > 0 ? "?" + queryParams.ToString() : string.Empty;
                return $"{uri.Scheme}://{uri.Host}{uri.AbsolutePath}{newQuery}";
            }

            return url;
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Failed to strip authentication from URL, using as-is");
            return url;
        }
    }

    /// <summary>
    /// Normalizes a GUID string to a consistent format for comparison.
    /// </summary>
    private string? NormalizeGuid(string input)
    {
        if (string.IsNullOrEmpty(input))
        {
            return null;
        }

        // Try to parse as GUID (handles both with and without dashes)
        if (Guid.TryParse(input, out var guid))
        {
            return guid.ToString("N"); // Always return format without dashes
        }

        return null;
    }

    /// <summary>
    /// Finds the freshest (most recently registered) stream that needs authentication or has a valid token.
    /// </summary>
    /// <returns>The freshest stream entry, or null if none found.</returns>
    private KeyValuePair<string, StreamInfo>? FindFreshestStream()
    {
        var now = DateTime.UtcNow;

        // Find streams that either need authentication (fresh deferred registration)
        // or have tokens that aren't expired yet
        var candidates = _streamMappings.Where(kvp =>
        {
            if (kvp.Value.NeedsAuthentication)
            {
                return true; // Fresh deferred registration
            }

            if (!kvp.Value.TokenExpiresAt.HasValue)
            {
                return true; // No expiry
            }

            // Token not expired yet
            return now < kvp.Value.TokenExpiresAt.Value;
        }).ToList();

        if (candidates.Count == 0)
        {
            return null;
        }

        // Prefer the most recently registered stream
        return candidates.OrderByDescending(kvp => kvp.Value.RegisteredAt).First();
    }

    /// <summary>
    /// Fetches and rewrites an HLS manifest to use proxy URLs.
    /// </summary>
    /// <param name="itemId">The item ID.</param>
    /// <param name="baseProxyUrl">The base proxy URL (e.g., https://jellyfin-server/Plugins/SRFPlay/Proxy/{itemId}).</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>The rewritten manifest content.</returns>
    public async Task<string?> GetRewrittenManifestAsync(
        string itemId,
        string baseProxyUrl,
        CancellationToken cancellationToken = default)
    {
        var authenticatedUrl = await GetAuthenticatedUrlAsync(itemId, cancellationToken).ConfigureAwait(false);
        if (authenticatedUrl == null)
        {
            return null;
        }

        try
        {
            _logger.LogInformation("Fetching manifest from: {Url}", authenticatedUrl);
            using var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
            var manifestContent = await httpClient.GetStringAsync(authenticatedUrl, cancellationToken).ConfigureAwait(false);

            _logger.LogDebug("Original manifest ({Length} bytes):\n{Content}", manifestContent.Length, manifestContent);

            // Rewrite the manifest to replace Akamai URLs with proxy URLs
            var rewrittenContent = RewriteManifestUrls(manifestContent, authenticatedUrl, baseProxyUrl);

            _logger.LogDebug("Rewritten manifest for item {ItemId} ({Length} bytes):\n{Content}", itemId, rewrittenContent.Length, rewrittenContent);
            return rewrittenContent;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to fetch manifest for item {ItemId} from {Url}", itemId, authenticatedUrl);
            return null;
        }
    }

    /// <summary>
    /// Fetches a segment from the original source.
    /// </summary>
    /// <param name="itemId">The item ID.</param>
    /// <param name="segmentPath">The segment path.</param>
    /// <param name="queryString">The original query string from the request (preserves segment-specific parameters like timestamps).</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>The segment content as bytes.</returns>
    public async Task<byte[]?> GetSegmentAsync(
        string itemId,
        string segmentPath,
        string? queryString = null,
        CancellationToken cancellationToken = default)
    {
        var authenticatedUrl = await GetAuthenticatedUrlAsync(itemId, cancellationToken).ConfigureAwait(false);
        if (authenticatedUrl == null)
        {
            return null;
        }

        try
        {
            // Build the full segment URL by combining the base URL with the segment path
            var baseUri = new Uri(authenticatedUrl);
            var baseUrl = $"{baseUri.Scheme}://{baseUri.Host}{string.Join('/', baseUri.AbsolutePath.Split('/')[..^1])}";

            // Use the original query string from the request (preserves segment-specific params like ?m=timestamp)
            // If no query string is provided, check if we need to add auth params from the master manifest
            var queryParams = string.Empty;
            if (!string.IsNullOrEmpty(queryString))
            {
                // Use the original query string from the segment request
                queryParams = queryString.StartsWith('?') ? queryString : $"?{queryString}";
            }
            else if (!segmentPath.Contains("hdntl=", StringComparison.OrdinalIgnoreCase))
            {
                // Only append master manifest query params if segment doesn't have path-based auth
                queryParams = baseUri.Query;
            }

            // Build full segment URL
            var segmentUrl = $"{baseUrl}/{segmentPath}{queryParams}";

            _logger.LogDebug(
                "Fetching segment - BaseUri: {BaseUri}, BaseUrl: {BaseUrl}, SegmentPath: {SegmentPath}, QueryString: {QueryString}, FullUrl: {FullUrl}",
                authenticatedUrl,
                baseUrl,
                segmentPath,
                queryString ?? "(none)",
                segmentUrl);
            using var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
            var segmentData = await httpClient.GetByteArrayAsync(segmentUrl, cancellationToken).ConfigureAwait(false);

            _logger.LogDebug("Successfully fetched segment {SegmentPath} ({Size} bytes)", segmentPath, segmentData.Length);
            return segmentData;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to fetch segment {SegmentPath} for item {ItemId}", segmentPath, itemId);
            return null;
        }
    }

    /// <summary>
    /// Fetches a segment as a streaming response for direct forwarding to the client.
    /// Uses HttpCompletionOption.ResponseHeadersRead to start streaming before the full
    /// segment is downloaded, reducing time-to-first-byte for live streams.
    /// </summary>
    /// <param name="itemId">The item ID.</param>
    /// <param name="segmentPath">The segment path.</param>
    /// <param name="queryString">The original query string from the request.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>The HTTP response for streaming, or null if not found.</returns>
    public async Task<HttpResponseMessage?> GetSegmentStreamAsync(
        string itemId,
        string segmentPath,
        string? queryString = null,
        CancellationToken cancellationToken = default)
    {
        var authenticatedUrl = await GetAuthenticatedUrlAsync(itemId, cancellationToken).ConfigureAwait(false);
        if (authenticatedUrl == null)
        {
            return null;
        }

        try
        {
            var baseUri = new Uri(authenticatedUrl);
            var baseUrl = $"{baseUri.Scheme}://{baseUri.Host}{string.Join('/', baseUri.AbsolutePath.Split('/')[..^1])}";

            var queryParams = string.Empty;
            if (!string.IsNullOrEmpty(queryString))
            {
                queryParams = queryString.StartsWith('?') ? queryString : $"?{queryString}";
            }
            else if (!segmentPath.Contains("hdntl=", StringComparison.OrdinalIgnoreCase))
            {
                queryParams = baseUri.Query;
            }

            var segmentUrl = $"{baseUrl}/{segmentPath}{queryParams}";

            _logger.LogDebug("Streaming segment - SegmentPath: {SegmentPath}, FullUrl: {FullUrl}", segmentPath, segmentUrl);

            var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
            var request = new HttpRequestMessage(HttpMethod.Get, segmentUrl);
            var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);

            if (!response.IsSuccessStatusCode)
            {
                _logger.LogWarning("Segment stream request failed with {StatusCode} for {SegmentPath}", response.StatusCode, segmentPath);
                response.Dispose();
                return null;
            }

            return response;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to stream segment {SegmentPath} for item {ItemId}", segmentPath, itemId);
            return null;
        }
    }

    /// <summary>
    /// Rewrites URLs in HLS manifest to point to proxy.
    /// </summary>
    /// <param name="manifestContent">The original manifest content.</param>
    /// <param name="originalBaseUrl">The original base URL.</param>
    /// <param name="proxyBaseUrl">The proxy base URL.</param>
    /// <returns>The rewritten manifest.</returns>
    private string RewriteManifestUrls(string manifestContent, string originalBaseUrl, string proxyBaseUrl)
    {
        var baseUri = new Uri(originalBaseUrl);
        var baseUrl = $"{baseUri.Scheme}://{baseUri.Host}{string.Join('/', baseUri.AbsolutePath.Split('/')[..^1])}";

        // Extract query parameters from proxyBaseUrl to propagate them
        var queryParams = string.Empty;
        var queryStart = proxyBaseUrl.IndexOf('?', StringComparison.Ordinal);
        if (queryStart >= 0)
        {
            queryParams = proxyBaseUrl[queryStart..];
            proxyBaseUrl = proxyBaseUrl[..queryStart]; // Remove query from base URL
            _logger.LogDebug("Extracted query parameters from proxy URL: {QueryParams}", queryParams);
        }

        // Helper function to rewrite a URL to proxy
        string RewriteUrl(string url)
        {
            // Try to parse as absolute URL
            if (Uri.TryCreate(url, UriKind.Absolute, out var absoluteUri))
            {
                // Check if it's from the same CDN host
                if (!absoluteUri.Host.Equals(baseUri.Host, StringComparison.OrdinalIgnoreCase))
                {
                    // External URL (e.g., subtitles from different domain) - leave as-is
                    _logger.LogDebug("Leaving external URL unchanged: {Url}", url);
                    return url;
                }

                // Same host - extract just the filename (last path segment)
                var segments = absoluteUri.AbsolutePath.Split('/');
                var filename = segments[^1];
                return $"{proxyBaseUrl}/{filename}{queryParams}";
            }

            // Relative URL - extract just the path without query params
            var path = url;
            var queryIndex = path.IndexOf('?', StringComparison.Ordinal);
            if (queryIndex >= 0)
            {
                path = path[..queryIndex];
            }

            return $"{proxyBaseUrl}/{path}{queryParams}";
        }

        // Pattern 1: Standalone URL lines (non-# lines ending with media extensions)
        var pattern1 = @"(?:^|\n)([^#\n][^\n]*\.(?:m3u8|ts|mp4|m4s|aac)[^\n]*)";
        var rewritten = Regex.Replace(manifestContent, pattern1, match =>
        {
            var url = match.Groups[1].Value.Trim();
            return $"\n{RewriteUrl(url)}";
        });

        // Pattern 2: URI="..." attributes in HLS tags (e.g., #EXT-X-MEDIA, #EXT-X-I-FRAME-STREAM-INF)
        var pattern2 = @"URI=""([^""]+)""";
        rewritten = Regex.Replace(rewritten, pattern2, match =>
        {
            var url = match.Groups[1].Value;
            return $"URI=\"{RewriteUrl(url)}\"";
        });

        return rewritten;
    }

    /// <summary>
    /// Extracts the token expiry time from a stream URL with hdnts parameter.
    /// </summary>
    /// <param name="url">The authenticated stream URL.</param>
    /// <returns>The expiry time, or null if not found.</returns>
    private DateTime? ExtractTokenExpiry(string url)
    {
        try
        {
            var uri = new Uri(url);
            var query = uri.Query;

            // Parse the hdnts parameter (e.g., "exp=1763282021")
            var match = Regex.Match(query, @"exp=(\d+)");
            if (match.Success && long.TryParse(match.Groups[1].Value, out var unixTimestamp))
            {
                // Convert Unix timestamp to DateTime
                var expiry = DateTimeOffset.FromUnixTimeSeconds(unixTimestamp).UtcDateTime;
                _logger.LogDebug("Extracted token expiry from URL: {Expiry} UTC (unix: {Unix})", expiry, unixTimestamp);
                return expiry;
            }

            _logger.LogDebug("No token expiry found in URL: {Url}", url);
            return null;
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Failed to extract token expiry from URL: {Url}", url);
            return null;
        }
    }

    /// <summary>
    /// Rewrites URLs in a variant (sub) manifest to point to the proxy.
    /// </summary>
    /// <param name="manifestContent">The variant manifest content.</param>
    /// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
    /// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
    /// <param name="isLiveStream">Whether this is a livestream; if so an EXT-X-START offset is injected.</param>
    /// <returns>The rewritten manifest content.</returns>
    public string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams, bool isLiveStream = false)
    {
        if (isLiveStream)
        {
            manifestContent = InjectLiveStartOffset(manifestContent);
        }

        string RewriteUrl(string url)
        {
            if (url.Contains("://", StringComparison.Ordinal))
            {
                // Absolute URL - extract filename and rewrite
                var uri = new Uri(url.Trim());
                var segments = uri.AbsolutePath.Split('/');
                var fileName = segments[^1];
                return $"{baseProxyUrl}/{fileName}{queryParams}";
            }

            // Relative URL - rewrite to proxy
            return $"{baseProxyUrl}/{url.Trim()}{queryParams}";
        }

        var lines = manifestContent.Split('\n');
        var result = new System.Text.StringBuilder();

        foreach (var line in lines)
        {
            if (string.IsNullOrWhiteSpace(line))
            {
                result.AppendLine(line);
            }
            else if (line.StartsWith('#'))
            {
                // HLS tag line - check for URI="..." attributes (e.g., #EXT-X-MAP:URI="init.mp4")
                if (line.Contains("URI=\"", StringComparison.Ordinal))
                {
                    var rewrittenLine = Regex.Replace(
                        line,
                        @"URI=""([^""]+)""",
                        match => $"URI=\"{RewriteUrl(match.Groups[1].Value)}\"");
                    result.AppendLine(rewrittenLine);
                }
                else
                {
                    result.AppendLine(line);
                }
            }
            else
            {
                // Non-tag line with URL - rewrite it
                result.AppendLine(RewriteUrl(line));
            }
        }

        return result.ToString();
    }

    /// <summary>
    /// Injects an <c>#EXT-X-START:TIME-OFFSET</c> tag into a live media playlist so that players
    /// (notably ExoPlayer on Android TV) begin a few segments back from the volatile live edge
    /// instead of at the edge itself, which otherwise causes stalling/jumping until a manual skip.
    /// The offset is sized relative to the playlist's own <c>EXT-X-TARGETDURATION</c> to stay
    /// RFC 8216 compliant (≥ 3 target durations from the edge for live playlists).
    /// </summary>
    /// <param name="manifestContent">The media playlist content.</param>
    /// <returns>The playlist with the tag injected, or unchanged if it is not applicable.</returns>
    private string InjectLiveStartOffset(string manifestContent)
    {
        var segmentsBack = Plugin.Instance?.Configuration?.LiveStartSegmentsBack ?? 3;
        if (segmentsBack <= 0)
        {
            return manifestContent; // Disabled by config.
        }

        // Only a media playlist (has segments, no variant list) should carry EXT-X-START.
        // A master/multivariant playlist or a VOD playlist (#EXT-X-ENDLIST) is left untouched.
        if (!manifestContent.Contains("#EXTINF", StringComparison.Ordinal)
            || manifestContent.Contains("#EXT-X-STREAM-INF", StringComparison.Ordinal)
            || manifestContent.Contains("#EXT-X-ENDLIST", StringComparison.Ordinal))
        {
            return manifestContent;
        }

        // Don't add a second tag if the source already specified a start point.
        if (manifestContent.Contains("#EXT-X-START", StringComparison.Ordinal))
        {
            _logger.LogDebug("Live playlist already contains #EXT-X-START; leaving as-is");
            return manifestContent;
        }

        var targetDurationMatch = Regex.Match(manifestContent, @"#EXT-X-TARGETDURATION:(\d+(?:\.\d+)?)");
        if (!targetDurationMatch.Success
            || !double.TryParse(
                targetDurationMatch.Groups[1].Value,
                System.Globalization.NumberStyles.Float,
                System.Globalization.CultureInfo.InvariantCulture,
                out var targetDuration)
            || targetDuration <= 0)
        {
            _logger.LogDebug("Could not read EXT-X-TARGETDURATION; skipping EXT-X-START injection");
            return manifestContent;
        }

        // RFC 8216: TIME-OFFSET SHOULD NOT be within 3 target durations of the live edge.
        var clampedSegments = Math.Max(3, segmentsBack);
        var offsetSeconds = clampedSegments * targetDuration;
        var startTag = string.Format(
            System.Globalization.CultureInfo.InvariantCulture,
            "#EXT-X-START:TIME-OFFSET=-{0:0.###},PRECISE=YES",
            offsetSeconds);

        _logger.LogInformation(
            "Injecting {StartTag} into live playlist (targetDuration={TargetDuration}s, segmentsBack={SegmentsBack})",
            startTag,
            targetDuration,
            clampedSegments);

        // Insert after the #EXTM3U header so it sits with the other top-level tags.
        var headerIndex = manifestContent.IndexOf("#EXTM3U", StringComparison.Ordinal);
        if (headerIndex < 0)
        {
            return startTag + "\n" + manifestContent;
        }

        var lineEnd = manifestContent.IndexOf('\n', headerIndex);
        if (lineEnd < 0)
        {
            return manifestContent + "\n" + startTag;
        }

        return manifestContent[..(lineEnd + 1)] + startTag + "\n" + manifestContent[(lineEnd + 1)..];
    }

    /// <summary>
    /// Cleans up old and expired stream mappings.
    /// </summary>
    public void CleanupOldMappings()
    {
        var cutoff = DateTime.UtcNow.AddHours(-24);
        var now = DateTime.UtcNow;
        var keysToRemove = new System.Collections.Generic.List<string>();

        foreach (var kvp in _streamMappings)
        {
            var shouldRemove = false;

            // Remove if registered more than 24 hours ago
            if (kvp.Value.RegisteredAt < cutoff)
            {
                shouldRemove = true;
                _logger.LogDebug("Marking item {ItemId} for cleanup (old registration)", kvp.Key);
            }

            // Remove if token has expired — but skip livestreams since their CDN tokens
            // expire every ~30s and get refreshed on-demand during playback
            if (kvp.Value.TokenExpiresAt.HasValue && kvp.Value.TokenExpiresAt.Value <= now && !kvp.Value.IsLiveStream)
            {
                shouldRemove = true;
                _logger.LogDebug("Marking item {ItemId} for cleanup (expired token)", kvp.Key);
            }

            if (shouldRemove)
            {
                keysToRemove.Add(kvp.Key);
            }
        }

        foreach (var key in keysToRemove)
        {
            _streamMappings.TryRemove(key, out _);
        }

        if (keysToRemove.Count > 0)
        {
            _logger.LogInformation("Cleaned up {Count} old/expired stream mappings", keysToRemove.Count);
        }
    }

    /// <summary>
    /// Registers a stream with multiple GUID format variations to handle Jellyfin's ID transformations.
    /// </summary>
    /// <param name="itemId">The item ID.</param>
    /// <param name="streamInfo">The stream information to register.</param>
    private void RegisterWithGuidFormats(string itemId, StreamInfo streamInfo)
    {
        _streamMappings.AddOrUpdate(itemId, streamInfo, (key, old) => streamInfo);

        if (Guid.TryParse(itemId, out var guid))
        {
            var formats = new[]
            {
                guid.ToString("N"), // Without dashes: 00000000000000000000000000000000
                guid.ToString("D"), // With dashes: 00000000-0000-0000-0000-000000000000
                guid.ToString("B"), // With braces: {00000000-0000-0000-0000-000000000000}
            };

            foreach (var format in formats)
            {
                if (format != itemId) // Don't duplicate the original
                {
                    _streamMappings.AddOrUpdate(format, streamInfo, (key, old) => streamInfo);
                }
            }

            _logger.LogDebug("Registered stream with {Count} GUID format variations", formats.Length);
        }
    }

    /// <summary>
    /// Stream information.
    /// </summary>
    private sealed class StreamInfo
    {
        public string AuthenticatedUrl { get; set; } = string.Empty;

        public string UnauthenticatedUrl { get; set; } = string.Empty;

        public DateTime RegisteredAt { get; set; }

        public DateTime? TokenExpiresAt { get; set; }

        /// <summary>
        /// Gets or sets the SRF URN for this stream (used for re-fetching fresh URLs).
        /// </summary>
        public string? Urn { get; set; }

        /// <summary>
        /// Gets or sets a value indicating whether this is a livestream.
        /// Livestreams always fetch fresh URLs from the API to avoid stale CDN paths.
        /// </summary>
        public bool IsLiveStream { get; set; }

        /// <summary>
        /// Gets or sets when this livestream URL was last fetched from the API.
        /// Used to prevent rapid-fire API calls from clients like Android TV.
        /// </summary>
        public DateTime? LastLivestreamFetchAt { get; set; }

        /// <summary>
        /// Gets or sets a value indicating whether this stream needs authentication on first access.
        /// True when registered via RegisterStreamDeferred (authentication deferred until playback).
        /// </summary>
        public bool NeedsAuthentication { get; set; }
    }
}
