using DodoSSH.Client.Auth;
using global::Android.Content;

namespace DodoSSH.Client.Android.Platform;

/// <summary>
/// Opens the authorization page in the system browser.
/// </summary>
/// <remarks>
/// <para>
/// <c>Process.Start</c> does not exist on this platform, so the desktop head's launcher cannot be reused —
/// but the rule it enforces is the same one and is enforced here too: the <em>system</em> browser, never an
/// embedded WebView. RFC 8252 §8.12 — an embedded user-agent can read the user's credentials as they are
/// typed, hides the real address bar, and cannot reuse an existing single-sign-on session.
/// </para>
/// <para>
/// An <c>ACTION_VIEW</c> intent rather than a Custom Tab. A Custom Tab is the nicer surface and shares the
/// browser's cookie jar just as this does, but it needs the <c>androidx.browser</c> package; this needs
/// nothing and satisfies the same requirement. Worth revisiting when something else pulls AndroidX in.
/// </para>
/// </remarks>
internal sealed class AndroidBrowserLauncher : IBrowserLauncher
{
    /// <inheritdoc />
    public Task OpenAsync(Uri url, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(url);
        cancellationToken.ThrowIfCancellationRequested();

        // Asserted rather than assumed, exactly as the desktop launcher does: an ACTION_VIEW on some other
        // scheme is a request to open whatever app claims it, and this only ever opens an authorize URL.
        if (!string.Equals(url.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)
            && !string.Equals(url.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal))
        {
            throw new ArgumentException($"Refusing to open a '{url.Scheme}' URL.", nameof(url));
        }

        var intent = new Intent(Intent.ActionView, global::Android.Net.Uri.Parse(url.AbsoluteUri));

        // NewTask because the launch comes from an application context rather than an activity one.
        intent.AddFlags(ActivityFlags.NewTask);

        PhoneEnvironment.Require().StartActivity(intent);

        // Only now, and only if the browser actually took the intent: this is what lets a return to this
        // application with no response be read as a cancellation. See AndroidRedirectCallback.Abandon.
        AndroidRedirectCallback.NotifyBrowserOpened();

        return Task.CompletedTask;
    }
}

/// <summary>
/// Receives the authorization response as an intent rather than on a socket.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why not the loopback listener.</b> RFC 8252 §7.3's loopback redirect assumes a machine where the
/// application's own port is the application's own. On a phone it is not: any installed application can
/// bind a loopback port and, on a race, take the authorization code. §8.3 names this, and the platform's
/// answer is to have the system route the redirect to the registered application instead.
/// </para>
/// <para>
/// <b>A private-use scheme, and its limit is worth stating.</b> The redirect is
/// <c>dev.dodotech.dodossh:/oauth</c> — the reversed package name, which RFC 8252 §7.1 recommends because
/// it is a namespace the application demonstrably controls. It is *not* an Android App Link, and the
/// difference is real: another application can also declare this scheme, and Android will offer the user a
/// chooser rather than refusing. What stops that being a compromise is PKCE — the code is useless without
/// the verifier, which never leaves this process — plus the state check. An App Link would close the gap
/// properly, at the cost of hosting an assetlinks.json on the DodoSSH server's own domain; that is the
/// upgrade, and it is a server change rather than a client one.
/// </para>
/// <para>
/// The completion HTML is ignored. There is no tab of this application's own to write it into: the system
/// hands the intent over and closes the browser itself.
/// </para>
/// </remarks>
internal sealed class AndroidRedirectCallback : IAuthorizationCallback
{
    /// <summary>The scheme, which must match the intent filter on MainActivity exactly.</summary>
    internal const string Scheme = "dev.dodotech.dodossh";

    private readonly TaskCompletionSource<CallbackResult> completion =
        new(TaskCreationOptions.RunContinuationsAsynchronously);

    private static AndroidRedirectCallback? waiting;

    /// <summary>Whether a browser was opened for a sign-in that has not answered yet.</summary>
    private static volatile bool browserOpen;

    /// <param name="path">
    /// The redirect path, from OidcClientOptions. Carried through so the desktop's configured value and
    /// this one cannot silently differ.
    /// </param>
    public AndroidRedirectCallback(string path)
    {
        RedirectUri = new Uri($"{Scheme}:{(path.StartsWith('/') ? path : "/" + path)}");

        // Exactly one sign-in can be outstanding, because exactly one activity receives the intent. A
        // second overlapping attempt would leave the first waiting forever on a response the second
        // consumed, so the earlier one is failed rather than orphaned.
        Interlocked.Exchange(ref waiting, this)?.completion.TrySetCanceled();
    }

    /// <inheritdoc />
    public Uri RedirectUri { get; }

    /// <summary>Records that the authorization page has been handed to the browser.</summary>
    /// <remarks>
    /// One half of the cancellation story; <see cref="Abandon"/> is the other. Called by
    /// <see cref="AndroidBrowserLauncher"/> rather than set when the wait starts, so that only a return
    /// from a browser this application actually opened counts as an answer that never came.
    /// </remarks>
    internal static void NotifyBrowserOpened() => browserOpen = true;

    /// <summary>Fails the waiting sign-in when the user comes back without a response.</summary>
    /// <remarks>
    /// <para>
    /// Called from <c>MainActivity.OnResume</c>, because this platform has no cancel event to subscribe
    /// to. Pressing back out of the login page, dismissing the browser, or closing a provider error page
    /// all look identical from here: this application is foreground again and no intent ever arrived.
    /// Left alone the flow sits on the five-minute browser timeout — that long with a disabled
    /// <em>Sign in</em> button under "Opening your browser to sign in…", which reads as a hung
    /// application rather than as the cancellation it is.
    /// </para>
    /// <para>
    /// It cannot steal a successful sign-in. Android delivers the redirect to <c>OnNewIntent</c> before
    /// resuming the activity, so by the time this runs the completion is already settled and the attempt
    /// below does nothing.
    /// </para>
    /// </remarks>
    internal static void Abandon()
    {
        // Guarded rather than unconditional, because most resumes have nothing to do with signing in:
        // launching the application, coming back from recents, returning from the keystore's fingerprint
        // prompt. Only a resume that follows a browser this class opened is an answer that never came.
        if (!browserOpen)
        {
            return;
        }

        browserOpen = false;

        // An exception rather than a cancellation, because OidcClient reads a cancelled wait as its own
        // timeout expiring and would report five minutes passing. This says what actually happened.
        Volatile.Read(ref waiting)?.completion.TrySetException(
            new OidcException("Sign-in was cancelled: the browser closed without signing you in."));
    }

    /// <summary>Hands a received redirect to whichever sign-in is waiting for it.</summary>
    /// <remarks>
    /// Called from <c>MainActivity.OnNewIntent</c>. Returns quietly when nothing is waiting: a redirect
    /// can arrive after the app was killed and relaunched, and that is a stale response rather than an
    /// error worth showing anybody.
    /// </remarks>
    internal static void Complete(Uri redirect)
    {
        browserOpen = false;

        var target = Volatile.Read(ref waiting);

        if (target is null)
        {
            return;
        }

        var parameters = new Dictionary<string, string>(StringComparer.Ordinal);

        // The query, and the fragment if the provider used one. Response parameters arrive in the query
        // for a code flow; reading both costs nothing and means a provider that answers errors in the
        // fragment does not look like a silent hang.
        foreach (var part in new[] { redirect.Query, redirect.Fragment })
        {
            foreach (var pair in part.TrimStart('?', '#').Split('&', StringSplitOptions.RemoveEmptyEntries))
            {
                var split = pair.Split('=', 2);

                parameters[Uri.UnescapeDataString(split[0])] =
                    split.Length == 2 ? Uri.UnescapeDataString(split[1]) : string.Empty;
            }
        }

        target.completion.TrySetResult(new CallbackResult(parameters));
    }

    /// <inheritdoc />
    public Task<CallbackResult> WaitForCallbackAsync(
        string completionHtml,
        CancellationToken cancellationToken) =>
        completion.Task.WaitAsync(cancellationToken);

    /// <inheritdoc />
    public void Dispose()
    {
        // Only if it is still this one. A later sign-in has already replaced it, and clearing the slot
        // then would silently break the attempt that is actually in flight — including its claim on the
        // browser, which is why the flag is cleared under the same condition and not beside it.
        if (ReferenceEquals(Interlocked.CompareExchange(ref waiting, null, this), this))
        {
            browserOpen = false;
        }

        completion.TrySetCanceled();
    }
}
