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

using System.Diagnostics;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;

namespace Microsoft.AspNetCore.ResponseCompression;

/// <inheritdoc />
public class ResponseCompressionProvider : IResponseCompressionProvider
{
    private readonly ICompressionProvider[] _providers;
    private readonly HashSet<string> _mimeTypes;
    private readonly HashSet<string> _excludedMimeTypes;
    private readonly bool _enableForHttps;
    private readonly ILogger _logger;

    /// <summary>
    /// If no compression providers are specified then GZip is used by default.
    /// </summary>
    /// <param name="services">Services to use when instantiating compression providers.</param>
    /// <param name="options">The options for this instance.</param>
    public ResponseCompressionProvider(IServiceProvider services, IOptions<ResponseCompressionOptions> options)
    {
        ArgumentNullException.ThrowIfNull(services);
        ArgumentNullException.ThrowIfNull(options);

        var responseCompressionOptions = options.Value;

        _providers = responseCompressionOptions.Providers.ToArray();
        if (_providers.Length == 0)
        {
            // Use the factory so it can resolve IOptions<GzipCompressionProviderOptions> from DI.
            _providers = new ICompressionProvider[]
            {
                    new CompressionProviderFactory(typeof(BrotliCompressionProvider)),
                    new CompressionProviderFactory(typeof(GzipCompressionProvider)),
            };
        }
        for (var i = 0; i < _providers.Length; i++)
        {
            var factory = _providers[i] as CompressionProviderFactory;
            if (factory != null)
            {
                _providers[i] = factory.CreateInstance(services);
            }
        }

        var mimeTypes = responseCompressionOptions.MimeTypes;
        if (mimeTypes == null || !mimeTypes.Any())
        {
            mimeTypes = ResponseCompressionDefaults.MimeTypes;
        }

        _mimeTypes = new HashSet<string>(mimeTypes, StringComparer.OrdinalIgnoreCase);

        _excludedMimeTypes = new HashSet<string>(
            responseCompressionOptions.ExcludedMimeTypes ?? Enumerable.Empty<string>(),
            StringComparer.OrdinalIgnoreCase
        );

        _enableForHttps = responseCompressionOptions.EnableForHttps;

        _logger = services.GetRequiredService<ILogger<ResponseCompressionProvider>>();
    }

    /// <inheritdoc />
    public virtual ICompressionProvider? GetCompressionProvider(HttpContext context)
    {
        // e.g. Accept-Encoding: gzip, deflate, sdch
        var accept = context.Request.Headers.AcceptEncoding;

        // Note this is already checked in CheckRequestAcceptsCompression which _should_ prevent any of these other methods from being called.
        if (StringValues.IsNullOrEmpty(accept))
        {
            Debug.Assert(false, "Duplicate check failed.");
            _logger.NoAcceptEncoding();
            return null;
        }

        if (!StringWithQualityHeaderValue.TryParseList(accept, out var encodings) || encodings.Count == 0)
        {
            _logger.NoAcceptEncoding();
            return null;
        }

        var candidates = new HashSet<ProviderCandidate>();

        foreach (var encoding in encodings)
        {
            var encodingName = encoding.Value;
            var quality = encoding.Quality.GetValueOrDefault(1);

            if (quality < double.Epsilon)
            {
                continue;
            }

            for (int i = 0; i < _providers.Length; i++)
            {
                var provider = _providers[i];

                if (StringSegment.Equals(provider.EncodingName, encodingName, StringComparison.OrdinalIgnoreCase))
                {
                    candidates.Add(new ProviderCandidate(provider.EncodingName, quality, i, provider));
                }
            }

            // Uncommon but valid options
            if (StringSegment.Equals("*", encodingName, StringComparison.Ordinal))
            {
                for (int i = 0; i < _providers.Length; i++)
                {
                    var provider = _providers[i];

                    // Any provider is a candidate.
                    candidates.Add(new ProviderCandidate(provider.EncodingName, quality, i, provider));
                }

                break;
            }

            if (StringSegment.Equals("identity", encodingName, StringComparison.OrdinalIgnoreCase))
            {
                // We add 'identity' to the list of "candidates" with a very low priority and no provider.
                // This will allow it to be ordered based on its quality (and priority) later in the method.
                candidates.Add(new ProviderCandidate("identity", quality, priority: int.MaxValue, provider: null));
            }
        }

        ICompressionProvider? selectedProvider = null;
        if (candidates.Count <= 1)
        {
            selectedProvider = candidates.FirstOrDefault().Provider;
        }
        else
        {
            selectedProvider = candidates
                .OrderByDescending(x => x.Quality)
                .ThenBy(x => x.Priority)
                .First().Provider;
        }

        if (selectedProvider == null)
        {
            // "identity" would match as a candidate but not have a provider implementation
            _logger.NoCompressionProvider();
            return null;
        }

        _logger.CompressingWith(selectedProvider.EncodingName);
        return selectedProvider;
    }

    /// <inheritdoc />
    public virtual bool ShouldCompressResponse(HttpContext context)
    {
        var httpsMode = context.Features.Get<IHttpsCompressionFeature>()?.Mode ?? HttpsCompressionMode.Default;

        // Check if the app has opted into or out of compression over HTTPS
        if (context.Request.IsHttps
            && (httpsMode == HttpsCompressionMode.DoNotCompress
                || !(_enableForHttps || httpsMode == HttpsCompressionMode.Compress)))
        {
            _logger.NoCompressionForHttps();
            return false;
        }

        if (context.Response.Headers.ContainsKey(HeaderNames.ContentRange))
        {
            _logger.NoCompressionDueToHeader(HeaderNames.ContentRange);
            return false;
        }

        if (context.Response.Headers.ContainsKey(HeaderNames.ContentEncoding))
        {
            _logger.NoCompressionDueToHeader(HeaderNames.ContentEncoding);
            return false;
        }

        var mimeType = context.Response.ContentType;

        if (string.IsNullOrEmpty(mimeType))
        {
            _logger.NoCompressionForContentType(mimeType ?? "(null)");
            return false;
        }

        var separator = mimeType.IndexOf(';');
        if (separator >= 0)
        {
            // Remove the content-type optional parameters
            mimeType = mimeType.Substring(0, separator);
            mimeType = mimeType.Trim();
        }

        var shouldCompress = ShouldCompressExact(mimeType) //check exact match type/subtype
            ?? ShouldCompressPartial(mimeType) //check partial match type/*
            ?? _mimeTypes.Contains("*/*"); //check wildcard */*

        if (shouldCompress)
        {
            _logger.ShouldCompressResponse();  // Trace, there will be more logs
            return true;
        }

        _logger.NoCompressionForContentType(mimeType);
        return false;
    }

    /// <inheritdoc />
    public bool CheckRequestAcceptsCompression(HttpContext context)
    {
        if (string.IsNullOrEmpty(context.Request.Headers.AcceptEncoding))
        {
            _logger.NoAcceptEncoding();
            return false;
        }

        _logger.RequestAcceptsCompression(); // Trace, there will be more logs
        return true;
    }

    private bool? ShouldCompressExact(string mimeType)
    {
        //Check excluded MIME types first, then included
        if (_excludedMimeTypes.Contains(mimeType))
        {
            return false;
        }

        if (_mimeTypes.Contains(mimeType))
        {
            return true;
        }

        return null;
    }

    private bool? ShouldCompressPartial(string? mimeType)
    {
        var slashPos = mimeType?.IndexOf('/');

        if (slashPos >= 0)
        {
            var partialMimeType = string.Concat(mimeType!.AsSpan(0, slashPos.Value), "/*");
            return ShouldCompressExact(partialMimeType);
        }

        return null;
    }

    private readonly struct ProviderCandidate : IEquatable<ProviderCandidate>
    {
        public ProviderCandidate(string encodingName, double quality, int priority, ICompressionProvider? provider)
        {
            EncodingName = encodingName;
            Quality = quality;
            Priority = priority;
            Provider = provider;
        }

        public string EncodingName { get; }

        public double Quality { get; }

        public int Priority { get; }

        public ICompressionProvider? Provider { get; }

        public bool Equals(ProviderCandidate other)
        {
            return string.Equals(EncodingName, other.EncodingName, StringComparison.OrdinalIgnoreCase);
        }

        public override bool Equals(object? obj)
        {
            return obj is ProviderCandidate candidate && Equals(candidate);
        }

        public override int GetHashCode()
        {
            return StringComparer.OrdinalIgnoreCase.GetHashCode(EncodingName);
        }
    }
}
