using System.Globalization;
using System.Net.WebSockets;
using System.Text.RegularExpressions;
using DodoSSH.Client.Terminal;

namespace DodoSSH.Client.App.Tests;

/// <summary>
/// Stands in for the terminal page, so the connect path can be exercised without a WebView.
/// </summary>
/// <remarks>
/// <para>
/// It attaches the way the real renderer does rather than reaching for the workspace's internals:
/// fetch the served page, read the token and socket URL the host substituted into it, then open the
/// WebSocket with the same two subprotocols. Anything cheaper — handing it the token directly — would
/// stop testing the part of the handshake that has actually been got wrong before.
/// </para>
/// <para>
/// Attaching is what completes <c>TerminalWorkspace.WaitForRendererAsync</c>, and that await is the
/// real gate on a first connection: the data plane drops frames when nothing is attached rather than
/// queueing them, so a session opened before this exists would lose its <c>SessionOpened</c> frame.
/// </para>
/// </remarks>
internal sealed partial class FakeRenderer : IAsyncDisposable
{
    private readonly ClientWebSocket socket;

    private FakeRenderer(ClientWebSocket socket) => this.socket = socket;

    /// <summary>Fetches the page and attaches a socket, as the real renderer would.</summary>
    internal static async Task<FakeRenderer> AttachAsync(
        TerminalWorkspace workspace,
        CancellationToken cancellationToken)
    {
        using var http = new HttpClient();

        var page = await http
            .GetStringAsync(workspace.PageUrl, cancellationToken)
            .ConfigureAwait(false);

        var token = Attribute(page, "data-token");
        var socketUrl = Attribute(page, "data-socket");

        var attached = new ClientWebSocket();

        attached.Options.AddSubProtocol(TerminalDataPlane.SubProtocol);
        attached.Options.AddSubProtocol($"token.{token}");

        // The listener requires the page's own origin, which is what makes a page in the user's
        // browser unable to reach this socket.
        attached.Options.SetRequestHeader(
            "Origin",
            string.Create(
                CultureInfo.InvariantCulture,
                $"{workspace.PageUrl.Scheme}://{workspace.PageUrl.Authority}"));

        try
        {
            await attached.ConnectAsync(new Uri(socketUrl), cancellationToken).ConfigureAwait(false);
        }
        catch
        {
            attached.Dispose();
            throw;
        }

        var renderer = new FakeRenderer(attached);

        await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(false);

        return renderer;
    }

    /// <inheritdoc />
    public async ValueTask DisposeAsync()
    {
        if (socket.State == WebSocketState.Open)
        {
            try
            {
                await socket
                    .CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None)
                    .ConfigureAwait(false);
            }
            catch (WebSocketException)
            {
                // The host may have gone first; there is nothing to salvage either way.
            }
        }

        socket.Dispose();
    }

    private static string Attribute(string page, string name)
    {
        var match = AttributeValue(name).Match(page);

        return match.Success
            ? match.Groups[1].Value
            : throw new InvalidOperationException(
                $"The served page carried no {name}. The host substitutes it at serve time, so "
                + $"either the placeholder is missing from the test's page asset or substitution broke.");
    }

    private static Regex AttributeValue(string name) =>
        new($"{Regex.Escape(name)}=\"([^\"]*)\"", RegexOptions.None, TimeSpan.FromSeconds(1));
}
