// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Moq;
using Xunit;

namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
{
    public class HeaderModelBinderTests
    {
        [Fact]
        public async Task HeaderBinder_BindsHeaders_ToStringCollection_WithoutInnerModelBinder()
        {
            // Arrange
            var binder = new HeaderModelBinder(NullLoggerFactory.Instance);
            var type = typeof(string[]);
            var header = "Accept";
            var headerValue = "application/json,text/json";

            var bindingContext = CreateContext(type);

            bindingContext.FieldName = header;
            bindingContext.HttpContext.Request.Headers.Add(header, new[] { headerValue });

            // Act
            await binder.BindModelAsync(bindingContext);

            // Assert
            Assert.True(bindingContext.Result.IsModelSet);
            Assert.Equal(headerValue.Split(','), bindingContext.Result.Model);
        }

        [Fact]
        public async Task HeaderBinder_BindsHeaders_ToStringType_WithoutInnerModelBinder()
        {
            // Arrange
            var type = typeof(string);
            var header = "User-Agent";
            var headerValue = "UnitTest";
            var bindingContext = CreateContext(type);

            var binder = new HeaderModelBinder(NullLoggerFactory.Instance);

            bindingContext.FieldName = header;
            bindingContext.HttpContext.Request.Headers.Add(header, new[] { headerValue });

            // Act
            await binder.BindModelAsync(bindingContext);

            // Assert
            Assert.True(bindingContext.Result.IsModelSet);
            Assert.Equal(headerValue, bindingContext.Result.Model);
        }

        [Theory]
        [InlineData(typeof(IEnumerable<string>))]
        [InlineData(typeof(ICollection<string>))]
        [InlineData(typeof(IList<string>))]
        [InlineData(typeof(List<string>))]
        [InlineData(typeof(LinkedList<string>))]
        [InlineData(typeof(StringList))]
        public async Task HeaderBinder_BindsHeaders_ForCollectionsItCanCreate_WithoutInnerModelBinder(
            Type destinationType)
        {
            // Arrange
            var header = "Accept";
            var headerValue = "application/json,text/json";
            var binder = new HeaderModelBinder(NullLoggerFactory.Instance);
            var bindingContext = CreateContext(destinationType);

            bindingContext.FieldName = header;
            bindingContext.HttpContext.Request.Headers.Add(header, new[] { headerValue });

            // Act
            await binder.BindModelAsync(bindingContext);

            // Assert
            Assert.True(bindingContext.Result.IsModelSet);
            Assert.IsAssignableFrom(destinationType, bindingContext.Result.Model);
            Assert.Equal(headerValue.Split(','), bindingContext.Result.Model as IEnumerable<string>);
        }

        [Fact]
        public async Task HeaderBinder_BindsHeaders_ToStringCollection()
        {
            // Arrange
            var type = typeof(string[]);
            var headerValue = "application/json,text/json";
            var bindingContext = CreateContext(type);
            var binder = CreateBinder(bindingContext);
            bindingContext.HttpContext.Request.Headers.Add("Header", new[] { headerValue });

            // Act
            await binder.BindModelAsync(bindingContext);

            // Assert
            Assert.True(bindingContext.Result.IsModelSet);
            Assert.Equal(headerValue.Split(','), bindingContext.Result.Model);
        }

        public static TheoryData<string, Type, object> BinderHeaderToSimpleTypesData
        {
            get
            {
                var guid = new Guid("3916A5B1-5FE4-4E09-9812-5CDC127FA5B1");

                return new TheoryData<string, Type, object>()
                {
                    { "10", typeof(int), 10 },
                    { "10.50", typeof(double), 10.50 },
                    { "10.50", typeof(IEnumerable<double>), new List<double>() { 10.50 } },
                    { "Sedan", typeof(CarType), CarType.Sedan },
                    { "", typeof(CarType?), null },
                    { "", typeof(string[]), Array.Empty<string>() },
                    { null, typeof(string[]), Array.Empty<string>() },
                    { "", typeof(IEnumerable<string>), new List<string>() },
                    { null, typeof(IEnumerable<string>), new List<string>() },
                    { guid.ToString(), typeof(Guid), guid },
                    { "foo", typeof(string), "foo" },
                    { "foo, bar", typeof(string), "foo, bar" },
                    { "foo, bar", typeof(string[]), new[]{ "foo", "bar" } },
                    { "foo, \"bar\"", typeof(string[]), new[]{ "foo", "bar" } },
                    { "\"foo,bar\"", typeof(string[]), new[]{ "foo,bar" } }
                };
            }
        }

        [Theory]
        [MemberData(nameof(BinderHeaderToSimpleTypesData))]
        public async Task HeaderBinder_BindsHeaders_ToSimpleTypes(
            string headerValue,
            Type modelType,
            object expectedModel)
        {
            // Arrange
            var bindingContext = CreateContext(modelType);
            var binder = CreateBinder(bindingContext);

            if (headerValue != null)
            {
                bindingContext.HttpContext.Request.Headers.Add("Header", headerValue);
            }

            // Act
            await binder.BindModelAsync(bindingContext);

            // Assert
            Assert.True(bindingContext.Result.IsModelSet);
            Assert.Equal(expectedModel, bindingContext.Result.Model);
        }

        [Theory]
        [InlineData(typeof(CarType?))]
        [InlineData(typeof(int?))]
        public async Task HeaderBinder_DoesNotSetModel_ForHeaderNotPresentOnRequest(Type modelType)
        {
            // Arrange
            var bindingContext = CreateContext(modelType);
            var binder = CreateBinder(bindingContext);

            // Act
            await binder.BindModelAsync(bindingContext);

            // Assert
            Assert.False(bindingContext.Result.IsModelSet);
            Assert.Null(bindingContext.Result.Model);
        }

        [Theory]
        [InlineData(typeof(string[]))]
        [InlineData(typeof(IEnumerable<string>))]
        public async Task HeaderBinder_DoesNotCreateEmptyCollection_ForNonTopLevelObjects(Type modelType)
        {
            // Arrange
            var bindingContext = CreateContext(modelType);
            bindingContext.IsTopLevelObject = false;
            var binder = CreateBinder(bindingContext);
            // No header on the request that the header value provider is looking for

            // Act
            await binder.BindModelAsync(bindingContext);

            // Assert
            Assert.False(bindingContext.Result.IsModelSet);
            Assert.Null(bindingContext.Result.Model);
        }

        [Theory]
        [InlineData(typeof(IEnumerable<string>))]
        [InlineData(typeof(ICollection<string>))]
        [InlineData(typeof(IList<string>))]
        [InlineData(typeof(List<string>))]
        [InlineData(typeof(LinkedList<string>))]
        [InlineData(typeof(StringList))]
        public async Task HeaderBinder_BindsHeaders_ForCollectionsItCanCreate(Type destinationType)
        {
            // Arrange
            var headerValue = "application/json,text/json";
            var bindingContext = CreateContext(destinationType);
            var binder = CreateBinder(bindingContext);
            bindingContext.HttpContext.Request.Headers.Add("Header", new[] { headerValue });

            // Act
            await binder.BindModelAsync(bindingContext);

            // Assert
            Assert.True(bindingContext.Result.IsModelSet);
            Assert.IsAssignableFrom(destinationType, bindingContext.Result.Model);
            Assert.Equal(headerValue.Split(','), bindingContext.Result.Model as IEnumerable<string>);
        }

        [Fact]
        public async Task HeaderBinder_ReturnsResult_ForReadOnlyDestination()
        {
            // Arrange
            var bindingContext = CreateContext(GetMetadataForReadOnlyArray());
            var binder = CreateBinder(bindingContext);
            bindingContext.HttpContext.Request.Headers.Add("Header", "application/json,text/json");

            // Act
            await binder.BindModelAsync(bindingContext);

            // Assert
            Assert.True(bindingContext.Result.IsModelSet);
            Assert.NotNull(bindingContext.Result.Model);
        }

        [Fact]
        public async Task HeaderBinder_ResetsTheBindingScope_GivingOriginalValueProvider()
        {
            // Arrange
            var expectedValueProvider = Mock.Of<IValueProvider>();
            var bindingContext = CreateContext(GetMetadataForType(typeof(string)), expectedValueProvider);
            var binder = CreateBinder(bindingContext);
            bindingContext.HttpContext.Request.Headers.Add("Header", "application/json,text/json");

            // Act
            await binder.BindModelAsync(bindingContext);

            // Assert
            Assert.True(bindingContext.Result.IsModelSet);
            Assert.Equal("application/json,text/json", bindingContext.Result.Model);
            Assert.Same(expectedValueProvider, bindingContext.ValueProvider);
        }

        [Fact]
        public async Task HeaderBinder_UsesValues_OnlyFromHeaderValueProvider()
        {
            // Arrange
            var testValueProvider = new Mock<IValueProvider>();
            testValueProvider
                .Setup(vp => vp.ContainsPrefix(It.IsAny<string>()))
                .Returns(true);
            testValueProvider
                .Setup(vp => vp.GetValue(It.IsAny<string>()))
                .Returns(new ValueProviderResult(new StringValues("foo,bar")));
            var bindingContext = CreateContext(GetMetadataForType(typeof(string)), testValueProvider.Object);
            var binder = CreateBinder(bindingContext);
            bindingContext.HttpContext.Request.Headers.Add("Header", "application/json,text/json");

            // Act
            await binder.BindModelAsync(bindingContext);

            // Assert
            Assert.True(bindingContext.Result.IsModelSet);
            Assert.Equal("application/json,text/json", bindingContext.Result.Model);
            Assert.Same(testValueProvider.Object, bindingContext.ValueProvider);
        }

        [Theory]
        [InlineData(typeof(int), "not-an-integer")]
        [InlineData(typeof(double), "not-an-double")]
        [InlineData(typeof(CarType?), "boo")]
        public async Task HeaderBinder_BindModelAsync_AddsErrorToModelState_OnInvalidInput(
            Type modelType,
            string headerValue)
        {
            // Arrange
            var bindingContext = CreateContext(modelType);
            var binder = CreateBinder(bindingContext);
            bindingContext.HttpContext.Request.Headers.Add("Header", headerValue);

            // Act
            await binder.BindModelAsync(bindingContext);

            // Assert
            var entry = bindingContext.ModelState["someprefix.Header"];
            Assert.NotNull(entry);
            var error = Assert.Single(entry.Errors);
            Assert.Equal($"The value '{headerValue}' is not valid.", error.ErrorMessage);
        }

        [Theory]
        [InlineData(typeof(int[]), "a, b")]
        [InlineData(typeof(IEnumerable<double>), "a, b")]
        [InlineData(typeof(ICollection<CarType>), "a, b")]
        public async Task HeaderBinder_BindModelAsync_AddsErrorToModelState_OnInvalid_CollectionInput(
            Type modelType,
            string headerValue)
        {
            // Arrange
            var headerValues = headerValue.Split(',').Select(s => s.Trim()).ToArray();
            var bindingContext = CreateContext(modelType);
            var binder = CreateBinder(bindingContext);
            bindingContext.HttpContext.Request.Headers.Add("Header", headerValue);

            // Act
            await binder.BindModelAsync(bindingContext);

            // Assert
            var entry = bindingContext.ModelState["someprefix.Header"];
            Assert.NotNull(entry);
            Assert.Equal(2, entry.Errors.Count);
            Assert.Equal($"The value '{headerValues[0]}' is not valid.", entry.Errors[0].ErrorMessage);
            Assert.Equal($"The value '{headerValues[1]}' is not valid.", entry.Errors[1].ErrorMessage);
        }

        private static DefaultModelBindingContext CreateContext(Type modelType)
        {
            return CreateContext(metadata: GetMetadataForType(modelType), valueProvider: null);
        }

        private static DefaultModelBindingContext CreateContext(
            ModelMetadata metadata,
            IValueProvider valueProvider = null)
        {
            if (valueProvider == null)
            {
                valueProvider = Mock.Of<IValueProvider>();
            }

            var options = new MvcOptions();
            var setup = new MvcCoreMvcOptionsSetup(new TestHttpRequestStreamReaderFactory());
            setup.Configure(options);

            var services = new ServiceCollection();
            services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
            services.AddSingleton(Options.Create(options));
            var serviceProvider = services.BuildServiceProvider();

            var headerName = "Header";

            return new DefaultModelBindingContext()
            {
                IsTopLevelObject = true,
                ModelMetadata = metadata,
                BinderModelName = metadata.BinderModelName,
                BindingSource = metadata.BindingSource,

                // HeaderModelBinder must always use the field name when getting the values from header value provider
                // but add keys into ModelState using the ModelName. This is for back compat reasons.
                ModelName = $"somePrefix.{headerName}",
                FieldName = headerName,

                ValueProvider = valueProvider,
                ModelState = new ModelStateDictionary(),
                ActionContext = new ActionContext()
                {
                    HttpContext = new DefaultHttpContext()
                    {
                        RequestServices = serviceProvider
                    }
                },
            };
        }

        private static IModelBinder CreateBinder(DefaultModelBindingContext bindingContext)
        {
            var factory = TestModelBinderFactory.Create(bindingContext.HttpContext.RequestServices);
            var metadata = bindingContext.ModelMetadata;
            return factory.CreateBinder(new ModelBinderFactoryContext()
            {
                Metadata = metadata,
                BindingInfo = new BindingInfo()
                {
                    BinderModelName = metadata.BinderModelName,
                    BinderType = metadata.BinderType,
                    BindingSource = metadata.BindingSource,
                    PropertyFilterProvider = metadata.PropertyFilterProvider,
                },
            });
        }

        private static ModelMetadata GetMetadataForType(Type modelType)
        {
            var metadataProvider = new TestModelMetadataProvider();
            metadataProvider.ForType(modelType).BindingDetails(d => d.BindingSource = BindingSource.Header);
            return metadataProvider.GetMetadataForType(modelType);
        }

        private static ModelMetadata GetMetadataForReadOnlyArray()
        {
            var metadataProvider = new TestModelMetadataProvider();
            metadataProvider
                .ForProperty<ModelWithReadOnlyArray>(nameof(ModelWithReadOnlyArray.ArrayProperty))
                .BindingDetails(bd => bd.BindingSource = BindingSource.Header);
            return metadataProvider.GetMetadataForProperty(
                typeof(ModelWithReadOnlyArray),
                nameof(ModelWithReadOnlyArray.ArrayProperty));
        }

        private class ModelWithReadOnlyArray
        {
            public string[] ArrayProperty { get; }
        }

        private class StringList : List<string>
        {
        }

        private enum CarType
        {
            Sedan,
            Coupe
        }
    }
}
