// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.IO.Pipes;

using Microsoft.CodeAnalysis;
using Microsoft.Testing.Platform.Helpers;

namespace Microsoft.Testing.Platform.IPC;

[Embedded]
#if !MTP_MSBUILD_TASKS
[UnsupportedOSPlatform("browser")]
#endif
internal sealed class NamedPipeClient : NamedPipeConnectionBase, IClient
{
    private const PipeOptions AsyncCurrentUserPipeOptions = PipeOptions.Asynchronous
#if NET
        | PipeOptions.CurrentUserOnly
#endif
        ;

    private readonly NamedPipeClientStream _namedPipeClientStream;
    private readonly SemaphoreSlim _lock = new(1, 1);
    private readonly IEnvironment _environment;
    private readonly bool _exitProcessOnConnectionLoss;

    private bool _disposed;

    public NamedPipeClient(string name)
        : this(name, new SystemEnvironment())
    {
    }

    public NamedPipeClient(string name, IEnvironment environment)
        : this(name, environment, exitProcessOnConnectionLoss: true)
    {
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="NamedPipeClient"/> class connecting to the named pipe
    /// <paramref name="name"/>.
    /// </summary>
    /// <param name="name">The OS-level named pipe name to connect to.</param>
    /// <param name="environment">The environment abstraction used for process-exit on connection loss.</param>
    /// <param name="exitProcessOnConnectionLoss">
    /// When <see langword="true"/> (the default) a lost connection - the server disconnecting while we write a
    /// request or before it sends a response - terminates the process via <see cref="IEnvironment.Exit(int)"/>.
    /// This is the right behavior for the primary data pipe: if we cannot talk to the host there is no way to
    /// recover. Set it to <see langword="false"/> for auxiliary channels (e.g. the reverse server-control pipe)
    /// where a dropped connection should surface as an exception the caller can handle cooperatively rather than
    /// killing the whole test host.
    /// </param>
    public NamedPipeClient(string name, IEnvironment environment, bool exitProcessOnConnectionLoss)
    {
        if (name is null)
        {
            throw new ArgumentNullException(nameof(name));
        }

        _namedPipeClientStream = new(".", name, PipeDirection.InOut, AsyncCurrentUserPipeOptions);
        PipeName = name;
        _environment = environment;
        _exitProcessOnConnectionLoss = exitProcessOnConnectionLoss;
    }

    public string PipeName { get; }

    public bool IsConnected => _namedPipeClientStream.IsConnected;

    public async Task ConnectAsync(CancellationToken cancellationToken)
        => await _namedPipeClientStream.ConnectAsync(cancellationToken).ConfigureAwait(false);

    public async Task<TResponse> RequestReplyAsync<TRequest, TResponse>(TRequest request, CancellationToken cancellationToken)
       where TRequest : IRequest
       where TResponse : IResponse
    {
        await _lock.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            INamedPipeSerializer requestNamedPipeSerializer = GetSerializer(typeof(TRequest));

            // Serialize and send the request
            try
            {
                await WriteMessageAsync(_namedPipeClientStream, requestNamedPipeSerializer, request, cancellationToken).ConfigureAwait(false);
            }
            catch (Exception ex) when (ex is IOException or ObjectDisposedException)
            {
                // The server disconnected while we were writing the request. Mirror the read-EOF handling
                // below: if we cannot deliver the request there's no way to recover, so exit abnormally
                // instead of surfacing a raw IPC error to the caller.
                if (!_exitProcessOnConnectionLoss)
                {
                    // Auxiliary channel: let the caller observe the disconnect and react cooperatively.
                    throw;
                }

                _environment.Exit((int)ExitCode.GenericFailure);
                throw;
            }

            // Read the response
            object? response = await ReadNextMessageAsync(_namedPipeClientStream, cancellationToken).ConfigureAwait(false);
            if (response is null)
            {
                // We are reading a message response.
                // If we cannot get a response, there is no way we can recover and continue executing.
                // This can happen if the other processes gets killed or crashes while while it's sending the response.
                // This is especially important for 'dotnet test', where the user can simply kill the dotnet.exe process themselves.
                // In that case, we want the MTP process to also die.
                // Exit code 1 indicates abnormal termination due to IPC connection loss.
                if (!_exitProcessOnConnectionLoss)
                {
                    // Auxiliary channel (e.g. the reverse server-control pipe): a dropped connection means the
                    // peer went away. Surface it as an exception so the caller can react (e.g. treat "host gone"
                    // as a cooperative cancel) instead of killing the whole test host.
                    throw new IOException($"Pipe '{PipeName}' was closed by the server before a response was received.");
                }

                // Surface a diagnostic on stderr so the user has a chance to understand why this process is exiting.
                // We deliberately use Console.Error (and not stdout) to avoid corrupting any machine-readable output
                // that may be flowing through stdout.
                try
                {
                    await Console.Error.WriteLineAsync($"[NamedPipeClient] Pipe '{PipeName}' was closed by the server before a response was received. The peer process likely exited or was killed. Terminating with exit code {(int)ExitCode.GenericFailure}.").ConfigureAwait(false);
                }
                catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException or NotSupportedException or ArgumentException or OperationCanceledException)
                {
                    // Best-effort diagnostic only; never let logging failures shadow the original problem.
                }

                _environment.Exit((int)ExitCode.GenericFailure);

                // _environment.Exit normally terminates the process and never returns. Guard against
                // alternate IEnvironment implementations (e.g. tests) that don't terminate by throwing
                // explicitly — otherwise we would fall through to the cast below and return null.
                throw new InvalidOperationException($"Pipe '{PipeName}' was closed by the server before a response was received.");
            }

            return (TResponse)response!;
        }
        finally
        {
            _lock.Release();
        }
    }

    public void Dispose()
    {
        if (_disposed)
        {
            return;
        }

        _lock.Dispose();
        DisposeBuffers();
        _namedPipeClientStream.Dispose();
        _disposed = true;
    }
}
