using System.Collections.Concurrent;
using System.Security.Cryptography;
using InABox.API;
using InABox.Core;
using InABox.Database;
using Microsoft.Exchange.WebServices.Data;

namespace InABox.API
{

    public static class CredentialsCache
    {

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

        private static void EnsureCache(bool force)
        {
            if (_cache == null || force)
            {
                var table = DbFactory.Provider.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 sUserTicks) &&
                        Encryption.Decrypt(password, "7mhvLnqMwkCAzN+zNGlyyg", out var sPassTicks))
                        if (long.TryParse(sUserTicks, out var userticks) && long.TryParse(sPassTicks, 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 e)
                {
                    Logger.Send(LogType.Error, "", string.Format("*** Unknown Error: {0}\n{1}", e.Message, e.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);
                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);
                return null;
            }
            if(session.User == CoreUtils.FullGuid)
            {
                return BypassedUser;
            }
            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 BypassedUser;
            
            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);
        }

        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 Dictionary<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 now = DateTime.Now;
            sessions = sessions
                .Where(x => x.Value.Expiry >= now)
                .ToDictionary(x => x.Key, x => x.Value);
        }

        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 e)
            {
                Logger.Send(LogType.Error, "", $"Error while saving session cache: {e.Message}");
            }
        }

        public static void LoadSessionCache()
        {
            try
            {
                if (CacheFile != null)
                {
                    sessions = Serialization.Deserialize<Dictionary<Guid, Session>>(new FileStream(CacheFile, FileMode.Open))
                        .Where(x => x.Value.Expiry != DateTime.MaxValue).ToDictionary(x => x.Key, x => x.Value);
                    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 session = Guid.NewGuid();

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

            return session;
        }

        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 = TwoFATries;
            }
        }

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

        private static readonly int TwoFATries = 3;

        public static readonly int CodeLength = 6;

        private static readonly TimeSpan Expiry2FACodeTime = 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 i = 0; i < CodeLength; i++)
            {
                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 session = NewSession(user, false);
            Logger.Send(LogType.Information, "", $"New login session {session} 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.Add(session, new AuthenticationCode(code, DateTime.Now + Expiry2FACodeTime));

                var recAddr = user.Recipient2FA;
                if (smsProvider.SendMessage(recAddr, $"Your authentication code is {code}. This code will expire in {Expiry2FACodeTime.Minutes} minutes."))
                {
                    Logger.Send(LogType.Information, "", "Code sent!");

                    var first = recAddr[..3];
                    var last = recAddr[^3..];

                    recipient = first + new string('*', recAddr.Length - 6) + last;
                    return session;
                }
                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 session;
            }
        }

        private static readonly int CodeModulo = (int)Math.Pow(10, CodeLength);

        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[hash.Length - 1] & 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 % CodeModulo).ToString().PadLeft(CodeLength, '0');
        }

        private static bool CheckAuthenticationCode(byte[] token, string code)
        {
            var time = DateTimeOffset.Now.ToUnixTimeSeconds();
            for (long i = time - 30; i <= time; i += 30)
            {
                if(GenerateGoogleAuthenticatorCode(i, 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);
                    }
                    valid = false;
                }
                else if (result.Expiry < DateTime.Now)
                {
                    authenticationCodes.Remove(sessionID);
                    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
    }
}