using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using Novell.Directory.Ldap;

namespace ZB.MOM.WW.MxGateway.Server.Dashboard;

public sealed class DashboardAuthenticator(
    IOptions<GatewayOptions> options,
    ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator
{
    private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized.";

    /// <inheritdoc />
    public async Task<DashboardAuthenticationResult> AuthenticateAsync(
        string? username,
        string? password,
        CancellationToken cancellationToken)
    {
        LdapOptions ldapOptions = options.Value.Ldap;
        DashboardOptions dashboardOptions = options.Value.Dashboard;
        if (!ldapOptions.Enabled
            || string.IsNullOrWhiteSpace(username)
            || string.IsNullOrWhiteSpace(password))
        {
            return DashboardAuthenticationResult.Fail(GenericFailureMessage);
        }

        if (!ldapOptions.UseTls && !ldapOptions.AllowInsecureLdap)
        {
            return DashboardAuthenticationResult.Fail(GenericFailureMessage);
        }

        string normalizedUsername = username.Trim();

        try
        {
            using LdapConnection connection = new();
            connection.SecureSocketLayer = ldapOptions.UseTls;

            await Task.Run(
                    () => connection.Connect(ldapOptions.Server, ldapOptions.Port),
                    cancellationToken)
                .ConfigureAwait(false);

            await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
            LdapEntry? candidate = await SearchUserAsync(
                    connection,
                    ldapOptions,
                    normalizedUsername,
                    cancellationToken)
                .ConfigureAwait(false);

            if (candidate is null)
            {
                return DashboardAuthenticationResult.Fail(GenericFailureMessage);
            }

            await Task.Run(
                    () => connection.Bind(candidate.Dn, password),
                    cancellationToken)
                .ConfigureAwait(false);

            await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
            LdapEntry? authenticatedEntry = await SearchUserAsync(
                    connection,
                    ldapOptions,
                    normalizedUsername,
                    cancellationToken)
                .ConfigureAwait(false);

            if (authenticatedEntry is null)
            {
                return DashboardAuthenticationResult.Fail(GenericFailureMessage);
            }

            string displayName = ReadAttribute(authenticatedEntry, ldapOptions.DisplayNameAttribute)
                ?? normalizedUsername;
            IReadOnlyList<string> groups = ReadAttributeValues(authenticatedEntry, ldapOptions.GroupAttribute);

            IReadOnlyList<string> roles = MapGroupsToRoles(groups, dashboardOptions.GroupToRole);
            if (roles.Count == 0)
            {
                logger.LogInformation(
                    "LDAP dashboard login denied for user {User}: no GroupToRole mapping matched their LDAP groups.",
                    normalizedUsername);

                return DashboardAuthenticationResult.Fail(GenericFailureMessage);
            }

            return DashboardAuthenticationResult.Success(CreatePrincipal(
                normalizedUsername,
                displayName,
                groups,
                roles));
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (LdapException ex)
        {
            logger.LogInformation(
                "LDAP dashboard login rejected for user {User}: result code {ResultCode}.",
                normalizedUsername,
                ex.ResultCode);

            return DashboardAuthenticationResult.Fail(GenericFailureMessage);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Unexpected LDAP dashboard login error for user {User}.", normalizedUsername);

            return DashboardAuthenticationResult.Fail(GenericFailureMessage);
        }
    }

    internal static string EscapeLdapFilter(string value)
    {
        StringBuilder builder = new(value.Length);
        foreach (char character in value)
        {
            builder.Append(character switch
            {
                '\\' => @"\5c",
                '*' => @"\2a",
                '(' => @"\28",
                ')' => @"\29",
                '\0' => @"\00",
                _ => character.ToString()
            });
        }

        return builder.ToString();
    }

    /// <summary>
    /// Maps the user's LDAP groups to dashboard roles. A user can pick up
    /// multiple roles; Admin and Viewer are the only legal values. Returns
    /// an empty list when no group matches (caller rejects the login).
    /// </summary>
    internal static IReadOnlyList<string> MapGroupsToRoles(
        IEnumerable<string> groups,
        IReadOnlyDictionary<string, string> groupToRole)
    {
        if (groupToRole.Count == 0)
        {
            return [];
        }

        HashSet<string> roles = new(StringComparer.Ordinal);
        foreach (string group in groups)
        {
            string normalizedGroup = group.Trim();

            // Lookup precedence (Server-040): the full literal group string is
            // tried first; only if that misses do we fall back to the leading
            // RDN value (e.g. "GwAdmin" extracted from
            // "ou=GwAdmin,ou=groups,..."). The map's comparer is
            // OrdinalIgnoreCase (see DashboardOptions.GroupToRole), so e.g.
            // "GwAdmin" and "gwadmin" both match.
            if (groupToRole.TryGetValue(normalizedGroup, out string? mapped)
                || groupToRole.TryGetValue(ExtractFirstRdnValue(normalizedGroup), out mapped))
            {
                roles.Add(mapped);
            }
        }

        return [.. roles];
    }

    internal static string ExtractFirstRdnValue(string distinguishedName)
    {
        int equalsIndex = distinguishedName.IndexOf('=');
        if (equalsIndex < 0)
        {
            return distinguishedName;
        }

        int valueStart = equalsIndex + 1;
        int commaIndex = distinguishedName.IndexOf(',', valueStart);

        return commaIndex > valueStart
            ? distinguishedName[valueStart..commaIndex]
            : distinguishedName[valueStart..];
    }

    private static Task BindServiceAccountAsync(
        LdapConnection connection,
        LdapOptions ldapOptions,
        CancellationToken cancellationToken)
    {
        return Task.Run(
            () => connection.Bind(ldapOptions.ServiceAccountDn, ldapOptions.ServiceAccountPassword),
            cancellationToken);
    }

    private static async Task<LdapEntry?> SearchUserAsync(
        LdapConnection connection,
        LdapOptions ldapOptions,
        string username,
        CancellationToken cancellationToken)
    {
        string filter = $"({ldapOptions.UserNameAttribute}={EscapeLdapFilter(username)})";
        ILdapSearchResults results = await Task.Run(
                () => connection.Search(
                    ldapOptions.SearchBase,
                    LdapConnection.ScopeSub,
                    filter,
                    attrs: null,
                    typesOnly: false),
                cancellationToken)
            .ConfigureAwait(false);

        LdapEntry? entry = null;
        while (results.HasMore())
        {
            LdapEntry next = results.Next();
            if (entry is not null)
            {
                return null;
            }

            entry = next;
        }

        return entry;
    }

    private static string? ReadAttribute(LdapEntry entry, string attributeName)
    {
        return ReadLdapAttribute(entry, attributeName)?.StringValue;
    }

    private static IReadOnlyList<string> ReadAttributeValues(LdapEntry entry, string attributeName)
    {
        LdapAttribute? attribute = ReadLdapAttribute(entry, attributeName);
        return attribute?.StringValueArray ?? [];
    }

    private static LdapAttribute? ReadLdapAttribute(LdapEntry entry, string attributeName)
    {
        return entry.GetAttribute(attributeName)
            ?? entry.GetAttribute(attributeName.ToLowerInvariant())
            ?? entry.GetAttribute(attributeName.ToUpperInvariant());
    }

    private static ClaimsPrincipal CreatePrincipal(
        string username,
        string displayName,
        IEnumerable<string> groups,
        IEnumerable<string> roles)
    {
        List<Claim> claims =
        [
            new Claim(ClaimTypes.NameIdentifier, username),
            new Claim(ClaimTypes.Name, displayName),
        ];

        claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
        claims.AddRange(groups.Select(group => new Claim(
            DashboardAuthenticationDefaults.LdapGroupClaimType,
            group)));

        ClaimsIdentity claimsIdentity = new(
            claims,
            DashboardAuthenticationDefaults.AuthenticationScheme,
            ClaimTypes.Name,
            ClaimTypes.Role);

        return new ClaimsPrincipal(claimsIdentity);
    }
}
