using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
using DodoSSH.Client.Session;
using static DodoSSH.Client.App.Platform.MacSecurity;

namespace DodoSSH.Client.App.Platform;

/// <summary>
/// Keeps the device key encrypted to a Secure Enclave key whose use requires the user's presence.
/// </summary>
/// <remarks>
/// <para>
/// The macOS counterpart of <see cref="WindowsDeviceKeyStore"/>, and the same argument holds it up:
/// <b>the consent is enforced by the platform, not by this class</b>. The unwrapping key is generated
/// inside the Secure Enclave and never leaves it — there is no code path, privileged or otherwise, that
/// turns it into bytes — and it is created under an access control requiring
/// <see cref="AccessControlFlags.UserPresence"/>, so Touch ID or the login password is a condition of
/// <em>using</em> it. Malware running as the user can ask for a decryption; it cannot answer the prompt,
/// and the attempt is visible.
/// </para>
/// <para>
/// A store that showed its own prompt and then read a protected file would be trivially bypassed, which
/// is the mistake ADR 0007 originally described and the Windows store's comment corrects. The correction
/// applies here unchanged.
/// </para>
/// <para>
/// <b>P-256 and ECIES, where Windows uses RSA-OAEP, and the difference is not a preference.</b> The
/// Secure Enclave holds exactly one kind of key: a 256-bit key on the NIST P-256 curve. It will not hold
/// an RSA key at any size. So the wrap is <c>eciesEncryptionCofactorX963SHA256AESGCM</c> — an ephemeral
/// agreement against the enclave's public half, X9.63-KDF to an AES-GCM key, and the ephemeral public
/// key carried in the output. The framework does all of that; what matters here is that the input is 32
/// bytes and there is no size limit worth worrying about.
/// </para>
/// <para>
/// <b>Sealing is silent and unsealing prompts, which is better than the Windows shape rather than merely
/// different.</b> On Windows, <c>CngKey.Create</c> with <c>ProtectKey</c> raises a dialog at creation as
/// well, because the policy means "protect this key with a PIN" and Windows sets that up there and then.
/// Here <see cref="SecKeyCopyPublicKey"/> works on an enclave key without any prompt, so registering a
/// device shows nothing and only unlock asks. <see cref="SaveAsync"/> is therefore not user-facing on
/// this platform — but it is still called from where the Windows one has to be, and relying on that
/// difference would make the shared caller platform-specific for no gain.
/// </para>
/// <para>
/// <b>What this cannot be tested against, and what follows from that.</b> Every method except
/// <see cref="IsAvailableAsync"/> and the empty case of <see cref="TryLoadAsync"/> needs an interactive
/// login session and real enclave hardware, so none can be exercised by an automated test — the same
/// line the Windows store draws. It also means <see cref="IsSupported"/> must probe rather than infer:
/// see its remarks for the three ordinary machines that have no usable enclave and must degrade to the
/// passphrase rather than fail at unlock.
/// </para>
/// </remarks>
[SupportedOSPlatform("macos")]
public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
{
    /// <summary>
    /// The keychain tag this application's enclave key is filed under.
    /// </summary>
    /// <remarks>
    /// Versioned for the reason the Windows key name is: a future change of curve or wrap algorithm 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 fingerprint had stopped working.
    ///
    /// Prefixed with the bundle identifier because the keychain is shared across every application the
    /// user runs, unlike a CNG key name, which is scoped to the user's key store already.
    /// </remarks>
    private const string KeyTag = "dev.dodotech.dodossh.devicekey.v1";

    /// <summary>
    /// Shown in the Touch ID prompt, so it has to read as a sentence to a person.
    /// </summary>
    /// <remarks>
    /// macOS composes it into "DodoSSH is trying to ...", so this is a verb phrase and not a sentence of
    /// its own. The same words the Windows consent dialog uses.
    /// </remarks>
    private const string ConsentPrompt = "unlock your DodoSSH vault";

    private readonly ClientPaths paths;

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

    /// <summary>
    /// Whether this Mac has a Secure Enclave that will hold a key for this build.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Probed by creating a throwaway key and deleting it, rather than by asking whether the hardware
    /// exists. Three ordinary situations answer "no" here and would otherwise only be discovered at the
    /// moment somebody tried to unlock:
    /// </para>
    /// <para>
    /// <b>An Intel Mac with no T2.</b> Apple Silicon and T2 machines have an enclave; earlier Intel
    /// models do not, and there is no single attribute that says so.
    /// </para>
    /// <para>
    /// <b>A build that is not code signed.</b> Enclave key creation requires a signing identity, so
    /// every <c>dotnet run</c> and every build from an IDE fails here with a missing-entitlement error.
    /// That is the correct answer rather than a nuisance: a development build should keep asking for the
    /// passphrase, and this is what makes it do so without a platform check somewhere else.
    /// </para>
    /// <para>
    /// <b>A machine with no login password set.</b> <see cref="AccessControlFlags.UserPresence"/> has
    /// nothing to demand, and the framework refuses the access control object rather than silently
    /// creating a key anybody could use.
    /// </para>
    /// <para>
    /// The probe uses its own tag and no UI policy, so nothing prompts and nothing collides with the
    /// real key. It is deleted immediately; a probe key left behind would accumulate one per launch.
    /// </para>
    /// </remarks>
    internal static bool IsSupported()
    {
        try
        {
            var probe = $"{KeyTag}.probe.{Guid.CreateVersion7():N}";

            using var scope = new CoreFoundationScope();

            var symbols = MacSymbols.Resolve();

            if (!symbols.Complete)
            {
                return false;
            }

            var key = CreateEnclaveKey(scope, symbols, probe);

            if (key == IntPtr.Zero)
            {
                return false;
            }

            // Discarded deliberately. The question this method answers is whether the enclave will make a
            // key, and it demonstrably just did; a failure to clean the probe up afterwards leaves one
            // stray keychain item and does not make the answer no.
            _ = DeleteKey(symbols, probe);

            return true;
        }
        catch (Exception exception) when (exception is DllNotFoundException
                                              or EntryPointNotFoundException
                                              or BadImageFormatException)
        {
            // A macOS without these frameworks is not a thing that exists, so this is really the guard
            // for the case that does: a future release renaming or removing one of them. The answer is
            // the same as for hardware that is absent — no device key, ask for the passphrase.
            return false;
        }
    }

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

    /// <inheritdoc />
    public async ValueTask SaveAsync(
        ReadOnlyMemory<byte> devicePrivateKey,
        CancellationToken cancellationToken)
    {
        var sealedKey = Seal(devicePrivateKey.Span)
            ?? throw new InvalidOperationException(
                "The Secure Enclave would not seal the device key. Check IsAvailableAsync before offering to register one.");

        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);
        }

        var symbols = MacSymbols.Resolve();

        if (symbols.Complete)
        {
            // Discarded, and that is deliberate: there is nothing a caller could do about a failure here,
            // and the file deleted above is the half that decides whether unlock will try at all. A key
            // left in the enclave with no ciphertext to open is inert.
            _ = DeleteKey(symbols, KeyTag);
        }

        return ValueTask.CompletedTask;
    }

    /// <remarks>
    /// Silent: it uses only the public half. Null on every failure, and the caller's answer to all of
    /// them is the same — do not offer a device unlock.
    /// </remarks>
    private static byte[]? Seal(ReadOnlySpan<byte> devicePrivateKey)
    {
        try
        {
            using var scope = new CoreFoundationScope();

            var symbols = MacSymbols.Resolve();

            if (!symbols.Complete)
            {
                return null;
            }

            // Created on first use rather than at registration, so that a device key re-registered after
            // a ForgetAsync gets a key again without anything having to notice that it had gone.
            var privateKey = FindKey(scope, symbols, KeyTag, prompt: null);

            if (privateKey == IntPtr.Zero)
            {
                privateKey = CreateEnclaveKey(scope, symbols, KeyTag);
            }

            if (privateKey == IntPtr.Zero)
            {
                return null;
            }

            var publicKey = scope.Keep(SecKeyCopyPublicKey(privateKey));

            if (publicKey == IntPtr.Zero)
            {
                return null;
            }

            var plaintext = Data(scope, devicePrivateKey);

            if (plaintext == IntPtr.Zero)
            {
                return null;
            }

            var ciphertext = scope.Keep(
                SecKeyCreateEncryptedData(publicKey, symbols.EciesAlgorithm, plaintext, out var error));

            scope.Keep(error);

            return ciphertext == IntPtr.Zero ? null : ToArray(ciphertext);
        }
        catch (Exception exception) when (exception is DllNotFoundException
                                              or EntryPointNotFoundException
                                              or BadImageFormatException)
        {
            return null;
        }
    }

    /// <remarks>
    /// <para>
    /// 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 or let the prompt time out, the enclave may have
    /// invalidated it after the login password was reset, 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
    /// are worth telling apart here — see <c>UnlockStatus.DeviceKeyUnavailable</c>.
    /// </para>
    /// <para>
    /// Blocking, and it blocks on a person. The prompt is modal to the application, so this must not run
    /// on a thread that is also expected to draw the window behind it.
    /// </para>
    /// </remarks>
    private static byte[]? Unseal(byte[] sealedKey)
    {
        try
        {
            using var scope = new CoreFoundationScope();

            var symbols = MacSymbols.Resolve();

            if (!symbols.Complete)
            {
                return null;
            }

            var privateKey = FindKey(scope, symbols, KeyTag, ConsentPrompt);

            if (privateKey == IntPtr.Zero)
            {
                return null;
            }

            var ciphertext = Data(scope, sealedKey);

            if (ciphertext == IntPtr.Zero)
            {
                return null;
            }

            var plaintext = scope.Keep(
                SecKeyCreateDecryptedData(privateKey, symbols.EciesAlgorithm, ciphertext, out var error));

            scope.Keep(error);

            return plaintext == IntPtr.Zero ? null : ToArray(plaintext);
        }
        catch (Exception exception) when (exception is DllNotFoundException
                                              or EntryPointNotFoundException
                                              or BadImageFormatException)
        {
            return null;
        }
    }

    /// <summary>
    /// Generates a key inside the Secure Enclave, filed under <paramref name="tag"/>. Owned by the scope.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The attribute dictionary is the whole security decision, so it is worth reading rather than
    /// pattern-matching. <c>TokenID = SecureEnclave</c> is what puts the private half in hardware;
    /// without it this silently generates an ordinary software key that behaves identically in every
    /// visible way and protects nothing.
    /// </para>
    /// <para>
    /// <c>AccessibleWhenUnlockedThisDeviceOnly</c> rather than any of the migratable classes, because a
    /// device key that could be restored onto another machine from a backup would no longer mean "this
    /// machine". The enclave already makes that impossible; saying it as well means the intent survives
    /// a future change of storage.
    /// </para>
    /// <para>
    /// <c>UseDataProtectionKeychain</c> is the macOS-specific one and the easiest to omit. Without it,
    /// macOS routes this to the older file-based keychain, which does not understand access control
    /// objects or the enclave, and the call fails with a parameter error that says nothing about the
    /// missing key.
    /// </para>
    /// </remarks>
    private static IntPtr CreateEnclaveKey(CoreFoundationScope scope, MacSymbols symbols, string tag)
    {
        var access = scope.Keep(SecAccessControlCreateWithFlags(
            IntPtr.Zero,
            symbols.AccessibleWhenUnlockedThisDeviceOnly,
            AccessControlFlags.PrivateKeyUsage | AccessControlFlags.UserPresence,
            out var accessError));

        scope.Keep(accessError);

        if (access == IntPtr.Zero)
        {
            return IntPtr.Zero;
        }

        var privateAttrs = Dictionary(
            scope,
            [symbols.AttrIsPermanent, symbols.AttrApplicationTag, symbols.AttrAccessControl],
            [symbols.True, TagData(scope, tag), access]);

        if (privateAttrs == IntPtr.Zero)
        {
            return IntPtr.Zero;
        }

        var keySize = Number(scope, 256);

        var parameters = Dictionary(
            scope,
            [
                symbols.AttrKeyType,
                symbols.AttrKeySizeInBits,
                symbols.AttrTokenId,
                symbols.UseDataProtectionKeychain,
                symbols.PrivateKeyAttrs,
            ],
            [
                symbols.KeyTypeEcSecPrimeRandom,
                keySize,
                symbols.TokenIdSecureEnclave,
                symbols.True,
                privateAttrs,
            ]);

        if (parameters == IntPtr.Zero)
        {
            return IntPtr.Zero;
        }

        var key = scope.Keep(SecKeyCreateRandomKey(parameters, out var error));

        scope.Keep(error);

        return key;
    }

    /// <summary>
    /// Looks the enclave key up by tag. Owned by the scope; zero when there is none.
    /// </summary>
    /// <remarks>
    /// <paramref name="prompt"/> is attached here and consumed later: the lookup itself does not raise
    /// anything, because a handle to an enclave key is not a use of it. The words reach the user at the
    /// decrypt, which is the operation the access control actually guards.
    ///
    /// <c>UseOperationPrompt</c> is deprecated in favour of an <c>LAContext</c>, and is used anyway. An
    /// LAContext would mean binding LocalAuthentication as well for one string, and the deprecated key
    /// still works; the day it stops, this call fails and the store degrades to the passphrase, which is
    /// the failure this whole class is built to degrade into.
    /// </remarks>
    private static IntPtr FindKey(CoreFoundationScope scope, MacSymbols symbols, string tag, string? prompt)
    {
        List<IntPtr> keys =
        [
            symbols.Class,
            symbols.AttrApplicationTag,
            symbols.AttrKeyType,
            symbols.UseDataProtectionKeychain,
            symbols.ReturnRef,
        ];

        List<IntPtr> values =
        [
            symbols.ClassKey,
            TagData(scope, tag),
            symbols.KeyTypeEcSecPrimeRandom,
            symbols.True,
            symbols.True,
        ];

        if (prompt is not null)
        {
            keys.Add(symbols.UseOperationPrompt);
            values.Add(scope.Keep(CFString(prompt)));
        }

        var query = Dictionary(scope, [.. keys], [.. values]);

        if (query == IntPtr.Zero)
        {
            return IntPtr.Zero;
        }

        var status = SecItemCopyMatching(query, out var result);

        // errSecItemNotFound is the ordinary answer on a machine that has never registered a device, and
        // it is not distinguished from any other failure for the reason the class remarks give.
        return status == Success ? scope.Keep(result) : IntPtr.Zero;
    }

    /// <summary>Removes the key with this tag from the keychain.</summary>
    /// <returns>Whether the keychain now has no key under this tag.</returns>
    /// <remarks>
    /// <c>ItemNotFound</c> counts as success, and that is the common case rather than an edge: it is
    /// what a machine that never registered a device answers, and what the second of two
    /// <see cref="ForgetAsync"/> calls answers. Treating it as a failure would make forgetting a device
    /// twice report a problem that does not exist.
    /// </remarks>
    private static bool DeleteKey(MacSymbols symbols, string tag)
    {
        using var scope = new CoreFoundationScope();

        var query = Dictionary(
            scope,
            [symbols.Class, symbols.AttrApplicationTag, symbols.UseDataProtectionKeychain],
            [symbols.ClassKey, TagData(scope, tag), symbols.True]);

        if (query == IntPtr.Zero)
        {
            return false;
        }

        var status = SecItemDelete(query);

        return status is Success or ItemNotFound;
    }

    // ---- Small CoreFoundation conveniences ---------------------------------------------------------

    /// <remarks>
    /// The arrays are pinned for the duration of the call and not beyond it, which is correct because
    /// <c>CFDictionaryCreate</c> copies them: the dictionary retains each key and value, and never reads
    /// the arrays again.
    /// </remarks>
    private static IntPtr Dictionary(CoreFoundationScope scope, IntPtr[] keys, IntPtr[] values)
    {
        // A zero anywhere means one of the constants did not resolve or an earlier allocation failed.
        // Passing it on produces a dictionary with a null key, which CFDictionaryCreate does not reject
        // — it crashes inside the callback table instead.
        if (Array.IndexOf(keys, IntPtr.Zero) >= 0 || Array.IndexOf(values, IntPtr.Zero) >= 0)
        {
            return IntPtr.Zero;
        }

        var symbols = MacSymbols.Resolve();

        unsafe
        {
            fixed (IntPtr* keyPtr = keys)
            fixed (IntPtr* valuePtr = values)
            {
                return scope.Keep(CFDictionaryCreate(
                    IntPtr.Zero,
                    (IntPtr)keyPtr,
                    (IntPtr)valuePtr,
                    keys.Length,
                    symbols.TypeDictionaryKeyCallBacks,
                    symbols.TypeDictionaryValueCallBacks));
            }
        }
    }

    /// <summary>Copies bytes into a CFData. Owned by the scope.</summary>
    /// <remarks>
    /// The pin lasts only as long as the call, which is correct: <c>CFDataCreate</c> copies, so the
    /// CFData does not reference this memory afterwards. <c>CFDataCreateWithBytesNoCopy</c> would not,
    /// and is not used for exactly that reason — it would hand the framework a pointer into the managed
    /// heap and rely on the object staying where the collector first put it.
    /// </remarks>
    private static IntPtr Data(CoreFoundationScope scope, ReadOnlySpan<byte> bytes)
    {
        unsafe
        {
            fixed (byte* pointer = bytes)
            {
                return scope.Keep(CFDataCreate(IntPtr.Zero, (IntPtr)pointer, bytes.Length));
            }
        }
    }

    /// <remarks>
    /// UTF-8 rather than any other encoding, and it only has to be consistent with itself: the tag is an
    /// opaque blob the keychain matches byte for byte, so what matters is that a lookup encodes it the
    /// same way the creation did. It is written once, here, for exactly that reason.
    /// </remarks>
    private static IntPtr TagData(CoreFoundationScope scope, string tag) =>
        Data(scope, Encoding.UTF8.GetBytes(tag));

    private static IntPtr Number(CoreFoundationScope scope, int value)
    {
        unsafe
        {
            return scope.Keep(CFNumberCreate(IntPtr.Zero, (nint)CFNumberIntType, (IntPtr)(&value)));
        }
    }

    /// <summary>Builds a CFString from a managed string. Owned, so the caller tracks it.</summary>
    /// <remarks>
    /// Built explicitly rather than left to the marshaller, because these calls take a
    /// <c>CFStringRef</c> and not a C string — the runtime's default marshalling would hand over a
    /// <c>char*</c>, which CoreFoundation reads as an object pointer and follows into nothing.
    /// </remarks>
    private static IntPtr CFString(string value)
    {
        var bytes = Encoding.UTF8.GetBytes(value);

        unsafe
        {
            fixed (byte* pointer = bytes)
            {
                // kCFStringEncodingUTF8 is 0x08000100, spelled out rather than named because it is the
                // only encoding constant this file uses.
                return CFStringCreateWithBytes(IntPtr.Zero, (IntPtr)pointer, bytes.Length, 0x08000100, false);
            }
        }
    }

    [LibraryImport(CoreFoundation)]
    private static partial IntPtr CFStringCreateWithBytes(
        IntPtr allocator,
        IntPtr bytes,
        nint numBytes,
        uint encoding,
        [MarshalAs(UnmanagedType.U1)] bool isExternalRepresentation);

    private static byte[] ToArray(IntPtr data)
    {
        var length = (int)CFDataGetLength(data);
        var pointer = CFDataGetBytePtr(data);

        if (length <= 0 || pointer == IntPtr.Zero)
        {
            return [];
        }

        var result = new byte[length];
        Marshal.Copy(pointer, result, 0, length);

        return result;
    }
}
