Editor/AccountPage.cs
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Sandbox;

namespace HumanoidMocapX.EditorTools;

/// <summary>Mocap adjustments › Processing › Account: who is signed in on the provider, what the
/// account may do (whitelist, Discord server, priority, clip and upload limits), and signing out or
/// switching accounts. On a processing machine it shows the SSH address and worker; on this computer
/// it says no account is needed; before setup, how to start. The provider shares no email address,
/// so none is shown.</summary>
sealed class AccountPage : Widget
{
    const float LabelWidth = 150;
    readonly HostingSettings settings;
    readonly SshSettings machine;
    readonly bool configured;
    readonly Action openSetup;
    readonly AccountAvatar avatar;
    readonly Label name, handle, problem;
    readonly Layout pills, actions;
    readonly Widget facts;
    CancellationTokenSource pending;

    internal AccountPage(Widget parent, HostingSettings settings, SshSettings machine, bool configured, Action openSetup) : base(parent)
    {
        this.settings = settings; this.machine = machine; this.configured = configured; this.openSetup = openSetup;
        Layout = Layout.Column(); Layout.Spacing = 12;
        // Identity: a plain row on the page's gray, like the facts below it.
        var identity = Layout.Add(new Widget(this) { Layout = Layout.Row(), FixedHeight = 44 });
        var row = identity.Layout; row.Spacing = 10;
        avatar = row.Add(new AccountAvatar(identity, 40));
        var who = row.AddColumn(1); who.Spacing = 4; who.AddStretchCell();
        var top = who.AddRow(); top.Spacing = 8;
        name = top.Add(Ui.Bold(new Label("", identity), 13));
        pills = top.AddRow(); pills.Spacing = 6;
        top.AddStretchCell();
        handle = who.Add(Ui.Muted(new Label("", identity)));
        who.AddStretchCell();
        actions = row.AddRow(); actions.Spacing = 8;
        problem = Layout.Add(Ui.Error(this));
        facts = Layout.Add(new Widget(this) { Layout = Layout.Column() });
        facts.Layout.Spacing = 12;
        // Spare height goes below the facts, so the identity panel keeps its own height.
        Layout.AddStretchCell();
        settings.Changed += Refresh;
        Refresh();
    }

    void Refresh()
    {
        if (!this.IsValid()) return;
        pills.Clear(true); actions.Clear(true); facts.Layout.Clear(true);
        if (!configured) { ShowNotSetUp(); return; }
        if (!settings.Enabled && machine.Enabled) { ShowMachine(); return; }
        if (!settings.Enabled) { ShowThisComputer(); return; }
        var profile = settings.SignedIn ? settings.Profile : null;
        var provider = ProcessingSetup.SavedProvider();
        if (profile is null)
        {
            avatar.ShowIcon("person"); name.Text = "Signed out";
            handle.Text = $"Sign in to {Host()} to see your account and send videos.";
            actions.Add(Ui.Primary(provider is null ? "Sign in" : "Sign in with " + provider.Label, "login", () => _ = SignInAsync(false)));
            return;
        }
        var user = profile.User;
        avatar.ShowAccount(user.Avatar, user.Name ?? user.Username);
        name.Text = user.Name ?? user.Username ?? "Name not provided";
        handle.Text = string.Join(" · ", new[] { user.Username is { } username && username != user.Name ? "@" + username : null,
            "Signed in with " + (provider?.Label ?? user.Provider) }.Where(part => part is not null));
        var (status, color) = profile switch
        {
            { Admin: true } => ("Admin", Ui.Accent),
            { CanProcess: true } => ("Whitelisted", Ui.Accent),
            { InGuild: false } => ("Join the Discord server", Theme.Yellow),
            _ => ("Not whitelisted", Theme.Red)
        };
        pills.Add(new Pill(this, status, color));
        if (profile.Limits is { Priority: true }) pills.Add(new Pill(this, "Priority", Theme.Yellow));
        actions.Add(Ui.Secondary("Switch account", "switch_account", () => _ = SignInAsync(true))).ToolTip =
            "Signs out here and opens sign-in in your browser. Choose or log in to the other account there.";
        actions.Add(Ui.Secondary("Sign out", "logout", () => _ = SignOutAsync()));

        var access = Group("Access");
        Fact(access, "Whitelist", profile.Whitelisted ? "Whitelisted" : "Not whitelisted", profile.Whitelisted ? Ui.Accent : Theme.Red);
        if (profile.InGuild is { } inGuild)
            Fact(access, "Discord server", inGuild ? profile.GuildOwner ? "Member · server owner" : "Member" : "Not a member",
                inGuild ? Ui.Accent : Theme.Yellow);
        Fact(access, "Can send videos", profile.CanProcess ? "Yes" : "Not yet: ask your provider to whitelist your account ID",
            profile.CanProcess ? Ui.Accent : Theme.Yellow);
        if (profile.Admin) Fact(access, "Role", "Admin · manages this provider", Ui.Accent);

        var limits = Group("Limits");
        if (profile.Limits is { } limit)
        {
            Fact(limits, "Queue", limit.Priority ? "Priority · starts ahead of standard jobs" : "Standard", limit.Priority ? Theme.Yellow : null);
            Fact(limits, "Longest clip", limit.MaximumClipSeconds is { } clip ? HostedCapture.Duration((int)clip) : "The provider's limit");
            Fact(limits, "Largest upload", Bytes(limit.MaximumUploadBytes));
        }
        else Fact(limits, "Limits", "Not reported by this provider");

        var details = Group("Details");
        Fact(details, "Account ID", user.Id, copy: user.Id);
        Fact(details, "Username", user.Username ?? "Not provided");
        Fact(details, "Provider", Host());
    }

    void ShowNotSetUp()
    {
        avatar.ShowIcon("settings"); name.Text = "Not set up";
        handle.Text = "Choose whether videos are processed on this computer or by a provider.";
        actions.Add(Ui.Primary("Set up", "tune", openSetup));
    }

    void ShowMachine()
    {
        avatar.ShowIcon("dns"); name.Text = "Processing machine";
        handle.Text = "No account needed: videos are copied to this machine over SSH and processed there.";
        pills.Add(new Pill(this, "SSH", Ui.Accent));
        var device = Group("Machine");
        Fact(device, "Address", machine.Address);
        Fact(device, "Worker", machine.Worker);
    }

    void ShowThisComputer()
    {
        avatar.ShowIcon("memory"); name.Text = "This computer";
        handle.Text = "No account needed: videos are processed here and never leave this computer.";
        pills.Add(new Pill(this, "Local", Ui.Accent));
        var device = Group("Device");
        Fact(device, "GPU", Sandbox.Engine.SystemInfo.Gpu is { Length: > 0 } gpu && gpu != "unset" ? gpu : "This GPU");
        Fact(device, "Frames at once", $"Up to {FlowVideoProcessor.LaneCapacity()} on this GPU");
    }

    Widget Group(string title)
    {
        var group = facts.Layout.Add(new Widget(facts) { Layout = Layout.Column() });
        group.Layout.Spacing = 2;
        group.Layout.Add(new SectionLine(group, title));
        return group;
    }

    static void Fact(Widget group, string label, string value, Color? color = null, string copy = null)
    {
        var row = group.Layout.Add(new FactRow(group)).Layout;
        var caption = row.Add(Ui.Muted(new Label(label, group))); caption.FixedWidth = LabelWidth;
        var text = row.Add(new Label(value, group) { WordWrap = true }, 1);
        if (color is { } tint) Ui.Colored(text, tint);
        if (copy is not null)
            row.Add(Ui.Icon(group, "content_copy", () => EditorUtility.Clipboard.Copy(copy), "Copy: give this to your provider to be whitelisted", 24));
    }

    static string Bytes(long bytes) => bytes >= 1L << 30 ? $"{bytes / (double)(1L << 30):0.#} GB" : $"{bytes / (1024 * 1024)} MB";

    string Host()
    {
        try { return settings.Endpoint().Host; } catch (ArgumentException) { return "your provider"; }
    }

    async Task SignInAsync(bool switchAccount)
    {
        if (pending is not null) return;
        if (ProcessingSetup.SavedProvider() is not { } provider) { openSetup(); return; }
        using var cancellation = new CancellationTokenSource(); pending = cancellation; var token = cancellation.Token;
        Ui.Show(problem, null);
        try
        {
            if (switchAccount) { using var client = new HostSignIn(); await client.SignOutAsync(settings); }
            await ProcessingSetup.SignInAsync(settings, provider, token);
        }
        catch (Exception failure) when (ProcessingSetup.IsSignInFailure(failure))
        {
            await EditorPipeline.SwitchToMainThread();
            if (!token.IsCancellationRequested && problem.IsValid())
                Ui.Show(problem, failure is OperationCanceledException ? "Sign-in timed out. Try again." : failure.Message);
        }
        finally { pending = null; }
    }

    async Task SignOutAsync()
    {
        using var client = new HostSignIn();
        await client.SignOutAsync(settings);
    }

    public override void OnDestroyed()
    {
        settings.Changed -= Refresh; pending?.Cancel();
        base.OnDestroyed();
    }

    /// <summary>One label and value, with a hairline below so rows scan like a table.</summary>
    sealed class FactRow : Widget
    {
        internal FactRow(Widget parent) : base(parent) { Layout = Layout.Row(); Layout.Margin = new Sandbox.UI.Margin(0, 6, 0, 6); Layout.Spacing = 8; MinimumHeight = 30; }

        protected override void OnPaint()
        {
            Paint.SetPen(MocapButton.Edge.WithAlpha(.6f), 1);
            Paint.DrawLine(new Vector2(0, Height - .5f), new Vector2(Width, Height - .5f));
        }
    }
}