Editor/Hosting/HostingSettings.cs
using System;
using System.Threading;

namespace HumanoidMocapX.EditorTools;

/// <summary>The provider choice and the signed-in session. The token and profile live only in
/// memory: closing the editor signs out, and nothing secret is written to disk. Any thread may
/// sign in or out; the session is swapped as one value.</summary>
internal sealed class HostingSettings
{
    internal bool Enabled;
    internal string ServiceUrl = "";
    HostSession session;

    /// <summary>Raised after sign-in, sign-out or a profile refresh, through <see cref="Dispatch"/>.</summary>
    internal event Action Changed;

    /// <summary>Runs <see cref="Changed"/>; the window sets it to queue onto the editor's main thread.</summary>
    internal Action<Action> Dispatch = handler => handler();

    internal string Token => Volatile.Read(ref session)?.Token ?? "";
    internal HostProfile Profile => Volatile.Read(ref session)?.Profile;
    internal DateTimeOffset ExpiresAt => Volatile.Read(ref session)?.ExpiresAt ?? default;
    internal bool SignedIn => Volatile.Read(ref session) is { } current && current.ExpiresAt > DateTimeOffset.UtcNow;

    /// <summary>The provider's base URL. Accepts a bare domain (HTTPS is assumed); plain HTTP is
    /// allowed only on loopback for local testing. Rejects credentials, queries and fragments.</summary>
    internal Uri Endpoint()
    {
        var text = ServiceUrl.Trim();
        if (text.Length > 0 && !text.Contains("://", StringComparison.Ordinal)) text = "https://" + text;
        if (!Uri.TryCreate(text, UriKind.Absolute, out var uri)
            || !(uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)
            || uri.UserInfo.Length != 0 || uri.Query.Length != 0 || uri.Fragment.Length != 0)
            throw new ArgumentException("Enter the provider's address, for example mocap.example.com.");
        return new Uri(uri.AbsoluteUri.TrimEnd('/') + "/");
    }

    /// <summary>Throws when there is no current sign-in, so callers can prompt for one.</summary>
    internal void RequireSignedIn()
    {
        if (!SignedIn) throw new ArgumentException("Sign in to your processing provider first.");
    }

    internal void SignIn(string token, DateTimeOffset expiresAt, HostProfile profile)
    {
        Volatile.Write(ref session, new(token, expiresAt, profile)); Notify();
    }

    /// <summary>Replaces the profile of the session that used <paramref name="token"/>.</summary>
    internal void Update(string token, HostProfile profile)
    {
        var current = Volatile.Read(ref session);
        if (current?.Token == token && Interlocked.CompareExchange(ref session, current with { Profile = profile }, current) == current) Notify();
    }

    internal void SignOut()
    {
        if (Interlocked.Exchange(ref session, null) is not null) Notify();
    }

    /// <summary>Signs out only if <paramref name="token"/> is still the current one, so a late 401
    /// for an old sign-in cannot end a newer one.</summary>
    internal void Expire(string token)
    {
        var current = Volatile.Read(ref session);
        if (current?.Token == token && Interlocked.CompareExchange(ref session, null, current) == current) Notify();
    }

    void Notify() { if (Changed is { } handlers) Dispatch(handlers); }

    sealed record HostSession(string Token, DateTimeOffset ExpiresAt, HostProfile Profile);
}

/// <summary>The signed-in account. Username, name and avatar are null when the provider does not
/// share them. InGuild is null when no Discord server applies. Limits are null from providers
/// that do not report them.</summary>
internal sealed record HostProfile(HostUser User, bool? InGuild, bool GuildOwner, bool Whitelisted, bool CanProcess, bool Admin,
    HostLimits Limits = null);

/// <summary>The account's queue priority, longest clip in seconds (null: the host's own) and
/// largest upload in bytes.</summary>
internal sealed record HostLimits(bool Priority, double? MaximumClipSeconds, long MaximumUploadBytes);

internal sealed record HostUser(string Id, string Provider, string Username, string Name, string Avatar);

/// <summary>One sign-in option offered by the provider's site.</summary>
internal sealed record HostProvider(string Name, string Label);