using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Ldap;

namespace ZB.MOM.WW.Auth.Ldap.Tests;

/// <summary>
/// Task 6 failure-mode tests. These pin the fail-closed contract: every error path returns a
/// structured <see cref="LdapAuthResult.Fail(LdapAuthFailure)"/>, the method never throws, and
/// a successful result always carries at least one group.
/// </summary>
public class LdapAuthServiceFailureTests
{
    // Mirrors the happy-path test defaults (insecure plaintext dev transport, service account
    // set, DisplayNameAttribute aligned with the fake's "displayName" key).
    private static LdapOptions Opts() => new()
    {
        Enabled = true,
        Server = "x",
        Port = 3893,
        Transport = LdapTransport.None,
        AllowInsecure = true,
        SearchBase = "dc=x",
        ServiceAccountDn = "cn=svc,dc=x",
        ServiceAccountPassword = "svcpw",
        UserNameAttribute = "cn",
        DisplayNameAttribute = "displayName",
        GroupAttribute = "memberOf",
    };

    [Fact]
    public async Task BadCredentials_WhenUserBindThrows()
    {
        var fake = new FakeLdapConnection()
            .WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" })
            .ThrowOnUserBind();

        var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
            .AuthenticateAsync("alice", "bad", default);

        Assert.False(r.Succeeded);
        Assert.Equal(LdapAuthFailure.BadCredentials, r.Failure);
    }

    [Fact]
    public async Task UserNotFound_WhenZeroMatches()
    {
        var fake = new FakeLdapConnection().WithNoMatch();

        var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
            .AuthenticateAsync("ghost", "pw", default);

        Assert.False(r.Succeeded);
        Assert.Equal(LdapAuthFailure.UserNotFound, r.Failure);
    }

    [Fact]
    public async Task AmbiguousUser_WhenMultipleMatches()
    {
        var fake = new FakeLdapConnection().WithDuplicateMatch();

        var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
            .AuthenticateAsync("alice", "pw", default);

        Assert.False(r.Succeeded);
        Assert.Equal(LdapAuthFailure.AmbiguousUser, r.Failure);
    }

    [Fact]
    public async Task AmbiguousUser_DoesNotAttemptUserBind()
    {
        var fake = new FakeLdapConnection().WithDuplicateMatch();

        await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
            .AuthenticateAsync("alice", "pw", default);

        // Only the service-account bind should have happened; never bind an ambiguous DN.
        Assert.Equal(new[] { "cn=svc,dc=x" }, fake.BoundDns);
    }

    [Fact]
    public async Task GroupLookupFailed_WhenUserHasNoGroups()
    {
        var fake = new FakeLdapConnection()
            .WithUserEntry("cn=alice,dc=x", memberOf: Array.Empty<string>());

        var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
            .AuthenticateAsync("alice", "pw", default);

        Assert.False(r.Succeeded);
        Assert.Equal(LdapAuthFailure.GroupLookupFailed, r.Failure);
    }

    [Fact]
    public async Task ServiceAccountBindFailed_Distinctly_WhenServiceBindThrows()
    {
        var fake = new FakeLdapConnection()
            .WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" })
            .ThrowOnServiceBind();

        var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
            .AuthenticateAsync("alice", "pw", default);

        Assert.False(r.Succeeded);
        Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, r.Failure);
        // Distinct from BadCredentials: a service-account problem is a system misconfiguration,
        // not the end user's fault.
        Assert.NotEqual(LdapAuthFailure.BadCredentials, r.Failure);
    }

    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    public async Task BadCredentials_WhenUsernameNullOrWhitespace_NoConnectionAttempted(string? username)
    {
        // I4: an empty/whitespace/null username is rejected up front as BadCredentials,
        // before any connection or bind is attempted (and a null can't NRE into the catch-all).
        var fake = new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" });

        var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
            .AuthenticateAsync(username!, "pw", default);

        Assert.False(r.Succeeded);
        Assert.Equal(LdapAuthFailure.BadCredentials, r.Failure);
        Assert.Null(fake.ConnectArgs);    // never connected
        Assert.Empty(fake.BoundDns);      // never bound
    }

    [Fact]
    public async Task Throws_WhenCancellationRequested()
    {
        // I3: a pre-cancelled token is observed at entry, before any work.
        var fake = new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" });
        var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));

        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => svc.AuthenticateAsync("alice", "pw", new CancellationToken(canceled: true)));

        Assert.Null(fake.ConnectArgs);    // never connected
    }

    [Fact]
    public async Task NeverThrows_OnConnectFailure()
    {
        var fake = new FakeLdapConnection()
            .WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" })
            .ThrowOnConnect();

        var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
            .AuthenticateAsync("alice", "pw", default);

        Assert.False(r.Succeeded);
        // Directory unreachable is a system-side failure -> bucketed under ServiceAccountBindFailed.
        Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, r.Failure);
    }
}
