﻿// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.ErrorReporting;
using Newtonsoft.Json;
using Roslyn.Utilities;
using StreamJsonRpc;

namespace Microsoft.CodeAnalysis.Interactive
{
    /// <summary>
    /// Represents a process that hosts an interactive session.
    /// </summary>
    /// <remarks>
    /// Handles spawning of the host process and communication between the local callers and the remote session.
    /// </remarks>
    internal sealed partial class InteractiveHost : IDisposable
    {
        internal const InteractiveHostPlatform DefaultPlatform = InteractiveHostPlatform.Desktop32;

        /// <summary>
        /// Use Unicode encoding for STDOUT and STDERR of the InteractiveHost process.
        /// Ideally, we would use UTF8 but SetConsoleOutputCP Windows API fails with "Invalid Handle" when Console.OutputEncoding is set to UTF8.
        /// (issue tracked by https://github.com/dotnet/roslyn/issues/47571, https://devdiv.visualstudio.com/DevDiv/_workitems/edit/1253106)
        /// Unicode is not ideal since the message printed directly to STDOUT/STDERR from native code that do not encode the output are going to be garbled
        /// (e.g. messages reported by CLR stack overflow and OOM exception handlers: https://github.com/dotnet/runtime/issues/45503).
        /// </summary>
        internal static readonly Encoding OutputEncoding = Encoding.Unicode;

        private static readonly JsonRpcTargetOptions s_jsonRpcTargetOptions = new JsonRpcTargetOptions()
        {
            // Do not allow JSON-RPC to automatically subscribe to events and remote their calls.
            NotifyClientOfEvents = false,

            // Only allow public methods (may be on internal types) to be invoked remotely.
            AllowNonPublicInvocation = false
        };

        private readonly Type _replServiceProviderType;
        private readonly string _initialWorkingDirectory;

        // adjustable for testing purposes
        private readonly int _millisecondsTimeout;
        private const int MaxAttemptsToCreateProcess = 2;

        private LazyRemoteService? _lazyRemoteService;
        private int _remoteServiceInstanceId;
        private TextWriter _output;
        private TextWriter _errorOutput;
        private readonly object _outputGuard;
        private readonly object _errorOutputGuard;

        /// <remarks>
        /// Test only setting.
        /// True to join output writing threads when the host is being disposed.
        /// We have to join the threads before each test is finished, otherwise xunit won't be able to unload the AppDomain.
        /// WARNING: Joining the threads might deadlock if <see cref="Dispose()"/> is executing on the UI thread, 
        /// since the threads are dispatching to UI thread to write the output to the editor buffer.
        /// </remarks>
        private readonly bool _joinOutputWritingThreadsOnDisposal;

        internal event Action<InteractiveHostPlatformInfo, InteractiveHostOptions, RemoteExecutionResult>? ProcessInitialized;

        public InteractiveHost(
            Type replServiceProviderType,
            string workingDirectory,
            int millisecondsTimeout = 5000,
            bool joinOutputWritingThreadsOnDisposal = false)
        {
            _millisecondsTimeout = millisecondsTimeout;
            _joinOutputWritingThreadsOnDisposal = joinOutputWritingThreadsOnDisposal;
            _output = TextWriter.Null;
            _errorOutput = TextWriter.Null;
            _replServiceProviderType = replServiceProviderType;
            _initialWorkingDirectory = workingDirectory;
            _outputGuard = new object();
            _errorOutputGuard = new object();
        }

        #region Test hooks

        internal event Action<char[], int>? OutputReceived;
        internal event Action<char[], int>? ErrorOutputReceived;

        internal Process? TryGetProcess()
            => _lazyRemoteService?.TryGetInitializedService()?.Service?.Process;

        internal async Task<RemoteService?> TryGetServiceAsync()
            => (await TryGetOrCreateRemoteServiceAsync().ConfigureAwait(false)).Service;

        // Triggered whenever we create a fresh process.
        // The ProcessExited event is not hooked yet.
        internal event Action<Process>? InteractiveHostProcessCreated;

        // Triggered whenever InteractiveHost process creation fails.
        internal event Action<Exception?, int?>? InteractiveHostProcessCreationFailed;

        #endregion

        ~InteractiveHost()
        {
            DisposeRemoteService();
        }

        // Dispose may be called anytime.
        public void Dispose()
        {
            // Run this in background to avoid deadlocking with UIThread operations performing with active outputs.
            _ = Task.Run(() => SetOutputs(TextWriter.Null, TextWriter.Null));

            DisposeRemoteService();
            GC.SuppressFinalize(this);
        }

        private void DisposeRemoteService()
        {
            Interlocked.Exchange(ref _lazyRemoteService, null)?.Dispose();
        }

        public void SetOutputs(TextWriter output, TextWriter errorOutput)
        {
            if (output == null)
            {
                throw new ArgumentNullException(nameof(output));
            }

            if (errorOutput == null)
            {
                throw new ArgumentNullException(nameof(errorOutput));
            }

            lock (_outputGuard)
            {
                _output.Flush();
                _output = output;
            }

            lock (_errorOutputGuard)
            {
                _errorOutput.Flush();
                _errorOutput = errorOutput;
            }
        }

        internal void OnOutputReceived(bool error, char[] buffer, int count)
        {
            (error ? ErrorOutputReceived : OutputReceived)?.Invoke(buffer, count);

            var writer = error ? _errorOutput : _output;
            var guard = error ? _errorOutputGuard : _outputGuard;

            lock (guard)
            {
                writer.Write(buffer, 0, count);
            }
        }

        private void WriteOutputInBackground(bool isError, string firstLine, string? secondLine = null)
        {
            var writer = isError ? _errorOutput : _output;
            var guard = isError ? _errorOutputGuard : _outputGuard;

            // We cannot guarantee that writers can perform writing synchronously 
            // without deadlocks with other operations.
            // This could happen, for example, for writers provided by the Interactive Window,
            // and in the case where the window is being disposed.
            Task.Run(() =>
            {
                lock (guard)
                {
                    writer.WriteLine(firstLine);
                    if (secondLine != null)
                    {
                        writer.WriteLine(secondLine);
                    }
                }
            });
        }

        private LazyRemoteService CreateRemoteService(InteractiveHostOptions options, bool skipInitialization)
        {
            return new LazyRemoteService(this, options, Interlocked.Increment(ref _remoteServiceInstanceId), skipInitialization);
        }

        private Task OnProcessExitedAsync(Process process)
        {
            ReportProcessExited(process);
            return TryGetOrCreateRemoteServiceAsync();
        }

        private void ReportProcessExited(Process process)
        {
            int? exitCode;
            try
            {
                exitCode = process.HasExited ? process.ExitCode : (int?)null;
            }
            catch
            {
                exitCode = null;
            }

            if (exitCode.HasValue)
            {
                WriteOutputInBackground(isError: true, string.Format(InteractiveHostResources.Hosting_process_exited_with_exit_code_0, exitCode.Value));
            }
        }

        private async Task<InitializedRemoteService> TryGetOrCreateRemoteServiceAsync()
        {
            try
            {
                LazyRemoteService? currentRemoteService = _lazyRemoteService;

                for (int attempt = 0; attempt < MaxAttemptsToCreateProcess; attempt++)
                {
                    // Remote service may be disposed anytime.
                    if (currentRemoteService == null)
                    {
                        return default;
                    }

                    var initializedService = await currentRemoteService.GetInitializedServiceAsync().ConfigureAwait(false);
                    if (initializedService.Service != null && initializedService.Service.Process.IsAlive())
                    {
                        return initializedService;
                    }

                    // Service failed to start or initialize or the process died.
                    var newService = CreateRemoteService(currentRemoteService.Options, skipInitialization: !initializedService.InitializationResult.Success);

                    var previousService = Interlocked.CompareExchange(ref _lazyRemoteService, newService, currentRemoteService);
                    if (previousService == currentRemoteService)
                    {
                        // we replaced the service whose process we know is dead:
                        currentRemoteService.Dispose();
                        currentRemoteService = newService;
                    }
                    else
                    {
                        // the process was reset in between our checks, try to use the new service:
                        newService.Dispose();
                        currentRemoteService = previousService;
                    }
                }

                WriteOutputInBackground(isError: true, InteractiveHostResources.Unable_to_create_hosting_process);
            }
            catch (OperationCanceledException)
            {
                // The user reset the process during initialization. 
                // The reset operation will recreate the process.
            }
            catch (Exception e) when (FatalError.ReportAndPropagate(e))
            {
                throw ExceptionUtilities.Unreachable();
            }

            return default;
        }

        private async Task<RemoteExecutionResult> ExecuteRemoteAsync(string targetName, params object?[] arguments)
            => (await InvokeRemoteAsync<RemoteExecutionResult.Data>(targetName, arguments).ConfigureAwait(false))?.Deserialize() ?? default;

        private async Task<TResult> InvokeRemoteAsync<TResult>(string targetName, params object?[] arguments)
        {
            var initializedRemoteService = await TryGetOrCreateRemoteServiceAsync().ConfigureAwait(false);
            if (initializedRemoteService.Service == null)
            {
                return default!;
            }

            return await InvokeRemoteAsync<TResult>(initializedRemoteService.Service, targetName, arguments).ConfigureAwait(false);
        }

        private static async Task<RemoteExecutionResult> ExecuteRemoteAsync(RemoteService remoteService, string targetName, params object?[] arguments)
            => (await InvokeRemoteAsync<RemoteExecutionResult.Data>(remoteService, targetName, arguments).ConfigureAwait(false))?.Deserialize() ?? default;

        private static async Task<TResult> InvokeRemoteAsync<TResult>(RemoteService remoteService, string targetName, params object?[] arguments)
        {
            try
            {
                return await remoteService.JsonRpc.InvokeAsync<TResult>(targetName, arguments).ConfigureAwait(false);
            }
            catch (Exception e) when (e is ObjectDisposedException || !remoteService.Process.IsAlive())
            {
                return default!;
            }
        }

        private static JsonRpc CreateRpc(Stream stream, object? incomingCallTarget)
        {
            var jsonFormatter = new JsonMessageFormatter();

            // disable interpreting of strings as DateTime during deserialization:
            jsonFormatter.JsonSerializer.DateParseHandling = DateParseHandling.None;

            var rpc = new JsonRpc(new HeaderDelimitedMessageHandler(stream, jsonFormatter))
            {
                CancelLocallyInvokedMethodsWhenConnectionIsClosed = true,
                ExceptionStrategy = ExceptionProcessing.ISerializable,
            };

            if (incomingCallTarget != null)
            {
                rpc.AddLocalRpcTarget(incomingCallTarget, s_jsonRpcTargetOptions);
            }

            rpc.StartListening();

            return rpc;
        }

        #region Operations

        public InteractiveHostOptions? OptionsOpt
            => _lazyRemoteService?.Options;

        /// <summary>
        /// Restarts and reinitializes the host process (or starts a new one if it is not running yet).
        /// </summary>
        /// <param name="options">The options to initialize the new process with.</param>
        public async Task<RemoteExecutionResult> ResetAsync(InteractiveHostOptions options)
        {
            try
            {
                // replace the existing service with a new one:
                var newService = CreateRemoteService(options, skipInitialization: false);

                var oldService = Interlocked.Exchange(ref _lazyRemoteService, newService);
                oldService?.Dispose();

                var initializedService = await TryGetOrCreateRemoteServiceAsync().ConfigureAwait(false);
                if (initializedService.Service == null)
                {
                    return default;
                }

                return initializedService.InitializationResult;
            }
            catch (Exception e) when (FatalError.ReportAndPropagate(e))
            {
                throw ExceptionUtilities.Unreachable();
            }
        }

        /// <summary>
        /// Asynchronously executes given code in the remote interactive session.
        /// </summary>
        /// <param name="code">The code to execute.</param>
        /// <remarks>
        /// This method is thread safe but operations are sent to the remote process
        /// asynchronously so tasks should be executed serially if order is important.
        /// </remarks>
        public Task<RemoteExecutionResult> ExecuteAsync(string code)
        {
            Contract.ThrowIfNull(code);
            return ExecuteRemoteAsync(nameof(Service.ExecuteAsync), code);
        }

        /// <summary>
        /// Asynchronously executes given code in the remote interactive session.
        /// </summary>
        /// <param name="path">The file to execute.</param>
        /// <exception cref="ArgumentNullException"><paramref name="path"/> is null.</exception>
        /// <remarks>
        /// This method is thread safe but operations are sent to the remote process
        /// asynchronously so tasks should be executed serially if order is important.
        /// </remarks>
        public Task<RemoteExecutionResult> ExecuteFileAsync(string path)
        {
            Contract.ThrowIfNull(path);
            return ExecuteRemoteAsync(nameof(Service.ExecuteFileAsync), path);
        }

        /// <summary>
        /// Asynchronously adds a reference to the set of available references for next submission.
        /// </summary>
        /// <param name="reference">The reference to add.</param>
        /// <remarks>
        /// This method is thread safe but operations are sent to the remote process
        /// asynchronously so tasks should be executed serially if order is important.
        /// </remarks>
        public Task<bool> AddReferenceAsync(string reference)
        {
            Contract.ThrowIfNull(reference);
            return InvokeRemoteAsync<bool>(nameof(Service.AddReferenceAsync), reference);
        }

        /// <summary>
        /// Sets the current session's search paths and base directory.
        /// </summary>
        public Task<RemoteExecutionResult> SetPathsAsync(ImmutableArray<string> referenceSearchPaths, ImmutableArray<string> sourceSearchPaths, string baseDirectory)
        {
            Contract.ThrowIfTrue(referenceSearchPaths.IsDefault);
            Contract.ThrowIfTrue(sourceSearchPaths.IsDefault);
            Contract.ThrowIfNull(baseDirectory);

            return ExecuteRemoteAsync(nameof(Service.SetPathsAsync), referenceSearchPaths, sourceSearchPaths, baseDirectory);
        }

        #endregion
    }
}
