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

using System.Collections.Concurrent;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.JSInterop.Infrastructure;
using static Microsoft.AspNetCore.Internal.LinkerFlags;

namespace Microsoft.JSInterop
{
    /// <summary>
    /// Abstract base class for a JavaScript runtime.
    /// </summary>
    public abstract partial class JSRuntime : IJSRuntime, IDisposable
    {
        private long _nextObjectReferenceId; // Initial value of 0 signals no object, but we increment prior to assignment. The first tracked object should have id 1
        private long _nextPendingTaskId = 1; // Start at 1 because zero signals "no response needed"
        private readonly ConcurrentDictionary<long, object> _pendingTasks = new();
        private readonly ConcurrentDictionary<long, IDotNetObjectReference> _trackedRefsById = new();
        private readonly ConcurrentDictionary<long, CancellationTokenRegistration> _cancellationRegistrations = new();

        internal readonly ArrayBuilder<byte[]> ByteArraysToBeRevived = new();

        /// <summary>
        /// Initializes a new instance of <see cref="JSRuntime"/>.
        /// </summary>
        protected JSRuntime()
        {
            JsonSerializerOptions = new JsonSerializerOptions
            {
                MaxDepth = 32,
                PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
                PropertyNameCaseInsensitive = true,
                Converters =
                {
                    new DotNetObjectReferenceJsonConverterFactory(this),
                    new JSObjectReferenceJsonConverter(this),
                    new JSStreamReferenceJsonConverter(this),
                    new DotNetStreamReferenceJsonConverter(this),
                    new ByteArrayJsonConverter(this),
                }
            };
        }

        /// <summary>
        /// Gets the <see cref="System.Text.Json.JsonSerializerOptions"/> used to serialize and deserialize interop payloads.
        /// </summary>
        protected internal JsonSerializerOptions JsonSerializerOptions { get; }

        /// <summary>
        /// Gets or sets the default timeout for asynchronous JavaScript calls.
        /// </summary>
        protected TimeSpan? DefaultAsyncTimeout { get; set; }

        /// <summary>
        /// Invokes the specified JavaScript function asynchronously.
        /// <para>
        /// <see cref="JSRuntime"/> will apply timeouts to this operation based on the value configured in <see cref="DefaultAsyncTimeout"/>. To dispatch a call with a different, or no timeout,
        /// consider using <see cref="InvokeAsync{TValue}(string, CancellationToken, object[])" />.
        /// </para>
        /// </summary>
        /// <typeparam name="TValue">The JSON-serializable return type.</typeparam>
        /// <param name="identifier">An identifier for the function to invoke. For example, the value <c>"someScope.someFunction"</c> will invoke the function <c>window.someScope.someFunction</c>.</param>
        /// <param name="args">JSON-serializable arguments.</param>
        /// <returns>An instance of <typeparamref name="TValue"/> obtained by JSON-deserializing the return value.</returns>
        public ValueTask<TValue> InvokeAsync<[DynamicallyAccessedMembers(JsonSerialized)] TValue>(string identifier, object?[]? args)
            => InvokeAsync<TValue>(0, identifier, args);

        /// <summary>
        /// Invokes the specified JavaScript function asynchronously.
        /// </summary>
        /// <typeparam name="TValue">The JSON-serializable return type.</typeparam>
        /// <param name="identifier">An identifier for the function to invoke. For example, the value <c>"someScope.someFunction"</c> will invoke the function <c>window.someScope.someFunction</c>.</param>
        /// <param name="cancellationToken">
        /// A cancellation token to signal the cancellation of the operation. Specifying this parameter will override any default cancellations such as due to timeouts
        /// (<see cref="DefaultAsyncTimeout"/>) from being applied.
        /// </param>
        /// <param name="args">JSON-serializable arguments.</param>
        /// <returns>An instance of <typeparamref name="TValue"/> obtained by JSON-deserializing the return value.</returns>
        public ValueTask<TValue> InvokeAsync<[DynamicallyAccessedMembers(JsonSerialized)] TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
            => InvokeAsync<TValue>(0, identifier, cancellationToken, args);

        internal async ValueTask<TValue> InvokeAsync<[DynamicallyAccessedMembers(JsonSerialized)] TValue>(long targetInstanceId, string identifier, object?[]? args)
        {
            if (DefaultAsyncTimeout.HasValue)
            {
                using var cts = new CancellationTokenSource(DefaultAsyncTimeout.Value);
                // We need to await here due to the using
                return await InvokeAsync<TValue>(targetInstanceId, identifier, cts.Token, args);
            }

            return await InvokeAsync<TValue>(targetInstanceId, identifier, CancellationToken.None, args);
        }

        internal ValueTask<TValue> InvokeAsync<[DynamicallyAccessedMembers(JsonSerialized)] TValue>(
            long targetInstanceId,
            string identifier,
            CancellationToken cancellationToken,
            object?[]? args)
        {
            var taskId = Interlocked.Increment(ref _nextPendingTaskId);
            var tcs = new TaskCompletionSource<TValue>();
            if (cancellationToken.CanBeCanceled)
            {
                _cancellationRegistrations[taskId] = cancellationToken.Register(() =>
                {
                    tcs.TrySetCanceled(cancellationToken);
                    CleanupTasksAndRegistrations(taskId);
                });
            }
            _pendingTasks[taskId] = tcs;

            try
            {
                if (cancellationToken.IsCancellationRequested)
                {
                    tcs.TrySetCanceled(cancellationToken);
                    CleanupTasksAndRegistrations(taskId);

                    return new ValueTask<TValue>(tcs.Task);
                }

                var argsJson = args is not null && args.Length != 0 ?
                    JsonSerializer.Serialize(args, JsonSerializerOptions) :
                    null;
                var resultType = JSCallResultTypeHelper.FromGeneric<TValue>();

                BeginInvokeJS(taskId, identifier, argsJson, resultType, targetInstanceId);

                return new ValueTask<TValue>(tcs.Task);
            }
            catch
            {
                CleanupTasksAndRegistrations(taskId);
                throw;
            }
        }

        private void CleanupTasksAndRegistrations(long taskId)
        {
            _pendingTasks.TryRemove(taskId, out _);
            if (_cancellationRegistrations.TryRemove(taskId, out var registration))
            {
                registration.Dispose();
            }
        }

        /// <summary>
        /// Begins an asynchronous function invocation.
        /// </summary>
        /// <param name="taskId">The identifier for the function invocation, or zero if no async callback is required.</param>
        /// <param name="identifier">The identifier for the function to invoke.</param>
        /// <param name="argsJson">A JSON representation of the arguments.</param>
        protected virtual void BeginInvokeJS(long taskId, string identifier, string? argsJson)
            => BeginInvokeJS(taskId, identifier, argsJson, JSCallResultType.Default, 0);

        /// <summary>
        /// Begins an asynchronous function invocation.
        /// </summary>
        /// <param name="taskId">The identifier for the function invocation, or zero if no async callback is required.</param>
        /// <param name="identifier">The identifier for the function to invoke.</param>
        /// <param name="argsJson">A JSON representation of the arguments.</param>
        /// <param name="resultType">The type of result expected from the invocation.</param>
        /// <param name="targetInstanceId">The instance ID of the target JS object.</param>
        protected abstract void BeginInvokeJS(long taskId, string identifier, string? argsJson, JSCallResultType resultType, long targetInstanceId);

        /// <summary>
        /// Completes an async JS interop call from JavaScript to .NET
        /// </summary>
        /// <param name="invocationInfo">The <see cref="DotNetInvocationInfo"/>.</param>
        /// <param name="invocationResult">The <see cref="DotNetInvocationResult"/>.</param>
        protected internal abstract void EndInvokeDotNet(
            DotNetInvocationInfo invocationInfo,
            in DotNetInvocationResult invocationResult);

        /// <summary>
        /// Transfers a byte array from .NET to JS.
        /// </summary>
        /// <param name="id">Atomically incrementing identifier for the byte array being transfered.</param>
        /// <param name="data">Byte array to be transfered to JS.</param>
        protected internal virtual void SendByteArray(int id, byte[] data)
        {
            throw new NotSupportedException("JSRuntime subclasses are responsible for implementing byte array transfer to JS.");
        }

        /// <summary>
        /// Accepts the byte array data being transferred from JS to DotNet.
        /// </summary>
        /// <param name="id">Identifier for the byte array being transfered.</param>
        /// <param name="data">Byte array to be transfered from JS.</param>
        protected internal virtual void ReceiveByteArray(int id, byte[] data)
        {
            if (id == 0)
            {
                // Starting a new transfer, clear out previously stored byte arrays
                // in case they haven't been cleared already.
                ByteArraysToBeRevived.Clear();
            }

            if (id != ByteArraysToBeRevived.Count)
            {
                throw new ArgumentOutOfRangeException($"Element id '{id}' cannot be added to the byte arrays to be revived with length '{ByteArraysToBeRevived.Count}'.", innerException: null);
            }

            ByteArraysToBeRevived.Append(data);
        }

        /// <summary>
        /// Provides a <see cref="Stream"/> for the data reference represented by <paramref name="jsStreamReference"/>.
        /// </summary>
        /// <param name="jsStreamReference"><see cref="IJSStreamReference"/> to produce a data stream for.</param>
        /// <param name="totalLength">Expected length of the incoming data stream.</param>
        /// <param name="cancellationToken"><see cref="CancellationToken" /> for cancelling read.</param>
        /// <returns><see cref="Stream"/> for the data reference represented by <paramref name="jsStreamReference"/>.</returns>
        protected internal virtual Task<Stream> ReadJSDataAsStreamAsync(IJSStreamReference jsStreamReference, long totalLength, CancellationToken cancellationToken = default)
        {
            // The reason it's virtual and not abstract is just for back-compat

            // JSRuntime subclasses should override this method, and implement their own system for returning a Stream
            // representing the contents of the IJSObjectReference (whose value on the JS side will be an ArrayBufferLike).
            // The transport mechanism will be completely different between, say, Server and WebAssembly.
            throw new NotSupportedException("The current JavaScript runtime does not support reading data streams.");
        }

        [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2072:RequiresUnreferencedCode", Justification = "We enforce trimmer attributes for JSON deserialized types on InvokeAsync.")]
        internal bool EndInvokeJS(long taskId, bool succeeded, ref Utf8JsonReader jsonReader)
        {
            if (!_pendingTasks.TryRemove(taskId, out var tcs))
            {
                // We should simply return if we can't find an id for the invocation.
                // This likely means that the method that initiated the call defined a timeout and stopped waiting.
                return false;
            }

            CleanupTasksAndRegistrations(taskId);

            try
            {
                if (succeeded)
                {
                    var resultType = TaskGenericsUtil.GetTaskCompletionSourceResultType(tcs);

                    var result = JsonSerializer.Deserialize(ref jsonReader, resultType, JsonSerializerOptions);
                    ByteArraysToBeRevived.Clear();
                    TaskGenericsUtil.SetTaskCompletionSourceResult(tcs, result);
                }
                else
                {
                    var exceptionText = jsonReader.GetString() ?? string.Empty;
                    TaskGenericsUtil.SetTaskCompletionSourceException(tcs, new JSException(exceptionText));
                }

                return true;
            }
            catch (Exception exception)
            {
                var message = $"An exception occurred executing JS interop: {exception.Message}. See InnerException for more details.";
                TaskGenericsUtil.SetTaskCompletionSourceException(tcs, new JSException(message, exception));
                return false;
            }
        }

        /// <summary>
        /// Transmits the stream data from .NET to JS. Subclasses should override this method and provide
        /// an implementation that transports the data to JS and calls DotNet.jsCallDispatcher.supplyDotNetStream.
        /// </summary>
        /// <param name="streamId">An identifier for the stream.</param>
        /// <param name="dotNetStreamReference">Reference to the .NET stream along with whether the stream should be left open.</param>
        protected internal virtual Task TransmitStreamAsync(long streamId, DotNetStreamReference dotNetStreamReference)
        {
            if (!dotNetStreamReference.LeaveOpen)
            {
                dotNetStreamReference.Stream.Dispose();
            }

            throw new NotSupportedException("The current JS runtime does not support sending streams from .NET to JS.");
        }

        internal long BeginTransmittingStream(DotNetStreamReference dotNetStreamReference)
        {
            // It's fine to share the ID sequence
            var streamId = Interlocked.Increment(ref _nextObjectReferenceId);

            // Fire and forget sending the stream so the client can proceed to
            // reading the stream.
            _ = TransmitStreamAsync(streamId, dotNetStreamReference);

            return streamId;
        }

        internal long TrackObjectReference<TValue>(DotNetObjectReference<TValue> dotNetObjectReference) where TValue : class
        {
            if (dotNetObjectReference == null)
            {
                throw new ArgumentNullException(nameof(dotNetObjectReference));
            }

            dotNetObjectReference.ThrowIfDisposed();

            var jsRuntime = dotNetObjectReference.JSRuntime;
            if (jsRuntime is null)
            {
                var dotNetObjectId = Interlocked.Increment(ref _nextObjectReferenceId);

                dotNetObjectReference.JSRuntime = this;
                dotNetObjectReference.ObjectId = dotNetObjectId;

                _trackedRefsById[dotNetObjectId] = dotNetObjectReference;
            }
            else if (!ReferenceEquals(this, jsRuntime))
            {
                throw new InvalidOperationException($"{dotNetObjectReference.GetType().Name} is already being tracked by a different instance of {nameof(JSRuntime)}." +
                    $" A common cause is caching an instance of {nameof(DotNetObjectReference<TValue>)} globally. Consider creating instances of {nameof(DotNetObjectReference<TValue>)} at the JSInterop callsite.");
            }

            Debug.Assert(dotNetObjectReference.ObjectId != 0);
            return dotNetObjectReference.ObjectId;
        }

        internal IDotNetObjectReference GetObjectReference(long dotNetObjectId)
        {
            return _trackedRefsById.TryGetValue(dotNetObjectId, out var dotNetObjectRef)
                ? dotNetObjectRef
                : throw new ArgumentException($"There is no tracked object with id '{dotNetObjectId}'. Perhaps the DotNetObjectReference instance was already disposed.", nameof(dotNetObjectId));
        }

        /// <summary>
        /// Stops tracking the specified .NET object reference.
        /// This may be invoked either by disposing a DotNetObjectRef in .NET code, or via JS interop by calling "dispose" on the corresponding instance in JavaScript code
        /// </summary>
        /// <param name="dotNetObjectId">The ID of the <see cref="DotNetObjectReference{TValue}"/>.</param>
        internal void ReleaseObjectReference(long dotNetObjectId) => _trackedRefsById.TryRemove(dotNetObjectId, out _);

        /// <summary>
        /// Dispose the JSRuntime.
        /// </summary>
        public void Dispose() => ByteArraysToBeRevived.Dispose();
    }
}
