using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Fido2NetLib;
using Fido2NetLib.Objects;

namespace Dacryptero.Session.Fido;

public class LoginUser : UserBase
{
    public List<(DB.DBSingleController controller, DB.DBAccess access)> Access { get; }

    public AssertionOptions AssertionOptions { get; }

    public PreLoginUser PreLogin { get; }

    public Web.Events.Receive.AccountLogin Login { get; }

    public LoginUser(PreLoginUser preLogin,
        List<(DB.DBSingleController controller, DB.DBAccess access)> access,
        Web.Events.Receive.AccountLogin login
    )
        : this(preLogin.Connection, preLogin.Configuration, access, preLogin, login)
    {}

    public LoginUser(Web.WebSocketConnection connection, Fido2Configuration configuration, 
        List<(DB.DBSingleController controller, DB.DBAccess access)> access, PreLoginUser preLogin,
        Web.Events.Receive.AccountLogin login
    )
        : base(connection, configuration)
    {
        Access = access;
        PreLogin = preLogin;
        Login = login;
        var fido = new Fido2(Configuration);
        AssertionOptions = fido.GetAssertionOptions(
            Access.Select(x => x.access.FidoDescriptor).ToArray(),
            UserVerificationRequirement.Preferred
        );
        AssertionOptions.Challenge = Challenge.ToArray();
    }

    public override (string method, JsonElement data) GetMessage()
    {
        var json = AssertionOptions.ToJson();
        return ("login", JsonDocument.Parse(json).RootElement);
    }

    /// <summary>
    /// [WITHOUT TFA!] Unlocks any suitable database. This is only allowed to use with deactivated
    /// TFA.
    /// </summary>
    /// <returns>error message</returns>
    /// <exception cref="InvalidOperationException" />
    public async Task<string?> Unlock()
    {
        if (Config.Default.Security.Tfa)
            throw new InvalidOperationException("This method is only allowed to use with deactivated TFA");

        var found = false;
        var sb = new StringBuilder();
        foreach (var (controller, access) in Access)
        {
            var error = PreLogin.UnlockDatabase ?
                UnlockDatabase(controller, access) : 
                OpenDatabase(controller, access);
            if (error is not null)
            {
                sb.AppendLine($"[{controller.Config.Name}] {error}");
            }
            else found = true;
        }

        await Connection.Send(new Web.Events.Send.InfoSend(Connection))
            .ConfigureAwait(false);

        if (sb.Length > 0)
            return sb.ToString();

        if (!found)
            return "user handle not found";

        return null;
    }

    public override async Task<string?> ValidateResult(JsonElement data)
    {
        var fido = new Fido2(Configuration);

        var parsedAssertion= Converter.GetAuthenticatorAssertionRawResponse(data);
        if (parsedAssertion is null)
            return "Cannot parse assertion data";
        
        AuthenticatorAssertionResponse resp;
        try
        {
            resp = AuthenticatorAssertionResponse.Parse(parsedAssertion);
        }
        catch (Exception e)
        {
            Serilog.Log.Error(e, "Cannot verify assertion");
            return $"cannot verify assertion\n{e.GetType()}: {e.Message}";
        }

        if (new Uri(resp.Origin).Host != AssertionOptions.RpId)
            return "wrong host provided";

        if (resp.UserHandle.Length == 0)
            resp.UserHandle = null;

        var found = false;
        var sb = new StringBuilder();
        foreach (var (controller, access) in Access)
        {
            if (resp.UserHandle != null && !IsEqual(access.FidoUserHandle.Span, resp.UserHandle))
                continue;
            var error = await Validate(resp, controller, access);
            if (error is not null)
            {
                sb.AppendLine($"[{controller.Config.Name}] {error}");
            }
            else found = true;
        }

        await Connection.Send(new Web.Events.Send.InfoSend(Connection))
            .ConfigureAwait(false);

        if (sb.Length > 0)
            return sb.ToString();

        if (!found)
            return "user handle not found";

        return null;
    }

    private async Task<string?> Validate(AuthenticatorAssertionResponse resp, DB.DBSingleController controller, DB.DBAccess access)
    {
        try
        {
            var result = await resp.VerifyAsync(
                options: AssertionOptions,
                expectedOrigin: resp.Origin,
                storedPublicKey: access.FidoPublicKey.ToArray(),
                storedSignatureCounter: access.FidoCounter,
                isUserHandleOwnerOfCredId: args =>
                {
                    return Task.FromResult(true);
                },
                requestTokenBindingId: null
            );

            access.FidoCounter = result.Counter;
            await controller.Config.SaveAsync().ConfigureAwait(false);
        }
        catch (Exception e)
        {
            Serilog.Log.Error(e, "Cannot verify assertion");
            return $"cannot verify assertion ({controller.Config.Name})\n{e.GetType()}: {e.Message}";
        }

        if (PreLogin.UnlockDatabase)
            return UnlockDatabase(controller, access);
        else return OpenDatabase(controller, access);
    }

    private string? UnlockDatabase(DB.DBSingleController controller, DB.DBAccess access)
    {
        var pdb = new Rfc2898DeriveBytes(Login.Password, access.EncSalt.ToArray(), 10000, HashAlgorithmName.SHA256);
        using var aes = Aes.Create();
        aes.Key = pdb.GetBytes(32);
        aes.IV = pdb.GetBytes(16);
        aes.Padding = PaddingMode.Zeros;
        using var m = new MemoryStream(access.EncKey.ToArray());
        using var c = new CryptoStream(m, aes.CreateDecryptor(aes.Key, aes.IV), CryptoStreamMode.Read);
        using var k = new MemoryStream();
        c.CopyTo(k);

        var key = k.ToArray();
        var hash = SHA512.HashData(key);
        var original = controller.Config.RecoveryKeyHash;

        if (original.Length != hash.Length || !original.Span.StartsWith(hash))
            return $"critical security error: decrypted key doesn't match to database key. The access to the database could be lost!";
        
        var info = new DB.Creation.UnlockedDBInfo(controller, key);
        Connection.UnlockedDBs.AddOrUpdate(controller.Config.Name, _ => info, (_, _) => info);
        
        return null;
    }

    private string? OpenDatabase(DB.DBSingleController controller, DB.DBAccess access)
    {
        if (!controller.IsOpen)
        {
            var pdb = new Rfc2898DeriveBytes(Login.Password, access.EncSalt.ToArray(), 10000, HashAlgorithmName.SHA256);
            using var aes = Aes.Create();
            aes.Key = pdb.GetBytes(32);
            aes.IV = pdb.GetBytes(16);
            aes.Padding = PaddingMode.Zeros;
            using var m = new MemoryStream(access.EncKey.ToArray());
            using var c = new CryptoStream(m, aes.CreateDecryptor(aes.Key, aes.IV), CryptoStreamMode.Read);
            using var k = new MemoryStream();
            c.CopyTo(k);

            if (!controller.Decrypt(k.ToArray()))
                return $"critical security error: decrypted key doesn't match to database key. The access to the database could be lost!";
        }

        Connection.Session.AddController(controller);

        Connection.Session.Usernames.Add(Login.Username);

        return null;
    }

    private static bool IsEqual(ReadOnlySpan<byte> left, ReadOnlySpan<byte> right)
    {
        if (left.Length != right.Length)
            return false;
        return left.StartsWith(right);
    }
}