using System.Net;
using System.Net.Http.Headers;
using ErsatzTV.Core.Interfaces.Streaming;
using Microsoft.Extensions.Logging;

namespace ErsatzTV.Infrastructure.Streaming;

/// <summary>
///     Probes a media-server remote-stream URL over HTTP.
/// </summary>
/// <remarks>
///     Deliberately fail-open: the only outcome that reports the media as gone is a 404 that came
///     from the media server itself (i.e. arrived after our <c>/media/{provider}/...</c> endpoint
///     redirected). A timeout, a transport failure, any other status, or a 404 raised by ErsatzTV's
///     own endpoint all report available, so a probe that cannot answer never turns a tune that
///     would have worked into an error card. (ersatztv#473)
/// </remarks>
public class HttpRemoteStreamProber(
    IHttpClientFactory httpClientFactory,
    ILogger<HttpRemoteStreamProber> logger) : IRemoteStreamProber
{
    private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(2);

    public async Task<bool> IsAvailable(string url, CancellationToken cancellationToken)
    {
        try
        {
            using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
            timeoutCts.CancelAfter(ProbeTimeout);

            using var request = new HttpRequestMessage(HttpMethod.Get, url);

            // ask for a single byte; media servers vary in their HEAD support, and this exercises the
            // same redirect chain ffmpeg will follow
            request.Headers.Range = new RangeHeaderValue(0, 0);

            using HttpClient client = httpClientFactory.CreateClient();
            using HttpResponseMessage response = await client.SendAsync(
                request,
                HttpCompletionOption.ResponseHeadersRead,
                timeoutCts.Token);

            if (response.StatusCode is HttpStatusCode.NotFound)
            {
                // only the MEDIA SERVER's 404 is evidence that the item is gone. our own
                // /media/{provider}/... endpoint also returns 404 when the media source is
                // unconfigured or momentarily missing (InternalController maps a failed
                // connection-parameter lookup to NotFound), and treating that as "gone" would fail
                // CLOSED for every item on that source. A media-server 404 always arrives after a
                // redirect, so an un-redirected 404 came from us and must fail open.
                if (WasRedirected(response, url))
                {
                    logger.LogWarning("Media server reported 404 for remote stream {Url}", url);
                    return false;
                }

                logger.LogDebug(
                    "Probe of {Url} returned 404 without redirecting to a media server; assuming the "
                    + "item is available rather than failing closed on our own endpoint",
                    url);

                return true;
            }

            // return the connection to the pool instead of aborting it by disposing an unread
            // stream - but ONLY where the server honoured the range, i.e. the body really is one
            // byte. A server that ignores `Range` answers 200 with the WHOLE FILE, and draining that
            // would download at line rate into memory on the streaming hot path, defeating the
            // ResponseHeadersRead above. There, abort the socket - much the cheaper evil.
            if (response.StatusCode is HttpStatusCode.PartialContent)
            {
                var singleByte = new byte[1];
                Stream body = await response.Content.ReadAsStreamAsync(timeoutCts.Token);
                await body.ReadAsync(singleByte, timeoutCts.Token);
            }

            return true;
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            // the CALLER cancelled (shutdown / client disconnect). that is a genuine signal, not a
            // probe failure, so it must propagate rather than be swallowed as fail-open.
            throw;
        }
        catch (Exception ex)
        {
            // fail open - a probe failure is not evidence that the media is gone
            logger.LogDebug(ex, "Unable to probe remote stream {Url}; assuming it is available", url);
            return true;
        }
    }

    private static bool WasRedirected(HttpResponseMessage response, string probeUrl)
    {
        Uri finalUri = response.RequestMessage?.RequestUri;
        if (finalUri is null || !Uri.TryCreate(probeUrl, UriKind.Absolute, out Uri requestedUri))
        {
            // can't tell where the 404 came from; fail open rather than guess
            return false;
        }

        // compare parsed Uris rather than strings. Uri.Equals compares normalized components, so it
        // can't mistake an escaping/casing difference for a redirect and fail CLOSED - the exact
        // failure this check exists to prevent. (A string compare on Uri.ToString() happens to agree
        // for our machine-generated URLs, since ToString unescapes; this is defense in depth, not a
        // fix for an observed bug.)
        return !Uri.Equals(finalUri, requestedUri);
    }
}
