using System.Security.Claims;
using Microsoft.Extensions.Logging.Abstractions;
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;
using ZB.MOM.WW.MxGateway.Server.Dashboard;

namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;

/// <summary>
/// Parity tests for <see cref="DashboardAuthenticator"/> after the cutover to the
/// shared <see cref="ILdapAuthService"/>. The library now owns connect/bind/search
/// (covered by its own tests); these tests fake the shared service to return known
/// groups and assert the dashboard-specific policy is unchanged: groups → roles via
/// <see cref="IGroupRoleMapper{TRole}"/>, no-roles-matched is denied, and the
/// resulting principal/claims keep their exact shape.
/// </summary>
public sealed class DashboardAuthenticatorTests
{
    /// <summary>A blank username is rejected without touching the LDAP provider.</summary>
    /// <param name="username">The blank/whitespace/null username under test.</param>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    public async Task AuthenticateAsync_BlankUsername_FailsWithoutCallingLdap(string? username)
    {
        FakeLdapAuthService ldap = new(LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
        DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());

        DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
            username,
            "password",
            CancellationToken.None);

        Assert.False(result.Succeeded);
        Assert.Null(result.Principal);
        Assert.False(ldap.WasCalled);
    }

    /// <summary>A blank password is rejected without touching the LDAP provider.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task AuthenticateAsync_BlankPassword_FailsWithoutCallingLdap()
    {
        FakeLdapAuthService ldap = new(LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
        DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());

        DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
            "admin",
            "  ",
            CancellationToken.None);

        Assert.False(result.Succeeded);
        Assert.Null(result.Principal);
        Assert.False(ldap.WasCalled);
    }

    /// <summary>
    /// A failed LDAP outcome (any failure bucket, including <see cref="LdapAuthFailure.Disabled"/>)
    /// maps to the generic dashboard failure without leaking the raw password.
    /// </summary>
    /// <param name="failure">The LDAP failure bucket under test.</param>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Theory]
    [InlineData(LdapAuthFailure.Disabled)]
    [InlineData(LdapAuthFailure.BadCredentials)]
    [InlineData(LdapAuthFailure.UserNotFound)]
    [InlineData(LdapAuthFailure.ServiceAccountBindFailed)]
    public async Task AuthenticateAsync_LdapFailure_ReturnsFailureWithoutRawCredentials(
        LdapAuthFailure failure)
    {
        FakeLdapAuthService ldap = new(LdapAuthResult.Fail(failure));
        DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());

        DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
            "admin",
            "admin123",
            CancellationToken.None);

        Assert.False(result.Succeeded);
        Assert.Null(result.Principal);
        Assert.DoesNotContain("admin123", result.FailureMessage, StringComparison.Ordinal);
    }

    /// <summary>
    /// On success the principal carries the resolved roles, the LDAP-group claims
    /// (short RDN names as returned by <see cref="ILdapAuthService"/>), the display
    /// name (ClaimTypes.Name), and the username (ClaimTypes.NameIdentifier), under the
    /// dashboard authentication scheme.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task AuthenticateAsync_Success_BuildsPrincipalWithExpectedClaims()
    {
        FakeLdapAuthService ldap = new(LdapAuthResult.Success(
            username: "admin",
            displayName: "Administrator",
            groups: ["GwAdmin", "GwReader"]));
        DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());

        DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
            "admin",
            "admin123",
            CancellationToken.None);

        Assert.True(result.Succeeded);
        ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);

        Assert.Equal("admin", principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
        Assert.Equal("Administrator", principal.FindFirst(ClaimTypes.Name)?.Value);

        // Identity is built with the dashboard scheme and Name/Role claim types so
        // IsInRole and Identity.Name keep working.
        ClaimsIdentity identity = Assert.IsType<ClaimsIdentity>(principal.Identity);
        Assert.Equal(DashboardAuthenticationDefaults.AuthenticationScheme, identity.AuthenticationType);
        Assert.Equal("Administrator", identity.Name);

        // Both groups mapped → both roles present.
        Assert.True(principal.IsInRole(DashboardRoles.Admin));
        Assert.True(principal.IsInRole(DashboardRoles.Viewer));

        // LDAP groups (already short RDN names from the service) are surfaced under
        // the dedicated group claim type.
        IReadOnlyList<string> groupClaims = principal.FindAll(
                DashboardAuthenticationDefaults.LdapGroupClaimType)
            .Select(claim => claim.Value)
            .ToList();
        Assert.Equal(["GwAdmin", "GwReader"], groupClaims);
    }

    /// <summary>
    /// The principal emits canonical ZbClaimTypes.Username ("zb:username") with
    /// the login username, ZbClaimTypes.Role (= ClaimTypes.Role) for each resolved role, and
    /// ZbClaimTypes.DisplayName ("zb:displayname") with the display name — while keeping
    /// ClaimTypes.NameIdentifier, ClaimTypes.Name, and mxgateway:ldap_group claims intact.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task AuthenticateAsync_Success_EmitsCanonicalZbClaims()
    {
        FakeLdapAuthService ldap = new(LdapAuthResult.Success(
            username: "jsmith",
            displayName: "John Smith",
            groups: ["GwAdmin", "GwReader"]));
        DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());

        DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
            "jsmith",
            "pw",
            CancellationToken.None);

        Assert.True(result.Succeeded);
        ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);

        // ZbClaimTypes.Username ("zb:username") carries the login username.
        Assert.Equal("jsmith", principal.FindFirstValue(ZbClaimTypes.Username));

        // ZbClaimTypes.DisplayName ("zb:displayname") carries the display name.
        Assert.Equal("John Smith", principal.FindFirstValue(ZbClaimTypes.DisplayName));

        // ZbClaimTypes.Role (= ClaimTypes.Role) is used for each role claim.
        IReadOnlyList<string> roleClaims = principal
            .FindAll(ZbClaimTypes.Role)
            .Select(c => c.Value)
            .OrderBy(r => r)
            .ToList();
        Assert.Contains(DashboardRoles.Admin, roleClaims);
        Assert.Contains(DashboardRoles.Viewer, roleClaims);

        // IsInRole still works (identity built with roleType = ZbClaimTypes.Role).
        Assert.True(principal.IsInRole(DashboardRoles.Admin));
        Assert.True(principal.IsInRole(DashboardRoles.Viewer));

        // ClaimTypes.Name still resolves as Identity.Name (nameType = ZbClaimTypes.Name = ClaimTypes.Name).
        Assert.Equal("John Smith", principal.Identity?.Name);

        // mxgateway:ldap_group claims are preserved.
        IReadOnlyList<string> groups = principal
            .FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType)
            .Select(c => c.Value)
            .ToList();
        Assert.Equal(["GwAdmin", "GwReader"], groups);
    }

    /// <summary>
    /// When the user authenticates but none of their groups map to a dashboard role,
    /// the login is denied (the long-standing "no roles matched → denied" rule).
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task AuthenticateAsync_NoRolesMatched_DeniesLogin()
    {
        FakeLdapAuthService ldap = new(LdapAuthResult.Success(
            username: "nobody",
            displayName: "No Body",
            groups: ["SomeUnmappedGroup"]));
        DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());

        DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
            "nobody",
            "pw",
            CancellationToken.None);

        Assert.False(result.Succeeded);
        Assert.Null(result.Principal);
    }

    /// <summary>
    /// Direct-injection path only (review C1): when an <see cref="ILdapAuthService"/>
    /// implementation hands the authenticator a full distinguished name as a group, the
    /// mapper's leading-RDN fallback still resolves the role, and whatever string the
    /// service supplied is surfaced verbatim on the group claim.
    /// <para>
    /// This is NOT the real production flow. The shared <c>ZB.MOM.WW.Auth.Ldap</c>
    /// provider strips each group DN to its short RDN name before returning it, so the
    /// authenticator never receives a full DN from the real library and the group claim
    /// in production carries the short name (e.g. <c>GwAdmin</c>), not the DN. This test
    /// uses a fake service to exercise only the authenticator's own pass-through of group
    /// values; see <see cref="AuthenticateAsync_Success_BuildsPrincipalWithExpectedClaims"/>
    /// for the realistic (already-short) group shape.
    /// </para>
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task AuthenticateAsync_GroupAsDistinguishedNameFromService_ResolvesRoleAndSurfacesServiceValue()
    {
        const string groupDn = "ou=GwAdmin,ou=groups,dc=zb,dc=local";
        FakeLdapAuthService ldap = new(LdapAuthResult.Success(
            username: "admin",
            displayName: "admin",
            groups: [groupDn]));
        DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());

        DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
            "admin",
            "admin123",
            CancellationToken.None);

        Assert.True(result.Succeeded);
        ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);
        // Role resolves via the leading-RDN fallback in DashboardGroupRoleMapping.
        Assert.True(principal.IsInRole(DashboardRoles.Admin));
        // The authenticator surfaces the value the (fake) service returned, verbatim.
        // With the real library this value would already be the short RDN ("GwAdmin").
        Assert.Contains(
            principal.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType),
            claim => claim.Value == groupDn);
    }

    /// <summary>The (already-trimmed) username from the LDAP result flows onto the principal.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task AuthenticateAsync_UsesUsernameFromLdapResult()
    {
        FakeLdapAuthService ldap = new(LdapAuthResult.Success(
            username: "canonical",
            displayName: "Canonical User",
            groups: ["GwReader"]));
        DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());

        DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
            "  canonical  ",
            "pw",
            CancellationToken.None);

        Assert.True(result.Succeeded);
        ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);
        Assert.Equal("canonical", principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
        // The authenticator trims before delegating, so the provider sees the canonical value.
        Assert.Equal("canonical", ldap.LastUsername);
    }

    private static DashboardAuthenticator CreateAuthenticator(
        ILdapAuthService ldapAuthService,
        Dictionary<string, string> groupToRole)
    {
        GatewayOptions options = new()
        {
            Dashboard = new DashboardOptions
            {
                GroupToRole = groupToRole,
            },
        };

        IGroupRoleMapper<string> roleMapper = new DashboardGroupRoleMapper(Options.Create(options));

        return new DashboardAuthenticator(
            ldapAuthService,
            roleMapper,
            Options.Create(options),
            NullLogger<DashboardAuthenticator>.Instance);
    }

    private static Dictionary<string, string> StandardMapping() => new(StringComparer.OrdinalIgnoreCase)
    {
        ["GwAdmin"] = DashboardRoles.Admin,
        ["GwReader"] = DashboardRoles.Viewer,
    };

    /// <summary>
    /// Fake <see cref="ILdapAuthService"/> returning a fixed result, recording whether it was
    /// invoked and with what username so the authenticator's pre-checks and trimming can be asserted.
    /// </summary>
    private sealed class FakeLdapAuthService(LdapAuthResult result) : ILdapAuthService
    {
        /// <summary>Gets a value indicating whether <see cref="AuthenticateAsync"/> was invoked.</summary>
        public bool WasCalled { get; private set; }

        /// <summary>Gets the username passed to the most recent <see cref="AuthenticateAsync"/> call.</summary>
        public string? LastUsername { get; private set; }

        /// <summary>Records the call and returns the configured fixed result.</summary>
        /// <param name="username">The username to authenticate.</param>
        /// <param name="password">The password to authenticate.</param>
        /// <param name="ct">Token to observe for cancellation.</param>
        /// <returns>The fixed <see cref="LdapAuthResult"/> configured for this fake.</returns>
        public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct)
        {
            WasCalled = true;
            LastUsername = username;
            return Task.FromResult(result);
        }
    }
}
