using System.Diagnostics;
using System.Security.Claims;
using Ablera.Serdica.Authentication.Extensions;
using Ablera.Serdica.Authority.Plugins.Base.Contracts;
using Ablera.Serdica.Authority.Plugins.Base.Models;
using Ablera.Serdica.Common.Tools.Extensions;
using Ablera.Serdica.Extensions.Novell.Directory.Ldap.Attributes;
using Ablera.Serdica.Extensions.Novell.Directory.Ldap.Contracts;
using Ablera.Serdica.Extensions.Novell.Directory.Ldap.Extensions;
using Ablera.Serdica.Extensions.Novell.Directory.Ldap.Models;
using Microsoft.Extensions.Logging;
using Novell.Directory.Ldap;

namespace Ablera.Serdica.Authority.Plugins.LdapUtilities.Services;

public abstract class LdapIdentityFacadeBase<TLdapIdentity, TKeyType>(
    ILogger<LdapIdentityFacadeBase<TLdapIdentity, TKeyType>> logger,
    ILdapSettingsProvider ldapSettingsProvider,
    IEmailNormalizer emailNormalizer,
    IUsernameNormalizer usernameNormalizer)
        : IAuthService<TLdapIdentity>,
          IUserRepository<TLdapIdentity>,
          IClaimStore<TLdapIdentity>,
          IPasswordManager<TLdapIdentity>,
          IAccountLockManager<TLdapIdentity>
            where TLdapIdentity : class, ILdapIdentity<TKeyType>
            where TKeyType : IEquatable<TKeyType>
{
    #region IAuthService

    public virtual async Task<AuthenticationResult> AuthenticateAsync(TLdapIdentity ldapIdentity, string password, bool lockoutOnFailure = false, CancellationToken ct = default)
    {
        if (string.IsNullOrWhiteSpace(password))
            return AuthenticationResult.Fail(AuthenticationCode.EmptyCredentials.ToScreamingSnakeCase());

        if (string.IsNullOrWhiteSpace(ldapIdentity.DistinguishedName))
            return AuthenticationResult.Fail(AuthenticationCode.AccountIsNotAuthenticaAble.ToScreamingSnakeCase());

        try
        {
            using var conn = new LdapConnection { SecureSocketLayer = ldapIdentity.LdapSettings.Ssl };
            await conn.ConnectAsync(ldapIdentity.LdapSettings.Url, ldapIdentity.LdapSettings.Port, ct);
            await conn.BindAsync(ldapIdentity.DistinguishedName, password, ct);

            if (!conn.Bound)
                return AuthenticationResult.Fail(AuthenticationCode.InvalidPassword.ToScreamingSnakeCase());

            var baseClaims = await GetBaseClaimsAsync(ldapIdentity).ConfigureAwait(true);
            var principal = new ClaimsPrincipal(
                                new ClaimsIdentity(
                                    baseClaims, GetType().Namespace));

            return AuthenticationResult.Success(principal);
        }
        catch (LdapException ex) when (ex.ResultCode == LdapException.InvalidCredentials)
        {
            return AuthenticationResult.Fail(AuthenticationCode.InvalidPassword.ToScreamingSnakeCase());
        }
        catch (LdapException ex) when (IsAccountLocked(ex))
        {
            return AuthenticationResult.Fail(AuthenticationCode.AccountIsLocked.ToScreamingSnakeCase());
        }
        catch (Exception e)
        {
            logger.LogError(e, "LDAP auth error for {User}", ldapIdentity.Username);
            return AuthenticationResult.Fail(AuthenticationCode.GenericError.ToScreamingSnakeCase());
        }
    }


    #endregion

    #region IUserStore

    public Task<TLdapIdentity?> FindByIdAsync(string id, CancellationToken ct = default)
        => FindAccountAsync(id, FindAccountType.Id, ct);

    public Task<TLdapIdentity?> FindByEmailAsync(string email, CancellationToken ct = default)
        => FindAccountAsync(email, FindAccountType.Email, ct);

    public Task<TLdapIdentity?> FindByNameAsync(string username, CancellationToken ct = default)
        => FindAccountAsync(username, FindAccountType.Username, ct);

    public async Task<OperationResult> CreateAsync(TLdapIdentity user, string password, CancellationToken ct = default)
    {
        if (user is null) return OperationResult.Fail("NULL_USER");
        if (string.IsNullOrWhiteSpace(password)) return OperationResult.Fail("EMPTY_PASSWORD");

        string userDn = string.Format(user.LdapSettings.DnTemplate, user.Username);

        try
        {
            using var conn = new LdapConnection { SecureSocketLayer = user.LdapSettings.Ssl };
            await conn.ConnectAsync(user.LdapSettings.Url, user.LdapSettings.Port, ct);
            await conn.BindAsync(user.LdapSettings.BindDn, user.LdapSettings.BindCredentials, ct);

            var attrs = new LdapAttributeSet();
            user.GetLdapAttributes<TLdapIdentity, TKeyType>()
                .ToList()
                .ForEach(a => attrs.Add(a));
            attrs.Add(new LdapAttribute("userPassword", password));

            await conn.AddAsync(new LdapEntry(userDn, attrs));

            return OperationResult.Success();
        }
        catch (LdapException ex) when (ex.ResultCode == LdapException.EntryAlreadyExists)
        {
            return OperationResult.Fail("ALREADY_EXISTS");
        }
        catch (Exception e)
        {
            logger.LogError(e, "LDAP create failed for {User}", user.Username);
            return OperationResult.Fail("LDAP_ERROR");
        }
    }

    public async Task<OperationResult> UpdateAsync(TLdapIdentity user, CancellationToken ct = default)
    {
        if (user is null) return OperationResult.Fail("NULL_USER");
        var ldapIdentity = await FindAccountAsync(user.Username, FindAccountType.Username, ct);
        if (ldapIdentity is null) return OperationResult.Fail("USER_NOT_FOUND");
        string userDn = string.Format(ldapIdentity.LdapSettings.DnTemplate, user.Username!);

        try
        {
            using var conn = await ConnectServiceAsync(ldapIdentity.LdapSettings, ct);
            var mods = ldapIdentity.GetLdapAttributes<TLdapIdentity, TKeyType>()
                           .Select(a => new LdapModification(LdapModification.Replace, a))
                           .ToArray();
            await conn.ModifyAsync(userDn, mods);

            return OperationResult.Success();
        }
        catch (Exception e)
        {
            logger.LogError(e, "LDAP update failed for {User}", user.Username);
            return OperationResult.Fail("LDAP_ERROR");
        }
    }

    #endregion

    #region IClaimStore

    public Task<IReadOnlyCollection<Claim>> GetBaseClaimsAsync(TLdapIdentity ldapIdentity, CancellationToken ct = default) 
        => Task.FromResult(
            ldapIdentity.Identity.BuildClaims(ldapIdentity.Username, ldapIdentity.GivenName, ldapIdentity.Surname));

    public Task<IReadOnlyCollection<Claim>?> GetRolesClaimsAsync(TLdapIdentity user, CancellationToken ct = default)
        => Task.FromResult<IReadOnlyCollection<Claim>?>(null);

    #endregion

    #region IPasswordManager

    public async Task<OperationResult> ChangePasswordAsync(TLdapIdentity ldapIdentity,
                                                           string currentPassword,
                                                           string newPassword,
                                                           CancellationToken ct = default)
    {
        if (string.IsNullOrWhiteSpace(newPassword))
            return OperationResult.Fail("EMPTY_NEW_PASSWORD");


        var auth = await AuthenticateAsync(ldapIdentity, currentPassword, false, ct);
        if (!auth.Succeeded) return OperationResult.Fail("INVALID_CURRENT_PASSWORD");

        string userDn = string.Format(ldapIdentity.LdapSettings.DnTemplate, ldapIdentity.Username);

        try
        {
            using var conn = await ConnectServiceAsync(ldapIdentity.LdapSettings, ct);
            await conn.ModifyAsync(userDn,
                        new LdapModification(LdapModification.Replace,
                                             new LdapAttribute("userPassword", newPassword)));

            return OperationResult.Success();
        }
        catch (Exception e)
        {
            logger.LogError(e, "LDAP change password failed for {User}", ldapIdentity.Username);
            return OperationResult.Fail("LDAP_ERROR");
        }
    }

    public async Task<OperationResult> ResetPasswordAsync(TLdapIdentity ldapIdentity,
                                                    string resetToken,
                                                    string newPassword,
                                                    CancellationToken ct = default)
    {
        if (string.IsNullOrWhiteSpace(newPassword))
            return OperationResult.Fail("EMPTY_NEW_PASSWORD");

        // @TODO:
        // Implement token issue and check to check resetToken

        string userDn = string.Format(ldapIdentity.LdapSettings.DnTemplate, ldapIdentity.Username);

        try
        {
            using var conn = await ConnectServiceAsync(ldapIdentity.LdapSettings, ct);
            await conn.ModifyAsync(userDn,
                        new LdapModification(LdapModification.Replace,
                                             new LdapAttribute("userPassword", newPassword)));

            return OperationResult.Success();
        }
        catch (Exception e)
        {
            logger.LogError(e, "LDAP change password failed for {User}", ldapIdentity.Username);
            return OperationResult.Fail("LDAP_ERROR");
        }
    }

    #endregion

    #region IAccountLockManager

    public async Task<OperationResult> LockAsync(TLdapIdentity ldapIdentity, CancellationToken ct = default)
    {

        string userDn = string.Format(ldapIdentity.LdapSettings.DnTemplate, ldapIdentity.Username);

        try
        {
            using var conn = await ConnectServiceAsync(ldapIdentity.LdapSettings, ct);
            LdapModification mod = ldapIdentity.LdapSettings.IsActiveDirectory
                ? new LdapModification(LdapModification.Replace,
                                        new LdapAttribute("userAccountControl", "514"))
                : new LdapModification(LdapModification.Replace,
                                        new LdapAttribute("pwdAccountLockedTime",
                                                          DateTime.UtcNow.ToString("yyyyMMddHHmmss'Z'")));
            await conn.ModifyAsync(userDn, mod);

            return OperationResult.Success();
        }
        catch (Exception e)
        {
            logger.LogWarning(e, "LDAP lock not supported for {User}", ldapIdentity.Username);
            return OperationResult.Fail("LOCK_FAILED");
        }
    }

    public async Task<OperationResult> UnlockAsync(TLdapIdentity ldapIdentity, CancellationToken ct = default)
    {
        string userDn = string.Format(ldapIdentity.LdapSettings.DnTemplate, ldapIdentity.Username);

        try
        {
            using var conn = await ConnectServiceAsync(ldapIdentity.LdapSettings, ct);
            LdapModification mod = ldapIdentity.LdapSettings.IsActiveDirectory
                ? new LdapModification(LdapModification.Replace,
                                        new LdapAttribute("userAccountControl", "512"))
                : new LdapModification(LdapModification.Delete,
                                        new LdapAttribute("pwdAccountLockedTime"));
            await conn.ModifyAsync(userDn, mod);

            return OperationResult.Success();
        }
        catch (Exception e)
        {
            logger.LogWarning(e, "LDAP unlock did not work for {User}", ldapIdentity.Username);
            return OperationResult.Fail("UNLOCK_FAILED");
        }
    }

    #endregion

    #region Helpers – discovery & connection


    private async Task<TLdapIdentity?> FindAccountAsync(
        string login,
        FindAccountType findAccountType,
        CancellationToken ct = default)
    {
        foreach (var settings in ldapSettingsProvider.Settings)
        {
            try
            {
                logger.LogInformation(
                "FindAccountAsync called by: {Caller}",
                new StackTrace(1, true).ToString()
            );

                logger.LogInformation(
                "Trying LDAP server {FriendlyName} ({Url}) for user {Login} (Type: {Type}, RequestId: {RequestId})",
                settings.FriendlyName,
                settings.Url,
                login,
                findAccountType,
                Activity.Current?.Id ?? "no-activity"
            );
                using var conn = await ConnectServiceAsync(settings, ct);
                var normalizedLogin = findAccountType switch
                {
                    FindAccountType.Email => emailNormalizer.Normalize(settings, login),
                    FindAccountType.Username => usernameNormalizer.Normalize(settings, login),
                    _ => login
                };
                string filter = string.Format(settings.SearchFilter, normalizedLogin);

                var res = await conn.SearchAsync(settings.SearchBase,
                    LdapConnection.ScopeSub,
                    filter,
                    null,
                    false, ct);

                if (!await res.HasMoreAsync()) continue;

                var entry = await res.NextAsync();

                // C# does not allow to add new() constraint add implicitly new these here
                var ldapIdentity = Activator.CreateInstance<TLdapIdentity>()!;

                // 1. copy the raw entry first
                ldapIdentity.DistinguishedName = entry.Dn;
                ldapIdentity.LdapSettings = settings;
                ldapIdentity.MergeLdapEntry<TLdapIdentity,TKeyType>(entry);

                return ldapIdentity;
            }
            catch (Exception ex)
            {
                logger.LogWarning(ex, "LDAP discovery failed on server {Url}", settings.Url);
            }
        }

        return null;
    }

    private async Task<LdapConnection> ConnectServiceAsync(LdapSettings cfg, CancellationToken ct)
    {
        var conn = new LdapConnection { SecureSocketLayer = cfg.Ssl };
        await conn.ConnectAsync(cfg.Url, cfg.Port, ct);
        await conn.BindAsync(cfg.BindDn, cfg.BindCredentials, ct);
        return conn;
    }

    private static bool IsAccountLocked(LdapException ex)
        => ex.ResultCode == 49 && ex.Message.Contains("775", StringComparison.Ordinal);

    #endregion
}
