using System.Text.RegularExpressions;
using System.Web;
using DodoSSH.Client.Auth;

namespace DodoSSH.SystemTests;

/// <summary>
/// Signs in to a real Keycloak by driving its login form over HTTP.
/// </summary>
/// <remarks>
/// <para>
/// Stands in for the system browser, and only for the browser — everything else in the flow is the real
/// thing. The authorization request, the login form, the redirect back to the loopback listener, the code
/// exchange and PKCE verification all happen exactly as they would for a person clicking through.
/// </para>
/// <para>
/// <b>Everything up to the redirect is awaited; the final hop is not.</b> That split is not tidiness.
/// <c>OidcClient</c> awaits the launcher before it awaits the callback, and the last hop of this flow is a
/// request <em>to</em> that callback — completing it inline would block on a request nobody is reading yet
/// and the sign-in would deadlock instead of failing. Awaiting the earlier steps is what makes a rejected
/// authorization request surface immediately, with Keycloak's own words, rather than as a five-minute wait
/// for a browser that was never going to arrive.
/// </para>
/// </remarks>
internal sealed partial class ScriptedBrowser(string username, string password) : IBrowserLauncher
{
    /// <summary>How many times a sign-in was driven, so the key binding's second one is visible.</summary>
    internal int SignInCount { get; private set; }

    /// <inheritdoc />
    public async Task OpenAsync(Uri url, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(url);

        SignInCount++;

        // Cookies are carried by hand, and automatic handling is off. That is not a shortcut — it is
        // required, and the reason is worth knowing:
        //
        // Keycloak marks its authentication-session cookies `Secure; SameSite=None`, because SameSite=None
        // is only legal alongside Secure. Over a development stack served on plain HTTP, a
        // spec-conformant client refuses to store a Secure cookie from an insecure origin, so
        // CookieContainer silently drops every one of them and the login POST comes back 400 with no
        // explanation. Browsers do complete this flow, because they treat loopback as a trustworthy
        // origin and make the exception. Carrying the cookies manually emulates that exception
        // deliberately, in one visible place, rather than looking like broken cookie handling.
        var handler = new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false };

        var http = new HttpClient(handler);

        try
        {
            var redirect = await SubmitCredentialsAsync(http, url, cancellationToken)
                .ConfigureAwait(false);

            // Deliberately not awaited: this is the request the loopback listener is waiting for, and it
            // is only read after this method returns. Ownership of the client passes to the continuation.
            _ = DeliverAsync(http, handler, redirect, cancellationToken);

        }
        catch
        {
            http.Dispose();
            handler.Dispose();
            throw;
        }
    }

    /// <summary>Fetches the login page and posts the credentials, returning where Keycloak sends us.</summary>
    private async Task<Uri> SubmitCredentialsAsync(
        HttpClient http,
        Uri authorizationUrl,
        CancellationToken cancellationToken)
    {
        using var request = new HttpRequestMessage(HttpMethod.Get, authorizationUrl);
        using var opened = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);

        var cookies = CollectCookies(opened);

        var loginPage = await opened.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
        var action = ExtractLoginAction(loginPage);

        using var credentials = new FormUrlEncodedContent(
            new Dictionary<string, string>(StringComparer.Ordinal)
            {
                ["username"] = username,
                ["password"] = password,
                ["credentialId"] = string.Empty,
            });

        using var login = new HttpRequestMessage(HttpMethod.Post, action) { Content = credentials };
        login.Headers.Add("Cookie", cookies);

        using var posted = await http.SendAsync(login, cancellationToken).ConfigureAwait(false);

        if (posted.Headers.Location is { } redirect)
        {
            return redirect;
        }

        // Keycloak answers a rejected login with another page rather than a header, so the reason is only
        // in the body. Reporting the status alone would be almost useless.
        var body = await posted.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);

        throw new InvalidOperationException(
            $"Keycloak answered the login form with {(int)posted.StatusCode} and no redirect. It said: "
            + Summarise(body));
    }

    /// <remarks>
    /// Only the <c>name=value</c> part of each <c>Set-Cookie</c>, which is all a request may send back.
    /// </remarks>
    private static string CollectCookies(HttpResponseMessage response)
    {
        if (!response.Headers.TryGetValues("Set-Cookie", out var values))
        {
            return string.Empty;
        }

        var pairs = values
            .Select(value => value.Split(';', 2)[0].Trim())
            .Where(pair => pair.Length > 0);

        return string.Join("; ", pairs);
    }

    private static async Task DeliverAsync(
        HttpClient http,
        HttpClientHandler handler,
        Uri redirect,
        CancellationToken cancellationToken)
    {
        try
        {
            using var delivered = await http.GetAsync(redirect, cancellationToken).ConfigureAwait(false);
        }
        catch (Exception)
        {
            // Nothing useful to do here. If the code never arrives, the sign-in fails on its own timeout
            // and rethrowing on an unobserved task would take the test host down with it instead.
        }
        finally
        {
            http.Dispose();
            handler.Dispose();
        }
    }

    /// <remarks>
    /// The form's action carries the session code and the execution id, so it cannot be constructed — it
    /// has to be read back out of the page, and it arrives HTML-escaped.
    /// </remarks>
    private static Uri ExtractLoginAction(string loginPage)
    {
        var match = LoginFormAction().Match(loginPage);

        if (!match.Success)
        {
            throw new InvalidOperationException(
                "Could not find Keycloak's login form, so the authorization request was rejected before a "
                + $"login was ever offered. Keycloak said: {Summarise(loginPage)}");
        }

        return new Uri(HttpUtility.HtmlDecode(match.Groups["action"].Value), UriKind.Absolute);
    }

    /// <remarks>Surfaces Keycloak's own error text, which is the only useful part of a rejection page.</remarks>
    private static string Summarise(string page)
    {
        var messages = ErrorMessage().Matches(page)
            .Select(match => match.Groups["text"].Value.Trim())
            .Where(text => text.Length > 0)
            .Distinct(StringComparer.Ordinal)
            .ToArray();

        if (messages.Length > 0)
        {
            return string.Join(" / ", messages.Select(text => $"\"{text}\""));
        }

        var title = PageTitle().Match(page);

        return title.Success
            ? $"a page titled \"{title.Groups["text"].Value.Trim()}\" with no error text"
            : $"nothing recognisable, in {page.Length} characters";
    }

    // A timeout because the input is a page from a server, and an unbounded backtrack on untrusted
    // input is a hang rather than a failure.
    [GeneratedRegex(
        """<form[^>]*id="kc-form-login"[^>]*action="(?<action>[^"]+)""",
        RegexOptions.IgnoreCase,
        matchTimeoutMilliseconds: 2000)]
    private static partial Regex LoginFormAction();

    // Matches both shapes Keycloak uses: the per-field "input-error" spans on a login page, and the
    // "kc-feedback" / alert text on a rejected request.
    [GeneratedRegex(
        """(?:id|class)="[^"]*(?:input-error|kc-feedback-text|pf-v5-c-alert__title)[^"]*"[^>]*>(?<text>[^<]{1,300})<""",
        RegexOptions.IgnoreCase,
        matchTimeoutMilliseconds: 2000)]
    private static partial Regex ErrorMessage();

    [GeneratedRegex("""<title>(?<text>[^<]{0,200})</title>""", RegexOptions.IgnoreCase,
        matchTimeoutMilliseconds: 2000)]
    private static partial Regex PageTitle();
}
