// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Http.Headers;
using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers;

namespace Microsoft.AspNetCore.Internal
{
    internal static partial class FileResultHelper
    {
        private const string AcceptRangeHeaderValue = "bytes";

        internal enum PreconditionState
        {
            Unspecified,
            NotModified,
            ShouldProcess,
            PreconditionFailed
        }

        internal static async Task WriteFileAsync(HttpContext context, Stream fileStream, RangeItemHeaderValue? range, long rangeLength)
        {
            const int BufferSize = 64 * 1024;
            var outputStream = context.Response.Body;
            using (fileStream)
            {
                try
                {
                    if (range == null)
                    {
                        await StreamCopyOperation.CopyToAsync(fileStream, outputStream, count: null, bufferSize: 64 * 1024, cancel: context.RequestAborted);
                    }
                    else
                    {
                        fileStream.Seek(range.From!.Value, SeekOrigin.Begin);
                        await StreamCopyOperation.CopyToAsync(fileStream, outputStream, rangeLength, BufferSize, context.RequestAborted);
                    }
                }
                catch (OperationCanceledException)
                {
                    // Don't throw this exception, it's most likely caused by the client disconnecting.
                    // However, if it was cancelled for any other reason we need to prevent empty responses.
                    context.Abort();
                }
            }
        }

        internal static (RangeItemHeaderValue? range, long rangeLength, bool serveBody) SetHeadersAndLog(
            HttpContext httpContext,
            in FileResultInfo result,
            long? fileLength,
            bool enableRangeProcessing,
            DateTimeOffset? lastModified,
            EntityTagHeaderValue? etag,
            ILogger logger)
        {
            var request = httpContext.Request;
            var httpRequestHeaders = request.GetTypedHeaders();

            // Since the 'Last-Modified' and other similar http date headers are rounded down to whole seconds,
            // round down current file's last modified to whole seconds for correct comparison.
            if (lastModified.HasValue)
            {
                lastModified = RoundDownToWholeSeconds(lastModified.Value);
            }

            var preconditionState = GetPreconditionState(httpRequestHeaders, lastModified, etag, logger);

            var response = httpContext.Response;
            SetLastModifiedAndEtagHeaders(response, lastModified, etag);

            // Short circuit if the preconditional headers process to 304 (NotModified) or 412 (PreconditionFailed)
            if (preconditionState == PreconditionState.NotModified)
            {
                response.StatusCode = StatusCodes.Status304NotModified;
                return (range: null, rangeLength: 0, serveBody: false);
            }
            else if (preconditionState == PreconditionState.PreconditionFailed)
            {
                response.StatusCode = StatusCodes.Status412PreconditionFailed;
                return (range: null, rangeLength: 0, serveBody: false);
            }

            response.ContentType = result.ContentType;
            SetContentDispositionHeader(httpContext, in result);

            if (fileLength.HasValue)
            {
                // Assuming the request is not a range request, and the response body is not empty, the Content-Length header is set to
                // the length of the entire file.
                // If the request is a valid range request, this header is overwritten with the length of the range as part of the
                // range processing (see method SetContentLength).

                response.ContentLength = fileLength.Value;

                // Handle range request
                if (enableRangeProcessing)
                {
                    SetAcceptRangeHeader(response);

                    // If the request method is HEAD or GET, PreconditionState is Unspecified or ShouldProcess, and IfRange header is valid,
                    // range should be processed and Range headers should be set
                    if ((HttpMethods.IsHead(request.Method) || HttpMethods.IsGet(request.Method))
                        && (preconditionState == PreconditionState.Unspecified || preconditionState == PreconditionState.ShouldProcess)
                        && (IfRangeValid(httpRequestHeaders, lastModified, etag, logger)))
                    {
                        return SetRangeHeaders(httpContext, httpRequestHeaders, fileLength.Value, logger);
                    }
                }
                else
                {
                    Log.NotEnabledForRangeProcessing(logger);
                }
            }

            return (range: null, rangeLength: 0, serveBody: !HttpMethods.IsHead(request.Method));
        }

        internal static bool IfRangeValid(
            RequestHeaders httpRequestHeaders,
            DateTimeOffset? lastModified,
            EntityTagHeaderValue? etag,
            ILogger logger)
        {
            // 14.27 If-Range
            var ifRange = httpRequestHeaders.IfRange;
            if (ifRange != null)
            {
                // If the validator given in the If-Range header field matches the
                // current validator for the selected representation of the target
                // resource, then the server SHOULD process the Range header field as
                // requested.  If the validator does not match, the server MUST ignore
                // the Range header field.
                if (ifRange.LastModified.HasValue)
                {
                    if (lastModified.HasValue && lastModified > ifRange.LastModified)
                    {
                        Log.IfRangeLastModifiedPreconditionFailed(logger, lastModified, ifRange.LastModified);
                        return false;
                    }
                }
                else if (etag != null && ifRange.EntityTag != null && !ifRange.EntityTag.Compare(etag, useStrongComparison: true))
                {
                    Log.IfRangeETagPreconditionFailed(logger, etag, ifRange.EntityTag);
                    return false;
                }
            }

            return true;
        }

        internal static PreconditionState GetPreconditionState(
            RequestHeaders httpRequestHeaders,
            DateTimeOffset? lastModified,
            EntityTagHeaderValue? etag,
            ILogger logger)
        {
            var ifMatchState = PreconditionState.Unspecified;
            var ifNoneMatchState = PreconditionState.Unspecified;
            var ifModifiedSinceState = PreconditionState.Unspecified;
            var ifUnmodifiedSinceState = PreconditionState.Unspecified;

            // 14.24 If-Match
            var ifMatch = httpRequestHeaders.IfMatch;
            if (etag != null)
            {
                ifMatchState = GetEtagMatchState(
                    useStrongComparison: true,
                    etagHeader: ifMatch,
                    etag: etag,
                    matchFoundState: PreconditionState.ShouldProcess,
                    matchNotFoundState: PreconditionState.PreconditionFailed);

                if (ifMatchState == PreconditionState.PreconditionFailed)
                {
                    Log.IfMatchPreconditionFailed(logger, etag);
                }
            }

            // 14.26 If-None-Match
            var ifNoneMatch = httpRequestHeaders.IfNoneMatch;
            if (etag != null)
            {
                ifNoneMatchState = GetEtagMatchState(
                    useStrongComparison: false,
                    etagHeader: ifNoneMatch,
                    etag: etag,
                    matchFoundState: PreconditionState.NotModified,
                    matchNotFoundState: PreconditionState.ShouldProcess);
            }

            var now = RoundDownToWholeSeconds(DateTimeOffset.UtcNow);

            // 14.25 If-Modified-Since
            var ifModifiedSince = httpRequestHeaders.IfModifiedSince;
            if (lastModified.HasValue && ifModifiedSince.HasValue && ifModifiedSince <= now)
            {
                var modified = ifModifiedSince < lastModified;
                ifModifiedSinceState = modified ? PreconditionState.ShouldProcess : PreconditionState.NotModified;
            }

            // 14.28 If-Unmodified-Since
            var ifUnmodifiedSince = httpRequestHeaders.IfUnmodifiedSince;
            if (lastModified.HasValue && ifUnmodifiedSince.HasValue && ifUnmodifiedSince <= now)
            {
                var unmodified = ifUnmodifiedSince >= lastModified;
                ifUnmodifiedSinceState = unmodified ? PreconditionState.ShouldProcess : PreconditionState.PreconditionFailed;

                if (ifUnmodifiedSinceState == PreconditionState.PreconditionFailed)
                {
                    Log.IfUnmodifiedSincePreconditionFailed(logger, lastModified, ifUnmodifiedSince);
                }
            }

            var state = GetMaxPreconditionState(ifMatchState, ifNoneMatchState, ifModifiedSinceState, ifUnmodifiedSinceState);
            return state;
        }

        private static PreconditionState GetEtagMatchState(
            bool useStrongComparison,
            IList<EntityTagHeaderValue> etagHeader,
            EntityTagHeaderValue etag,
            PreconditionState matchFoundState,
            PreconditionState matchNotFoundState)
        {
            if (etagHeader?.Count > 0)
            {
                var state = matchNotFoundState;
                foreach (var entityTag in etagHeader)
                {
                    if (entityTag.Equals(EntityTagHeaderValue.Any) || entityTag.Compare(etag, useStrongComparison))
                    {
                        state = matchFoundState;
                        break;
                    }
                }

                return state;
            }

            return PreconditionState.Unspecified;
        }

        private static (RangeItemHeaderValue? range, long rangeLength, bool serveBody) SetRangeHeaders(
            HttpContext httpContext,
            RequestHeaders httpRequestHeaders,
            long fileLength,
            ILogger logger)
        {
            var response = httpContext.Response;
            var httpResponseHeaders = response.GetTypedHeaders();
            var serveBody = !HttpMethods.IsHead(httpContext.Request.Method);

            // Range may be null for empty range header, invalid ranges, parsing errors, multiple ranges
            // and when the file length is zero.
            var (isRangeRequest, range) = RangeHelper.ParseRange(
                httpContext,
                httpRequestHeaders,
                fileLength,
                logger);

            if (!isRangeRequest)
            {
                return (range: null, rangeLength: 0, serveBody);
            }

            // Requested range is not satisfiable
            if (range == null)
            {
                // 14.16 Content-Range - A server sending a response with status code 416 (Requested range not satisfiable)
                // SHOULD include a Content-Range field with a byte-range-resp-spec of "*". The instance-length specifies
                // the current length of the selected resource.  e.g. */length
                response.StatusCode = StatusCodes.Status416RangeNotSatisfiable;
                httpResponseHeaders.ContentRange = new ContentRangeHeaderValue(fileLength);
                response.ContentLength = 0;

                return (range: null, rangeLength: 0, serveBody: false);
            }

            response.StatusCode = StatusCodes.Status206PartialContent;
            httpResponseHeaders.ContentRange = new ContentRangeHeaderValue(
                range.From!.Value,
                range.To!.Value,
                fileLength);

            // Overwrite the Content-Length header for valid range requests with the range length.
            var rangeLength = SetContentLength(response, range);

            return (range, rangeLength, serveBody);
        }

        private static long SetContentLength(HttpResponse response, RangeItemHeaderValue range)
        {
            var start = range.From!.Value;
            var end = range.To!.Value;
            var length = end - start + 1;
            response.ContentLength = length;
            return length;
        }

        private static void SetContentDispositionHeader(HttpContext httpContext, in FileResultInfo result)
        {
            if (!string.IsNullOrEmpty(result.FileDownloadName))
            {
                // From RFC 2183, Sec. 2.3:
                // The sender may want to suggest a filename to be used if the entity is
                // detached and stored in a separate file. If the receiving MUA writes
                // the entity to a file, the suggested filename should be used as a
                // basis for the actual filename, where possible.
                var contentDisposition = new ContentDispositionHeaderValue("attachment");
                contentDisposition.SetHttpFileName(result.FileDownloadName);
                httpContext.Response.Headers.ContentDisposition = contentDisposition.ToString();
            }
        }

        private static void SetLastModifiedAndEtagHeaders(HttpResponse response, DateTimeOffset? lastModified, EntityTagHeaderValue? etag)
        {
            var httpResponseHeaders = response.GetTypedHeaders();
            if (lastModified.HasValue)
            {
                httpResponseHeaders.LastModified = lastModified;
            }
            if (etag != null)
            {
                httpResponseHeaders.ETag = etag;
            }
        }

        private static void SetAcceptRangeHeader(HttpResponse response)
        {
            response.Headers.AcceptRanges = AcceptRangeHeaderValue;
        }

        private static PreconditionState GetMaxPreconditionState(params PreconditionState[] states)
        {
            var max = PreconditionState.Unspecified;
            for (var i = 0; i < states.Length; i++)
            {
                if (states[i] > max)
                {
                    max = states[i];
                }
            }

            return max;
        }

        private static DateTimeOffset RoundDownToWholeSeconds(DateTimeOffset dateTimeOffset)
        {
            var ticksToRemove = dateTimeOffset.Ticks % TimeSpan.TicksPerSecond;
            return dateTimeOffset.Subtract(TimeSpan.FromTicks(ticksToRemove));
        }

        internal static partial class Log
        {
            [LoggerMessage(17, LogLevel.Debug, "Writing the requested range of bytes to the body.", EventName = "WritingRangeToBody")]
            public static partial void WritingRangeToBody(ILogger logger);

            [LoggerMessage(34, LogLevel.Debug,
                "Current request's If-Match header check failed as the file's current etag '{CurrentETag}' does not match with any of the supplied etags.",
                EventName = "IfMatchPreconditionFailed")]
            public static partial void IfMatchPreconditionFailed(ILogger logger, EntityTagHeaderValue currentETag);

            [LoggerMessage(35, LogLevel.Debug,
                "Current request's If-Unmodified-Since header check failed as the file was modified (at '{lastModified}') after the If-Unmodified-Since date '{IfUnmodifiedSinceDate}'.",
                EventName = "IfUnmodifiedSincePreconditionFailed")]
            public static partial void IfUnmodifiedSincePreconditionFailed(
                ILogger logger,
                DateTimeOffset? lastModified,
                DateTimeOffset? ifUnmodifiedSinceDate);

            [LoggerMessage(36, LogLevel.Debug,
                "Could not serve range as the file was modified (at {LastModified}) after the if-Range's last modified date '{IfRangeLastModified}'.",
                EventName = "IfRangeLastModifiedPreconditionFailed")]
            public static partial void IfRangeLastModifiedPreconditionFailed(
                ILogger logger,
                DateTimeOffset? lastModified,
                DateTimeOffset? IfRangeLastModified);

            [LoggerMessage(37, LogLevel.Debug,
                "Could not serve range as the file's current etag '{CurrentETag}' does not match the If-Range etag '{IfRangeETag}'.",
                EventName = "IfRangeETagPreconditionFailed")]
            public static partial void IfRangeETagPreconditionFailed(
                ILogger logger,
                EntityTagHeaderValue currentETag,
                EntityTagHeaderValue IfRangeETag);

            [LoggerMessage(38, LogLevel.Debug,
                "The file result has not been enabled for processing range requests. To enable it, set the EnableRangeProcessing property on the result to 'true'.",
                EventName = "NotEnabledForRangeProcessing")]
            public static partial void NotEnabledForRangeProcessing(ILogger logger);
        }

    }
}
