using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using DodoSSH.Contracts;

namespace DodoSSH.Client.Api;

/// <summary>Supplies the bearer token for API calls, refreshing it when needed.</summary>
/// <remarks>
/// An abstraction because token lifetime is the auth layer's problem, not the API client's. The
/// client asks for a token per request and never caches one, so a refresh that happens mid-session is
/// invisible here rather than something every call site has to remember to handle.
/// </remarks>
public interface IAccessTokenProvider
{
    /// <summary>Returns a currently-valid access token.</summary>
    ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken);
}

/// <summary>
/// The account calls: who am I, and publish my first key.
/// </summary>
/// <remarks>
/// Separated for the same reason as <see cref="ISyncApi"/>. What the session layer does with these is
/// decide between enrolling and unlocking, and persist the result so the next launch needs no network;
/// testing that against a stubbed HTTP transport would prove the right bytes were sent and nothing
/// about the decision.
/// </remarks>
public interface IAccountApi
{
    /// <summary>Reads the caller's profile, unlock material and reachable vaults.</summary>
    Task<MeResponse> GetMeAsync(CancellationToken cancellationToken);

    /// <summary>Publishes the caller's first identity key and creates their personal vault.</summary>
    Task<EnrollmentResponse> EnrollAsync(EnrollmentRequest request, CancellationToken cancellationToken);

    /// <summary>
    /// Registers a device key against an account that is already enrolled.
    /// </summary>
    /// <remarks>
    /// On the interface rather than only on the client, because the session layer decides <em>when</em> to
    /// offer this — after an unlock, never before — and that decision is worth testing without HTTP.
    /// </remarks>
    Task<RegisterDeviceResponse> RegisterDeviceAsync(
        RegisterDeviceRequest request,
        CancellationToken cancellationToken);

    /// <summary>
    /// Withdraws a device key, so that machine can no longer unlock without the passphrase.
    /// </summary>
    /// <returns>
    /// Whether the account had that device. False means it did not, which a caller withdrawing its own
    /// device should treat as having arrived rather than as a failure — another machine may have revoked it
    /// first, and the goal state is the same either way.
    /// </returns>
    Task<bool> RevokeDeviceAsync(Guid deviceId, CancellationToken cancellationToken);
}

/// <summary>
/// Teams, their members, and the vaults they own.
/// </summary>
/// <remarks>
/// Separated from <see cref="IVaultGrantApi"/> although the two are used together, because they are
/// different kinds of act. Everything here changes what the <em>server</em> will serve and can be
/// performed by anything holding a token. Issuing a grant needs a vault key, which only an unlocked
/// session has — so the two live behind different interfaces and are tested against different fakes.
/// </remarks>
public interface ITeamApi
{
    /// <summary>Lists the teams the caller belongs to.</summary>
    Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken);

    /// <summary>Creates a team, with the caller as its owner.</summary>
    Task<TeamSummary> CreateTeamAsync(CreateTeamRequest request, CancellationToken cancellationToken);

    /// <summary>Renames a team, or changes its description.</summary>
    Task<TeamSummary> UpdateTeamAsync(
        Guid teamId,
        UpdateTeamRequest request,
        CancellationToken cancellationToken);

    /// <summary>
    /// Archives a team. Refused while it still owns vaults.
    /// </summary>
    /// <returns>
    /// Whether there was a team to archive. False means there was not, which a caller driving towards
    /// "that team is gone" should treat as having arrived.
    /// </returns>
    Task<bool> ArchiveTeamAsync(Guid teamId, CancellationToken cancellationToken);

    /// <summary>Hands ownership to another member, demoting the outgoing owner to admin.</summary>
    Task TransferTeamOwnershipAsync(
        Guid teamId,
        TransferTeamOwnershipRequest request,
        CancellationToken cancellationToken);

    /// <summary>Lists a team's members.</summary>
    Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
        Guid teamId,
        CancellationToken cancellationToken);

    /// <summary>Adds a member to a team.</summary>
    Task<TeamMemberSummary> AddTeamMemberAsync(
        Guid teamId,
        AddTeamMemberRequest request,
        CancellationToken cancellationToken);

    /// <summary>Changes a member's role.</summary>
    Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
        Guid teamId,
        Guid userId,
        ChangeTeamMemberRoleRequest request,
        CancellationToken cancellationToken);

    /// <summary>
    /// Removes a member, revoking every vault key grant they hold from this team.
    /// </summary>
    /// <returns>
    /// Whether the team had that member. False means it did not, which a caller driving towards
    /// "they are not in this team" should treat as having arrived.
    /// </returns>
    Task<bool> RemoveTeamMemberAsync(Guid teamId, Guid userId, CancellationToken cancellationToken);

    /// <summary>Creates a vault owned by a team, with the creator's key grant.</summary>
    Task<VaultSummary> CreateTeamVaultAsync(
        Guid teamId,
        CreateTeamVaultRequest request,
        CancellationToken cancellationToken);
}

/// <summary>
/// The public-key directory and the log that makes it checkable.
/// </summary>
/// <remarks>
/// The two belong together and are used together: a directory answer is a claim, and the key log is
/// what turns it into something a client can verify. Splitting them would make it possible to build a
/// caller that reads one and not the other, which is precisely the mistake — see ADR 0001 — that
/// undoes end-to-end encryption entirely.
/// </remarks>
public interface IDirectoryApi
{
    /// <summary>Looks a user up by exact email address. There is no search.</summary>
    Task<IReadOnlyList<DirectoryEntry>> LookupByEmailAsync(
        string email,
        CancellationToken cancellationToken);

    /// <summary>Looks up an account the caller shares a team with.</summary>
    Task<DirectoryEntry?> LookupByIdAsync(Guid userId, CancellationToken cancellationToken);

    /// <summary>Reads entries after a sequence, with the log's current head.</summary>
    Task<KeyLogPage> ReadKeyLogAsync(
        long afterSequence,
        int? limit,
        CancellationToken cancellationToken);
}

/// <summary>
/// Vault key grants: who can open a vault, and the record of who let them.
/// </summary>
/// <remarks>
/// The wrapped key and the signature are produced by an unlocked session and are opaque to everything
/// between it and the recipient, this interface included.
/// </remarks>
public interface IVaultGrantApi
{
    /// <summary>
    /// Renames a vault.
    /// </summary>
    /// <remarks>
    /// Here rather than on <see cref="ITeamApi"/> because the subject is a vault, and because the vaults
    /// screen that calls it is about vaults — the team a vault belongs to is behind it, and renaming one
    /// is not an operation on the team. The server renames that team with it where it owns nothing else.
    /// </remarks>
    Task<VaultSummary> RenameVaultAsync(
        Guid vaultId,
        UpdateVaultRequest request,
        CancellationToken cancellationToken);

    /// <summary>
    /// Deletes a vault and withdraws every key to it.
    /// </summary>
    /// <returns>Whether there was a vault to delete.</returns>
    /// <remarks>
    /// <para>
    /// False rather than an exception for a vault that is already gone, exactly as
    /// <see cref="RevokeVaultGrantAsync"/> answers about a grant: a caller driving towards "this vault is
    /// no longer there" has arrived, and two admins deleting the same vault must not leave the slower one
    /// looking at an error about something that happened.
    /// </para>
    /// <para>
    /// What it does not do is reach anybody's machine. A member who synced before this holds their copy
    /// afterwards — see ADR 0001 on revocation — and the interface that offers this has to say so.
    /// </para>
    /// </remarks>
    Task<bool> DeleteVaultAsync(Guid vaultId, CancellationToken cancellationToken);

    /// <summary>Lists who holds a key to this vault.</summary>
    Task<VaultGrantsResponse> ListVaultGrantsAsync(Guid vaultId, CancellationToken cancellationToken);

    /// <summary>Records a vault key wrapped to another member.</summary>
    Task IssueVaultGrantAsync(
        Guid vaultId,
        IssueVaultGrantRequest request,
        CancellationToken cancellationToken);

    /// <summary>
    /// Advances this vault to a fresh key generation, wrapped to the caller.
    /// </summary>
    /// <returns>The vault at its new generation, with the caller's grants for the earlier ones.</returns>
    /// <remarks>
    /// The key is generated by the caller and sealed to itself; the server contributes the moment it
    /// takes effect, which is the one part a client cannot decide on its own. Wrapping the new
    /// generation to everybody else is a separate act, and it is the caller's — see
    /// <see cref="IssueVaultGrantAsync"/>.
    /// </remarks>
    Task<VaultSummary> RekeyVaultAsync(
        Guid vaultId,
        RekeyVaultRequest request,
        CancellationToken cancellationToken);

    /// <summary>
    /// Withdraws a member's key to this vault.
    /// </summary>
    /// <returns>Whether there was a live grant to withdraw.</returns>
    /// <remarks>
    /// Blocks future reads and nothing else. Whatever they have already pulled is on their machine;
    /// the remediation for a departure is rotating the SSH credential. See ADR 0001.
    /// </remarks>
    Task<bool> RevokeVaultGrantAsync(
        Guid vaultId,
        Guid userId,
        CancellationToken cancellationToken);
}

/// <summary>
/// The two vault-synchronisation calls, separated so the sync engine can be driven without HTTP.
/// </summary>
/// <remarks>
/// The sync engine's job is a conflict-resolution policy, and testing a policy against a stubbed
/// transport only proves that the right bytes were sent. Behind this interface the suite runs an
/// in-memory server that enforces the real version checks, assigns real change sequences and issues
/// real cursors — so a test can assert what happens when two clients edit one host, which is the
/// question that actually matters.
/// </remarks>
public interface ISyncApi
{
    /// <summary>Reads vault changes after a cursor.</summary>
    Task<SyncPullResponse> SyncPullAsync(
        Guid vaultId,
        SyncPullRequest request,
        CancellationToken cancellationToken);

    /// <summary>Applies a batch of vault changes.</summary>
    Task<SyncPushResponse> SyncPushAsync(
        Guid vaultId,
        SyncPushRequest request,
        CancellationToken cancellationToken);
}

/// <summary>
/// The typed client for one DodoSSH server.
/// </summary>
/// <remarks>
/// <para>
/// Everything goes through <c>DodoSSH.Contracts</c> and its source-generated serialiser, which is the
/// actual contract between the two sides — not the OpenAPI document. Requests are written with
/// <c>StrictRequestOptions</c> on the server and read here with <c>ResponseOptions</c>, so an older
/// client tolerates a newer server's extra fields instead of failing on them.
/// </para>
/// <para>
/// Discovery is unauthenticated by necessity: a client has to learn how to authenticate before it can.
/// Everything else carries a bearer token.
/// </para>
/// </remarks>
public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens)
    : IAccountApi, ISyncApi, ITeamApi, IDirectoryApi, IVaultGrantApi
{
    private const string MetaPath = "/api/v1/meta";
    private const string ConfigurationPath = "/.well-known/dodossh-configuration";
    private const string MePath = "/api/v1/me";
    private const string EnrollmentPath = "/api/v1/me/enrollment";
    private const string DevicesPath = "/api/v1/me/devices";
    private const string DirectoryPath = "/api/v1/directory";
    private const string KeyLogPath = "/api/v1/keylog";
    private const string TeamsPath = "/api/v1/teams";

    /// <summary>
    /// Reads the server's capabilities, versions and limits.
    /// </summary>
    /// <remarks>
    /// Unauthenticated, and the replacement for URL-based API versioning: when client and server
    /// upgrade independently — normal for self-hosted software — a client has to ask what this
    /// particular server supports rather than assume. See ADR 0002.
    /// </remarks>
    public Task<MetaResponse> GetMetaAsync(CancellationToken cancellationToken) =>
        GetAnonymousAsync(MetaPath, DodoSshJsonContext.Default.MetaResponse, cancellationToken);

    /// <summary>
    /// Reads everything needed to begin authenticating.
    /// </summary>
    /// <remarks>
    /// This is the onboarding story: the user types one server URL and the client discovers the OIDC
    /// authority, the client id, the scopes and the relay from it.
    /// </remarks>
    public Task<DodoSshConfiguration> GetConfigurationAsync(CancellationToken cancellationToken) =>
        GetAnonymousAsync(
            ConfigurationPath,
            DodoSshJsonContext.Default.DodoSshConfiguration,
            cancellationToken);

    /// <summary>
    /// Reads the caller's profile, unlock material and reachable vaults.
    /// </summary>
    /// <remarks>
    /// The first authenticated call a client makes, and the only one that works before enrollment. It
    /// also provisions the account, so its <c>UserId</c> is available before enrolling — which matters,
    /// because the secret bundle's AAD binds to that id and therefore cannot be built any earlier.
    /// </remarks>
    public Task<MeResponse> GetMeAsync(CancellationToken cancellationToken) =>
        SendAsync(HttpMethod.Get, MePath, null, DodoSshJsonContext.Default.MeResponse, cancellationToken);

    /// <summary>Publishes the caller's first identity key and creates their personal vault.</summary>
    public Task<EnrollmentResponse> EnrollAsync(
        EnrollmentRequest request,
        CancellationToken cancellationToken) =>
        SendAsync(
            HttpMethod.Post,
            EnrollmentPath,
            JsonContent.Create(request, DodoSshJsonContext.Default.EnrollmentRequest),
            DodoSshJsonContext.Default.EnrollmentResponse,
            cancellationToken);

    /// <summary>Registers a device key against an already-enrolled account.</summary>
    /// <remarks>
    /// Requires an unlocked vault, because the wrap can only be produced by something holding the secret
    /// bundle. That is also what proves possession to the server, which is why there is no challenge here.
    /// </remarks>
    public Task<RegisterDeviceResponse> RegisterDeviceAsync(
        RegisterDeviceRequest request,
        CancellationToken cancellationToken) =>
        SendAsync(
            HttpMethod.Post,
            DevicesPath,
            JsonContent.Create(request, DodoSshJsonContext.Default.RegisterDeviceRequest),
            DodoSshJsonContext.Default.RegisterDeviceResponse,
            cancellationToken);

    /// <inheritdoc />
    public Task<bool> RevokeDeviceAsync(Guid deviceId, CancellationToken cancellationToken) =>
        DeleteAsync(
            string.Create(CultureInfo.InvariantCulture, $"{DevicesPath}/{deviceId}"),
            cancellationToken);

    /// <summary>Reads vault changes after a cursor.</summary>
    /// <remarks>
    /// A POST despite being a read: the filters live in the body, cursors are opaque, and no caching is
    /// wanted.
    /// </remarks>
    public Task<SyncPullResponse> SyncPullAsync(
        Guid vaultId,
        SyncPullRequest request,
        CancellationToken cancellationToken) =>
        SendAsync(
            HttpMethod.Post,
            $"/api/v1/vaults/{vaultId}/sync/pull",
            JsonContent.Create(request, DodoSshJsonContext.Default.SyncPullRequest),
            DodoSshJsonContext.Default.SyncPullResponse,
            cancellationToken);

    /// <summary>
    /// Applies a batch of vault changes.
    /// </summary>
    /// <remarks>
    /// Succeeds with per-operation status even when individual operations failed, so one stale item
    /// cannot block everything else a client queued while offline. Callers must inspect
    /// <c>SyncPushResult.Status</c> rather than treating a 200 as everything having applied.
    /// </remarks>
    public Task<SyncPushResponse> SyncPushAsync(
        Guid vaultId,
        SyncPushRequest request,
        CancellationToken cancellationToken) =>
        SendAsync(
            HttpMethod.Post,
            $"/api/v1/vaults/{vaultId}/sync/push",
            JsonContent.Create(request, DodoSshJsonContext.Default.SyncPushRequest),
            DodoSshJsonContext.Default.SyncPushResponse,
            cancellationToken);

    /// <inheritdoc />
    public Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken) =>
        SendAsync(
            HttpMethod.Get,
            TeamsPath,
            null,
            DodoSshJsonContext.Default.IReadOnlyListTeamSummary,
            cancellationToken);

    /// <inheritdoc />
    public Task<TeamSummary> CreateTeamAsync(
        CreateTeamRequest request,
        CancellationToken cancellationToken) =>
        SendAsync(
            HttpMethod.Post,
            TeamsPath,
            JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamRequest),
            DodoSshJsonContext.Default.TeamSummary,
            cancellationToken);

    /// <inheritdoc />
    public Task<TeamSummary> UpdateTeamAsync(
        Guid teamId,
        UpdateTeamRequest request,
        CancellationToken cancellationToken) =>
        SendAsync(
            HttpMethod.Put,
            string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}"),
            JsonContent.Create(request, DodoSshJsonContext.Default.UpdateTeamRequest),
            DodoSshJsonContext.Default.TeamSummary,
            cancellationToken);

    /// <inheritdoc />
    public Task<bool> ArchiveTeamAsync(Guid teamId, CancellationToken cancellationToken) =>
        DeleteAsync(
            string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}"),
            cancellationToken);

    /// <inheritdoc />
    public Task TransferTeamOwnershipAsync(
        Guid teamId,
        TransferTeamOwnershipRequest request,
        CancellationToken cancellationToken) =>
        SendNoContentAsync(
            HttpMethod.Post,
            string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/owner"),
            JsonContent.Create(request, DodoSshJsonContext.Default.TransferTeamOwnershipRequest),
            cancellationToken);

    /// <inheritdoc />
    public Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
        Guid teamId,
        CancellationToken cancellationToken) =>
        SendAsync(
            HttpMethod.Get,
            string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members"),
            null,
            DodoSshJsonContext.Default.IReadOnlyListTeamMemberSummary,
            cancellationToken);

    /// <inheritdoc />
    public Task<TeamMemberSummary> AddTeamMemberAsync(
        Guid teamId,
        AddTeamMemberRequest request,
        CancellationToken cancellationToken) =>
        SendAsync(
            HttpMethod.Post,
            string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members"),
            JsonContent.Create(request, DodoSshJsonContext.Default.AddTeamMemberRequest),
            DodoSshJsonContext.Default.TeamMemberSummary,
            cancellationToken);

    /// <inheritdoc />
    public Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
        Guid teamId,
        Guid userId,
        ChangeTeamMemberRoleRequest request,
        CancellationToken cancellationToken) =>
        SendAsync(
            HttpMethod.Put,
            string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members/{userId}/role"),
            JsonContent.Create(request, DodoSshJsonContext.Default.ChangeTeamMemberRoleRequest),
            DodoSshJsonContext.Default.TeamMemberSummary,
            cancellationToken);

    /// <inheritdoc />
    public Task<bool> RemoveTeamMemberAsync(
        Guid teamId,
        Guid userId,
        CancellationToken cancellationToken) =>
        DeleteAsync(
            string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members/{userId}"),
            cancellationToken);

    /// <inheritdoc />
    public Task<VaultSummary> CreateTeamVaultAsync(
        Guid teamId,
        CreateTeamVaultRequest request,
        CancellationToken cancellationToken) =>
        SendAsync(
            HttpMethod.Post,
            string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/vaults"),
            JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamVaultRequest),
            DodoSshJsonContext.Default.VaultSummary,
            cancellationToken);

    /// <summary>
    /// Looks a user up by exact email address.
    /// </summary>
    /// <remarks>
    /// The address is escaped into the query string, which is the one place in this client where a
    /// value a user typed reaches a URL. <see cref="Uri.EscapeDataString"/> rather than string
    /// concatenation: an unescaped <c>&amp;</c> or <c>#</c> in an address would silently become a
    /// lookup for something else.
    /// </remarks>
    public Task<IReadOnlyList<DirectoryEntry>> LookupByEmailAsync(
        string email,
        CancellationToken cancellationToken)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(email);

        return SendAsync(
            HttpMethod.Get,
            $"{DirectoryPath}?email={Uri.EscapeDataString(email)}",
            null,
            DodoSshJsonContext.Default.IReadOnlyListDirectoryEntry,
            cancellationToken);
    }

    /// <inheritdoc />
    public async Task<DirectoryEntry?> LookupByIdAsync(
        Guid userId,
        CancellationToken cancellationToken)
    {
        var entries = await SendAsync(
                HttpMethod.Get,
                string.Create(CultureInfo.InvariantCulture, $"{DirectoryPath}?userId={userId}"),
                null,
                DodoSshJsonContext.Default.IReadOnlyListDirectoryEntry,
                cancellationToken)
            .ConfigureAwait(false);

        return entries.Count == 0 ? null : entries[0];
    }

    /// <inheritdoc />
    public Task<KeyLogPage> ReadKeyLogAsync(
        long afterSequence,
        int? limit,
        CancellationToken cancellationToken)
    {
        var path = limit is null
            ? string.Create(CultureInfo.InvariantCulture, $"{KeyLogPath}?after={afterSequence}")
            : string.Create(
                CultureInfo.InvariantCulture, $"{KeyLogPath}?after={afterSequence}&limit={limit}");

        return SendAsync(
            HttpMethod.Get, path, null, DodoSshJsonContext.Default.KeyLogPage, cancellationToken);
    }

    /// <inheritdoc />
    public Task<VaultSummary> RenameVaultAsync(
        Guid vaultId,
        UpdateVaultRequest request,
        CancellationToken cancellationToken) =>
        SendAsync(
            HttpMethod.Put,
            string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}"),
            JsonContent.Create(request, DodoSshJsonContext.Default.UpdateVaultRequest),
            DodoSshJsonContext.Default.VaultSummary,
            cancellationToken);

    /// <inheritdoc />
    public Task<bool> DeleteVaultAsync(Guid vaultId, CancellationToken cancellationToken) =>
        DeleteAsync(
            string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}"),
            cancellationToken);

    /// <inheritdoc />
    public Task<VaultGrantsResponse> ListVaultGrantsAsync(
        Guid vaultId,
        CancellationToken cancellationToken) =>
        SendAsync(
            HttpMethod.Get,
            string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants"),
            null,
            DodoSshJsonContext.Default.VaultGrantsResponse,
            cancellationToken);

    /// <inheritdoc />
    public Task IssueVaultGrantAsync(
        Guid vaultId,
        IssueVaultGrantRequest request,
        CancellationToken cancellationToken) =>
        SendNoContentAsync(
            HttpMethod.Post,
            string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants"),
            JsonContent.Create(request, DodoSshJsonContext.Default.IssueVaultGrantRequest),
            cancellationToken);

    /// <inheritdoc />
    public Task<VaultSummary> RekeyVaultAsync(
        Guid vaultId,
        RekeyVaultRequest request,
        CancellationToken cancellationToken) =>
        SendAsync(
            HttpMethod.Post,
            string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/rekey"),
            JsonContent.Create(request, DodoSshJsonContext.Default.RekeyVaultRequest),
            DodoSshJsonContext.Default.VaultSummary,
            cancellationToken);

    /// <inheritdoc />
    public Task<bool> RevokeVaultGrantAsync(
        Guid vaultId,
        Guid userId,
        CancellationToken cancellationToken) =>
        DeleteAsync(
            string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants/{userId}"),
            cancellationToken);

    private async Task<T> GetAnonymousAsync<T>(
        string path,
        System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> typeInfo,
        CancellationToken cancellationToken)
    {
        using var request = new HttpRequestMessage(HttpMethod.Get, path);
        return await SendCoreAsync(request, typeInfo, cancellationToken).ConfigureAwait(false);
    }

    private async Task<T> SendAsync<T>(
        HttpMethod method,
        string path,
        HttpContent? content,
        System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> typeInfo,
        CancellationToken cancellationToken)
    {
        using var request = new HttpRequestMessage(method, path) { Content = content };

        var token = await tokens.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);

        return await SendCoreAsync(request, typeInfo, cancellationToken).ConfigureAwait(false);
    }

    /// <summary>
    /// Sends a request whose success carries no body.
    /// </summary>
    /// <remarks>
    /// Its own path for the reason <see cref="DeleteAsync"/> gives, minus the 404: a grant that will
    /// not be recorded, or an ownership transfer that will not happen, is a failure with a problem
    /// document behind it, so there is nothing here to translate into a return value.
    /// </remarks>
    private async Task SendNoContentAsync(
        HttpMethod method,
        string path,
        HttpContent? content,
        CancellationToken cancellationToken)
    {
        using var request = new HttpRequestMessage(method, path) { Content = content };

        var token = await tokens.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);

        using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);

        if (!response.IsSuccessStatusCode)
        {
            var body = await response.Content
                .ReadAsStringAsync(cancellationToken)
                .ConfigureAwait(false);

            throw DodoSshApiException.FromResponse(response.StatusCode, body);
        }
    }

    /// <summary>
    /// Sends a delete whose success carries no body.
    /// </summary>
    /// <returns>True for a 2xx, false for a 404; anything else throws.</returns>
    /// <remarks>
    /// Its own path rather than <see cref="SendAsync{T}"/> with some empty response type, because the two
    /// disagree about what a missing body means. Everywhere else a 200 with nothing in it is a server bug
    /// worth an exception; here it is the answer.
    /// </remarks>
    private async Task<bool> DeleteAsync(string path, CancellationToken cancellationToken)
    {
        using var request = new HttpRequestMessage(HttpMethod.Delete, path);

        var token = await tokens.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);

        using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);

        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return false;
        }

        if (!response.IsSuccessStatusCode)
        {
            var body = await response.Content
                .ReadAsStringAsync(cancellationToken)
                .ConfigureAwait(false);

            throw DodoSshApiException.FromResponse(response.StatusCode, body);
        }

        return true;
    }

    private async Task<T> SendCoreAsync<T>(
        HttpRequestMessage request,
        System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> typeInfo,
        CancellationToken cancellationToken)
    {
        using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);

        if (!response.IsSuccessStatusCode)
        {
            var body = await response.Content
                .ReadAsStringAsync(cancellationToken)
                .ConfigureAwait(false);

            throw DodoSshApiException.FromResponse(response.StatusCode, body);
        }

        var value = await response.Content
            .ReadFromJsonAsync(typeInfo, cancellationToken)
            .ConfigureAwait(false);

        // A 200 with a null body is a server bug, but it must not surface as a NullReferenceException
        // three frames further up where the cause is invisible.
        return value ?? throw new DodoSshApiException(
            HttpStatusCode.OK,
            null,
            $"The server returned an empty body where a {typeof(T).Name} was expected.");
    }
}
