using Novell.Directory.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Ldap.Internal;

namespace ZB.MOM.WW.Auth.Ldap;

/// <summary>
/// Authenticates a user against an LDAP directory using the bind-then-search idiom:
/// bind as the service account, search for the user entry, then re-bind as the user
/// to verify their password. Connection mechanics are delegated to an
/// <see cref="ILdapConnection"/> so the logic is unit-testable without a live server.
/// </summary>
/// <remarks>
/// Fully fail-closed: authentication never throws to the caller — every error, expected
/// or unexpected, is mapped to a structured <see cref="LdapAuthResult.Fail(LdapAuthFailure)"/>.
/// A success is only returned for a user that resolved to exactly one entry, whose password
/// verified, AND who has at least one group (zero groups is never admitted as success).
/// Service-account bind failures (<see cref="LdapAuthFailure.ServiceAccountBindFailed"/>) are
/// kept distinct from end-user bind failures (<see cref="LdapAuthFailure.BadCredentials"/>) so
/// a system misconfiguration is not mistaken for bad user input.
/// </remarks>
public sealed class LdapAuthService : ILdapAuthService
{
    private readonly LdapOptions _options;
    private readonly ILdapConnectionFactory _connectionFactory;

    /// <summary>
    /// Production constructor: binds against a live directory via the real
    /// Novell-backed connection factory. The TLS server-certificate validation policy
    /// (<see cref="LdapOptions.ServerCertificateValidationCallback"/> or the
    /// <see cref="LdapOptions.AllowInsecure"/> bypass) is carried into the factory so each
    /// connection is built with it.
    /// </summary>
    public LdapAuthService(LdapOptions options)
        : this(options, new NovellLdapConnectionFactory(
            options.AllowInsecure, options.ServerCertificateValidationCallback))
    {
    }

    /// <summary>
    /// Test/seam constructor: accepts an injected <see cref="ILdapConnectionFactory"/>
    /// so the bind/search logic can be exercised without a live directory. Internal
    /// because the connection seam is an implementation detail.
    /// </summary>
    internal LdapAuthService(LdapOptions options, ILdapConnectionFactory connectionFactory)
    {
        _options = options;
        _connectionFactory = connectionFactory;
    }

    /// <inheritdoc />
    /// <remarks>
    /// Fail-closed contract: this method never throws to the caller. Every stage of the
    /// bind-then-search-then-bind flow is wrapped so that any error — expected or unexpected —
    /// is mapped to a structured <see cref="LdapAuthResult.Fail(LdapAuthFailure)"/>. A
    /// <c>Succeeded == true</c> result is only ever returned when the user resolved to exactly
    /// one entry, their password verified, AND at least one group was extracted.
    /// </remarks>
    public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct)
    {
        // The Novell calls behind ILdapConnection are synchronous and blocking, so the token
        // cannot interrupt an in-progress operation; it is only observed here at entry, before
        // any work begins.
        ct.ThrowIfCancellationRequested();
        return Task.FromResult(Authenticate(username, password));
    }

    private LdapAuthResult Authenticate(string username, string password)
    {
        // 1. Feature gate: an explicitly disabled provider must never touch the network.
        if (!_options.Enabled)
            return LdapAuthResult.Fail(LdapAuthFailure.Disabled);

        // 2. Reject a missing username before anything else — guarding here means a null
        //    username can't NRE into the catch-all and surface as a system-side failure.
        if (string.IsNullOrWhiteSpace(username))
            return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);

        // 3. Normalise once, up front, so the same canonical value flows into the LDAP
        //    filter and the returned result (avoids two identities for one person).
        username = username.Trim();

        // The whole flow runs inside an outer fail-closed guard: a StageFailure carries an
        // already-mapped failure out of a stage, and any OTHER unexpected exception defaults to
        // the most conservative system-side bucket. Either way the caller gets a structured result.
        try
        {
            using var conn = _connectionFactory.Create();

            // 4. Open the connection (transport/TLS handling lives in the adapter). The
            //    per-operation timeout (ConnectionTimeoutMs) is applied by the adapter here.
            //    A failure to connect/upgrade means the directory is unreachable — a
            //    system-side fault, not the user's, so map it to ServiceAccountBindFailed.
            // NOTE: the LdapAuthFailure enum has no dedicated DirectoryUnavailable value;
            //       ServiceAccountBindFailed is the closest system-side bucket. A future
            //       Abstractions change could add DirectoryUnavailable to disambiguate.
            try
            {
                conn.Connect(
                    _options.Server,
                    _options.Port,
                    _options.Transport,
                    _options.AllowInsecure,
                    _options.ConnectionTimeoutMs,
                    _options.ServerCertificateValidationCallback);
            }
            catch (LdapException)
            {
                throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed);
            }

            // 5. Service-account bind so we can search for the user's DN. A bind failure
            //    here is a service-account misconfiguration — DISTINCT from a user-credential
            //    failure — so it maps to ServiceAccountBindFailed.
            try
            {
                conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword);
            }
            catch (LdapException)
            {
                throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed);
            }

            // 6. Search for the user entry by the configured username attribute. A search
            //    failure is infrastructure (directory unreachable / unhealthy) — system-side.
            // NOTE: same enum limitation as Connect above; ServiceAccountBindFailed is the
            //       closest system-side bucket until a DirectoryUnavailable value exists.
            IReadOnlyList<LdapSearchEntry> entries;
            try
            {
                var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})";
                entries = conn.Search(_options.SearchBase, filter, BuildSearchAttributes());
            }
            catch (LdapException)
            {
                throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed);
            }

            // 7. Require exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
            //    We never attempt a user bind against an ambiguous DN.
            if (entries.Count == 0)
                return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound);
            if (entries.Count >= 2)
                return LdapAuthResult.Fail(LdapAuthFailure.AmbiguousUser);

            var entry = entries[0];

            // 8. User bind: re-bind as the resolved DN to verify the password. A bind failure
            //    here is the end user's bad credentials.
            try
            {
                conn.Bind(entry.Dn, password);
            }
            catch (LdapException)
            {
                throw new StageFailure(LdapAuthFailure.BadCredentials);
            }

            // 9. Group extraction. Fail closed: an empty/missing group set, or any error while
            //    extracting groups, is a GroupLookupFailed — never a zero-group success.
            IReadOnlyList<string> groups;
            try
            {
                groups = ExtractGroups(entry);
            }
            catch
            {
                throw new StageFailure(LdapAuthFailure.GroupLookupFailed);
            }

            if (groups.Count == 0)
                return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);

            // 10. Success — and only here, with a verified password and >= 1 group.
            var displayName = ExtractDisplayName(entry, username);
            return LdapAuthResult.Success(username, displayName, groups);
        }
        catch (StageFailure stage)
        {
            // A stage mapped its own failure; surface it as a structured result.
            return LdapAuthResult.Fail(stage.Failure);
        }
        catch
        {
            // Belt-and-braces: ANY unexpected exception fails closed to the most conservative
            // system-side bucket rather than propagating to the caller.
            return LdapAuthResult.Fail(LdapAuthFailure.ServiceAccountBindFailed);
        }
    }

    /// <summary>
    /// Internal control-flow exception that carries an already-mapped <see cref="LdapAuthFailure"/>
    /// out of a stage to the single fail-closed catch site. Never escapes this type.
    /// </summary>
    private sealed class StageFailure : Exception
    {
        public StageFailure(LdapAuthFailure failure) => Failure = failure;

        public LdapAuthFailure Failure { get; }
    }

    /// <summary>
    /// Builds the distinct attribute list requested from the directory. The display-name
    /// and group attributes are de-duplicated so we never request the same attribute twice
    /// when an operator configures them to the same value.
    /// </summary>
    private IReadOnlyList<string> BuildSearchAttributes()
    {
        if (string.Equals(_options.DisplayNameAttribute, _options.GroupAttribute, StringComparison.OrdinalIgnoreCase))
            return new[] { _options.DisplayNameAttribute };

        return new[] { _options.DisplayNameAttribute, _options.GroupAttribute };
    }

    /// <summary>
    /// Returns the first value of the configured display-name attribute, falling back to
    /// the (already normalised) username when the directory entry has no such attribute.
    /// </summary>
    private string ExtractDisplayName(LdapSearchEntry entry, string username)
    {
        if (entry.Attributes.TryGetValue(_options.DisplayNameAttribute, out var values) && values.Count > 0)
            return values[0];

        return username;
    }

    /// <summary>
    /// Extracts group short names from the configured group attribute. Each value is a
    /// group DN (e.g. <c>cn=Engineers,ou=g,dc=x</c>); the first RDN's value is returned
    /// (e.g. <c>Engineers</c>), RFC 4514 escape-aware so an escaped comma in the CN is
    /// preserved rather than truncating the name.
    /// </summary>
    private IReadOnlyList<string> ExtractGroups(LdapSearchEntry entry)
    {
        if (!entry.Attributes.TryGetValue(_options.GroupAttribute, out var values) || values.Count == 0)
            return Array.Empty<string>();

        var groups = new List<string>(values.Count);
        foreach (var value in values)
            groups.Add(ToGroupShortName(value));

        return groups;
    }

    /// <summary>
    /// Yields a group's short name from its DN by returning the value of the first RDN
    /// (e.g. <c>cn=Engineers,ou=g,dc=x</c> → <c>Engineers</c>). The extraction is RFC 4514
    /// escape-aware (<see cref="LdapEscaping.FirstRdnValue"/>), so a CN that legitimately
    /// contains an escaped comma — <c>cn=Eng\,ineers,...</c> or <c>cn=A\2cB,...</c> — is
    /// returned intact rather than truncated at the escaped comma. Values with no <c>=</c>
    /// are returned unchanged.
    /// </summary>
    private static string ToGroupShortName(string groupDn)
        => LdapEscaping.FirstRdnValue(groupDn);
}
