using System.Security.Cryptography;
using System.Text;
using DodoSSH.Crypto;
using Microsoft.EntityFrameworkCore;

namespace DodoSSH.Client.Storage;

/// <summary>
/// The sign-in this machine may resume without opening a browser.
/// </summary>
/// <remarks>
/// <para>
/// One refresh token, sealed under the LocalCacheKey and bound to the user it belongs to. Everything about
/// why it is sealed rather than stored — and what a locked machine therefore cannot do — is on
/// <see cref="RememberedSignInRow"/>.
/// </para>
/// <para>
/// The server it belongs to is deliberately not recorded here: <c>unlock_material</c> already holds it, and
/// two copies of one fact is two facts that can disagree. A cache holds one account and one server.
/// </para>
/// </remarks>
public sealed class RememberedSignInStore(
    IDbContextFactory<ClientCacheContext> contexts,
    LocalCacheProtector protector,
    Guid userId,
    TimeProvider clock)
{
    /// <summary>Remembers a refresh token, replacing whatever was there.</summary>
    /// <remarks>
    /// Called again whenever the provider rotates the token. Keeping the one this client first received
    /// would leave a rotating provider refusing the next launch, which is the failure that reads as "the
    /// application randomly signs me out".
    /// </remarks>
    public async Task SaveAsync(string refreshToken, CancellationToken cancellationToken)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(refreshToken);

        var context = contexts.CreateDbContext();
        await using var scope = context.ConfigureAwait(false);

        var row = await context.Set<RememberedSignInRow>()
            .SingleOrDefaultAsync(cancellationToken)
            .ConfigureAwait(false);

        if (row is null)
        {
            row = new RememberedSignInRow();
            context.Add(row);
        }

        var bytes = Encoding.UTF8.GetBytes(refreshToken);

        try
        {
            row.SealedRefreshToken = protector.Protect(
                CryptoSpec.AadResourceType.User, userId, bytes);
        }
        finally
        {
            // The managed copy this method made, not the string it was handed — see the remark on
            // ReadAsync for what a .NET string does and does not allow here.
            CryptographicOperations.ZeroMemory(bytes);
        }

        row.UpdatedAtUtc = clock.GetUtcNow();

        await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
    }

    /// <summary>
    /// Reads the remembered token, or null when there is none this key can open.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Null rather than an exception for a record that will not open, on the same reasoning as
    /// <see cref="LocalCacheProtector.TryUnprotect"/>: a cache written under a different identity is an
    /// ordinary situation and the answer is to sign in again, not to fail.
    /// </para>
    /// <para>
    /// It comes back as a <see cref="string"/>, which cannot be wiped. That is the same bargain the private
    /// key and password editors already make — every HTTP client on the way to the token endpoint wants a
    /// string — and pretending otherwise with a <c>SecureString</c> would buy nothing this process's memory
    /// does not already give away.
    /// </para>
    /// </remarks>
    public async Task<string?> ReadAsync(CancellationToken cancellationToken)
    {
        var context = contexts.CreateDbContext();
        await using var scope = context.ConfigureAwait(false);

        var row = await context.Set<RememberedSignInRow>()
            .AsNoTracking()
            .SingleOrDefaultAsync(cancellationToken)
            .ConfigureAwait(false);

        if (row is null)
        {
            return null;
        }

        var opened = protector.TryUnprotect(
            CryptoSpec.AadResourceType.User, userId, row.SealedRefreshToken);

        return opened is null ? null : Encoding.UTF8.GetString(opened);
    }

    /// <summary>Forgets the remembered sign-in, so the next launch has to use a browser.</summary>
    /// <remarks>
    /// Used when the provider refuses the token — a revoked session, a rotation this machine missed — as
    /// well as when the user signs out. Keeping a token that has already been refused would mean retrying
    /// it once a minute for the life of the profile.
    /// </remarks>
    public async Task ForgetAsync(CancellationToken cancellationToken)
    {
        var context = contexts.CreateDbContext();
        await using var scope = context.ConfigureAwait(false);

        await context.Set<RememberedSignInRow>()
            .ExecuteDeleteAsync(cancellationToken)
            .ConfigureAwait(false);
    }
}
