using System.Security.Claims;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.MxGateway.Server.Configuration;

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

/// <summary>
/// Authenticates interactive dashboard logins against LDAP. The bind/search
/// mechanics are delegated to the shared <see cref="ILdapAuthService"/>
/// (<c>ZB.MOM.WW.Auth.Ldap</c>), which performs bind-then-search, fails closed,
/// and never throws — returning the user's display name and LDAP groups on
/// success. This class keeps the dashboard-specific policy: groups are resolved
/// to dashboard roles via <see cref="IGroupRoleMapper{TRole}"/>, a login with no
/// matching role is denied, and the resulting <see cref="ClaimsPrincipal"/> is
/// shaped exactly as before (see <see cref="CreatePrincipal"/>).
/// </summary>
/// <param name="ldapAuthService">Shared LDAP bind-then-search provider.</param>
/// <param name="roleMapper">Maps LDAP groups to dashboard roles.</param>
/// <param name="options">
/// Gateway options supplying <c>MxGateway:Dashboard:GroupToTag</c>, the map that turns the user's
/// LDAP groups into the dashboard visibility tags stamped on the cookie principal (SEC-25).
/// </param>
/// <param name="logger">Logger for diagnostic, credential-free login outcomes.</param>
public sealed class DashboardAuthenticator(
    ILdapAuthService ldapAuthService,
    IGroupRoleMapper<string> roleMapper,
    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)
    {
        if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
        {
            return DashboardAuthenticationResult.Fail(GenericFailureMessage);
        }

        string normalizedUsername = username.Trim();

        // The shared service owns connect/bind/search and the fail-closed contract:
        // it returns Fail(Disabled) when LDAP is off, enforces TLS-or-AllowInsecure via
        // its startup validator, and never throws. We only translate its outcome into a
        // dashboard principal here.
        LdapAuthResult ldapResult = await ldapAuthService
            .AuthenticateAsync(normalizedUsername, password, cancellationToken)
            .ConfigureAwait(false);

        if (!ldapResult.Succeeded)
        {
            return DashboardAuthenticationResult.Fail(GenericFailureMessage);
        }

        GroupRoleMapping<string> mapping = await roleMapper
            .MapAsync(ldapResult.Groups, cancellationToken)
            .ConfigureAwait(false);

        IReadOnlyList<string> roles = mapping.Roles;
        if (roles.Count == 0)
        {
            // Preserve the long-standing "no roles matched -> login denied" rule.
            logger.LogInformation(
                "LDAP dashboard login denied for user {User}: no GroupToRole mapping matched their LDAP groups.",
                ldapResult.Username);

            return DashboardAuthenticationResult.Fail(GenericFailureMessage);
        }

        return DashboardAuthenticationResult.Success(CreatePrincipal(
            ldapResult.Username,
            ldapResult.DisplayName,
            ldapResult.Groups,
            roles,
            options.Value.Dashboard.GroupToTag));
    }

    /// <summary>
    /// Builds the dashboard <see cref="ClaimsPrincipal"/> from the LDAP outcome.
    /// </summary>
    /// <param name="username">
    /// The (trimmed) login name. Emitted as <see cref="ClaimTypes.NameIdentifier"/> (kept for
    /// back-compat reads) and as the canonical <see cref="ZbClaimTypes.Username"/> ("zb:username").
    /// </param>
    /// <param name="displayName">
    /// The user's display name. Emitted as <see cref="ZbClaimTypes.Name"/> (= <see cref="ClaimTypes.Name"/>
    /// so <c>Identity.Name</c> resolves) and as <see cref="ZbClaimTypes.DisplayName"/> ("zb:displayname")
    /// for cross-app consistency.
    /// </param>
    /// <param name="groups">
    /// The user's LDAP groups, as returned by <see cref="ILdapAuthService"/>. NOTE
    /// (review C1): these are <b>already-normalized short RDN names</b> (e.g.
    /// <c>GwAdmin</c>), not raw distinguished names. The shared
    /// <c>ZB.MOM.WW.Auth.Ldap</c> provider strips each group DN to its first RDN
    /// value before returning it, so the <see cref="DashboardAuthenticationDefaults.LdapGroupClaimType"/>
    /// claim carries the short name. This differs from the pre-cutover behaviour,
    /// which surfaced the raw <c>memberOf</c> values (full DNs) on the claim; the
    /// claim is informational only (no policy or UI reads its value — authorization
    /// is role-based), so the shape change is non-breaking for dashboard consumers.
    /// </param>
    /// <param name="roles">The dashboard roles resolved from <paramref name="groups"/>.</param>
    /// <param name="groupToTag">
    /// The configured <c>Dashboard:GroupToTag</c> map. The tags it grants are stamped as
    /// <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/> claims so a
    /// cookie-authenticated circuit carries its grant without a hub-token round-trip — the
    /// session-details page's in-process subscribe seam reads exactly these claims.
    /// </param>
    private static ClaimsPrincipal CreatePrincipal(
        string username,
        string displayName,
        IEnumerable<string> groups,
        IEnumerable<string> roles,
        IReadOnlyDictionary<string, string[]> groupToTag)
    {
        // Materialized because the groups are read twice below (group claims and tag mapping) and
        // the source is only guaranteed to be enumerable.
        string[] groupNames = groups as string[] ?? [.. groups];

        List<Claim> claims =
        [
            // Keep NameIdentifier so any existing read-site that uses it continues to work.
            new Claim(ClaimTypes.NameIdentifier, username),
            // Canonical login-username claim.
            new Claim(ZbClaimTypes.Username, username),
            // ZbClaimTypes.Name == ClaimTypes.Name — drives Identity.Name resolution.
            new Claim(ZbClaimTypes.Name, displayName),
            // Canonical display-name claim for cross-app consistency.
            new Claim(ZbClaimTypes.DisplayName, displayName),
        ];

        // ZbClaimTypes.Role == ClaimTypes.Role — drives IsInRole and [Authorize(Roles=...)].
        claims.AddRange(roles.Select(role => new Claim(ZbClaimTypes.Role, role)));
        // Groups are short RDN names from ILdapAuthService (see param doc above), so
        // this claim value is the short group name, not the original DN.
        // LdapGroupClaimType is MxGateway-specific ("mxgateway:ldap_group") — no ZbClaimType for groups.
        claims.AddRange(groupNames.Select(group => new Claim(
            DashboardAuthenticationDefaults.LdapGroupClaimType,
            group)));
        // Dashboard event-visibility tags (SEC-25). Visibility only — never a data-access grant —
        // and never logged: only the decision, never the tag values, reaches diagnostics.
        claims.AddRange(DashboardGroupTagMapping
            .MapGroupsToTags(groupNames, groupToTag)
            .Select(tag => new Claim(DashboardAuthenticationDefaults.DashboardTagClaimType, tag)));

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

        return new ClaimsPrincipal(claimsIdentity);
    }
}
