using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Net.Sockets;
using System.Reflection;
using System.Text;

namespace DodoSSH.SystemTests;

/// <summary>
/// The DodoSSH API, running as a child process for the length of a test run.
/// </summary>
/// <remarks>
/// <para>
/// A process rather than a <c>WebApplicationFactory</c>, which is what the API's own integration suite
/// uses. Two reasons, and both are the point of this suite. The client establishes its connection by
/// creating an <c>HttpClient</c> for a URL the user typed, so there is no seam to hand a test handler
/// through without inventing one that exists only for tests. And a test host replaces the entry point,
/// Kestrel and the content root — so it never proves that <c>Program.cs</c> composes, that the committed
/// <c>appsettings.json</c> is found and layered in the documented order, or that the server answers on a
/// socket.
/// </para>
/// <para>
/// It runs out of the API's own output directory, which is what makes its configuration real: the working
/// directory becomes the content root, so the <c>appsettings.json</c> beside the assembly is the one that
/// ships. Only the values that cannot be known before the containers start are overridden, through the
/// <c>DODOSSH_</c> environment prefix that <c>Program.cs</c> adds last and which therefore wins.
/// </para>
/// </remarks>
internal sealed class ApiProcess : IAsyncDisposable
{
    /// <summary>Assembly metadata written by the <c>DodoCaptureApiPath</c> target in this csproj.</summary>
    private const string ApiPathKey = "DodoSSH.ApiAssemblyPath";

    private static readonly TimeSpan ReadyTimeout = TimeSpan.FromSeconds(90);
    private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(250);

    private readonly Process process;
    private readonly StringBuilder log = new();

    private ApiProcess(Process process, Uri baseUrl)
    {
        this.process = process;
        BaseUrl = baseUrl;

        process.OutputDataReceived += Capture;
        process.ErrorDataReceived += Capture;
        process.BeginOutputReadLine();
        process.BeginErrorReadLine();
    }

    /// <summary>Where the API is listening.</summary>
    internal Uri BaseUrl { get; }

    /// <summary>Everything the API has written to its console.</summary>
    /// <remarks>
    /// Kept so a failure can be explained. A child process that dies during startup is otherwise a
    /// connection refused with no cause, which is a genuinely miserable thing to debug.
    /// </remarks>
    internal string Log
    {
        get
        {
            lock (log)
            {
                return log.ToString();
            }
        }
    }

    /// <summary>Starts the API against the given database and identity provider, and waits for readiness.</summary>
    internal static async Task<ApiProcess> StartAsync(
        string connectionString,
        Uri authority,
        CancellationToken cancellationToken)
    {
        var baseUrl = new Uri(
            string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{FreeLoopbackPort()}"));

        var api = new ApiProcess(Launch(baseUrl, connectionString, authority), baseUrl);

        try
        {
            await api.WaitUntilReadyAsync(cancellationToken);
            return api;
        }
        catch
        {
            await api.DisposeAsync();
            throw;
        }
    }

    /// <inheritdoc />
    public async ValueTask DisposeAsync()
    {
        try
        {
            if (!process.HasExited)
            {
                // No graceful shutdown: Ctrl+C cannot be delivered to a specific child on Windows, and
                // the drain this would exercise belongs to the relay, which M1 does not ship.
                process.Kill(entireProcessTree: true);
            }
        }
        catch (InvalidOperationException)
        {
            // Exited between the check and the kill.
        }

        await process.WaitForExitAsync(CancellationToken.None);

        process.Dispose();
    }

    private static Process Launch(Uri baseUrl, string connectionString, Uri authority)
    {
        var assembly = ResolveApiAssembly();
        var launcher = AppHost(assembly);

        var startInfo = new ProcessStartInfo
        {
            FileName = launcher ?? "dotnet",

            // The content root: WebApplication.CreateBuilder takes the working directory, and the
            // appsettings files live beside the assembly.
            WorkingDirectory = Path.GetDirectoryName(assembly)!,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
        };

        if (launcher is null)
        {
            startInfo.ArgumentList.Add("exec");
            startInfo.ArgumentList.Add(assembly);
        }

        // Development, because the committed development configuration is precisely what this suite
        // exists to check — the realm file and appsettings.Development.json are a pair, and the first bug
        // this suite found lived in exactly that pair.
        startInfo.Environment["ASPNETCORE_ENVIRONMENT"] = "Development";
        startInfo.Environment["ASPNETCORE_URLS"] = baseUrl.ToString();

        startInfo.Environment["DODOSSH_ConnectionStrings__Postgres"] = connectionString;
        startInfo.Environment["DODOSSH_Oidc__Authority"] = authority.ToString();

        // Advertised to clients in the discovery document, so it has to be the port actually bound
        // rather than the 5233 a developer runs on.
        startInfo.Environment["DODOSSH_Server__PublicBaseUrl"] = baseUrl.ToString();
        startInfo.Environment["DODOSSH_Relay__WebSocketUrl"] =
            new UriBuilder(baseUrl) { Scheme = "ws", Path = "/api/v1/relay/connect" }.Uri.ToString();

        try
        {
            return Start(startInfo);
        }
        catch (Win32Exception) when (launcher is not null)
        {
            // The launcher is there but would not run. On Linux a checkout or artefact copy that lost the
            // execute bit is the usual cause, and the runtime can load the assembly directly regardless —
            // so this falls back rather than failing CI on a file mode.
            startInfo.FileName = "dotnet";
            startInfo.ArgumentList.Insert(0, assembly);
            startInfo.ArgumentList.Insert(0, "exec");

            return Start(startInfo);
        }
    }

    private static Process Start(ProcessStartInfo startInfo) =>
        Process.Start(startInfo)
        ?? throw new InvalidOperationException(
            $"Could not start the API: {startInfo.FileName} produced no process.");

    /// <summary>
    /// The native launcher beside the assembly, if the build produced one.
    /// </summary>
    /// <remarks>
    /// Preferred over <c>dotnet exec</c> only because it needs nothing on PATH. The fallback is not
    /// hypothetical tidiness: a build with <c>UseAppHost=false</c> produces no launcher at all.
    /// </remarks>
    private static string? AppHost(string assembly)
    {
        var candidate = Path.ChangeExtension(assembly, OperatingSystem.IsWindows() ? ".exe" : null);

        return File.Exists(candidate) ? candidate : null;
    }

    private static string ResolveApiAssembly()
    {
        var path = typeof(ApiProcess).Assembly
            .GetCustomAttributes<AssemblyMetadataAttribute>()
            .FirstOrDefault(attribute => string.Equals(attribute.Key, ApiPathKey, StringComparison.Ordinal))
            ?.Value;

        if (string.IsNullOrEmpty(path))
        {
            throw new InvalidOperationException(
                $"The build did not record {ApiPathKey}. The DodoCaptureApiPath target in "
                + "DodoSSH.SystemTests.csproj is what writes it.");
        }

        if (!File.Exists(path))
        {
            throw new InvalidOperationException(
                $"The API was recorded at {path}, but nothing is there. Build the solution.");
        }

        return path;
    }

    /// <remarks>
    /// Bound and released rather than left at zero, because the port has to be known before the process
    /// starts: it goes into <c>Server:PublicBaseUrl</c>, which the API reads at startup and hands to
    /// clients. The window in which another process could take it is a few milliseconds wide, and the
    /// alternative — reading the bound port back out of Kestrel's log line — cannot be fed to
    /// configuration that has already been read.
    /// </remarks>
    private static int FreeLoopbackPort()
    {
        using var probe = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);

        probe.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 0));

        return ((System.Net.IPEndPoint)probe.LocalEndPoint!).Port;
    }

    private void Capture(object sender, DataReceivedEventArgs args)
    {
        if (args.Data is null)
        {
            return;
        }

        lock (log)
        {
            log.AppendLine(args.Data);
        }
    }

    /// <remarks>
    /// Readiness rather than liveness, and that distinction is load-bearing: <c>/healthz/ready</c> also
    /// asserts the database is reachable and that no migration is pending. Waiting on it means a schema the
    /// fixture failed to apply is reported here, against the API's own opinion of it, rather than as an
    /// inscrutable 500 in the middle of enrollment.
    /// </remarks>
    private async Task WaitUntilReadyAsync(CancellationToken cancellationToken)
    {
        using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        deadline.CancelAfter(ReadyTimeout);

        using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };

        var ready = new Uri(BaseUrl, "healthz/ready");

        try
        {
            while (!deadline.IsCancellationRequested && !process.HasExited)
            {
                if (await RespondsAsync(http, ready, deadline.Token))
                {
                    return;
                }

                await Task.Delay(PollInterval, deadline.Token);
            }
        }
        catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
        {
            // The deadline, not the caller. Reported below with the API's own output.
        }

        cancellationToken.ThrowIfCancellationRequested();

        throw new InvalidOperationException(
            string.Create(
                CultureInfo.InvariantCulture,
                $"The API did not become ready at {ready} within {ReadyTimeout.TotalSeconds:N0}s. "
                + $"{(process.HasExited ? $"It exited with code {process.ExitCode}." : "It is still running.")}"
                + $" Its output was:{Environment.NewLine}{Log}"));
    }

    private static async Task<bool> RespondsAsync(HttpClient http, Uri url, CancellationToken token)
    {
        try
        {
            using var response = await http.GetAsync(url, token);
            return response.IsSuccessStatusCode;
        }
        catch (HttpRequestException)
        {
            return false;
        }
        catch (TaskCanceledException)
        {
            // Either the per-request timeout or the deadline. The loop condition decides which.
            return false;
        }
    }
}
