using System.Security.Claims;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Ldap;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using LibraryLdapOptions = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions;

namespace ZB.MOM.WW.MxGateway.IntegrationTests;

[Collection(LiveResourcesCollection.Name)]
[Trait("Category", "LiveLdap")]
public sealed class DashboardLdapLiveTests
{
    /// <summary>Verifies that an admin user in the GwAdmin group authenticates successfully.</summary>
    [LiveLdapFact]
    public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds()
    {
        DashboardAuthenticator authenticator = CreateAuthenticator();

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

        Assert.True(result.Succeeded);
        Assert.NotNull(result.Principal);
        Assert.Equal("admin", result.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
        Assert.Contains(result.Principal.Claims, claim =>
            claim.Type == DashboardAuthenticationDefaults.LdapGroupClaimType
            && claim.Value.Contains("GwAdmin", StringComparison.OrdinalIgnoreCase));

        // IntegrationTests-023: DashboardAuthenticator builds the principal with a
        // ClaimTypes.Role claim resolved from the LDAP groups via the
        // DashboardGroupRoleMapper. The seeded GroupToRole map (GwAdmin -> Admin)
        // means the admin principal must carry Role=Admin alongside the raw LDAP-group
        // claim. A regression in the group→role mapping would fail this assertion.
        Assert.Contains(result.Principal.Claims, claim =>
            claim.Type == ClaimTypes.Role
            && claim.Value == DashboardRoles.Admin);
    }

    /// <summary>Verifies that a readonly user without GwAdmin group fails to authenticate.</summary>
    [LiveLdapFact]
    public async Task AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails()
    {
        DashboardAuthenticator authenticator = CreateAuthenticator();

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

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

    /// <summary>Verifies that authentication with wrong password fails without leaking the password.</summary>
    [LiveLdapFact]
    public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword()
    {
        // Exercises the user-bind-failure branch: the user exists and the service
        // account search succeeds, but the candidate bind is rejected.
        const string wrongPassword = "definitely-not-the-admin-password";
        DashboardAuthenticator authenticator = CreateAuthenticator();

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

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

    /// <summary>Verifies that authentication with unknown username fails.</summary>
    [LiveLdapFact]
    public async Task AuthenticateAsync_UnknownUsername_Fails()
    {
        // Exercises the user-not-found branch: the service-account search returns no
        // entry, so no candidate bind is attempted.
        DashboardAuthenticator authenticator = CreateAuthenticator();

        DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
            "no-such-user-9f3c1",
            "irrelevant-password",
            CancellationToken.None);

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

    /// <summary>Verifies that authentication fails gracefully when the server is unreachable.</summary>
    [LiveLdapFact]
    public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
    {
        // Exercises the connect-failure path: a closed loopback port produces a
        // connection error that the shared LdapAuthService must absorb into a Fail
        // result rather than propagating an exception to the dashboard.
        DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions() with
        {
            // 1 is a reserved port number that no LDAP server listens on.
            Port = 1,
        });

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

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

    private static DashboardAuthenticator CreateAuthenticator() => CreateAuthenticator(LibraryOptions());

    private static DashboardAuthenticator CreateAuthenticator(LibraryLdapOptions ldapOptions)
    {
        GatewayOptions gatewayOptions = new()
        {
            Dashboard = new DashboardOptions
            {
                GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
                {
                    ["GwAdmin"] = DashboardRoles.Admin,
                },
            },
        };

        return new DashboardAuthenticator(
            new LdapAuthService(ldapOptions),
            new DashboardGroupRoleMapper(Options.Create(gatewayOptions)),
            NullLogger<DashboardAuthenticator>.Instance);
    }

    /// <summary>
    /// Builds the shared library <see cref="LibraryLdapOptions"/> by binding the real
    /// <c>MxGateway:Ldap</c> configuration section the same way production does in
    /// <c>AddZbLdapAuth(configuration, "MxGateway:Ldap")</c>, rather than hand-copying the
    /// gateway shadow <c>LdapOptions</c> defaults field by field (IntegrationTests-028).
    /// Binding the section directly onto the shared type means the live tests exercise the
    /// exact option-binding path production uses, pick up every shared field (including
    /// <see cref="LibraryLdapOptions.ConnectionTimeoutMs"/>, which governs the
    /// unreachable-server test's timing) at whatever value the operator configured, and
    /// cannot silently drop a field added to the shared type. The gateway's
    /// <c>appsettings.json</c> seeds the dev directory connection (localhost:3893,
    /// plaintext, AllowInsecure).
    /// </summary>
    private static LibraryLdapOptions LibraryOptions()
    {
        string repositoryRoot = IntegrationTestEnvironment.ResolveRepositoryRoot(AppContext.BaseDirectory);
        string appSettingsPath = Path.Combine(
            repositoryRoot,
            "src",
            "ZB.MOM.WW.MxGateway.Server",
            "appsettings.json");

        IConfiguration configuration = new ConfigurationBuilder()
            .AddJsonFile(appSettingsPath, optional: false)
            .Build();

        // Same section production binds in AddZbLdapAuth(configuration, "MxGateway:Ldap").
        // Get<T> returns null only when the section is absent; appsettings.json always
        // carries it, so fall back to shared defaults defensively rather than throw.
        return configuration.GetSection("MxGateway:Ldap").Get<LibraryLdapOptions>()
            ?? new LibraryLdapOptions();
    }
}
