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

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

/// <summary>
///     LDAP bind-and-search authentication mirrored from ScadaLink's <c>LdapAuthService</c>
///     (CLAUDE.md memory: <c>scadalink_reference.md</c>) — same bind semantics, TLS guard, and
///     service-account search-then-bind path. Adapted for the Admin app's role-mapping shape
///     (LDAP group names → Admin roles via <see cref="LdapOptions.GroupToRole"/>).
/// </summary>
public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapAuthService> logger)
    : ILdapAuthService
{
    private readonly LdapOptions _options = options.Value;

    public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
    {
        if (string.IsNullOrWhiteSpace(username))
            return new(false, null, null, [], [], "Username is required");
        if (string.IsNullOrWhiteSpace(password))
            return new(false, null, null, [], [], "Password is required");

        if (!_options.UseTls && !_options.AllowInsecureLdap)
            return new(false, null, username, [], [],
                "Insecure LDAP is disabled. Enable UseTls or set 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);

            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, // request ALL attributes so we can inspect memberOf + dn-derived group
                        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));
                        }

                        // Fallback: GLAuth places users under ou=PrimaryGroup,baseDN. When the
                        // directory doesn't populate memberOf (or populates it differently), the
                        // user's primary group name is recoverable from the second RDN of the DN.
                        if (groups.Count == 0 && !string.IsNullOrEmpty(entry.Dn))
                        {
                            var primary = ExtractOuSegment(entry.Dn);
                            if (primary is not null) groups.Add(primary);
                        }
                    }
                    catch (LdapException) { break; } // no-more-entries signalled by exception
                }
            }
            catch (LdapException ex)
            {
                logger.LogWarning(ex, "LDAP attribute lookup failed for {User}", username);
            }

            conn.Disconnect();

            var roles = RoleMapper.Map(groups, _options.GroupToRole);
            return new(true, displayName, username, groups, roles, null);
        }
        catch (LdapException ex)
        {
            logger.LogWarning(ex, "LDAP bind failed for {User}", username);
            return new(false, null, username, [], [], "Invalid username or password");
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            logger.LogError(ex, "Unexpected LDAP error for {User}", username);
            return new(false, null, username, [], [], "Unexpected authentication error");
        }
    }

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

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

            var filter = $"(uid={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");

    /// <summary>
    ///     Pulls the first <c>ou=Value</c> segment from a DN. GLAuth encodes a user's primary
    ///     group as an <c>ou=</c> RDN immediately above the user's <c>cn=</c>, so this recovers
    ///     the group name when <see cref="LdapOptions.GroupAttribute"/> is absent from the entry.
    /// </summary>
    internal static string? ExtractOuSegment(string dn)
    {
        var segments = dn.Split(',');
        foreach (var segment in segments)
        {
            var trimmed = segment.Trim();
            if (trimmed.StartsWith("ou=", StringComparison.OrdinalIgnoreCase))
                return trimmed[3..];
        }
        return null;
    }

    internal static string ExtractFirstRdnValue(string dn)
    {
        var equalsIdx = dn.IndexOf('=');
        if (equalsIdx < 0) return dn;

        var valueStart = equalsIdx + 1;
        var commaIdx = dn.IndexOf(',', valueStart);
        return commaIdx > valueStart ? dn[valueStart..commaIdx] : dn[valueStart..];
    }
}
