using Microsoft.Extensions.Logging;
using Novell.Directory.Ldap;

namespace ZB.MOM.WW.OtOpcUa.Server.Security;

/// <summary>
///     <see cref="IUserAuthenticator"/> that binds to the configured LDAP directory to validate
///     the (username, password) pair, then pulls group membership and maps to OPC UA roles.
///     Mirrors the bind-then-search pattern in <c>Admin.Security.LdapAuthService</c> but stays
///     in the Server project so the Server process doesn't take a cross-app dependency on Admin.
/// </summary>
public sealed class LdapUserAuthenticator(LdapOptions options, ILogger<LdapUserAuthenticator> logger)
    : IUserAuthenticator
{
    public async Task<UserAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
    {
        if (!options.Enabled)
            return new UserAuthResult(false, null, [], "LDAP authentication disabled");
        if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
            return new UserAuthResult(false, null, [], "Credentials required");

        if (!options.UseTls && !options.AllowInsecureLdap)
            return new UserAuthResult(false, null, [],
                "Insecure LDAP is disabled. Set UseTls or AllowInsecureLdap for dev/test.");

        try
        {
            using var conn = new LdapConnection();
            if (options.UseTls) conn.SecureSocketLayer = true;
            await Task.Run(() => conn.Connect(options.Server, options.Port), ct);

            var bindDn = await ResolveUserDnAsync(conn, username, ct);
            await Task.Run(() => conn.Bind(bindDn, password), ct);

            // Rebind as service account for attribute read, if configured — otherwise the just-
            // bound user reads their own entry (works when ACL permits self-read).
            if (!string.IsNullOrWhiteSpace(options.ServiceAccountDn))
                await Task.Run(() => conn.Bind(options.ServiceAccountDn, options.ServiceAccountPassword), ct);

            var displayName = username;
            var groups = new List<string>();

            try
            {
                var filter = $"(cn={EscapeLdapFilter(username)})";
                var results = await Task.Run(() =>
                    conn.Search(options.SearchBase, LdapConnection.ScopeSub, filter, attrs: null, typesOnly: false), ct);

                while (results.HasMore())
                {
                    try
                    {
                        var entry = results.Next();
                        var name = entry.GetAttribute(options.DisplayNameAttribute);
                        if (name is not null) displayName = name.StringValue;

                        var groupAttr = entry.GetAttribute(options.GroupAttribute);
                        if (groupAttr is not null)
                        {
                            foreach (var groupDn in groupAttr.StringValueArray)
                                groups.Add(ExtractFirstRdnValue(groupDn));
                        }

                        // GLAuth fallback: primary group is encoded as the ou= RDN above cn=.
                        if (groups.Count == 0 && !string.IsNullOrEmpty(entry.Dn))
                        {
                            var primary = ExtractOuSegment(entry.Dn);
                            if (primary is not null) groups.Add(primary);
                        }
                    }
                    catch (LdapException) { break; }
                }
            }
            catch (LdapException ex)
            {
                logger.LogWarning(ex, "LDAP attribute lookup failed for {User}", username);
            }

            conn.Disconnect();

            var roles = groups
                .Where(g => options.GroupToRole.ContainsKey(g))
                .Select(g => options.GroupToRole[g])
                .Distinct(StringComparer.OrdinalIgnoreCase)
                .ToList();

            return new UserAuthResult(true, displayName, roles, null);
        }
        catch (LdapException ex)
        {
            logger.LogInformation("LDAP bind rejected user {User}: {Reason}", username, ex.ResultCode);
            return new UserAuthResult(false, null, [], "Invalid username or password");
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            logger.LogError(ex, "Unexpected LDAP error for {User}", username);
            return new UserAuthResult(false, null, [], "Authentication error");
        }
    }

    private async Task<string> ResolveUserDnAsync(LdapConnection conn, string username, CancellationToken ct)
    {
        if (username.Contains('=')) return username; // caller passed a DN directly

        if (!string.IsNullOrWhiteSpace(options.ServiceAccountDn))
        {
            await Task.Run(() => conn.Bind(options.ServiceAccountDn, options.ServiceAccountPassword), ct);

            var filter = $"({options.UserNameAttribute}={EscapeLdapFilter(username)})";
            var results = await Task.Run(() =>
                conn.Search(options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct);

            if (results.HasMore())
                return results.Next().Dn;

            throw new LdapException("User not found", LdapException.NoSuchObject,
                $"No entry for uid={username}");
        }

        return string.IsNullOrWhiteSpace(options.SearchBase)
            ? $"cn={username}"
            : $"cn={username},{options.SearchBase}";
    }

    internal static string EscapeLdapFilter(string input) =>
        input.Replace("\\", "\\5c")
             .Replace("*", "\\2a")
             .Replace("(", "\\28")
             .Replace(")", "\\29")
             .Replace("\0", "\\00");

    internal static string? ExtractOuSegment(string dn)
    {
        foreach (var segment in dn.Split(','))
        {
            var trimmed = segment.Trim();
            if (trimmed.StartsWith("ou=", StringComparison.OrdinalIgnoreCase))
                return trimmed[3..];
        }
        return null;
    }

    internal static string ExtractFirstRdnValue(string dn)
    {
        var eq = dn.IndexOf('=');
        if (eq < 0) return dn;
        var valueStart = eq + 1;
        var comma = dn.IndexOf(',', valueStart);
        return comma > valueStart ? dn[valueStart..comma] : dn[valueStart..];
    }
}
