using System.Security.Cryptography;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Sync;
using DodoSSH.Crypto;
using NSec.Cryptography;

namespace DodoSSH.Client.Session;

/// <summary>Why an unlock did or did not produce a session.</summary>
public enum UnlockStatus
{
    /// <summary>Not a legal value.</summary>
    Unspecified = 0,

    /// <summary>The vault is open.</summary>
    Unlocked = 1,

    /// <summary>
    /// This machine has never been enrolled, so there is nothing here to unlock. The user has to sign in
    /// to a server first, which needs a network.
    /// </summary>
    NotEnrolled = 2,

    /// <summary>
    /// The passphrase did not open the wrap.
    /// </summary>
    /// <remarks>
    /// The overwhelmingly common failure, and a return value rather than an exception for that reason.
    /// It is also indistinguishable from a tampered wrap, which is correct: the AEAD tag is the only
    /// evidence either way, and no passphrase verifier is stored anywhere. See docs/crypto.md §2.
    /// </remarks>
    WrongPassphrase = 3,

    /// <summary>
    /// The identity opened but no vault grant did, so there is nothing readable.
    /// </summary>
    /// <remarks>
    /// What a rekey looks like before new grants arrive. Distinguished from a wrong passphrase because
    /// the remedy is completely different — this one needs a member with Share to finish the rekey, and
    /// telling the user to retype their passphrase would be actively misleading.
    /// </remarks>
    NoReadableVault = 4,

    /// <summary>The cached KDF parameters are not something this build can use.</summary>
    UnsupportedKdf = 5,

    /// <summary>No device key is registered on this machine, so there is nothing to unlock with.</summary>
    /// <remarks>
    /// The ordinary state for a machine nobody has opted in on, and not an error. A caller that offers
    /// device unlock should check this before showing a gesture prompt that cannot lead anywhere.
    /// </remarks>
    NoDeviceKey = 6,

    /// <summary>
    /// A device key is registered but this machine would not hand it over.
    /// </summary>
    /// <remarks>
    /// The user declined the gesture, or the platform invalidated the key — a Hello key does not survive a
    /// PIN reset. The two are deliberately not distinguished: the remedy is the passphrase either way, and
    /// a message naming which one describes the keystore rather than telling the user anything useful.
    /// </remarks>
    DeviceKeyUnavailable = 7,

    /// <summary>The device key was retrieved and did not open the wrap.</summary>
    /// <remarks>
    /// What a rotated identity looks like from a machine whose device wrap predates it. Distinct from
    /// <see cref="DeviceKeyUnavailable"/> because this one will never succeed again — the wrap is for a
    /// bundle that no longer exists, and the device has to be registered afresh from an unlocked session.
    /// </remarks>
    DeviceKeyRejected = 8,
}

/// <summary>The result of an unlock attempt.</summary>
/// <param name="Status">What happened.</param>
/// <param name="Session">The open vault, present only when <paramref name="Status"/> is unlocked.</param>
/// <param name="Message">Something to show the user. Never contains secret material.</param>
public sealed record UnlockOutcome(UnlockStatus Status, VaultSession? Session, string Message)
{
    /// <summary>Whether a session came back.</summary>
    public bool IsUnlocked => Status == UnlockStatus.Unlocked && Session is not null;
}

/// <summary>
/// Opens the vault from what is already on this machine.
/// </summary>
/// <remarks>
/// <para>
/// <b>This path touches no network, deliberately and testably.</b> The Argon2id salt, its cost
/// parameters and the wrapped identity bundle are all cached at enrollment, so deriving the master key
/// and opening the bundle need nothing but the passphrase. Fetching any of it at unlock time would make
/// an offline launch impossible, which is the single most common moment a user actually needs their
/// hosts.
/// </para>
/// <para>
/// Nothing derived here is persisted. The master key exists for the duration of this method and is
/// zeroed before it returns; what survives is the cache subkey and the identity keys, in the session,
/// until the session is disposed.
/// </para>
/// </remarks>
public sealed class SessionOpener(
    ClientCacheFactory caches,
    TimeProvider clock,
    SyncOptions? options = null)
{
    private readonly SyncOptions options = options ?? SyncOptions.Default;

    /// <summary>Reads who this machine is enrolled as, without needing a passphrase.</summary>
    /// <remarks>
    /// Lets the unlock screen greet the user by name and show which server they are enrolled against,
    /// which is the difference between an unlock prompt and an unexplained password box.
    /// </remarks>
    public Task<StoredUnlockMaterial?> ReadProfileAsync(CancellationToken cancellationToken) =>
        new UnlockStore(caches, clock).ReadAsync(cancellationToken);

    /// <summary>Attempts to open the vault.</summary>
    public async Task<UnlockOutcome> UnlockAsync(string passphrase, CancellationToken cancellationToken)
    {
        ArgumentException.ThrowIfNullOrEmpty(passphrase);

        var profile = await ReadProfileAsync(cancellationToken).ConfigureAwait(false);

        if (profile is null)
        {
            return new UnlockOutcome(
                UnlockStatus.NotEnrolled,
                null,
                "This machine is not enrolled yet. Sign in to a DodoSSH server to set it up.");
        }

        if (!TryReadKdf(profile, out var kdf))
        {
            return new UnlockOutcome(
                UnlockStatus.UnsupportedKdf,
                null,
                $"The stored key derivation settings ('{profile.KdfParameters.Algorithm}') are not "
                + "supported by this version. Update DodoSSH.");
        }

        var bundle = OpenBundle(profile, passphrase, kdf, out var protector);

        if (bundle is null)
        {
            return new UnlockOutcome(
                UnlockStatus.WrongPassphrase, null, "That passphrase did not open the vault.");
        }

        try
        {
            return await BuildSessionAsync(profile, bundle, protector!, cancellationToken)
                .ConfigureAwait(false);
        }
        catch
        {
            protector!.Dispose();
            bundle.Dispose();
            throw;
        }
    }

    /// <summary>
    /// Attempts to open the vault with this machine's device key instead of the passphrase.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Touches no network, exactly as the passphrase path does not: the device wrap is cached at
    /// registration precisely so the one unlock that saves the user typing is not the one that needs to be
    /// online. A gesture on a plane is the case this exists for.
    /// </para>
    /// <para>
    /// Every failure returns rather than throws, and every failure has the same remedy — ask for the
    /// passphrase. That is why the caller gets a status and a sentence and not an exception: none of these
    /// are exceptional, and a cancelled fingerprint prompt is the most ordinary thing here.
    /// </para>
    /// </remarks>
    public async Task<UnlockOutcome> UnlockWithDeviceAsync(
        IDeviceKeyStore deviceKeys,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(deviceKeys);

        var profile = await ReadProfileAsync(cancellationToken).ConfigureAwait(false);

        if (profile is null)
        {
            return new UnlockOutcome(
                UnlockStatus.NotEnrolled,
                null,
                "This machine is not enrolled yet. Sign in to a DodoSSH server to set it up.");
        }

        if (profile.DeviceWrappedPrivateKey is not { } wrap)
        {
            return new UnlockOutcome(
                UnlockStatus.NoDeviceKey,
                null,
                "This machine has no device key registered. Unlock with your passphrase.");
        }

        var material = await deviceKeys.TryLoadAsync(cancellationToken).ConfigureAwait(false);

        if (material is null)
        {
            return new UnlockOutcome(
                UnlockStatus.DeviceKeyUnavailable,
                null,
                "This machine did not release its device key. Unlock with your passphrase.");
        }

        return await OpenWithDeviceAsync(profile, wrap, material, cancellationToken)
            .ConfigureAwait(false);
    }

    /// <remarks>
    /// The raw scalar is zeroed before this returns whatever happens. It came out of a keystore into a
    /// managed array, which is the one span of its life nothing else is guarding it.
    /// </remarks>
    private async Task<UnlockOutcome> OpenWithDeviceAsync(
        StoredUnlockMaterial profile,
        byte[] wrap,
        byte[] material,
        CancellationToken cancellationToken)
    {
        UserSecretBundle? bundle;

        try
        {
            bundle = OpenSealedBundle(profile, wrap, material);
        }
        finally
        {
            CryptographicOperations.ZeroMemory(material);
        }

        if (bundle is null)
        {
            return new UnlockOutcome(
                UnlockStatus.DeviceKeyRejected,
                null,
                "This machine's device key no longer opens the vault. Unlock with your passphrase; the "
                + "device can then be registered again.");
        }

        var protector = LocalCacheProtector.From(bundle);

        try
        {
            return await BuildSessionAsync(profile, bundle, protector, cancellationToken)
                .ConfigureAwait(false);
        }
        catch
        {
            protector.Dispose();
            bundle.Dispose();
            throw;
        }
    }

    /// <remarks>
    /// Returns null for a scalar of the wrong length as well as for a wrap that does not open, because a
    /// keystore handing back something that is not a key is the same situation from here: this machine
    /// cannot unlock and the passphrase can.
    /// </remarks>
    private static UserSecretBundle? OpenSealedBundle(
        StoredUnlockMaterial profile,
        byte[] wrap,
        byte[] material)
    {
        if (material.Length != CryptoSpec.SymmetricKeySize)
        {
            return null;
        }

        using var deviceKey = Key.Import(
            KeyAgreementAlgorithm.X25519,
            material,
            KeyBlobFormat.RawPrivateKey);

        return UserSecretBundle.TryOpenSealed(
            deviceKey,
            wrap,
            DshAad.UserSecretBundle(profile.UserId, profile.KeyGeneration));
    }

    /// <remarks>
    /// The master key lives only inside this method — it opens the bundle and is then done with. The cache
    /// protector derives from the bundle rather than from the master key, which is what lets a device or
    /// recovery unlock reach the same cache; see <see cref="UserSecretBundle.DeriveLocalCacheKey"/>.
    /// </remarks>
    private static UserSecretBundle? OpenBundle(
        StoredUnlockMaterial profile,
        string passphrase,
        Argon2Profile kdf,
        out LocalCacheProtector? protector)
    {
        protector = null;

        using var master = MasterKey.Derive(passphrase, profile.KdfParameters.Salt, kdf);

        var descriptor = DshAad.UserSecretBundle(profile.UserId, profile.KeyGeneration);
        var bundle = master.TryOpenBundle(profile.WrappedPrivateKey, descriptor);

        if (bundle is null)
        {
            return null;
        }

        try
        {
            protector = LocalCacheProtector.From(bundle);
            return bundle;
        }
        catch
        {
            bundle.Dispose();
            throw;
        }
    }

    private async Task<UnlockOutcome> BuildSessionAsync(
        StoredUnlockMaterial profile,
        UserSecretBundle bundle,
        LocalCacheProtector protector,
        CancellationToken cancellationToken)
    {
        var vaults = await new VaultStore(caches, clock)
            .ListAsync(cancellationToken)
            .ConfigureAwait(false);

        var keyring = VaultKeyring.Open(bundle, vaults);

        try
        {
            var active = vaults.FirstOrDefault(vault => keyring.CanRead(vault.VaultId));

            if (active is null)
            {
                keyring.Dispose();
                protector.Dispose();
                bundle.Dispose();

                return new UnlockOutcome(
                    UnlockStatus.NoReadableVault,
                    null,
                    vaults.Count == 0
                        ? "No vaults are cached on this machine yet. Sign in to synchronise them."
                        : "Your key does not open any cached vault. It was probably rotated; a member "
                          + "with sharing rights needs to re-issue your access.");
            }

            var session = new VaultSession(
                profile, vaults, active.VaultId, bundle, protector, keyring, caches, clock, options);

            return new UnlockOutcome(UnlockStatus.Unlocked, session, $"Unlocked '{active.Name}'.");
        }
        catch
        {
            keyring.Dispose();
            throw;
        }
    }

    /// <remarks>
    /// The parameters travel with the wrap so that raising them later is a per-user migration at next
    /// unlock rather than a breaking change. The cost of that is having to handle values this build does
    /// not recognise, which is what this is: a clear message beats an exception from inside libsodium.
    /// </remarks>
    private static bool TryReadKdf(StoredUnlockMaterial profile, out Argon2Profile kdf)
    {
        kdf = Argon2Profile.PassphraseDefault;

        if (!string.Equals(profile.KdfParameters.Algorithm, "argon2id", StringComparison.Ordinal))
        {
            return false;
        }

        try
        {
            kdf = Argon2Profile.FromStoredParameters(
                profile.KdfParameters.MemoryKibibytes,
                profile.KdfParameters.Passes,
                profile.KdfParameters.Parallelism);

            return true;
        }
        catch (ArgumentOutOfRangeException)
        {
            return false;
        }
        catch (NotSupportedException)
        {
            return false;
        }
    }
}
