// Copyright © WireMock.Net

using System.Net.Http.Headers;
using System.Text;
using Stef.Validation;
using WireMock.Constants;
using WireMock.Matchers;
using WireMock.Serialization;
using WireMock.Types;

namespace WireMock.Util;

internal static class BodyParser
{
    private static readonly Encoding DefaultEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
    private static readonly Encoding[] SupportedBodyAsStringEncodingForMultipart = [DefaultEncoding, Encoding.ASCII];

    /*
        HEAD - No defined body semantics.
        GET - No defined body semantics.
        PUT - Body supported.
        POST - Body supported.
        DELETE - No defined body semantics.
        TRACE - Body not supported.
        OPTIONS - Body supported but no semantics on usage (maybe in the future).
        CONNECT - No defined body semantics
        PATCH - Body supported.
    */
    private static readonly Dictionary<string, bool> BodyAllowedForMethods = new()
    {
        { HttpRequestMethod.HEAD, false },
        { HttpRequestMethod.GET, false },
        { HttpRequestMethod.PUT, true },
        { HttpRequestMethod.POST, true },
        { HttpRequestMethod.DELETE, true },
        { HttpRequestMethod.TRACE, false },
        { HttpRequestMethod.OPTIONS, true },
        { HttpRequestMethod.CONNECT, false },
        { HttpRequestMethod.PATCH, true }
    };

    private static readonly IStringMatcher[] MultipartContentTypesMatchers =
    [
        new WildcardMatcher("multipart/*", true)
    ];

    private static readonly IStringMatcher[] JsonContentTypesMatchers =
    [
        new WildcardMatcher("application/json", true),
        new WildcardMatcher("application/vnd.*+json", true)
    ];

    private static readonly IStringMatcher FormUrlEncodedMatcher = new WildcardMatcher("application/x-www-form-urlencoded", true);

    private static readonly IStringMatcher[] TextContentTypeMatchers =
    [
        new WildcardMatcher("text/*", true),
        new RegexMatcher("^application\\/(java|type)script$", true),
        new WildcardMatcher("application/*xml", true),
        FormUrlEncodedMatcher
    ];

    private static readonly IStringMatcher[] GrpcContentTypesMatchers =
    [
        new WildcardMatcher("application/grpc", true),
        new WildcardMatcher("application/grpc+proto", true)
    ];

    public static bool ShouldParseBody(string? httpMethod, bool allowBodyForAllHttpMethods)
    {
        if (string.IsNullOrEmpty(httpMethod))
        {
            return false;
        }

        if (allowBodyForAllHttpMethods)
        {
            return true;
        }

        if (BodyAllowedForMethods.TryGetValue(httpMethod!.ToUpper(), out var allowed))
        {
            return allowed;
        }

        // If we don't have any knowledge of this method, we should assume that a body *may*
        // be present, so we should parse it if it is. Therefore, if a new method is added to
        // the HTTP Method Registry, we only really need to add it to BodyAllowedForMethods if
        // we want to make it clear that a body is *not* allowed.
        return true;
    }

    public static BodyType DetectBodyTypeFromContentType(string? contentTypeValue)
    {
        if (string.IsNullOrEmpty(contentTypeValue) || !MediaTypeHeaderValue.TryParse(contentTypeValue, out var contentType))
        {
            return BodyType.Bytes;
        }

        if (FormUrlEncodedMatcher.IsMatch(contentType.MediaType).IsPerfect())
        {
            return BodyType.FormUrlEncoded;
        }

        if (TextContentTypeMatchers.Any(matcher => matcher.IsMatch(contentType.MediaType).IsPerfect()))
        {
            return BodyType.String;
        }

        if (JsonContentTypesMatchers.Any(matcher => matcher.IsMatch(contentType.MediaType).IsPerfect()))
        {
            return BodyType.Json;
        }

        if (GrpcContentTypesMatchers.Any(matcher => matcher.IsMatch(contentType.MediaType).IsPerfect()))
        {
            return BodyType.ProtoBuf;
        }

        if (MultipartContentTypesMatchers.Any(matcher => matcher.IsMatch(contentType.MediaType).IsPerfect()))
        {
            return BodyType.MultiPart;
        }

        return BodyType.Bytes;
    }

    public static async Task<BodyData> ParseAsync(BodyParserSettings settings)
    {
        Guard.NotNull(settings);

        var (ContentType, Bytes) = await ReadBytesAsync(settings.Stream, settings.ContentEncoding, settings.DecompressGZipAndDeflate).ConfigureAwait(false);
        var data = new BodyData
        {
            BodyAsBytes = Bytes,
            DetectedCompression = ContentType,
            DetectedBodyType = BodyType.Bytes,
            DetectedBodyTypeFromContentType = DetectBodyTypeFromContentType(settings.ContentType)
        };

        // In case of MultiPart: check if the BodyAsBytes is a valid UTF8 or ASCII string, in that case read as String else keep as-is
        if (data.DetectedBodyTypeFromContentType == BodyType.MultiPart)
        {
            if (BytesEncodingUtils.TryGetEncoding(data.BodyAsBytes, out var encoding) &&
                SupportedBodyAsStringEncodingForMultipart.Select(x => x.Equals(encoding)).Any())
            {
                data.BodyAsString = encoding.GetString(data.BodyAsBytes);
                data.Encoding = encoding;
                data.DetectedBodyType = BodyType.String;
            }

            return data;
        }

        // Try to get the body as String, FormUrlEncoded or Json
        if (IsProbablyText(data.BodyAsBytes))
        {
            data.BodyAsString = DefaultEncoding.GetString(data.BodyAsBytes);
            data.Encoding = DefaultEncoding;
            data.DetectedBodyType = BodyType.String;

            // If string is not null or empty, try to deserialize the string to a IDictionary<string, string>
            if (settings.DeserializeFormUrlEncoded &&
                data.DetectedBodyTypeFromContentType == BodyType.FormUrlEncoded &&
                QueryStringParser.TryParse(data.BodyAsString, false, out var nameValueCollection)
            )
            {
                data.BodyAsFormUrlEncoded = nameValueCollection;
                data.DetectedBodyType = BodyType.FormUrlEncoded;
            }

            // If string is not null or empty, try to deserialize the string
            if (settings.DeserializeJson && settings.DefaultJsonConverter.IsValidJson(data.BodyAsString))
            {
                try
                {
                    data.BodyAsJson = settings.DefaultJsonConverter.Deserialize<object>(data.BodyAsString, JsonSerializationConstants.JsonConverterOptionsWithDateParsingNone);
                    data.DetectedBodyType = BodyType.Json;
                }
                catch
                {
                    // JsonConverter failed, just ignore.
                }
            }
        }

        return data;
    }

    private static async Task<(string? ContentType, byte[] Bytes)> ReadBytesAsync(Stream stream, string? contentEncoding = null, bool decompressGZipAndDeflate = true)
    {
        using var memoryStream = new MemoryStream();
        await stream.CopyToAsync(memoryStream).ConfigureAwait(false);
        byte[] data = memoryStream.ToArray();

        var type = contentEncoding?.ToLowerInvariant();
        if (decompressGZipAndDeflate && type is "gzip" or "deflate")
        {
            return (type, CompressionUtils.Decompress(type, data));
        }

        return (null, data);
    }

    private static bool IsProbablyText(byte[] data)
    {
        if (data.Length == 0)
        {
            return true;
        }

        // 1) Quick binary detection
        for (int i = 0; i < data.Length; i++)
        {
            if (data[i] == 0)
            {
                return false;
            }
        }

        // 2) Validate UTF-8
        string text;
        try
        {
            text = DefaultEncoding.GetString(data);
        }
        catch
        {
            return false;
        }

        // 3) Count printable characters
        var printable = text.Count(c => !char.IsControl(c) || char.IsWhiteSpace(c));
        var ratio = (double)printable / text.Length;

        // Threshold commonly used by tools like git
        return ratio > 0.85;
    }
}