using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Server.Security;

namespace ZB.MOM.WW.OtOpcUa.Server.Tests;

/// <summary>
///     Live-service tests against the dev GLAuth instance at <c>localhost:3893</c>. Skipped
///     when the port is unreachable so the test suite stays portable on boxes without a
///     running directory. Closes LMX follow-up #4 — the server-side <see cref="LdapUserAuthenticator"/>
///     is exercised end-to-end against a real LDAP server (same one the Admin process uses),
///     not just the flow-shape unit tests from PR 19.
/// </summary>
/// <remarks>
///     The <c>Admin.Tests</c> project already has a live-bind test for its own
///     <c>LdapAuthService</c>; this pair catches divergence between the two bind paths — the
///     Server authenticator has to work even when the Server process is on a machine that
///     doesn't have the Admin assemblies loaded, and the two share no code by design
///     (cross-app dependency avoidance). If one side drifts past the other on LDAP filter
///     construction, DN resolution, or memberOf parsing, these tests surface it.
/// </remarks>
[Trait("Category", "LiveLdap")]
public sealed class LdapUserAuthenticatorLiveTests
{
    private const string GlauthHost = "localhost";
    private const int GlauthPort = 3893;

    private static bool GlauthReachable()
    {
        try
        {
            using var client = new TcpClient();
            var task = client.ConnectAsync(GlauthHost, GlauthPort);
            return task.Wait(TimeSpan.FromSeconds(1)) && client.Connected;
        }
        catch { return false; }
    }

    // GLAuth dev directory groups are named identically to the OPC UA roles
    // (ReadOnly / WriteOperate / WriteTune / WriteConfigure / AlarmAck), so the map is an
    // identity translation. The authenticator still exercises every step of the pipeline —
    // bind, memberOf lookup, group-name extraction, GroupToRole lookup — against real LDAP
    // data; the identity map just means the assertion is phrased with no surprise rename
    // in the middle.
    private static LdapOptions GlauthOptions() => new()
    {
        Enabled = true,
        Server = GlauthHost,
        Port = GlauthPort,
        UseTls = false,
        AllowInsecureLdap = true,
        SearchBase = "dc=lmxopcua,dc=local",
        // Search-then-bind: service account resolves the user's full DN (cn=<user> lives
        // under ou=<primary-group>,ou=users), the authenticator binds that DN with the
        // user's password, then stays on the service-account session for memberOf lookup.
        // Without this path, GLAuth ACLs block the authenticated user from reading their
        // own entry in full — a plain self-search returns zero results and the role list
        // ends up empty.
        ServiceAccountDn = "cn=serviceaccount,dc=lmxopcua,dc=local",
        ServiceAccountPassword = "serviceaccount123",
        DisplayNameAttribute = "cn",
        GroupAttribute = "memberOf",
        UserNameAttribute = "cn", // GLAuth keys users by cn — see LdapOptions xml-doc.
        GroupToRole = new(StringComparer.OrdinalIgnoreCase)
        {
            ["ReadOnly"] = "ReadOnly",
            ["WriteOperate"] = WriteAuthzPolicy.RoleWriteOperate,
            ["WriteTune"] = WriteAuthzPolicy.RoleWriteTune,
            ["WriteConfigure"] = WriteAuthzPolicy.RoleWriteConfigure,
            ["AlarmAck"] = "AlarmAck",
        },
    };

    private static LdapUserAuthenticator NewAuthenticator() =>
        new(GlauthOptions(), NullLogger<LdapUserAuthenticator>.Instance);

    [Fact]
    public async Task Valid_credentials_bind_and_return_success()
    {
        if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");

        var result = await NewAuthenticator().AuthenticateAsync("readonly", "readonly123", TestContext.Current.CancellationToken);

        result.Success.ShouldBeTrue(result.Error);
        result.DisplayName.ShouldNotBeNullOrEmpty();
    }

    [Fact]
    public async Task Writeop_user_gets_WriteOperate_role_from_group_mapping()
    {
        // Drives end-to-end: bind as writeop, memberOf lists the WriteOperate group, the
        // authenticator surfaces WriteOperate via GroupToRole. If this test fails,
        // WriteAuthzPolicy.IsAllowed for an Operate-tier write would also fail
        // (WriteOperate is the exact string the policy checks for), so the failure mode is
        // concrete, not abstract.
        if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");

        var result = await NewAuthenticator().AuthenticateAsync("writeop", "writeop123", TestContext.Current.CancellationToken);

        result.Success.ShouldBeTrue(result.Error);
        result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteOperate);
    }

    [Fact]
    public async Task Admin_user_gets_multiple_roles_from_multiple_groups()
    {
        if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");

        // 'admin' has primarygroup=ReadOnly and othergroups=[WriteOperate, AlarmAck,
        // WriteTune, WriteConfigure] per the GLAuth dev config — the authenticator must
        // surface every mapped role, not just the primary group. Guards against a regression
        // where the memberOf parsing stops after the first match or misses the primary-group
        // fallback.
        var result = await NewAuthenticator().AuthenticateAsync("admin", "admin123", TestContext.Current.CancellationToken);

        result.Success.ShouldBeTrue(result.Error);
        result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteOperate);
        result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteTune);
        result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteConfigure);
        result.Roles.ShouldContain("AlarmAck");
    }

    [Fact]
    public async Task Wrong_password_returns_failure()
    {
        if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");

        var result = await NewAuthenticator().AuthenticateAsync("readonly", "wrong-pw", TestContext.Current.CancellationToken);

        result.Success.ShouldBeFalse();
        result.Error.ShouldNotBeNullOrEmpty();
    }

    [Fact]
    public async Task Unknown_user_returns_failure()
    {
        if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");

        var result = await NewAuthenticator().AuthenticateAsync("no-such-user-42", "whatever", TestContext.Current.CancellationToken);

        result.Success.ShouldBeFalse();
    }

    [Fact]
    public async Task Empty_credentials_fail_without_touching_the_directory()
    {
        // Pre-flight guard — doesn't require GLAuth.
        var result = await NewAuthenticator().AuthenticateAsync("", "", TestContext.Current.CancellationToken);
        result.Success.ShouldBeFalse();
        result.Error.ShouldContain("Credentials", Case.Insensitive);
    }
}
