// 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.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;

namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
{
    public class FormFileModelBinderTest
    {
        [Fact]
        public async Task FormFileModelBinder_SingleFile_BindSuccessful()
        {
            // Arrange
            var formFiles = new FormFileCollection
            {
                GetMockFormFile("file", "file1.txt")
            };
            var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
            var bindingContext = GetBindingContext(typeof(IEnumerable<IFormFile>), httpContext);
            var binder = new FormFileModelBinder(NullLoggerFactory.Instance);

            // Act
            await binder.BindModelAsync(bindingContext);

            // Assert
            Assert.True(bindingContext.Result.IsModelSet);

            var entry = bindingContext.ValidationState[bindingContext.Result.Model];
            Assert.False(entry.SuppressValidation);
            Assert.Equal("file", entry.Key);
            Assert.Null(entry.Metadata);
        }

        [Fact]
        public async Task FormFileModelBinder_SingleFileAtTopLevel_BindSuccessfully_WithEmptyModelName()
        {
            // Arrange
            var formFiles = new FormFileCollection
            {
                GetMockFormFile("file", "file1.txt")
            };

            var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
            var binder = new FormFileModelBinder(NullLoggerFactory.Instance);

            // Mimic ParameterBinder overwriting ModelName on top level model. In this top-level binding case,
            // FormFileModelBinder uses FieldName from the get-go. (OriginalModelName will be checked but ignored.)
            var bindingContext = DefaultModelBindingContext.CreateBindingContext(
                new ActionContext { HttpContext = httpContext },
                Mock.Of<IValueProvider>(),
                new EmptyModelMetadataProvider().GetMetadataForType(typeof(IFormFile)),
                bindingInfo: null,
                modelName: "file");
            bindingContext.ModelName = string.Empty;

            // Act
            await binder.BindModelAsync(bindingContext);

            // Assert
            Assert.True(bindingContext.Result.IsModelSet);

            var entry = bindingContext.ValidationState[bindingContext.Result.Model];
            Assert.False(entry.SuppressValidation);
            Assert.Equal("file", entry.Key);
            Assert.Null(entry.Metadata);
        }

        [Fact]
        public async Task FormFileModelBinder_SingleFileWithinTopLevelPoco_BindSuccessfully()
        {
            // Arrange
            const string propertyName = nameof(NestedFormFiles.Files);
            var formFiles = new FormFileCollection
            {
                GetMockFormFile($"{propertyName}", "file1.txt")
            };

            var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
            var binder = new FormFileModelBinder(NullLoggerFactory.Instance);

            // In this non-top-level binding case, FormFileModelBinder tries ModelName and succeeds.
            var propertyInfo = typeof(NestedFormFiles).GetProperty(propertyName);
            var metadata = new EmptyModelMetadataProvider().GetMetadataForProperty(
                propertyInfo,
                propertyInfo.PropertyType);
            var bindingContext = DefaultModelBindingContext.CreateBindingContext(
                new ActionContext { HttpContext = httpContext },
                Mock.Of<IValueProvider>(),
                metadata,
                bindingInfo: null,
                modelName: "FileList");
            bindingContext.IsTopLevelObject = false;
            bindingContext.Model = new FileList();
            bindingContext.ModelName = propertyName;

            // Act
            await binder.BindModelAsync(bindingContext);

            // Assert
            Assert.True(bindingContext.Result.IsModelSet);

            var entry = bindingContext.ValidationState[bindingContext.Result.Model];
            Assert.False(entry.SuppressValidation);
            Assert.Equal($"{propertyName}", entry.Key);
            Assert.Null(entry.Metadata);
        }

        [Fact]
        public async Task FormFileModelBinder_SingleFileWithinTopLevelPoco_BindSuccessfully_WithShortenedModelName()
        {
            // Arrange
            const string propertyName = nameof(NestedFormFiles.Files);
            var formFiles = new FormFileCollection
            {
                GetMockFormFile($"FileList.{propertyName}", "file1.txt")
            };

            var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
            var binder = new FormFileModelBinder(NullLoggerFactory.Instance);

            // Mimic ParameterBinder overwriting ModelName on top level model then ComplexTypeModelBinder entering a
            // nested context for the NestedFormFiles property. In this non-top-level binding case, FormFileModelBinder
            // tries ModelName then falls back to add an (OriginalModelName + ".") prefix.
            var propertyInfo = typeof(NestedFormFiles).GetProperty(propertyName);
            var metadata = new EmptyModelMetadataProvider().GetMetadataForProperty(
                propertyInfo,
                propertyInfo.PropertyType);
            var bindingContext = DefaultModelBindingContext.CreateBindingContext(
                new ActionContext { HttpContext = httpContext },
                Mock.Of<IValueProvider>(),
                metadata,
                bindingInfo: null,
                modelName: "FileList");
            bindingContext.IsTopLevelObject = false;
            bindingContext.Model = new FileList();
            bindingContext.ModelName = propertyName;

            // Act
            await binder.BindModelAsync(bindingContext);

            // Assert
            Assert.True(bindingContext.Result.IsModelSet);

            var entry = bindingContext.ValidationState[bindingContext.Result.Model];
            Assert.False(entry.SuppressValidation);
            Assert.Equal($"FileList.{propertyName}", entry.Key);
            Assert.Null(entry.Metadata);
        }

        [Fact]
        public async Task FormFileModelBinder_SingleFileWithinTopLevelDictionary_BindSuccessfully()
        {
            // Arrange
            var formFiles = new FormFileCollection
            {
                GetMockFormFile("[myFile]", "file1.txt")
            };

            var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
            var binder = new FormFileModelBinder(NullLoggerFactory.Instance);

            // In this non-top-level binding case, FormFileModelBinder tries ModelName and succeeds.
            var bindingContext = DefaultModelBindingContext.CreateBindingContext(
                new ActionContext { HttpContext = httpContext },
                Mock.Of<IValueProvider>(),
                new EmptyModelMetadataProvider().GetMetadataForType(typeof(IFormFile)),
                bindingInfo: null,
                modelName: "FileDictionary");
            bindingContext.IsTopLevelObject = false;
            bindingContext.ModelName = "[myFile]";

            // Act
            await binder.BindModelAsync(bindingContext);

            // Assert
            Assert.True(bindingContext.Result.IsModelSet);

            var entry = bindingContext.ValidationState[bindingContext.Result.Model];
            Assert.False(entry.SuppressValidation);
            Assert.Equal("[myFile]", entry.Key);
            Assert.Null(entry.Metadata);
        }

        [Fact]
        public async Task FormFileModelBinder_SingleFileWithinTopLevelDictionary_BindSuccessfully_WithShortenedModelName()
        {
            // Arrange
            var formFiles = new FormFileCollection
            {
                GetMockFormFile("FileDictionary[myFile]", "file1.txt")
            };

            var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
            var binder = new FormFileModelBinder(NullLoggerFactory.Instance);

            // Mimic ParameterBinder overwriting ModelName on top level model then DictionaryModelBinder entering a
            // nested context for the KeyValuePair.Value property. In this non-top-level binding case,
            // FormFileModelBinder tries ModelName then falls back to add an OriginalModelName prefix.
            var bindingContext = DefaultModelBindingContext.CreateBindingContext(
                new ActionContext { HttpContext = httpContext },
                Mock.Of<IValueProvider>(),
                new EmptyModelMetadataProvider().GetMetadataForType(typeof(IFormFile)),
                bindingInfo: null,
                modelName: "FileDictionary");
            bindingContext.IsTopLevelObject = false;
            bindingContext.ModelName = "[myFile]";

            // Act
            await binder.BindModelAsync(bindingContext);

            // Assert
            Assert.True(bindingContext.Result.IsModelSet);

            var entry = bindingContext.ValidationState[bindingContext.Result.Model];
            Assert.False(entry.SuppressValidation);
            Assert.Equal("FileDictionary[myFile]", entry.Key);
            Assert.Null(entry.Metadata);
        }

        [Fact]
        public async Task FormFileModelBinder_ExpectMultipleFiles_BindSuccessful()
        {
            // Arrange
            var formFiles = GetTwoFiles();
            var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
            var bindingContext = GetBindingContext(typeof(IEnumerable<IFormFile>), httpContext);
            var binder = new FormFileModelBinder(NullLoggerFactory.Instance);

            // Act
            await binder.BindModelAsync(bindingContext);

            // Assert
            Assert.True(bindingContext.Result.IsModelSet);

            var entry = bindingContext.ValidationState[bindingContext.Result.Model];
            Assert.False(entry.SuppressValidation);
            Assert.Equal("file", entry.Key);
            Assert.Null(entry.Metadata);

            var files = Assert.IsAssignableFrom<IList<IFormFile>>(bindingContext.Result.Model);
            Assert.Equal(2, files.Count);
        }

        [Theory]
        [InlineData(typeof(IFormFile[]))]
        [InlineData(typeof(ICollection<IFormFile>))]
        [InlineData(typeof(IList<IFormFile>))]
        [InlineData(typeof(IFormFileCollection))]
        [InlineData(typeof(List<IFormFile>))]
        [InlineData(typeof(LinkedList<IFormFile>))]
        [InlineData(typeof(FileList))]
        [InlineData(typeof(FormFileCollection))]
        public async Task FormFileModelBinder_BindsFiles_ForCollectionsItCanCreate(Type destinationType)
        {
            // Arrange
            var binder = new FormFileModelBinder(NullLoggerFactory.Instance);
            var formFiles = GetTwoFiles();
            var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
            var bindingContext = GetBindingContext(destinationType, httpContext);

            // Act
            await binder.BindModelAsync(bindingContext);

            // Assert
            Assert.True(bindingContext.Result.IsModelSet);
            Assert.IsAssignableFrom(destinationType, bindingContext.Result.Model);
            Assert.Equal(formFiles, bindingContext.Result.Model as IEnumerable<IFormFile>);
        }

        [Fact]
        public async Task FormFileModelBinder_ExpectSingleFile_BindFirstFile()
        {
            // Arrange
            var formFiles = GetTwoFiles();
            var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
            var bindingContext = GetBindingContext(typeof(IFormFile), httpContext);
            var binder = new FormFileModelBinder(NullLoggerFactory.Instance);

            // Act
            await binder.BindModelAsync(bindingContext);

            // Assert
            Assert.True(bindingContext.Result.IsModelSet);
            var file = Assert.IsAssignableFrom<IFormFile>(bindingContext.Result.Model);
            Assert.Equal("file1.txt", file.FileName);
        }

        [Fact]
        public async Task FormFileModelBinder_ReturnsFailedResult_WhenNoFilePosted()
        {
            // Arrange
            var formFiles = new FormFileCollection();
            var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
            var bindingContext = GetBindingContext(typeof(IFormFile), httpContext);
            var binder = new FormFileModelBinder(NullLoggerFactory.Instance);

            // Act
            await binder.BindModelAsync(bindingContext);

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

        [Fact]
        public async Task FormFileModelBinder_ReturnsFailedResult_WhenNamesDoNotMatch()
        {
            // Arrange
            var formFiles = new FormFileCollection
            {
                GetMockFormFile("different name", "file1.txt")
            };
            var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
            var bindingContext = GetBindingContext(typeof(IFormFile), httpContext);
            var binder = new FormFileModelBinder(NullLoggerFactory.Instance);

            // Act
            await binder.BindModelAsync(bindingContext);

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

        [Theory]
        [InlineData(true, "FieldName")]
        [InlineData(false, "ModelName")]
        public async Task FormFileModelBinder_UsesFieldNameForTopLevelObject(bool isTopLevel, string expected)
        {
            // Arrange
            var formFiles = new FormFileCollection
            {
                GetMockFormFile("FieldName", "file1.txt"),
                GetMockFormFile("ModelName", "file1.txt")
            };
            var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));

            var bindingContext = GetBindingContext(typeof(IFormFile), httpContext);
            bindingContext.IsTopLevelObject = isTopLevel;
            bindingContext.FieldName = "FieldName";
            bindingContext.ModelName = "ModelName";

            var binder = new FormFileModelBinder(NullLoggerFactory.Instance);

            // Act
            await binder.BindModelAsync(bindingContext);

            // Assert
            Assert.True(bindingContext.Result.IsModelSet);
            var file = Assert.IsAssignableFrom<IFormFile>(bindingContext.Result.Model);

            Assert.Equal(expected, file.Name);
        }

        [Fact]
        public async Task FormFileModelBinder_ReturnsFailedResult_WithEmptyContentDisposition()
        {
            // Arrange
            var formFiles = new FormFileCollection
            {
                new Mock<IFormFile>().Object
            };
            var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
            var bindingContext = GetBindingContext(typeof(IFormFile), httpContext);
            var binder = new FormFileModelBinder(NullLoggerFactory.Instance);

            // Act
            await binder.BindModelAsync(bindingContext);

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

        [Fact]
        public async Task FormFileModelBinder_ReturnsFailedResult_WithNoFileNameAndZeroLength()
        {
            // Arrange
            var formFiles = new FormFileCollection
            {
                GetMockFormFile("file", "")
            };
            var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
            var bindingContext = GetBindingContext(typeof(IFormFile), httpContext);
            var binder = new FormFileModelBinder(NullLoggerFactory.Instance);

            // Act
            await binder.BindModelAsync(bindingContext);

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

        [Fact]
        public async Task FormFileModelBinder_ReturnsResult_ForReadOnlyDestination()
        {
            // Arrange
            var binder = new FormFileModelBinder(NullLoggerFactory.Instance);
            var formFiles = GetTwoFiles();
            var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
            var bindingContext = GetBindingContextForReadOnlyArray(httpContext);

            // Act
            await binder.BindModelAsync(bindingContext);

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

        [Fact]
        public async Task FormFileModelBinder_ReturnsFailedResult_ForCollectionsItCannotCreate()
        {
            // Arrange
            var binder = new FormFileModelBinder(NullLoggerFactory.Instance);
            var formFiles = GetTwoFiles();
            var httpContext = GetMockHttpContext(GetMockFormCollection(formFiles));
            var bindingContext = GetBindingContext(typeof(ISet<IFormFile>), httpContext);

            // Act
            await binder.BindModelAsync(bindingContext);

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

        private static DefaultModelBindingContext GetBindingContextForReadOnlyArray(HttpContext httpContext)
        {
            var metadataProvider = new TestModelMetadataProvider();
            metadataProvider
                .ForProperty<ModelWithReadOnlyArray>(nameof(ModelWithReadOnlyArray.ArrayProperty))
                .BindingDetails(bd => bd.BindingSource = BindingSource.Header);
            var metadata = metadataProvider.GetMetadataForProperty(
                typeof(ModelWithReadOnlyArray),
                nameof(ModelWithReadOnlyArray.ArrayProperty));

            return GetBindingContext(metadata, httpContext);
        }

        private static DefaultModelBindingContext GetBindingContext(Type modelType, HttpContext httpContext)
        {
            var metadataProvider = new EmptyModelMetadataProvider();
            var metadata = metadataProvider.GetMetadataForType(modelType);

            return GetBindingContext(metadata, httpContext);
        }

        private static DefaultModelBindingContext GetBindingContext(
            ModelMetadata metadata,
            HttpContext httpContext)
        {
            var bindingContext = new DefaultModelBindingContext
            {
                ActionContext = new ActionContext()
                {
                    HttpContext = httpContext,
                },
                ModelMetadata = metadata,
                ModelName = "file",
                ModelState = new ModelStateDictionary(),
                ValidationState = new ValidationStateDictionary(),
            };

            return bindingContext;
        }

        private static HttpContext GetMockHttpContext(IFormCollection formCollection)
        {
            var httpContext = new Mock<HttpContext>();
            httpContext.Setup(h => h.Request.ReadFormAsync(It.IsAny<CancellationToken>()))
                .Returns(Task.FromResult(formCollection));
            httpContext.Setup(h => h.Request.HasFormContentType).Returns(true);
            return httpContext.Object;
        }

        private static FormFileCollection GetTwoFiles()
        {
            var formFiles = new FormFileCollection
            {
                GetMockFormFile("file", "file1.txt"),
                GetMockFormFile("file", "file2.txt"),
            };

            return formFiles;
        }

        private static IFormCollection GetMockFormCollection(FormFileCollection formFiles)
        {
            var formCollection = new Mock<IFormCollection>();
            formCollection.Setup(f => f.Files).Returns(formFiles);
            return formCollection.Object;
        }

        private static IFormFile GetMockFormFile(string modelName, string filename)
        {
            var formFile = new Mock<IFormFile>();
            formFile.Setup(f => f.Name).Returns(modelName);
            formFile.Setup(f => f.FileName).Returns(filename);

            return formFile.Object;
        }

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

        private class FileList : List<IFormFile>
        {
        }

        private class NestedFormFiles
        {
            public FileList Files { get; }
        }
    }
}
