using System.Runtime.Versioning;
using System.Security.Cryptography;
using DodoSSH.Client.Session;

namespace DodoSSH.Client.App.Platform;

/// <summary>
/// Picks the device key store this desktop machine can actually offer.
/// </summary>
/// <remarks>
/// <para>
/// One place decides, so nothing above has to carry a platform guard. A machine with no secure hardware,
/// or one that is neither Windows nor macOS, gets <see cref="UnavailableDeviceKeyStore"/> and therefore
/// keeps asking for the passphrase — which is the honest answer rather than a degraded one.
/// </para>
/// <para>
/// <b>Both real stores are asked whether they work rather than told that they do.</b> Each
/// <c>IsSupported</c> probes by doing the thing — creating a throwaway key and deleting it — because on
/// both platforms the provider is present and reports itself present on machines where creating a key
/// fails: a Windows box with no usable TPM, a Mac with no Secure Enclave, and on macOS also every
/// unsigned development build, since enclave keys need a signing identity. Inferring from the OS would
/// mean each of those discovering the truth at the moment somebody tried to unlock.
/// </para>
/// <para>
/// <b>"Desktop", because the choice belongs to a head rather than to the session layer.</b> This file used
/// to live in <c>DodoSSH.Client.Session</c>, which was the one thing keeping that project from being
/// portable: everything else in it is platform-neutral, and a Windows CNG dependency in the middle of the
/// vault code meant a second head could not reference it without dragging Windows along. The seam that
/// makes the move free is <see cref="IDeviceKeyStore"/>, which was already there — the session takes a
/// store and has never known which one. See <c>docs/android-port.md</c>.
/// </para>
/// </remarks>
public static class DesktopDeviceKeyStores
{
    /// <summary>The best store this machine supports.</summary>
    public static IDeviceKeyStore ForThisMachine(ClientPaths paths)
    {
        ArgumentNullException.ThrowIfNull(paths);

        if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
        {
            return new WindowsDeviceKeyStore(paths);
        }

        if (OperatingSystem.IsMacOS() && MacDeviceKeyStore.IsSupported())
        {
            return new MacDeviceKeyStore(paths);
        }

        return new UnavailableDeviceKeyStore();
    }
}

/// <summary>
/// Keeps the device key encrypted to a TPM-resident key whose use requires the user's consent.
/// </summary>
/// <remarks>
/// <para>
/// <b>The consent is enforced by CNG, not by this class</b>, and that distinction is the entire security
/// value. A store that read a DPAPI blob after showing its own prompt would be trivially bypassed:
/// malware running as the user would skip the prompt, read the file and call
/// <c>CryptUnprotectData</c> itself. Here the unwrapping key lives in the TPM under
/// <see cref="CngUIProtectionLevels.ProtectKey"/>, so the Windows consent dialog is a condition of
/// <em>using</em> the key. Malware can ask; it cannot answer, and the attempt is visible.
/// </para>
/// <para>
/// This is a refinement of what ADR 0007 describes, and stronger than it: the ADR reasoned about a
/// gesture gating a protected blob and did not notice that a gate inside the process is not a gate. The
/// mechanism recorded there has been corrected to match this.
/// </para>
/// <para>
/// The TPM key is RSA rather than the ECDH one might expect, because it is used to encrypt 32 bytes and
/// nothing else. OAEP over a 2048-bit key carries 190, so there is no need for an agreement step, and no
/// need for the device key itself to be an algorithm the TPM understands — which is what keeps the DSH1
/// device wrap unchanged at X25519. See ADR 0007 for why changing that is a separate decision.
/// </para>
/// <para>
/// <b>Both ends prompt, and that was measured rather than assumed.</b> Encrypting uses only the public
/// half, so it was reasonable to expect sealing to be silent — it is not. <c>CngKey.Create</c> with this
/// UI policy blocks on a dialog at <em>creation</em>, because <c>ProtectKey</c> means "protect this key
/// with a PIN", and Windows asks the user to set that up there and then. So registering a device shows one
/// setup dialog and every unlock shows a consent dialog.
/// </para>
/// <para>
/// That is the right shape for an opt-in feature, but it has two consequences worth knowing before
/// touching this file. Every method except <see cref="IsSupported"/> and the empty case of
/// <see cref="TryLoadAsync"/> needs an interactive desktop, so <b>none of them can be exercised by an
/// automated test</b> — see <c>WindowsDeviceKeyStoreTests</c> for where that line falls. And a caller must
/// treat <see cref="SaveAsync"/> as user-facing: it belongs on a UI thread, behind a button somebody
/// pressed, never on a background pass.
/// </para>
/// </remarks>
[SupportedOSPlatform("windows")]
public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
{
    /// <remarks>
    /// Versioned, so a future change of algorithm or padding can create a new key beside the old one
    /// rather than failing to open blobs written by a previous build. A device that cannot be opened
    /// falls back to the passphrase, which is survivable — but silently, and a user would only notice
    /// their gesture had stopped working.
    /// </remarks>
    private const string KeyName = "DodoSSH.DeviceKey.v1";

    private const string PlatformProvider = "Microsoft Platform Crypto Provider";

    /// <remarks>Shown in the Windows consent dialog, so it has to read as a sentence to a person.</remarks>
    private const string ConsentPrompt = "Unlock your DodoSSH vault";

    private readonly ClientPaths paths;

    /// <summary>Creates the store.</summary>
    public WindowsDeviceKeyStore(ClientPaths paths)
    {
        ArgumentNullException.ThrowIfNull(paths);
        this.paths = paths;
    }

    /// <summary>
    /// Whether this machine has a TPM the platform provider will hold a key in.
    /// </summary>
    /// <remarks>
    /// Probed by creating a throwaway key and deleting it, rather than by asking the provider whether it
    /// exists. The provider is registered on machines with no usable TPM as well, and reports itself
    /// present right up to the point where creating a key fails — so the only honest test is the one that
    /// does the thing. No UI policy on the probe, so nothing prompts.
    /// </remarks>
    internal static bool IsSupported()
    {
        var probe = $"DodoSSH.Probe.{Guid.CreateVersion7():N}";

        try
        {
            using var key = CngKey.Create(
                CngAlgorithm.Rsa,
                probe,
                new CngKeyCreationParameters { Provider = new CngProvider(PlatformProvider) });

            key.Delete();
            return true;
        }
        catch (CryptographicException)
        {
            return false;
        }
        catch (PlatformNotSupportedException)
        {
            return false;
        }
    }

    /// <inheritdoc />
    public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
        ValueTask.FromResult(IsSupported());

    /// <inheritdoc />
    public async ValueTask SaveAsync(
        ReadOnlyMemory<byte> devicePrivateKey,
        CancellationToken cancellationToken)
    {
        using var key = OpenOrCreate();
        using var rsa = new RSACng(key);

        var sealedKey = rsa.Encrypt(devicePrivateKey.Span, RSAEncryptionPadding.OaepSHA256);

        paths.EnsureCreated();

        await File.WriteAllBytesAsync(paths.DeviceKeyFile, sealedKey, cancellationToken)
            .ConfigureAwait(false);
    }

    /// <inheritdoc />
    public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
    {
        if (!File.Exists(paths.DeviceKeyFile))
        {
            return null;
        }

        var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
            .ConfigureAwait(false);

        return Unseal(sealedKey);
    }

    /// <inheritdoc />
    public ValueTask ForgetAsync(CancellationToken cancellationToken)
    {
        if (File.Exists(paths.DeviceKeyFile))
        {
            File.Delete(paths.DeviceKeyFile);
        }

        if (CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
        {
            using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider));
            key.Delete();
        }

        return ValueTask.CompletedTask;
    }

    /// <remarks>
    /// This is the call that prompts. Every failure becomes null, and the set is wider than it looks: the
    /// key may be gone, the user may have cancelled, the TPM may be locked out after too many wrong PINs,
    /// or the blob may predate a key that has since been replaced. None of them are distinguishable to a
    /// user and all have the same remedy, so none of them are worth telling apart here — see
    /// <c>UnlockStatus.DeviceKeyUnavailable</c>.
    /// </remarks>
    private static byte[]? Unseal(byte[] sealedKey)
    {
        try
        {
            if (!CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
            {
                return null;
            }

            using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider));
            using var rsa = new RSACng(key);

            return rsa.Decrypt(sealedKey, RSAEncryptionPadding.OaepSHA256);
        }
        catch (CryptographicException)
        {
            return null;
        }
    }

    /// <remarks>
    /// The UI policy is set at creation and cannot be added afterwards, which is why this opens an existing
    /// key rather than ever reconfiguring one: a key created without the policy would decrypt silently, and
    /// silently is the one behaviour this whole file exists to prevent.
    /// </remarks>
    private static CngKey OpenOrCreate()
    {
        var provider = new CngProvider(PlatformProvider);

        if (CngKey.Exists(KeyName, provider))
        {
            return CngKey.Open(KeyName, provider);
        }

        return CngKey.Create(
            CngAlgorithm.Rsa,
            KeyName,
            new CngKeyCreationParameters
            {
                Provider = provider,
                UIPolicy = new CngUIPolicy(CngUIProtectionLevels.ProtectKey, ConsentPrompt),

                // Machine-wide would put one key behind every account on the computer. This key stands for
                // "this user, on this machine", which is what a device wrap means.
                KeyCreationOptions = CngKeyCreationOptions.None,
            });
    }
}
