using global::Android.Hardware.Biometrics;
using global::Javax.Crypto;

namespace DodoSSH.Client.Android.Platform;

/// <summary>Raised when the gesture did not happen — cancelled, failed, or nothing enrolled.</summary>
/// <remarks>
/// One exception for every refusal, because <see cref="AndroidDeviceKeyStore"/> answers all of them the
/// same way. It carries the platform's own message only so it can reach a log; nothing shows it to a user,
/// who has just watched the system's own dialogue say the same thing better.
/// </remarks>
internal sealed class BiometricRefusedException(string message) : Exception(message);

/// <summary>
/// Puts the system's biometric prompt in front of a cipher, and waits for it.
/// </summary>
/// <remarks>
/// <para>
/// The cipher is handed to the prompt rather than merely being used after it, and that is the whole point.
/// A prompt that only returned "yes" would be a boolean this process could be tricked into skipping;
/// binding the cipher to the prompt means the keystore itself will not perform the operation unless the
/// gesture actually happened. The key is unusable to a caller that did not go through here.
/// </para>
/// <para>
/// Separated from the store because it is the platform half and it is callback-shaped, and because it is
/// the piece most likely to need a second implementation — androidx.biometric, if the floor ever drops
/// below API 28.
/// </para>
/// </remarks>
internal static class BiometricGate
{
    public static async Task AuthenticateAsync(Cipher cipher, string title, CancellationToken cancellationToken)
    {
        var context = PhoneEnvironment.Require();

        var builder = new BiometricPrompt.Builder(context)
            .SetTitle(title)!
            .SetDescription("Releases this phone's device key. The vault itself never leaves it.")!;

        if (OperatingSystem.IsAndroidVersionAtLeast(30))
        {
            // Device credential beside biometrics deliberately: a phone whose fingerprint reader is wet,
            // or whose owner has none enrolled, still has a PIN, and the key is guarded either way. The
            // alternative is an unlock screen that silently stops offering the fast path.
            // BiometricManagerAuthenticators, not BiometricManager.Authenticators: .NET for Android
            // flattens Java's nested classes, so the Java documentation's name is not the C# one.
            // Cast because the binding types the flags as an enum and the setter as the raw int Java uses.
            builder.SetAllowedAuthenticators(
                (int)(BiometricManagerAuthenticators.BiometricStrong
                    | BiometricManagerAuthenticators.DeviceCredential));
        }
        else
        {
            // API 28 and 29 have no allowed-authenticators list, and a prompt with no negative button is
            // rejected outright at build time. The button is the only way out of the dialogue on these two
            // releases, which is why it says what it does.
            builder.SetNegativeButton(
                "Use passphrase",
                context.MainExecutor!,
                new RefusalListener());
        }

        var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);

        var signal = new global::Android.OS.CancellationSignal();

        // The token has to reach the dialogue, not merely the await. Without this a cancelled unlock leaves
        // the system prompt on screen over an application that has stopped waiting for it.
        using var registration = cancellationToken.Register(signal.Cancel);

        builder.Build().Authenticate(
            new BiometricPrompt.CryptoObject(cipher),
            signal,
            context.MainExecutor!,
            new Callback(completion));

        // Awaited here rather than returned, so the cancellation registration above outlives the prompt
        // it is there to cancel.
        await completion.Task.ConfigureAwait(false);
    }

    private sealed class Callback(TaskCompletionSource completion) : BiometricPrompt.AuthenticationCallback
    {
        public override void OnAuthenticationSucceeded(BiometricPrompt.AuthenticationResult? result) =>
            completion.TrySetResult();

        /// <remarks>
        /// Terminal, unlike <c>OnAuthenticationFailed</c>. An error is the prompt giving up — cancelled,
        /// locked out, nothing enrolled — whereas a failure is one finger not being recognised, and the
        /// prompt stays up and keeps trying after it. Completing the task on a failure would abandon a
        /// dialogue that is still on screen.
        /// </remarks>
        public override void OnAuthenticationError(BiometricErrorCode errorCode, global::Java.Lang.ICharSequence? errString) =>
            completion.TrySetException(
                new BiometricRefusedException(errString?.ToString() ?? $"Biometric error {errorCode}."));
    }

    private sealed class RefusalListener : global::Java.Lang.Object, global::Android.Content.IDialogInterfaceOnClickListener
    {
        /// <remarks>
        /// Nothing to do: dismissing the prompt raises <c>OnAuthenticationError</c> as well, and that is
        /// where the wait is completed. Answering here too would be a second completion on the same task.
        /// </remarks>
        public void OnClick(global::Android.Content.IDialogInterface? dialog, int which)
        {
        }
    }
}
