using DodoSSH.Contracts;
using Microsoft.EntityFrameworkCore;

namespace DodoSSH.Client.Storage;

/// <summary>
/// Thrown when the cache belongs to a different account than the one signing in.
/// </summary>
/// <remarks>
/// Loud on purpose. Silently adopting the cache would mix one user's items into another's vault list
/// and, worse, would offer an unlock prompt whose passphrase can never work.
/// </remarks>
public sealed class CacheIdentityMismatchException : InvalidOperationException
{
    /// <summary>Creates the exception.</summary>
    public CacheIdentityMismatchException(string message)
        : base(message)
    {
    }

    /// <summary>Creates the exception.</summary>
    public CacheIdentityMismatchException()
        : base("This cache belongs to a different account.")
    {
    }

    /// <summary>Creates the exception.</summary>
    public CacheIdentityMismatchException(string message, Exception innerException)
        : base(message, innerException)
    {
    }
}

/// <summary>
/// The material an offline unlock needs.
/// </summary>
/// <remarks>
/// <para>
/// This store is the reason the client works on a plane. The Argon2id salt and the wrapped secret
/// bundle are cached the moment the server hands them over, so deriving the master key and opening the
/// bundle need no network at all. Fetching either at unlock time would make an offline launch
/// impossible, which is the most common moment a user actually needs their vault.
/// </para>
/// <para>
/// Neither value is a secret. The salt is public by construction and the bundle is ciphertext whose key
/// exists only in the user's head. The master key itself is never written here or anywhere else.
/// </para>
/// </remarks>
public sealed class UnlockStore(IDbContextFactory<ClientCacheContext> contexts, TimeProvider clock)
{
    /// <summary>Reads the cached material, or null when this cache has never been enrolled.</summary>
    public async Task<StoredUnlockMaterial?> ReadAsync(CancellationToken cancellationToken)
    {
        var context = contexts.CreateDbContext();
        await using var scope = context.ConfigureAwait(false);

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

        return row is null ? null : ToStored(row);
    }

    /// <summary>
    /// Writes the material, replacing what is there.
    /// </summary>
    /// <exception cref="CacheIdentityMismatchException">
    /// The cache already holds a different user. One cache file is one account; see
    /// <see cref="UnlockMaterialRow"/> for why multiple accounts are not half-supported here.
    /// </exception>
    public async Task SaveAsync(StoredUnlockMaterial material, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(material);

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

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

        if (row is null)
        {
            row = new UnlockMaterialRow();
            context.Add(row);
        }
        else if (row.UserId != material.UserId)
        {
            throw new CacheIdentityMismatchException(
                $"This cache holds user {row.UserId}; refusing to overwrite it with {material.UserId}.");
        }

        Apply(row, material, clock.GetUtcNow());

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

    /// <summary>
    /// Records a device wrap against the existing profile.
    /// </summary>
    /// <remarks>
    /// Separate from <see cref="SaveAsync"/> because a device is registered long after enrollment, from an
    /// unlocked session, and nothing else about the profile changes when it happens.
    /// </remarks>
    /// <exception cref="InvalidOperationException">This cache has never been enrolled.</exception>
    public async Task AttachDeviceAsync(
        Guid deviceId,
        byte[] wrap,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(wrap);

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

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

        if (row is null)
        {
            throw new InvalidOperationException(
                "This cache is not enrolled, so there is no profile to attach a device to.");
        }

        row.DeviceId = deviceId;
        row.DeviceWrappedPrivateKey = wrap;
        row.UpdatedAtUtc = clock.GetUtcNow();

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

    /// <summary>Forgets the device wrap, so this machine goes back to asking for the passphrase.</summary>
    /// <remarks>
    /// Local only, and deliberately survivable: the server's wrap row is deleted separately, and a cache
    /// that has forgotten its wrap while the server still lists the device is merely a machine that asks
    /// for a passphrase. The reverse — a wrap here for a device the server has revoked — is the one that
    /// would be confusing, and it resolves itself the moment the wrap fails to open.
    /// </remarks>
    public async Task DetachDeviceAsync(CancellationToken cancellationToken)
    {
        var context = contexts.CreateDbContext();
        await using var scope = context.ConfigureAwait(false);

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

        if (row is null)
        {
            return;
        }

        row.DeviceId = null;
        row.DeviceWrappedPrivateKey = null;
        row.UpdatedAtUtc = clock.GetUtcNow();

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

    /// <remarks>
    /// <b>The device columns are written only when the incoming material carries them.</b> This method
    /// runs on every sign-in, from a <c>/me</c> response that knows nothing about this machine's
    /// keystore — so assigning them unconditionally would quietly delete the device wrap on the next
    /// launch, and the user would find their fingerprint had stopped working for no visible reason. An
    /// enrollment that registers a device in the same breath still gets them written, because it supplies
    /// them.
    /// </remarks>
    private static void Apply(
        UnlockMaterialRow row,
        StoredUnlockMaterial material,
        DateTimeOffset now)
    {
        if (material.DeviceWrappedPrivateKey is not null)
        {
            row.DeviceWrappedPrivateKey = material.DeviceWrappedPrivateKey;
            row.DeviceId = material.DeviceId;
        }

        row.ServerUrl = material.ServerUrl;
        row.UserId = material.UserId;
        row.Issuer = material.Issuer;
        row.Subject = material.Subject;
        row.Email = material.Email;
        row.DisplayName = material.DisplayName;
        row.KeyGeneration = material.KeyGeneration;
        row.WrappedPrivateKey = material.WrappedPrivateKey;
        row.KdfAlgorithm = material.KdfParameters.Algorithm;
        row.KdfSalt = material.KdfParameters.Salt;
        row.KdfMemoryKibibytes = material.KdfParameters.MemoryKibibytes;
        row.KdfPasses = material.KdfParameters.Passes;
        row.KdfParallelism = material.KdfParameters.Parallelism;
        row.UpdatedAtUtc = now;
    }

    private static StoredUnlockMaterial ToStored(UnlockMaterialRow row) =>
        new(
            row.ServerUrl,
            row.UserId,
            row.Issuer,
            row.Subject,
            row.Email,
            row.DisplayName,
            row.KeyGeneration,
            row.WrappedPrivateKey,
            new KdfParameters(
                row.KdfAlgorithm,
                row.KdfSalt,
                row.KdfMemoryKibibytes,
                row.KdfPasses,
                row.KdfParallelism),
            row.UpdatedAtUtc,
            row.DeviceWrappedPrivateKey,
            row.DeviceId);
}
