using System.Security.Cryptography;
using DodoSSH.Client.Api;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Storage;
using DodoSSH.Contracts;
using DodoSSH.Crypto;

namespace DodoSSH.Client.Session;

/// <summary>What the server said about this account.</summary>
public enum ProvisionStatus
{
    /// <summary>Not a legal value.</summary>
    Unspecified = 0,

    /// <summary>
    /// No identity key exists yet. The user must choose a passphrase and enroll before anything else
    /// works.
    /// </summary>
    EnrollmentRequired = 1,

    /// <summary>Enrolled, and everything an offline unlock needs is now cached.</summary>
    Ready = 2,
}

/// <summary>The result of talking to the server about this account.</summary>
/// <param name="Status">What happened.</param>
/// <param name="Me">The profile the server reported.</param>
/// <param name="RecoveryCode">
/// Present only immediately after enrolling. <b>Must be shown once and never stored.</b> Losing this
/// along with the passphrase and every enrolled device means the vault is unrecoverable, and there is no
/// server-side reset by design — see docs/crypto.md §10.
/// </param>
/// <param name="Message">Something to show the user.</param>
public sealed record ProvisionOutcome(
    ProvisionStatus Status,
    MeResponse Me,
    string? RecoveryCode,
    string Message);

/// <summary>
/// Gets this machine from "signed in" to "has everything an offline unlock needs".
/// </summary>
/// <remarks>
/// <para>
/// The only part of the client that requires a network. Everything it does is in service of the part
/// that does not: it caches the KDF salt, the wrapped identity bundle and the vault grants, so every
/// later launch opens the vault with nothing but the passphrase.
/// </para>
/// <para>
/// After enrolling it re-reads <c>/me</c> rather than caching what it believes it sent. That is a
/// deliberate round trip: it proves the server stored what this client thinks it did, and the passphrase
/// the user just chose is then verified against the cached wrap on the very next unlock rather than on
/// some future launch when they have forgotten which one they typed.
/// </para>
/// </remarks>
public sealed class AccountProvisioner(
    IAccountApi api,
    IKeyBindingAuthorizer keyBinding,
    ClientCacheFactory caches,
    TimeProvider clock,
    Argon2Profile? passphraseProfile = null)
{
    /// <summary>Reads the account and caches whatever an offline unlock will need.</summary>
    public async Task<ProvisionOutcome> RefreshAsync(
        string serverUrl,
        CancellationToken cancellationToken)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(serverUrl);

        var me = await api.GetMeAsync(cancellationToken).ConfigureAwait(false);

        if (me.EnrollmentRequired)
        {
            return new ProvisionOutcome(
                ProvisionStatus.EnrollmentRequired,
                me,
                null,
                "This account has no vault key yet. Choose a passphrase to create one.");
        }

        await CacheAsync(serverUrl, me, cancellationToken).ConfigureAwait(false);

        return new ProvisionOutcome(
            ProvisionStatus.Ready, me, null, "Signed in. Unlock with your vault passphrase.");
    }

    /// <summary>
    /// Creates this account's identity key and personal vault.
    /// </summary>
    /// <param name="serverUrl">The server this profile belongs to.</param>
    /// <param name="passphrase">The vault passphrase. Never transmitted or stored.</param>
    /// <param name="deviceName">Human-readable name for this machine, shown in the key statement.</param>
    /// <param name="vaultName">Display name for the personal vault.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <remarks>
    /// <b>No device key is registered.</b> Its private half belongs in the OS keystore, and nothing wires
    /// one up yet — so registering it would put a wrap on the server that no key can open and would make
    /// the account's device list claim this machine can unlock without a passphrase. Until the keystore
    /// is wired, the passphrase is required on every launch. That is a limitation, not a design choice.
    /// </remarks>
    public async Task<ProvisionOutcome> EnrollAsync(
        string serverUrl,
        string passphrase,
        string deviceName,
        string vaultName,
        CancellationToken cancellationToken)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(serverUrl);
        ArgumentException.ThrowIfNullOrEmpty(passphrase);

        var me = await api.GetMeAsync(cancellationToken).ConfigureAwait(false);

        if (!me.EnrollmentRequired)
        {
            // Someone else already enrolled this account — another machine, or a retry whose answer was
            // lost. Caching what is there is the right move; re-enrolling would replace a key other
            // people may already have wrapped vault keys to.
            await CacheAsync(serverUrl, me, cancellationToken).ConfigureAwait(false);

            return new ProvisionOutcome(
                ProvisionStatus.Ready,
                me,
                null,
                "This account was already enrolled. Unlock with your existing vault passphrase.");
        }

        var enrollment = new ClientEnrollment(api, keyBinding, clock, passphraseProfile);

        var outcome = await enrollment
            .EnrollAsync(me, passphrase, deviceName, vaultName, bindThisDevice: false, cancellationToken)
            .ConfigureAwait(false);

        try
        {
            var enrolled = await api.GetMeAsync(cancellationToken).ConfigureAwait(false);

            await CacheAsync(serverUrl, enrolled, cancellationToken).ConfigureAwait(false);

            return new ProvisionOutcome(
                ProvisionStatus.Ready,
                enrolled,
                outcome.RecoveryCode,
                "Your vault was created. Write the recovery code down before continuing.");
        }
        finally
        {
            // The session keys are re-derived from the cached wrap at unlock, so nothing here needs to
            // survive this method — and a vault key left in a managed array is a vault key in a heap dump.
            outcome.Bundle.Dispose();
            CryptographicOperations.ZeroMemory(outcome.PersonalVaultKey);
        }
    }

    /// <remarks>
    /// Both halves matter. Without the unlock material there is no offline unlock; without the vault
    /// grants an offline launch could open the identity bundle and still not decrypt a single item.
    /// </remarks>
    private async Task CacheAsync(
        string serverUrl,
        MeResponse me,
        CancellationToken cancellationToken)
    {
        if (me.WrappedPrivateKey is null || me.KdfParameters is null || me.KeyGeneration is null)
        {
            throw new InvalidOperationException(
                "The server reported an enrolled account without the material needed to unlock it. "
                + "Refusing to cache a profile that could never be opened.");
        }

        await new UnlockStore(caches, clock).SaveAsync(
            new StoredUnlockMaterial(
                serverUrl,
                me.UserId,
                me.Issuer,
                me.Subject,
                me.Email,
                me.DisplayName,
                (uint)me.KeyGeneration.Value,
                me.WrappedPrivateKey,
                me.KdfParameters,
                clock.GetUtcNow()),
            cancellationToken).ConfigureAwait(false);

        await new VaultStore(caches, clock).ReplaceAllAsync(
            [.. me.Vaults.Select(ToStored)],
            cancellationToken).ConfigureAwait(false);
    }

    private static StoredVault ToStored(VaultSummary summary) =>
        new(
            summary.VaultId,
            summary.Name,
            summary.IsPersonal,
            summary.TeamId,
            summary.KeyGeneration,
            summary.Permissions,
            summary.WrappedVaultKey,
            summary.RekeyRequired,
            summary.PriorKeyWraps);
}
