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

#nullable enable

using System.Collections.Concurrent;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;
using Microsoft.AspNetCore.Mvc.Core;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Microsoft.AspNetCore.Mvc.ModelBinding;

/// <summary>
/// A factory for <see cref="IModelBinder"/> instances.
/// </summary>
public partial class ModelBinderFactory : IModelBinderFactory
{
    private readonly IModelMetadataProvider _metadataProvider;
    private readonly IModelBinderProvider[] _providers;
    private readonly ConcurrentDictionary<Key, IModelBinder> _cache;
    private readonly IServiceProvider _serviceProvider;

    /// <summary>
    /// Creates a new <see cref="ModelBinderFactory"/>.
    /// </summary>
    /// <param name="metadataProvider">The <see cref="IModelMetadataProvider"/>.</param>
    /// <param name="options">The <see cref="IOptions{TOptions}"/> for <see cref="MvcOptions"/>.</param>
    /// <param name="serviceProvider">The <see cref="IServiceProvider"/>.</param>
    public ModelBinderFactory(
        IModelMetadataProvider metadataProvider,
        IOptions<MvcOptions> options,
        IServiceProvider serviceProvider)
    {
        _metadataProvider = metadataProvider;
        _providers = options.Value.ModelBinderProviders.ToArray();
        _serviceProvider = serviceProvider;
        _cache = new ConcurrentDictionary<Key, IModelBinder>();

        var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
        var logger = loggerFactory.CreateLogger<ModelBinderFactory>();
        Log.RegisteredModelBinderProviders(logger, _providers);
    }

    /// <inheritdoc />
    public IModelBinder CreateBinder(ModelBinderFactoryContext context)
    {
        ArgumentNullException.ThrowIfNull(context);

        if (_providers.Length == 0)
        {
            throw new InvalidOperationException(Resources.FormatModelBinderProvidersAreRequired(
                typeof(MvcOptions).FullName,
                nameof(MvcOptions.ModelBinderProviders),
                typeof(IModelBinderProvider).FullName));
        }

        if (TryGetCachedBinder(context.Metadata, context.CacheToken, out var binder))
        {
            return binder;
        }

        // Perf: We're calling the Uncached version of the API here so we can:
        // 1. avoid allocating a context when the value is already cached
        // 2. avoid checking the cache twice when the value is not cached
        var providerContext = new DefaultModelBinderProviderContext(this, context);
        binder = CreateBinderCoreUncached(providerContext, context.CacheToken);
        if (binder == null)
        {
            var message = Resources.FormatCouldNotCreateIModelBinder(providerContext.Metadata.ModelType);
            throw new InvalidOperationException(message);
        }

        Debug.Assert(!(binder is PlaceholderBinder));
        AddToCache(context.Metadata, context.CacheToken, binder);

        return binder;
    }

    // Called by the DefaultModelBinderProviderContext when we're recursively creating a binder
    // so that all intermediate results can be cached.
    private IModelBinder CreateBinderCoreCached(DefaultModelBinderProviderContext providerContext, object? token)
    {
        if (TryGetCachedBinder(providerContext.Metadata, token, out var binder))
        {
            return binder;
        }

        // We're definitely creating a binder for an non-root node here, so it's OK for binder creation
        // to fail.
        binder = CreateBinderCoreUncached(providerContext, token) ?? NoOpBinder.Instance;

        if (!(binder is PlaceholderBinder))
        {
            AddToCache(providerContext.Metadata, token, binder);
        }

        return binder;
    }

    private IModelBinder? CreateBinderCoreUncached(DefaultModelBinderProviderContext providerContext, object? token)
    {
        if (!providerContext.Metadata.IsBindingAllowed)
        {
            return NoOpBinder.Instance;
        }

        // A non-null token will usually be passed in at the top level (ParameterDescriptor likely).
        // This prevents us from treating a parameter the same as a collection-element - which could
        // happen looking at just model metadata.
        var key = new Key(providerContext.Metadata, token);

        // The providerContext.Visited is used here to break cycles in recursion. We need a separate
        // per-operation cache for cycle breaking because the global cache (_cache) needs to always stay
        // in a valid state.
        //
        // We store null as a sentinel inside the providerContext.Visited to track the fact that we've visited
        // a given node but haven't yet created a binder for it. We don't want to eagerly create a
        // PlaceholderBinder because that would result in lots of unnecessary indirection and allocations.
        var visited = providerContext.Visited;

        if (visited.TryGetValue(key, out var binder))
        {
            if (binder != null)
            {
                return binder;
            }

            // If we're currently recursively building a binder for this type, just return
            // a PlaceholderBinder. We'll fix it up later to point to the 'real' binder
            // when the stack unwinds.
            binder = new PlaceholderBinder();
            visited[key] = binder;
            return binder;
        }

        // OK this isn't a recursive case (yet) so add an entry and then ask the providers
        // to create the binder.
        visited.Add(key, null);

        IModelBinder? result = null;

        for (var i = 0; i < _providers.Length; i++)
        {
            var provider = _providers[i];
            result = provider.GetBinder(providerContext);
            if (result != null)
            {
                break;
            }
        }

        // If the PlaceholderBinder was created, then it means we recursed. Hook it up to the 'real' binder.
        if (visited[key] is PlaceholderBinder placeholderBinder)
        {
            // It's also possible that user code called into `CreateBinder` but then returned null, we don't
            // want to create something that will null-ref later so just hook this up to the no-op binder.
            placeholderBinder.Inner = result ?? NoOpBinder.Instance;
        }

        if (result != null)
        {
            visited[key] = result;
        }

        return result;
    }

    private void AddToCache(ModelMetadata metadata, object? cacheToken, IModelBinder binder)
    {
        Debug.Assert(metadata != null);
        Debug.Assert(binder != null);

        if (cacheToken == null)
        {
            return;
        }

        _cache.TryAdd(new Key(metadata, cacheToken), binder);
    }

    private bool TryGetCachedBinder(ModelMetadata metadata, object? cacheToken, [NotNullWhen(true)] out IModelBinder? binder)
    {
        Debug.Assert(metadata != null);

        if (cacheToken == null)
        {
            binder = null;
            return false;
        }

        return _cache.TryGetValue(new Key(metadata, cacheToken), out binder);
    }

    private sealed class DefaultModelBinderProviderContext : ModelBinderProviderContext
    {
        private readonly ModelBinderFactory _factory;

        public DefaultModelBinderProviderContext(
            ModelBinderFactory factory,
            ModelBinderFactoryContext factoryContext)
        {
            _factory = factory;
            Metadata = factoryContext.Metadata;
            BindingInfo bindingInfo;
            if (factoryContext.BindingInfo != null)
            {
                bindingInfo = new BindingInfo(factoryContext.BindingInfo);
            }
            else
            {
                bindingInfo = new BindingInfo();
            }

            bindingInfo.TryApplyBindingInfo(Metadata);
            BindingInfo = bindingInfo;

            MetadataProvider = _factory._metadataProvider;
            Visited = new Dictionary<Key, IModelBinder?>();
        }

        private DefaultModelBinderProviderContext(
            DefaultModelBinderProviderContext parent,
            ModelMetadata metadata,
            BindingInfo bindingInfo)
        {
            Metadata = metadata;

            _factory = parent._factory;
            MetadataProvider = parent.MetadataProvider;
            Visited = parent.Visited;
            BindingInfo = bindingInfo;
        }

        public override BindingInfo BindingInfo { get; }

        public override ModelMetadata Metadata { get; }

        public override IModelMetadataProvider MetadataProvider { get; }

        public Dictionary<Key, IModelBinder?> Visited { get; }

        public override IServiceProvider Services => _factory._serviceProvider;

        public override IModelBinder CreateBinder(ModelMetadata metadata)
        {
            var bindingInfo = new BindingInfo();
            bindingInfo.TryApplyBindingInfo(metadata);

            return CreateBinder(metadata, bindingInfo);
        }

        public override IModelBinder CreateBinder(ModelMetadata metadata, BindingInfo bindingInfo)
        {
            ArgumentNullException.ThrowIfNull(metadata);
            ArgumentNullException.ThrowIfNull(bindingInfo);

            // For non-root nodes we use the ModelMetadata as the cache token. This ensures that all non-root
            // nodes with the same metadata will have the same binder. This is OK because for an non-root
            // node there's no opportunity to customize binding info like there is for a parameter.
            var token = metadata;

            var nestedContext = new DefaultModelBinderProviderContext(this, metadata, bindingInfo);
            return _factory.CreateBinderCoreCached(nestedContext, token);
        }
    }

    // This key allows you to specify a ModelMetadata which represents the type/property being bound
    // and a 'token' which acts as an arbitrary discriminator.
    //
    // This is necessary because the same metadata might be bound as a top-level parameter (with BindingInfo on
    // the ParameterDescriptor) or in a call to TryUpdateModel (no BindingInfo) or as a collection element.
    //
    // We need to be able to tell the difference between these things to avoid over-caching.
    private readonly struct Key : IEquatable<Key>
    {
        private readonly ModelMetadata _metadata;
        private readonly object? _token; // Explicitly using ReferenceEquality for tokens.

        public Key(ModelMetadata metadata, object? token)
        {
            _metadata = metadata;
            _token = token;
        }

        public bool Equals(Key other)
        {
            return _metadata.Equals(other._metadata) && object.ReferenceEquals(_token, other._token);
        }

        public override bool Equals(object? obj)
        {
            return obj is Key other && Equals(other);
        }

        public override int GetHashCode()
        {
            return HashCode.Combine(_metadata, RuntimeHelpers.GetHashCode(_token));
        }

        public override string ToString()
        {
            switch (_metadata.MetadataKind)
            {
                case ModelMetadataKind.Parameter:
                    return $"{_token} (Parameter: '{_metadata.ParameterName}' Type: '{_metadata.ModelType.Name}')";
                case ModelMetadataKind.Property:
                    return $"{_token} (Property: '{_metadata.ContainerType!.Name}.{_metadata.PropertyName}' " +
                        $"Type: '{_metadata.ModelType.Name}')";
                case ModelMetadataKind.Type:
                    return $"{_token} (Type: '{_metadata.ModelType.Name}')";
                default:
                    return $"Unsupported MetadataKind '{_metadata.MetadataKind}'.";
            }
        }
    }

    private static partial class Log
    {
        [LoggerMessage(12, LogLevel.Debug, "Registered model binder providers, in the following order: {ModelBinderProviders}", EventName = "RegisteredModelBinderProviders")]
        public static partial void RegisteredModelBinderProviders(ILogger logger, IModelBinderProvider[] modelBinderProviders);
    }
}
