Editor/Core/RelayDeployer.cs
using System;
using System.Collections.Generic;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;

namespace TeamCreate;

public sealed record DeployResult(bool Ok, string RelayUrl, string Error, bool Cancelled = false);

/// <summary>
/// Puts the relay on the person's own free Cloudflare account, from inside the editor, with the token they created once. It follows the same calls
/// Wrangler makes: find the account, make sure it has a workers.dev name, upload the Worker, switch its workers.dev address on, and wait until that address
/// answers. <see cref="DeployResult.Ok"/> is true only after the address answered, so a half-finished deploy is never reported as a relay to use.
/// Running it again on an account that already has the relay updates it in place and keeps any access key the owner set.
/// </summary>
public sealed class RelayDeployer
{
    public const int Steps = 6;

    private const int HealthAttempts = 10;

    private const int SubdomainAttempts = 5;

    private readonly ICloudflareTransport transport;

    private readonly string workerSource;

    private readonly Func<string, string> healthUrlFor;

    private readonly Func<TimeSpan, CancellationToken, Task> delay;

    private readonly Func<string> newSubdomainName;

    private readonly string apiBase;

    /// <param name="healthUrlFor">Maps the workers.dev host to the address to check. The editor checks the real address; a test points it at a stand-in.</param>
    /// <param name="delay">How to wait between health checks. A test passes one that does not wait.</param>
    /// <param name="newSubdomainName">Makes a candidate workers.dev name for an account that has none.</param>
    /// <param name="apiBase">Cloudflare's API address. Only a test in a disposable copy ever passes another.</param>
    public RelayDeployer(ICloudflareTransport transport, string workerSource, Func<string, string> healthUrlFor = null,
        Func<TimeSpan, CancellationToken, Task> delay = null, Func<string> newSubdomainName = null, string apiBase = null)
    {
        this.apiBase = apiBase;
        this.transport = transport ?? throw new ArgumentNullException(nameof(transport));
        this.workerSource = string.IsNullOrEmpty(workerSource) ? throw new ArgumentException("The relay source is empty.", nameof(workerSource)) : workerSource;
        this.healthUrlFor = healthUrlFor ?? (host => "https://" + host + "/health");
        this.delay = delay ?? ((span, token) => Task.Delay(span, token));
        this.newSubdomainName = newSubdomainName ?? DefaultSubdomainName;
    }

    public async Task<DeployResult> RunAsync(string token, Action<string> progress, CancellationToken cancellationToken)
    {
        progress ??= _ => { };
        try
        {
            return await Deploy(token, progress, cancellationToken);
        }
        catch (OperationCanceledException)
        {
            return new DeployResult(false, null, "Setup was cancelled. Nothing was changed on this computer.", Cancelled: true);
        }
    }

    private async Task<DeployResult> Deploy(string token, Action<string> progress, CancellationToken ct)
    {
        // 1. The account the token can use.
        progress(Step(1, "finding the Cloudflare account"));
        CloudflareResponse accounts = await Send(CloudflareApi.Request("GET", "/accounts?per_page=50", token, apiBase: apiBase), ct);
        if (!(IsOk(accounts) && CloudflareApi.ResultOf(accounts.Body) is JsonArray list))
        {
            return Fail("finding the account", accounts, token);
        }
        string accountId = null;
        string accountName = null;
        foreach (JsonNode item in list)
        {
            if (item is JsonObject account && account["id"] is JsonValue id && id.TryGetValue(out string idText) && !string.IsNullOrWhiteSpace(idText))
            {
                accountId = idText;
                accountName = account["name"] is JsonValue name && name.TryGetValue(out string nameText) ? nameText : null;
                break;
            }
        }
        if (accountId == null)
        {
            return new DeployResult(false, null, "The token cannot see a Cloudflare account. Create it from the page the Open Cloudflare button shows and choose your account when it asks.");
        }
        string root = "/accounts/" + Uri.EscapeDataString(accountId);
        progress(Step(1, "using the account" + (string.IsNullOrWhiteSpace(accountName) ? string.Empty : " \"" + accountName + "\"")));

        // 2. The account's workers.dev name, made if the account has none yet.
        progress(Step(2, "checking the workers.dev name"));
        CloudflareResponse subdomain = await Send(CloudflareApi.Request("GET", root + "/workers/subdomain", token, apiBase: apiBase), ct);
        string host = null;
        if (IsOk(subdomain))
        {
            host = SubdomainHost(CloudflareApi.ResultOf(subdomain.Body));
        }
        else if (subdomain.Status != null && CloudflareApi.ErrorCode(subdomain.Body) == 10007)
        {
            SubdomainOutcome registered = await RegisterSubdomain(root, token, progress, ct);
            if (registered.Host == null)
            {
                return Fail("creating the workers.dev name", registered.Failure, token);
            }
            host = registered.Host;
        }
        else
        {
            return Fail("checking the workers.dev name", subdomain, token);
        }
        if (host == null)
        {
            return new DeployResult(false, null, "Cloudflare did not report a workers.dev name for this account. Open the Workers page on dash.cloudflare.com once, choose a name, and try again.");
        }

        // 3. Is the relay already there? Its migration tag decides whether the upload carries the one-time Durable Object migration.
        progress(Step(3, "checking for an existing relay"));
        CloudflareResponse existing = await Send(CloudflareApi.Request("GET", root + "/workers/services/" + CloudflareApi.ScriptName, token, apiBase: apiBase), ct);
        bool exists = false;
        string appliedTag = null;
        if (IsOk(existing))
        {
            exists = true;
            appliedTag = TagOf(CloudflareApi.ResultOf(existing.Body));
        }
        else if (existing.Status == null || existing.Status != 404)
        {
            return Fail("checking for an existing relay", existing, token);
        }
        bool sendMigrations = !string.Equals(appliedTag, CloudflareApi.MigrationTag, StringComparison.Ordinal);

        // 4. The Worker itself.
        progress(Step(4, exists ? "updating the relay" : "uploading the relay"));
        using (var content = CloudflareApi.UploadContent(workerSource, sendMigrations, keepSecrets: exists))
        {
            CloudflareResponse upload = await Send(CloudflareApi.Request("PUT", root + "/workers/scripts/" + CloudflareApi.ScriptName + CloudflareApi.UploadQuery, token, content, apiBase: apiBase), ct);
            if (!IsOk(upload))
            {
                return Fail("uploading the relay", upload, token);
            }
        }

        // 5. Switch its workers.dev address on.
        progress(Step(5, "switching the relay's address on"));
        using (var body = CloudflareApi.Json(new JsonObject { ["enabled"] = true }))
        {
            CloudflareResponse route = await Send(CloudflareApi.Request("POST", root + "/workers/scripts/" + CloudflareApi.ScriptName + "/subdomain", token, body, withScriptApiDate: true, apiBase: apiBase), ct);
            if (!IsOk(route))
            {
                return Fail("switching the relay's address on", route, token);
            }
        }

        // 6. Wait until the address answers. A new Worker takes a few seconds to appear.
        string workerHost = CloudflareApi.ScriptName + "." + host;
        string healthUrl = healthUrlFor(workerHost);
        for (int attempt = 1; attempt <= HealthAttempts; attempt++)
        {
            progress(Step(6, "waiting for the relay to answer (" + attempt + " of " + HealthAttempts + ")"));
            CloudflareResponse health = await Send(new CloudflareRequest { Method = "GET", Url = healthUrl }, ct);
            if (health.Status == 200 && IsRelayHealth(health.Body))
            {
                return new DeployResult(true, CloudflareApi.RelayUrlFor(workerHost), null);
            }
            if (attempt < HealthAttempts)
            {
                await delay(TimeSpan.FromSeconds(3), ct);
            }
        }
        return new DeployResult(false, null, "The relay was uploaded but " + workerHost + " did not answer within about thirty seconds. It is not in use. Try again in a minute; nothing needs to be cleaned up.");
    }

    private sealed record SubdomainOutcome(string Host, CloudflareResponse Failure);

    private async Task<SubdomainOutcome> RegisterSubdomain(string root, string token, Action<string> progress, CancellationToken ct)
    {
        CloudflareResponse failure = null;
        for (int attempt = 0; attempt < SubdomainAttempts; attempt++)
        {
            string candidate = newSubdomainName();
            progress(Step(2, "creating the workers.dev name \"" + candidate + "\""));
            using var body = CloudflareApi.Json(new JsonObject { ["subdomain"] = candidate });
            CloudflareResponse created = await Send(CloudflareApi.Request("PUT", root + "/workers/subdomain", token, body, apiBase: apiBase), ct);
            if (IsOk(created))
            {
                return new SubdomainOutcome(SubdomainHost(CloudflareApi.ResultOf(created.Body)) ?? candidate + ".workers.dev", null);
            }
            failure = created;
            // 10031: the name is taken. Any other answer will not change with another name.
            if (created.Status == null || CloudflareApi.ErrorCode(created.Body) != 10031)
            {
                return new SubdomainOutcome(null, failure);
            }
        }
        return new SubdomainOutcome(null, failure);
    }

    private async Task<CloudflareResponse> Send(CloudflareRequest request, CancellationToken ct)
    {
        ct.ThrowIfCancellationRequested();
        CloudflareResponse response = await transport.SendAsync(request, ct);
        ct.ThrowIfCancellationRequested();
        return response ?? new CloudflareResponse(null, null, "no answer");
    }

    private static bool IsOk(CloudflareResponse response)
    {
        return response != null && response.Status is >= 200 and < 300 && CloudflareApi.ResultOf(response.Body) != null;
    }

    private static DeployResult Fail(string step, CloudflareResponse response, string token)
    {
        return new DeployResult(false, null, CloudflareApi.Explain(step, response, token));
    }

    private static string Step(int number, string text)
    {
        return "Step " + number + " of " + Steps + ": " + text + "…";
    }

    private static string SubdomainHost(JsonNode result)
    {
        if (result is JsonObject obj && obj["subdomain"] is JsonValue value && value.TryGetValue(out string name) && !string.IsNullOrWhiteSpace(name))
        {
            return name.Trim() + ".workers.dev";
        }
        return null;
    }

    private static string TagOf(JsonNode result)
    {
        if (result is JsonObject obj && obj["default_environment"] is JsonObject environment && environment["script"] is JsonObject script
            && script["migration_tag"] is JsonValue tag && tag.TryGetValue(out string text))
        {
            return text;
        }
        return null;
    }

    private static bool IsRelayHealth(string body)
    {
        try
        {
            return JsonNode.Parse(body ?? string.Empty) is JsonObject obj && obj["status"]?.GetValue<string>() == "ok" && obj["service"]?.GetValue<string>() == "collab-relay";
        }
        catch (Exception ex) when (ex is System.Text.Json.JsonException || ex is InvalidOperationException || ex is FormatException)
        {
            return false;
        }
    }

    /// <summary>A name like <c>collab-k3x9q2</c>: readable, unlikely to collide, and changeable later in the Cloudflare dashboard.</summary>
    private static string DefaultSubdomainName()
    {
        const string alphabet = "abcdefghijkmnpqrstuvwxyz23456789";
        var bytes = new byte[6];
        System.Security.Cryptography.RandomNumberGenerator.Fill(bytes);
        var chars = new char[bytes.Length];
        for (int i = 0; i < bytes.Length; i++)
        {
            chars[i] = alphabet[bytes[i] % alphabet.Length];
        }
        return "collab-" + new string(chars);
    }
}