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

namespace ScadaLink.Security;

public class LdapAuthService
{
    private readonly SecurityOptions _options;
    private readonly ILogger<LdapAuthService> _logger;

    public LdapAuthService(IOptions<SecurityOptions> options, ILogger<LdapAuthService> logger)
    {
        _options = options?.Value ?? throw new ArgumentNullException(nameof(options));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

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

        if (string.IsNullOrWhiteSpace(password))
            return new LdapAuthResult(false, null, null, null, "Password is required.");

        // Trim once, up front: a username with leading/trailing whitespace (copy-paste
        // artefacts, mobile keyboards) is otherwise passed verbatim into the LDAP filter,
        // the fallback bind DN, and — most consequentially — the JWT Username claim and
        // audit trail, producing two distinct identities for the same person
        // (Security-015). The IsNullOrWhiteSpace guard above already rejects an
        // all-whitespace value, so the trimmed result here is always non-empty.
        username = NormalizeUsername(username);

        // Enforce TLS unless explicitly allowed for dev/test
        if (_options.LdapTransport == LdapTransport.None && !_options.AllowInsecureLdap)
        {
            return new LdapAuthResult(false, null, null, null,
                "Insecure LDAP connections are not allowed. Enable TLS or set AllowInsecureLdap for dev/test.");
        }

        try
        {
            using var connection = new LdapConnection();

            // Bound how long a hung LDAP server can pin a thread-pool thread. The
            // `ct` passed to Task.Run below only prevents the work item from starting;
            // it cannot interrupt an in-progress blocking Connect/Bind/Search. This
            // timeout is the real safeguard (Security-009).
            ApplyConnectionTimeout(connection);

            // LDAPS: TLS negotiated at connection time. StartTLS: connect plaintext,
            // then upgrade the session before any credentials are sent.
            if (_options.LdapTransport == LdapTransport.Ldaps)
            {
                connection.SecureSocketLayer = true;
            }

            await Task.Run(() => connection.Connect(_options.LdapServer, _options.LdapPort), ct);

            if (_options.LdapTransport == LdapTransport.StartTls)
            {
                await Task.Run(() => connection.StartTls(), ct);

                if (!connection.Tls)
                {
                    return new LdapAuthResult(false, null, null, null,
                        "StartTLS upgrade did not produce an encrypted session.");
                }
            }

            // Resolve the user's actual DN, then bind with their credentials
            var bindDn = await ResolveUserDnAsync(connection, username, ct);
            await Task.Run(() => connection.Bind(bindDn, password), ct);

            // Re-bind as service account for attribute/group lookup (user may lack search rights)
            if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn))
            {
                await Task.Run(() =>
                    connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct);
            }

            // Query for user attributes and group memberships
            var displayName = username;
            var groups = new List<string>();
            var groupLookupSucceeded = true;

            try
            {
                var searchFilter = $"({_options.LdapUserIdAttribute}={EscapeLdapFilter(username)})";
                var searchResults = await Task.Run(() =>
                    connection.Search(
                        _options.LdapSearchBase,
                        LdapConnection.ScopeSub,
                        searchFilter,
                        new[] { _options.LdapDisplayNameAttribute, _options.LdapGroupAttribute },
                        false), ct);

                // `HasMore()` is the loop guard for end-of-results; it returns false
                // when the enumeration is exhausted. An LdapException thrown by
                // `Next()` inside a HasMore()-guarded loop is therefore NOT a benign
                // "no more results" sentinel — it is a genuine error (referral failure,
                // server-side limit, transport drop mid-enumeration). The previous
                // `catch (LdapException) { break; }` silently truncated the group list
                // and masked a partial outage (Security-012); such an exception now
                // propagates to the outer catch and fails the login.
                while (searchResults.HasMore())
                {
                    var entry = searchResults.Next();

                    var dnAttr = entry.GetAttribute(_options.LdapDisplayNameAttribute);
                    if (dnAttr != null)
                        displayName = dnAttr.StringValue;

                    var groupAttr = entry.GetAttribute(_options.LdapGroupAttribute);
                    if (groupAttr != null)
                    {
                        foreach (var groupDn in groupAttr.StringValueArray)
                        {
                            groups.Add(ExtractFirstRdnValue(groupDn));
                        }
                    }
                }
            }
            catch (LdapException ex)
            {
                // A failed group/attribute lookup on initial login means the directory
                // is partially unavailable. The design's LDAP-failure rule requires new
                // logins to FAIL when LDAP is unavailable — admitting the user here
                // would yield an authenticated session with zero roles (Security-012).
                _logger.LogWarning(ex, "LDAP group/attribute lookup failed for user {Username}; failing the login per the LDAP-failure rule", username);
                groupLookupSucceeded = false;
            }

            connection.Disconnect();

            return BuildAuthResultFromGroupLookup(username, displayName, groups, groupLookupSucceeded);
        }
        catch (LdapException ex)
        {
            _logger.LogWarning(ex, "LDAP authentication failed for user {Username}", username);
            return new LdapAuthResult(false, null, username, null, "Invalid username or password.");
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            _logger.LogError(ex, "Unexpected error during LDAP authentication for user {Username}", username);
            return new LdapAuthResult(false, null, username, null, "An unexpected error occurred during authentication.");
        }
    }

    /// <summary>
    /// Applies <see cref="SecurityOptions.LdapConnectionTimeoutMs"/> to both the socket
    /// connect timeout and the per-operation (bind/search) time limit, so a hung or
    /// unresponsive LDAP server cannot pin a thread-pool thread indefinitely. The
    /// <c>CancellationToken</c> handed to the <c>Task.Run</c> wrappers only guards
    /// work-item scheduling and cannot interrupt an in-progress blocking call.
    /// </summary>
    private void ApplyConnectionTimeout(LdapConnection connection)
    {
        var timeoutMs = _options.LdapConnectionTimeoutMs;
        if (timeoutMs <= 0)
            return;

        connection.ConnectionTimeout = timeoutMs;

        // LdapConstraints.TimeLimit is the server-side operation time limit in ms.
        var constraints = connection.Constraints;
        constraints.TimeLimit = timeoutMs;
        connection.Constraints = constraints;
    }

    /// <summary>
    /// Resolves the user's full DN. When a service account is configured, performs a
    /// search-then-bind lookup. Otherwise falls back to constructing the DN directly.
    /// </summary>
    private async Task<string> ResolveUserDnAsync(LdapConnection connection, string username, CancellationToken ct)
    {
        // If a service account is configured, search for the user's actual DN
        if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn))
        {
            await Task.Run(() =>
                connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct);

            var searchFilter = $"({_options.LdapUserIdAttribute}={EscapeLdapFilter(username)})";
            var searchResults = await Task.Run(() =>
                connection.Search(
                    _options.LdapSearchBase,
                    LdapConnection.ScopeSub,
                    searchFilter,
                    new[] { "dn" },
                    false), ct);

            if (searchResults.HasMore())
            {
                var entry = searchResults.Next();
                return entry.Dn;
            }

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

        // Fallback: construct the bind DN directly from the configured user-id
        // attribute. The username is RFC 4514 DN-escaped so it cannot alter the
        // DN structure (Security-005). The previous Contains('=') shortcut that
        // accepted a raw caller-supplied DN has been removed — accepting an
        // arbitrary DN from untrusted input let a client choose the bind identity.
        return BuildFallbackUserDn(username, _options.LdapSearchBase, _options.LdapUserIdAttribute);
    }

    /// <summary>
    /// Builds the no-service-account fallback bind DN as
    /// <c>{userIdAttribute}={escaped-username}[,{searchBase}]</c>. The username is
    /// escaped per RFC 4514 so DN metacharacters in untrusted input cannot inject
    /// additional RDN components or change the bind identity.
    /// </summary>
    public static string BuildFallbackUserDn(string username, string searchBase, string userIdAttribute)
    {
        var rdn = $"{userIdAttribute}={EscapeLdapDn(username)}";
        return string.IsNullOrWhiteSpace(searchBase) ? rdn : $"{rdn},{searchBase}";
    }

    /// <summary>
    /// Escapes a string for use as an RFC 4514 DN attribute value: the special
    /// characters <c>, + " \ &lt; &gt; ;</c> are backslash-escaped, as are a leading
    /// or trailing space and a leading <c>#</c>.
    /// </summary>
    public static string EscapeLdapDn(string input)
    {
        if (string.IsNullOrEmpty(input))
            return input;

        var sb = new System.Text.StringBuilder(input.Length + 8);
        for (var i = 0; i < input.Length; i++)
        {
            var c = input[i];
            var isEdgeSpace = c == ' ' && (i == 0 || i == input.Length - 1);
            var isLeadingHash = c == '#' && i == 0;
            switch (c)
            {
                case ',':
                case '+':
                case '"':
                case '\\':
                case '<':
                case '>':
                case ';':
                    sb.Append('\\').Append(c);
                    break;
                case '\0':
                    sb.Append("\\00");
                    break;
                default:
                    if (isEdgeSpace || isLeadingHash)
                        sb.Append('\\');
                    sb.Append(c);
                    break;
            }
        }
        return sb.ToString();
    }

    private static string EscapeLdapFilter(string input)
    {
        return input
            .Replace("\\", "\\5c")
            .Replace("*", "\\2a")
            .Replace("(", "\\28")
            .Replace(")", "\\29")
            .Replace("\0", "\\00");
    }

    /// <summary>
    /// Normalises a username by trimming leading and trailing whitespace. Applied once
    /// at the top of <see cref="AuthenticateAsync"/> so the same canonical value flows
    /// into the LDAP filter, the fallback bind DN, and the JWT <c>Username</c> claim —
    /// avoiding two distinct identities for the same person (Security-015).
    /// </summary>
    public static string NormalizeUsername(string username)
        => username?.Trim() ?? string.Empty;

    /// <summary>
    /// Builds the final <see cref="LdapAuthResult"/> for a login attempt once the user
    /// bind has succeeded. When the group/attribute lookup failed
    /// (<paramref name="groupLookupSucceeded"/> is false) the directory is partially
    /// unavailable, so the login is FAILED per the design's LDAP-failure rule rather
    /// than returning an authenticated session with zero roles (Security-012). When the
    /// lookup succeeded, an empty <paramref name="groups"/> list is a genuine
    /// "no mapped groups" outcome and the login succeeds.
    /// </summary>
    public static LdapAuthResult BuildAuthResultFromGroupLookup(
        string username,
        string displayName,
        IReadOnlyList<string> groups,
        bool groupLookupSucceeded)
    {
        if (!groupLookupSucceeded)
        {
            return new LdapAuthResult(false, null, username, null,
                "The directory is temporarily unavailable. Please try again.");
        }

        return new LdapAuthResult(true, displayName, username, groups, null);
    }

    /// <summary>
    /// Extracts the value of the first RDN from a DN, e.g.
    /// <c>ou=SCADA-Admins,ou=groups,dc=...</c> → <c>SCADA-Admins</c>. The scan is
    /// RFC 4514 escape-aware: a backslash-escaped <c>,</c> inside the RDN value does
    /// not terminate it, and recognised escape sequences are unescaped, so a group CN
    /// that legitimately contains a comma is returned intact (Security-013).
    /// </summary>
    public static string ExtractFirstRdnValue(string dn)
    {
        if (string.IsNullOrEmpty(dn))
            return dn;

        var equalsIndex = dn.IndexOf('=');
        if (equalsIndex < 0)
            return dn;

        var valueStart = equalsIndex + 1;
        var sb = new System.Text.StringBuilder(dn.Length - valueStart);

        for (var i = valueStart; i < dn.Length; i++)
        {
            var c = dn[i];
            if (c == '\\' && i + 1 < dn.Length)
            {
                var next = dn[i + 1];
                // RFC 4514 hex escape: \XX (two hex digits).
                if (i + 2 < dn.Length && IsHexDigit(next) && IsHexDigit(dn[i + 2]))
                {
                    sb.Append((char)Convert.ToInt32(dn.Substring(i + 1, 2), 16));
                    i += 2;
                }
                else
                {
                    // Single-character escape (e.g. \, \+ \\ \" \; etc.) — emit the
                    // escaped character literally and skip the backslash.
                    sb.Append(next);
                    i += 1;
                }
                continue;
            }

            if (c == ',')
            {
                // Unescaped comma terminates the first RDN.
                break;
            }

            sb.Append(c);
        }

        return sb.ToString();
    }

    private static bool IsHexDigit(char c)
        => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
}
